Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79969e9d5d | ||
|
|
a1c51d62a0 | ||
|
|
bce11514a9 | ||
|
|
530bb4fca7 | ||
|
|
ae02252a1d | ||
|
|
ad90634a18 | ||
|
|
02887e265b | ||
|
|
c20e71bddc | ||
|
|
e616e80819 | ||
|
|
71f2cd2597 | ||
|
|
1471708f35 | ||
|
|
456e452b30 | ||
|
|
4e6eb32929 | ||
|
|
591e44721a | ||
|
|
2528f6b709 | ||
|
|
ad569d4e6d | ||
|
|
2eb6a6f8bb | ||
|
|
a0be43d76c | ||
|
|
9ae1326be2 | ||
|
|
1ab9ed22c7 | ||
|
|
5e6231493e | ||
|
|
f85acad5db | ||
|
|
f558e3e7df | ||
|
|
87b74fd015 | ||
|
|
b2404291b0 | ||
|
|
e816c1fd39 | ||
|
|
a6be917637 | ||
|
|
8664ec2926 | ||
|
|
22bc732334 | ||
|
|
4f4d8913b5 | ||
|
|
f4ae20969a | ||
|
|
7d13a48caa | ||
|
|
40b7db16c1 | ||
|
|
765ad08ee6 | ||
|
|
fbcb8f8408 | ||
|
|
ab7a2b147b | ||
|
|
047cc9efd2 | ||
|
|
d8f21f4e68 | ||
|
|
54f7f4c7ab | ||
|
|
c8aba83589 | ||
|
|
3b05f5b373 | ||
|
|
7eb773099f | ||
|
|
ba57d99806 | ||
|
|
28ce95f045 | ||
|
|
2fe717cb62 | ||
|
|
9bba53d5c0 | ||
|
|
53bf5a0634 | ||
|
|
096193a8ec | ||
|
|
e23788d717 | ||
|
|
5b69d6cec9 | ||
|
|
11127b1a89 | ||
|
|
d9e2103be7 | ||
|
|
a515eb2e63 | ||
|
|
3fcfc7d12d | ||
|
|
865862ee7c | ||
|
|
690f38207c | ||
|
|
2e6356c7f4 | ||
|
|
01e711be77 | ||
|
|
2cb0ed148e | ||
|
|
74d878a1c5 | ||
|
|
dfd3b2349e | ||
|
|
32e0dd7bd5 | ||
|
|
68546e63e8 | ||
|
|
bec07fc277 | ||
|
|
8b97fc6106 | ||
|
|
7e63963e4f | ||
|
|
28d590a4e5 | ||
|
|
982eb9b1fa | ||
|
|
933863529a | ||
|
|
4cb8ad566b | ||
|
|
7c6a68268b | ||
|
|
2e14f1f451 | ||
|
|
8c41414e04 | ||
|
|
921ff89edf | ||
|
|
9665edf35b | ||
|
|
277c5f1ba5 | ||
|
|
24b518c663 | ||
|
|
d065242413 | ||
|
|
e7da258d17 | ||
|
|
0a5fa715d4 | ||
|
|
dac8a0a2a7 | ||
|
|
8ab8f8ae80 | ||
|
|
9efd290061 | ||
|
|
4140b8e32c | ||
|
|
6b2b3828a5 | ||
|
|
b809aaa92e | ||
|
|
9ee75cf694 | ||
|
|
cd278ffda8 | ||
|
|
817ec3645b | ||
|
|
25f889c6d8 | ||
|
|
af1cd39fdf | ||
|
|
5e08a2768b | ||
|
|
79a0c15214 | ||
|
|
5571cf1aea | ||
|
|
2eb33587fe | ||
|
|
7037c7ff94 | ||
|
|
689bb8c98d | ||
|
|
9d32592ed1 | ||
|
|
3ff8e86fe8 | ||
|
|
7689a1f80c |
No files matched your search
@@ -0,0 +1,319 @@
|
||||
name: Console Windows
|
||||
|
||||
# 控制台(apps/console)的 Windows 安装包构建。
|
||||
#
|
||||
# 为什么单独一个工作流、而不塞进 windows-e2e
|
||||
# ----------------------------------------
|
||||
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
|
||||
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
|
||||
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
|
||||
#
|
||||
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
|
||||
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
|
||||
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
|
||||
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
|
||||
#
|
||||
# 架构(为什么必须两套)
|
||||
# --------------------
|
||||
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
|
||||
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
|
||||
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
|
||||
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
|
||||
#
|
||||
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
|
||||
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
|
||||
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
|
||||
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- 'apps/agent/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 控制台安装包 · ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
|
||||
bundles: msi,nsis
|
||||
pe: '0x8664'
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
|
||||
bundles: nsis
|
||||
pe: '0xaa64'
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 12
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: 安装 Rust 工具链
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: 缓存 Rust 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 这条断言是必需项,不是「顺手检查」。
|
||||
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
|
||||
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
|
||||
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
|
||||
# 故先断言「我到底在什么架构上编」,再往下走。
|
||||
- name: 断言构建机架构与目标一致
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$arch = node -p "process.arch"
|
||||
Write-Host "node process.arch = $arch"
|
||||
Write-Host "runner = ${{ matrix.runner }}"
|
||||
if ($arch -ne '${{ matrix.arch }}') {
|
||||
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
|
||||
}
|
||||
|
||||
- name: 构建 workspace 包(含被控端)
|
||||
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
|
||||
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
|
||||
|
||||
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
|
||||
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
|
||||
working-directory: apps/console
|
||||
run: |
|
||||
pnpm sidecar:build
|
||||
pnpm prepare:node
|
||||
|
||||
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
|
||||
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
|
||||
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
|
||||
- name: 断言随包 node.exe 的 PE 架构
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$node = 'apps/console/src-tauri/runtime/node.exe'
|
||||
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
|
||||
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
|
||||
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
|
||||
$actual = '0x{0:x4}' -f $machine
|
||||
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
|
||||
if ($actual -ne '${{ matrix.pe }}') {
|
||||
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
|
||||
}
|
||||
|
||||
- name: 打包安装包(${{ matrix.bundles }})
|
||||
working-directory: apps/console
|
||||
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
|
||||
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
|
||||
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: 验证产物自举
|
||||
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
|
||||
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
# ⚠️ 断言的是**最终交付物**,不是中间产物。
|
||||
#
|
||||
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
|
||||
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
|
||||
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
|
||||
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
|
||||
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
|
||||
#
|
||||
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
|
||||
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
|
||||
# 故改为**解开包、查内容**,一次钉死三件事:
|
||||
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
|
||||
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
|
||||
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
|
||||
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
|
||||
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
|
||||
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
|
||||
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
|
||||
shell: pwsh
|
||||
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expect = '${{ matrix.pe }}'
|
||||
$bundle = 'apps/console/src-tauri/target/release/bundle'
|
||||
|
||||
function Get-PeMachine([string]$path) {
|
||||
$fs = [System.IO.File]::OpenRead($path)
|
||||
try {
|
||||
$br = [System.IO.BinaryReader]::new($fs)
|
||||
$fs.Position = 0x3C
|
||||
$peOff = $br.ReadInt32()
|
||||
$fs.Position = $peOff
|
||||
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
|
||||
return '0x{0:x4}' -f $br.ReadUInt16()
|
||||
} finally { $fs.Dispose() }
|
||||
}
|
||||
|
||||
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
|
||||
function Find-PayloadRoot([string[]]$candidates) {
|
||||
foreach ($c in $candidates) {
|
||||
if (-not (Test-Path $c)) { continue }
|
||||
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($hit) { return $c }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
|
||||
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
|
||||
$deadline = (Get-Date).AddSeconds($seconds)
|
||||
do {
|
||||
$r = Find-PayloadRoot $candidates
|
||||
if ($r) { return $r }
|
||||
Start-Sleep -Seconds 1
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $null
|
||||
}
|
||||
|
||||
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
|
||||
function Assert-Payload([string]$root, [string]$label) {
|
||||
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
|
||||
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
|
||||
if (-not $agent) {
|
||||
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
|
||||
}
|
||||
if (-not $node) {
|
||||
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
|
||||
}
|
||||
$mb = [math]::Round($node.Length / 1MB, 1)
|
||||
$machine = Get-PeMachine $node.FullName
|
||||
Write-Host " node.exe $mb MB PE Machine=$machine"
|
||||
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
|
||||
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
|
||||
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
|
||||
if ($machine -ne $expect) {
|
||||
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
|
||||
}
|
||||
}
|
||||
|
||||
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
|
||||
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
|
||||
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
|
||||
|
||||
$checked = 0
|
||||
|
||||
foreach ($m in $msis) {
|
||||
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
|
||||
# 管理安装(/a)= 只解包不注册,正合此处用途
|
||||
$dest = Join-Path $PWD 'pkg-extract/msi'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
$log = Join-Path $PWD 'msi-extract.log'
|
||||
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
|
||||
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
|
||||
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
|
||||
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
|
||||
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
|
||||
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
|
||||
Write-Host " msiexec 退出码 = $($p.ExitCode)"
|
||||
# 3010 = 成功但需重启,不是失败
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
|
||||
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
|
||||
}
|
||||
$root = Wait-PayloadRoot @($dest)
|
||||
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
|
||||
Assert-Payload $root $m.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
foreach ($n in $nsis) {
|
||||
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
|
||||
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
|
||||
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
|
||||
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
|
||||
# (不含空格),而不是通常带空格的用户目录。
|
||||
$dest = Join-Path $PWD 'pkg-extract/nsis'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
|
||||
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
|
||||
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
|
||||
Write-Host " 安装器退出码 = $($p.ExitCode)"
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
|
||||
}
|
||||
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
|
||||
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
|
||||
$root = Wait-PayloadRoot @(
|
||||
$dest,
|
||||
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
|
||||
(Join-Path $env:ProgramFiles 'nodeagent-console')
|
||||
)
|
||||
if (-not $root) {
|
||||
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
|
||||
}
|
||||
Write-Host " 安装位置:$root"
|
||||
Assert-Payload $root $n.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
|
||||
|
||||
- name: 上传安装包
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nodeagent-console-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
apps/console/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/console/src-tauri/target/release/bundle/nsis/*.exe
|
||||
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Rust Shell
|
||||
|
||||
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
|
||||
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
|
||||
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
|
||||
#
|
||||
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
|
||||
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Rust 壳单测(真跑 sidecar)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
|
||||
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
- name: 安装 Rust 工具链(含 clippy)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
|
||||
# 不需要 GUI,但整个 crate 要能编过。
|
||||
- name: 安装 Tauri 系统依赖
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev
|
||||
|
||||
- name: 安装 Node(sidecar 单测要真跑它)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
|
||||
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
|
||||
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
|
||||
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
|
||||
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
|
||||
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
|
||||
|
||||
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
|
||||
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
|
||||
- name: 构建 sidecar(resources 引用的产物)
|
||||
run: pnpm --filter nodeagent-console sidecar:build
|
||||
|
||||
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
|
||||
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
|
||||
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
|
||||
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
|
||||
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
|
||||
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
|
||||
run: pnpm --filter nodeagent-console prepare:node
|
||||
|
||||
- name: 注入片段应声明三份资源(node + sidecar + agent)
|
||||
run: |
|
||||
node -e '
|
||||
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
|
||||
const r = g.bundle.resources;
|
||||
const keys = Object.keys(r);
|
||||
if (keys.length !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
|
||||
const vals = Object.values(r);
|
||||
for (const need of ["sidecar.mjs", "agent.mjs"]) {
|
||||
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
|
||||
}
|
||||
if (!vals.includes("node") && !vals.includes("node.exe")) {
|
||||
console.error("缺少 node 运行时", vals); process.exit(1);
|
||||
}
|
||||
console.log("✓", JSON.stringify(r));
|
||||
'
|
||||
|
||||
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
|
||||
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
|
||||
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
|
||||
# 这类问题只在产物中暴露(源码与单测都正常)。
|
||||
- name: 验证运行时 + sidecar + agent 可自举
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- name: 缓存 cargo 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
- name: Clippy(告警即失败)
|
||||
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
|
||||
|
||||
- name: 单测(真跑 Node sidecar 进程)
|
||||
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -49,31 +49,129 @@ jobs:
|
||||
- name: 类型检查
|
||||
run: pnpm typecheck
|
||||
|
||||
# 控制台「随包分发的 Node 运行时 + 被控端 agent.mjs」在 Windows 上的分支
|
||||
# 此前零覆盖(准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip
|
||||
# 分支)→ 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举,
|
||||
# 并用同一个 node 解析 agent.mjs。
|
||||
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
|
||||
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
|
||||
# agent.mjs 的前置是 `pnpm -r build`(上面的「构建」步骤已覆盖)。
|
||||
- name: 控制台 Node 运行时 + 被控端(Windows 分支)与自举验证
|
||||
run: |
|
||||
pnpm --filter nodeagent-console sidecar:build
|
||||
pnpm --filter nodeagent-console prepare:node
|
||||
node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
|
||||
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
|
||||
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
|
||||
run: node --test "tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: E2E — 协议与跨平台能力
|
||||
run: node tests/e2e/run.mjs
|
||||
|
||||
- name: E2E — Windows 专属能力(服务 / 软件 / winget)
|
||||
run: node tests/e2e/windows.mjs
|
||||
|
||||
# ⚠️ 这一步刻意用 **powershell(Windows PowerShell 5.1)** 而非 pwsh。
|
||||
# 5.1 读无 BOM 的 .ps1 会按 ANSI/GBK 解析,中文注释会破坏字符串边界 →
|
||||
# 整份脚本 ParseError(真机 2026-10-04 踩过:install.ps1 报「","后面缺少表达式」)。
|
||||
# 之前的冒烟全用 pwsh(7+ 认无 BOM UTF-8),所以 CI 绿、真机炸。
|
||||
# 这里只做「解析 + 首行 help」检查:5.1 能否读懂,就是真机的核心约束。
|
||||
# ⚠️ 必须用 shell: powershell(runner 上的 powershell 即 Windows PowerShell 5.1)。
|
||||
# 不要用 shell: cmd 再套 powershell -Command "..." —— 多行带引号经 cmd 转义会全乱
|
||||
# (实测:Missing closing '}' / '$errs' is not recognized,是步骤自身坏,不是脚本坏)。
|
||||
- name: 冒烟 — 脚本可被 Windows PowerShell 5.1 解析(BOM 守卫)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
foreach ($f in @('scripts\install.ps1', 'scripts\uninstall.ps1', 'scripts\rescue-network.ps1')) {
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$null, [ref]$errs)
|
||||
if ($errs -and $errs.Count -gt 0) { throw ('5.1 parse FAILED: ' + $f + ' -> ' + $errs[0].Message) }
|
||||
Write-Host ('OK 5.1 parse: ' + $f)
|
||||
}
|
||||
|
||||
- name: 冒烟 — install.ps1 安装链路
|
||||
shell: pwsh
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
./scripts/install.ps1 -Port 18769 -NodeId ci_win
|
||||
|
||||
$task = Get-ScheduledTask -TaskName "nodeagent" -ErrorAction Stop
|
||||
Write-Host "计划任务状态: $($task.State)"
|
||||
Write-Host "Scheduled task state: $($task.State)"
|
||||
|
||||
$cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json
|
||||
if ($cfg.port -ne 18769) { throw "端口配置错误: $($cfg.port)" }
|
||||
if (-not $cfg.key) { throw "预共享密钥缺失" }
|
||||
if (-not $cfg.tls) { throw "TLS 默认应开启" }
|
||||
if ($cfg.port -ne 18769) { throw "port mismatch: $($cfg.port)" }
|
||||
if (-not $cfg.key) { throw "PSK missing in agent.json" }
|
||||
if (-not $cfg.tls) { throw "TLS should be on by default" }
|
||||
|
||||
$rule = Get-NetFirewallRule -DisplayName "nodeagent (TCP 18769)" -ErrorAction Stop
|
||||
if (-not $rule) { throw "防火墙规则未创建" }
|
||||
if (-not $rule) { throw "Firewall rule" }
|
||||
|
||||
Write-Host "OK — 计划任务 / 配置 / 防火墙均已就位"
|
||||
Write-Host "OK scheduled task / config / firewallall in place"
|
||||
|
||||
# 一键入口冒烟:真跑 install.cmd / control.cmd。
|
||||
# 目的不是「再装一遍」,而是证明双击路径可用 —— 这些脚本是用户唯一的
|
||||
# 上手入口,语法或编码一旦坏了,只有真机 cmd.exe 才测得出来。
|
||||
# 端口用 18770 避开前一步的 18770 之外的安装,便于分辨是哪一步装的。
|
||||
- name: 冒烟 — install.cmd 双击链路(含自提权与 PSK 读取)
|
||||
shell: cmd
|
||||
run: call scripts\install.cmd 18770
|
||||
env:
|
||||
CI: "true"
|
||||
- name: 冒烟 — control.cmd 状态查询
|
||||
shell: cmd
|
||||
run: scripts\control.cmd 18770 < nul
|
||||
env:
|
||||
CI: "true"
|
||||
- name: 冒烟 — 产物包结构与编码
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
pnpm pack:win
|
||||
$zip = "release/nodeagent-win-x64.zip"
|
||||
if (-not (Test-Path $zip)) { throw "zip not produced: $zip" }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip))
|
||||
try {
|
||||
$names = $z.Entries.Name
|
||||
# 必须随包分发
|
||||
foreach ($need in @('install.cmd','control.cmd','install.ps1','control.ps1','README.txt','agent.mjs','node.exe')) {
|
||||
if ($names -notcontains $need) { throw "missing in package: $need" }
|
||||
}
|
||||
# 必须**不**在包内:PSK.txt 是现生成给本机的,打进公开资产等于泄露密钥
|
||||
# (eaca9e0 起已从 zip 剔除;此处反向断言守住该性质)
|
||||
foreach ($forbidden in @('PSK.txt','*.key','*.pem','certs')) {
|
||||
if ($names -contains $forbidden) { throw "package must NOT contain: $forbidden" }
|
||||
}
|
||||
if ($names -match 'PSK\.txt') { throw "package leaks PSK.txt" }
|
||||
foreach ($e in $z.Entries) {
|
||||
$s = $e.Open(); $buf = New-Object byte[] 3
|
||||
[void]$s.Read($buf, 0, 3); $s.Close()
|
||||
$hasBom = ($buf[0] -eq 0xEF -and $buf[1] -eq 0xBB -and $buf[2] -eq 0xBF)
|
||||
if ($e.Name -like '*.ps1' -and -not $hasBom) { throw "$($e.Name) lost its BOM in package" }
|
||||
if ($e.Name -like '*.cmd' -and $hasBom) { throw "$($e.Name) must not have a BOM" }
|
||||
}
|
||||
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
|
||||
} finally { $z.Dispose() }
|
||||
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
|
||||
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
|
||||
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
|
||||
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
|
||||
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
|
||||
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/pack.mjs --arch $arch
|
||||
}
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
|
||||
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
|
||||
}
|
||||
- name: 冒烟 — 被控端监听
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -108,7 +206,9 @@ jobs:
|
||||
|
||||
- name: 清理
|
||||
if: always()
|
||||
shell: pwsh
|
||||
# 清理失败不掩盖真实结论(前置挂了会连带这里报错)
|
||||
continue-on-error: true
|
||||
shell: powershell
|
||||
run: |
|
||||
Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
./scripts/uninstall.ps1 -Port 18769 -Purge
|
||||
@@ -40,3 +40,17 @@ release/
|
||||
# 重装备份数据(含设备软件清单,不入库)
|
||||
../win-backup/
|
||||
win-backup/
|
||||
|
||||
# Rust 构建产物
|
||||
# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后
|
||||
# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。
|
||||
/crates/target/
|
||||
|
||||
# 密钥材料(路线图 §6.2):被控端数据目录 keys/、ed25519/TLS 私钥
|
||||
keys/
|
||||
*.key
|
||||
*.pem
|
||||
!*.pub
|
||||
|
||||
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
|
||||
.workbuddy/
|
||||
@@ -0,0 +1,563 @@
|
||||
# 变更日志
|
||||
|
||||
本文件记录 nodeagent 的显著变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
---
|
||||
|
||||
## [未发布]
|
||||
|
||||
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
|
||||
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
|
||||
> 有日志功能,可以后台运行和开机自启等等功能」。
|
||||
>
|
||||
> 末段另含两轮纯界面调整:**一律不使用图标**(emoji 在部分渲染器里退化成豆腐块)、
|
||||
> **配色收敛为「简约白 / 高级灰」两套中性灰阶主题**。
|
||||
|
||||
### 新增 —— 本机被控端托管(`apps/console/src-tauri/src/agent.rs`,约 560 行)
|
||||
|
||||
- **新增「本机服务」页(`/local`,默认首页)与「服务日志」页(`/logs`)**。
|
||||
默认路由由 `/nodes` 改为 `/local`:打开应用先看到「本机被控端」的启停面板,
|
||||
而不是先要求用户填一个连接地址 —— 这是「运行应用之后就有启动/停止/重启」的落点。
|
||||
- **进程生命周期管理**:`AgentManager`(`STOPPED / STARTING / RUNNING / STOPPING / CRASHED` 五态)。
|
||||
启动、停止、重启均走界面按钮;`stop()` 在 Windows 走 `taskkill /PID <pid> /T /F`(连子进程树一起收),
|
||||
POSIX 走 `child.kill()`,随后**轮询确认端口已释放**才算停干净。
|
||||
**重复启动被拒绝**(不会起出两个进程),**异常退出自动重启**(上限 5 次,超限停在 `CRASHED` 并保留原因)。
|
||||
- **日志功能**:同时捕获被控端 `stdout`/`stderr` 两路,环形缓冲 4000 行。
|
||||
每行带一个**单调递增的 `seq`** —— 这是刻意设计:环形缓冲淘汰旧行后,若用「位置下标」做游标,
|
||||
前端的增量拉取会静默错位;独立 `AtomicU64` 发号可跨淘汰保持连续。
|
||||
界面侧:实时事件推送、关键字过滤、三流(stdout/stderr/system)复选框、自动滚动、
|
||||
暂停接收、重新拉取、滚到底部、导出文本、清空。
|
||||
- **界面化自更新**:`check_update` / `download` / `apply_update` 三个能力。
|
||||
- `check_update` 读 GitHub Release 的**稳定别名** `latest.json`(而非 API),**不占用 API 配额**、不受限流影响。
|
||||
- `download` 的 `--sha256` 是**必填**(不校验哈希等于开放远程代码执行),校验不通过**不落盘**,写后再复核一次。
|
||||
- `apply_update` 的替换顺序:下载到 `runtime/agent.mjs.staged-<pid>` → 复用已有被控端停服 →
|
||||
备份现有脚本 → **原子替换** → 写 `agent.seed.json` 版本 → 按需回启。
|
||||
任一步失败都停在替换之前,不会留下半截产物。
|
||||
- **开机自启**(`autostart.rs`):macOS 写 LaunchAgent `~/Library/LaunchAgents/cn.jkooe.nodeagent-console.plist`
|
||||
(`RunAtLoad`;**刻意不用 `KeepAlive`**,否则会与控制台的崩溃自愈形成两层守护互相打架);
|
||||
Windows 用计划任务(**登录用户 + `Interactive` + `Highest`**)。
|
||||
一个易错点:macOS 的 `bootstrap` 目标 `gui/<uid>` 里的 uid **不能硬编码 501**(那只是首个本地用户的常见值),
|
||||
改为运行时 `id -u` 现取。
|
||||
- **后台常驻(托盘)**:托盘图标 + 右键菜单(显示主窗口 / 启动 / 停止 / 重启 / 退出);
|
||||
左键单击图标唤出窗口。窗口 `CloseRequested` → `prevent_close()` + `hide()`,
|
||||
**关窗不退出**,与「后台运行」的要求一致;真正退出走托盘菜单。
|
||||
- **`--agent-only` 仅服务模式**:开机自启拉起的是**控制台自身**(GUI 子系统程序,不带控制台窗口),
|
||||
进入该模式后不显示窗口、只把被控端托起来;而不是去直接拉起裸 `node`。
|
||||
|
||||
### 新增 —— 消灭命令行窗口(三条措施,缺一不可)
|
||||
|
||||
| # | 措施 | 位置 | 不加会怎样 |
|
||||
|---|---|---|---|
|
||||
| 1 | 控制台是 GUI 子系统程序,自身不弹控制台 | Tauri 默认 | 打开应用先弹黑窗 |
|
||||
| 2 | spawn `node.exe` 时加 `CREATE_NO_WINDOW`(`0x0800_0000`) | `agent.rs` 的 `creation_flags` | **每次启动被控端都闪一个黑窗** —— 正是皇上要求消灭的现象 |
|
||||
| 3 | 开机自启拉起控制台而非裸 `node` | `autostart.rs` + `--agent-only` | 开机时黑窗一闪而过 |
|
||||
|
||||
### 新增 —— 契约守卫(`tests/unit/console-commands.test.mjs`,8 用例)
|
||||
|
||||
把「改了名字两边不同步、但不报错」的三类**静默失效**钉死,避免「界面点了没反应、控制台无异常」:
|
||||
|
||||
| # | 守卫 | 抓什么 |
|
||||
|---|---|---|
|
||||
| 1 | 前端每个 `invoke("x")` 都已在 Rust `generate_handler!` 注册 | 前端调了个不存在的命令 → 运行时才 `not found` |
|
||||
| 2 | Rust 注册的命令都被前端使用 | 死命令堆积(改名后残留的旧命令) |
|
||||
| 3 | `emit("na-*")` ⇄ `listen("na-*")` 双向一致 | 事件名不匹配 → **界面永远不刷新,且无任何报错** |
|
||||
| 4 | `tauri.bundle.gen.json` 资源目标名 ⇄ Rust `resolve_*` 字面量 | 映射改名 → 生产包内找不到 sidecar/node/agent |
|
||||
| 5 | sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
|
||||
| 6 | 新页面已注册路由 + 默认路由指向 `/local` + 懒加载的 view 文件真实存在 | 「写了页面但进不去」/循环依赖导致的路由表推导失败 |
|
||||
| 7 | 自定义样式用到的 `var(--na-*)` 都由 `cssVars()` 定义 | 拼错变量名**不报错**,颜色只是静默落回继承值 —— 类型检查、构建、运行全都不吭声 |
|
||||
| 8 | `index.html` 的首帧兜底色 ⇄ 调色板的 `bg` | 首帧底色是全应用**唯一允许的色值重复处**;允许重复就必须有守卫 |
|
||||
|
||||
> **已注入故障实测会红**:① 把 `agent_start` 改成 `agent_startX`、`na-agent-state` 改成 `na-agent-statee`
|
||||
> → 当场 2 条失败;② 把某处 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`
|
||||
> → 第 7 条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff` → 第 8 条失败。
|
||||
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。未验证过会红的守卫等于没有守卫。
|
||||
>
|
||||
> 第 7 条的正则刻意放宽到 `[^)\s,;]+` 而非 `[a-z0-9-]+` —— 后者遇到含大写的拼错会在中途停下导致
|
||||
> **整个模式不匹配**,于是「写错的变量名」反而逃过检查。这是注入故障时才发现的守卫自身漏报。
|
||||
|
||||
### 变更 —— 打包与单一实现
|
||||
|
||||
- **被控端 esbuild 配置抽为 `scripts/lib/bundle-agent.mjs`**(`repoRoot` / `repoVersion` / `gitShortCommit` / `bundleAgent`),
|
||||
`scripts/pack.mjs`(被控端发布包)与 `scripts/prepare-console.mjs`(控制台资源)**共用同一份**。
|
||||
此前 `pack.mjs` 里内联了一份配置,控制台若再内联一份就是**两套真相**(改一处漏一处,只在产物里暴露)。
|
||||
该模块刻意**不注入构建时间** —— 否则每次打包字节都不同,产物指纹比对会失真。
|
||||
- **控制台随包分发被控端脚本**:资源由 2 份(`sidecar.mjs` / `node`)增至 **3 份**(+ `agent.mjs`)。
|
||||
带来的效果:用户装完控制台,**本机被控端已经就绪**,不需要另外手工安装一次才能点「启动」。
|
||||
- **播种防降级**:`<数据目录>/runtime/agent.seed.json` 记录版本,
|
||||
只有「**随包版本 > 已安装版本**」才覆盖数据目录里的脚本。
|
||||
若写成「同版本才跳过」,则用户被控端自更新到更新版本后,下次开控制台会被**静默降回旧版**。
|
||||
版本比较按**数值逐段**(`2.0.10 > 2.0.9`),字符串比较会误判。
|
||||
- `verify-bundle.mjs` 增加 `agent.mjs` 存在性检查 + 用打包内的 node 跑 `--check agent.mjs`。
|
||||
|
||||
### 变更 —— CI
|
||||
|
||||
- `.github/workflows/rust.yml`:资源数断言 2 → **3**;`paths` 增加 `scripts/lib/bundle-agent.mjs` 与 `apps/agent/**`;
|
||||
构建步骤补 `@nodeagent/agent`(`prepare:node` 现在依赖它的 dist,
|
||||
**新步骤必须逐条核对前置产物由哪一步生成、顺序是否在其后** —— 本地验证时产物早就在,会把这层依赖完全掩盖)。
|
||||
- `.github/workflows/windows-e2e.yml`:步骤名与注释同步为含 `agent.mjs` 的说明。
|
||||
|
||||
### 新增 —— Windows 端控制台安装包(`.github/workflows/console-windows.yml`)
|
||||
|
||||
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`),而它的主战场恰恰是
|
||||
「在 Windows 上管控其他机器」。本轮补上 Windows 构建:**双架构矩阵** ——
|
||||
`windows-latest` 出 x64 `.msi` + NSIS `-setup.exe`,`windows-11-arm` 出 arm64 NSIS `-setup.exe`。
|
||||
|
||||
- **为什么必须双架构**:与 v2.0.2 那次事故同源 —— 包里装着错架构的 `node.exe` 时,
|
||||
ARM Windows 上「能启动、能 listen,但 TLS 握手不完成」,而 macOS 侧 100% 复现不出。
|
||||
控制台更甚:它内含 node 运行时,还要用这个 node **托起本机被控端**,即自己就是 TLS 服务端。
|
||||
- **为什么用原生 ARM runner 而非交叉编译**:`prepare-console.mjs` 的自检要**真执行 `node.exe`**,
|
||||
x64 机器跑不了 arm64 二进制;交叉编译会逼着引入「跳过自检」这类**削弱验证**的开关 ——
|
||||
而产出错架构运行时的时候,自检正是唯一能拦住它的那道闸。
|
||||
- **`cfg!(windows)` 分支第一次被编译器看到**:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
|
||||
故 `taskkill` 收进程树、PowerShell 计划任务、`node.exe` 路径等分支此前从未产出过 Windows 产物。
|
||||
- **实测产物**:x64 `.msi` **34.2 MiB** / NSIS **23.2 MiB**;arm64 NSIS **19.5 MiB**(均低于 40MB 目标)。
|
||||
`if-no-files-found: error` + `fail-fast: false`:一条架构没产出就报错,同时一条挂了不掩盖另一条的结果。
|
||||
- **尚未接入 `scripts/release.mjs`**:安装包目前只作为 CI artifact 产出,未随 Release 发布。
|
||||
|
||||
> **首个 run 两个 job 全红,但错的不是构建,是断言。**(run `38151366534`)
|
||||
> 两个 job 的失败点完全一致,而日志显示构建链路**一路全绿**:构建机架构(arm64 runner 上
|
||||
> `process.arch = arm64`,原生属实)、随包 `node.exe` 的 PE Machine(`0x8664` / `0xaa64`)、
|
||||
> `tauri build`、产物自举(打包内 node v22.23.3 真跑起打包内 sidecar)四步全 success。
|
||||
> 死掉的是本步自己写的守卫 —— 原断言「安装包体积 > 50MB」,理由是「含 Node 运行时约 80MB」,
|
||||
> 这**把未压缩的目录形态(`.app` ≈ 92 MiB)当成了压缩安装包的体积**:
|
||||
> LZMA 下 `node.exe` 自身能压到 1/3~1/4,实测 `.msi` 34.2 MiB、NSIS 19.5~23.2 MiB,阈值必然误杀。
|
||||
>
|
||||
> 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
|
||||
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
|
||||
> 判据必须落在**产物内容**上,不能落在**间接指标**上。
|
||||
>
|
||||
> 改为**解开安装包查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
|
||||
> 两者都是各自格式的核心能力,不引任何外部依赖),一次钉死三件事:
|
||||
> ① `node.exe` / `sidecar.mjs` / `agent.mjs` **三样随包资源必须都在**(缺任一,真机上就是
|
||||
> 「应用能开、但托不起被控端」);② `node.exe` 的 **PE Machine = 目标架构**(最终交付物级架构断言);
|
||||
> ③ `node.exe` 解压后 **> 40MB**(体积在这是**正确用法**:内容已验明后的自明性检查,
|
||||
> 而不是「运行时在不在」的**替代判据**)。附带收益:**真装一遍本身就是安装器冒烟**。
|
||||
> 另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
|
||||
>
|
||||
> **第二个 run(`38152850819`)两 job 仍同点红,但已是另一处原因,且同样是静默失效**:
|
||||
> 日志里 `msiexec /a 失败(退出码 )` —— `$LASTEXITCODE` 是**空的**,而整步从开始到抛错
|
||||
> 只过了 **0.8 秒**。根因是 `msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,
|
||||
> PowerShell 用 `&` 调用时**既不等待其结束、也不设置 `$LASTEXITCODE`**
|
||||
> (`$null -ne 0` 判真 → 安装器还没跑完就抛错,报错读起来像「安装失败」,实为「压根没等」);
|
||||
> arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。
|
||||
> 修法:改 `Start-Process -Wait -PassThru` 读 `.ExitCode`;参数拼成**单个字符串**传,
|
||||
> 避免数组被自动加引号而破坏 NSIS `/D=` 的「原样、不加引号、且居末」硬要求;
|
||||
> 加 `Wait-PayloadRoot`(30 秒收敛)防安装器「自己先退出、子进程继续装」的竞态误报。
|
||||
>
|
||||
> **第三个 run(`38154282653`)双绿**,断言给出产物实证 —— x64 `.msi`/`-setup.exe` 内均解出
|
||||
> **`node.exe` 82.9 MB / PE `0x8664`**、arm64 `-setup.exe` 内解出 **`node.exe` 73.5 MB / PE `0xaa64`**,
|
||||
> 三样随包资源齐全(sidecar 232 KB、agent 1060 KB)。
|
||||
> 这一步证明的恰是体积断言永远无法区分的那件事:**19.5MB 的压缩包里真的装着 73.5MB 的 arm64 运行时**。
|
||||
|
||||
### 变更 —— 界面一律不使用图标
|
||||
|
||||
- **去掉全部 emoji 图标**:路由表 `meta.icon` 字段整体移除,侧栏导航由「图标 + 标题」改为**纯文字**。
|
||||
触发原因是一次真实的渲染事故:emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**(截图对比可见),
|
||||
而「哪套字体栈一定带 emoji」跨平台并不成立 —— 与其赌字形,不如不用。
|
||||
- **`n-alert` / `n-empty` / `n-popconfirm` 的内置图形一并关闭**:这三个组件的 `showIcon` 默认均为 `true`,
|
||||
故 16 处 `n-empty` 的空态插画、7 处 `n-alert` 的类型图标、3 处 `n-popconfirm` 的询问图标全部消失,
|
||||
只剩文字与配色(严重程度仍由 `type` 的底色/边框区分,信息不丢)。
|
||||
`n-empty` 是**空态里的插画**(比图标更「图形」),在 9 个页面都有,是最容易漏掉的一类。
|
||||
- **夹在文案里的符号也去掉**:`TerminalView` 的「⚠ 高权限操作,请谨慎」保留纯文字。
|
||||
- **清理随包的模板残留资产**:删掉 `public/vite.svg`、`public/tauri.svg`、`src/assets/vue.svg`
|
||||
与 `index.html` 的 favicon 声明。`public/` 下的文件会被整体复制进产物,
|
||||
此前等于把 Vite / Tauri 的 logo 一起发了出去;`index.html` 的标题也还是模板默认值
|
||||
(原生窗口标题取自 `tauri.conf.json`,不受影响),本次一并改齐。
|
||||
|
||||
> **边界(以下不算「界面图标」,未动)**:① 托盘图标 —— 菜单栏没有图标就点不到,
|
||||
> 关窗后应用将无法寻回,属**功能必需**;② 被控端 CLI 自身打印的文本标记(`⚠️`/`ℹ️`/`✅`,
|
||||
> 见 `apps/agent/src/index.ts`、`packages/client/src/keys.ts`、`apps/agent/src/metrics.ts`)——
|
||||
> 它们是**程序输出内容**,会原样出现在「服务日志」页里,但不是界面元素;
|
||||
> ③ 控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。
|
||||
|
||||
### 变更 —— 配色收敛为两套中性灰阶主题
|
||||
|
||||
- **两套可切换主题:`简约白`(浅)与 `高级灰`(深)**,侧栏底部一行切换,选择存 `localStorage`。
|
||||
两套都**只用中性灰阶**:去掉原先的青色品牌字(`#63e2b7`)与 naive-ui 默认的绿色主色,
|
||||
主按钮 / 开关激活态 / 进度条 / 勾选框统一改吃「主色位 = 中性灰」这一个值。
|
||||
- 白主题的**页底刻意不是 `#ffffff`**(`#f4f4f5`):卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
|
||||
整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
|
||||
- **语义色不是去掉,而是降饱和**(灰绿 / 灰琥珀 / 灰红 / 灰蓝)。
|
||||
这是运维工具,`运行中 / 出错` 必须一眼可辨,全部灰化会导致误读;故它们只出现在徽章与告警这类小面积上,
|
||||
底色观感仍是「白 + 灰」。严重程度在关闭图标后本就更依赖 `type` 的底色/边框。
|
||||
- **色值只有一份定义**(`src/theme/index.ts` 的 `Palette`):既生成 naive-ui 的 `themeOverrides`,
|
||||
又由 `App.vue` 在运行期注入成 `--na-*` CSS 变量供自定义样式使用。
|
||||
原因是若「TS 调色板」与「CSS 写死的色值」各存一份,换主题时会**一半新一半旧且不报错、类型也抓不到**
|
||||
—— 这正是本轮要消灭的失效模式。
|
||||
- **清掉 17 处写死的色值**(散在 8 个文件:日志三流着色、代码块/预览底色、节点选中边框、历史悬停等),
|
||||
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
|
||||
- **主题偏好只存 `localStorage`,不落 Rust 侧 settings**:那是给「仅服务模式」(无窗口、常驻托盘)
|
||||
也要生效的偏好用的,而主题是纯图形偏好 —— 没有窗口时毫无意义。为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
|
||||
- **首帧防闪**:`index.html` 内联一段极小脚本,在 Vue 接管前先把上次主题的底色刷上,
|
||||
否则「高级灰」用户每次启动都会看到一帧刺眼的白。此处**刻意重复了 2 个底色值**(真值在 `theme/index.ts`),
|
||||
因为在此之前没有任何模块可依赖;已在两处都注明「改底色时同步」。
|
||||
|
||||
### 说明 —— 本轮**刻意不升版本号**
|
||||
|
||||
本轮新增的全部是**控制台**功能,**被控端能力集合未变**(未增删任何 `system.*` / `fs.*` / `app.*` 能力)。
|
||||
而 `scripts/lib/bundle-agent.mjs` 会把**根 `package.json` 的 version 注入 `agent.mjs`**(`__AGENT_VERSION__`):
|
||||
一旦为控制台功能去升版本号,被控端就会**谎报**一个它并未包含的版本 —— 属版本纪律所禁止。
|
||||
故版本号维持 **2.0.2**,本批改动记入本节,待真正含被控端变更时由 `pnpm release minor` 一并升号。
|
||||
|
||||
### 修复 —— 图形能力在 200% 缩放下坐标错位(真机实证,**属被控端变更**)
|
||||
|
||||
> ⚠️ 与上一节结论**不同**:本节改的是**被控端**(`apps/agent/src/**`)。
|
||||
> 按上一节自己立的规矩,发版时**必须**升版本号(PATCH,→ 2.0.3),
|
||||
> 否则 `agent.mjs` 会谎报一个并不包含本修复的版本。
|
||||
|
||||
真机(Parallels 27 + Win11 ARM64,**200% 缩放**)暴露的缺陷:
|
||||
|
||||
被控端的 PowerShell 助手进程**默认 DPI 不感知**,于是**同一个进程里并存两套坐标系**:
|
||||
|
||||
| 调用 | 不感知时的结果 | 实际危害 |
|
||||
|---|---|---|
|
||||
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1800×1010 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
|
||||
| `CopyFromScreen` | 按**物理**像素裁剪 | `screen.capture` 缺省**只截到左上 1/4** |
|
||||
| `SetCursorPos` | 被系统 **×2** | `input.mouse.*` 喂 100,100 → 光标落到 200,200 |
|
||||
| UIA(`screen.find`) | **物理**坐标(与进程是否感知无关) | 与上面三项**口径不一致** |
|
||||
|
||||
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,必须人工除以 `scale`
|
||||
(上一轮就是靠这个临时绕法才点中的主题按钮 —— **能绕,但属用户侧的隐式约定,必须消灭**)。
|
||||
|
||||
- **新增 `apps/agent/src/util/dpi.ts`**:`PS_DPI_AWARE` 常量,把 `SetProcessDPIAware()` 提到脚本首行,
|
||||
并附完整真机实证表与「不要删」警告。
|
||||
- **注入到全部坐标相关脚本**:`capabilities/screen.ts`(`listDisplays` / `captureWindows`)、
|
||||
`capabilities/input.ts`(`buildInputPsScript` + 点击后读回光标那一次)、
|
||||
`capabilities/window.ts`(`WIN_HELPER_PRELUDE` —— `window.list` / `window.focus` 都走它)、
|
||||
`capabilities/record.ts`(全屏探测)。
|
||||
- **从 OCR 前奏的 `try{}` 里移除** `SetProcessDPIAware`(`win-ps/prelude-ocr.ts`):
|
||||
放在 `try` 里一旦抛错就被 catch 吞掉 → 助手静默退回不感知。
|
||||
- **新增 8 条守卫(`tests/unit/dpi-awareness.test.mjs`)**,关键设计是**断言「实际下发的脚本文本」
|
||||
而非「源码里出现过常量」** —— 首版用源码扫描,注入故障实测**不会变红**(同文件别处也引用了该常量);
|
||||
改为断言 `buildInputPsScript()` / `buildListDisplaysScript()` / `buildCaptureScript()` / `WIN_HELPER_PRELUDE`
|
||||
的脚本串后,二次注入故障**精准变红**。另含一条「`::SetProcessDPIAware()` 恰好出现 1 次」防重复注入
|
||||
(正则用 `::` 区分**调用**与 P/Invoke **声明**,否则把声明也算一次 → 误报 2 次)。
|
||||
- **修复后真机复验(三项全过,零换算)**:
|
||||
|
||||
| 断言 | 结果 |
|
||||
|---|---|
|
||||
| `screen.info` | 报 **3600×2020**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
|
||||
| `input.mouse.move(x,y)` → 读回光标(物理) | 四次抽样**精确等于** (x,y) ✅ |
|
||||
| `screen.find` 物理矩形 → `input.mouse.click` **直接点击** | 移动后光标 = 物理中心;主题单选框 `selected` 翻转且**可逆** ✅ |
|
||||
|
||||
### 修复 —— 安全加固批次:18 条实机复现缺陷(**属被控端变更**)
|
||||
|
||||
> 来源:一轮「静态审查 + 真机实测」的完整缺陷普查(报告 `bug-report-2026-10-13.html`)。
|
||||
> 基线 v2.0.2 / `ad90634` 上 `test:unit` **291/291** + `test:e2e` **30/30** **全绿** ——
|
||||
> 也就是说 **18 条全部落在既有覆盖之外**。分级 **2×P0 / 5×P1 / 10×P2 / 1×P3**。
|
||||
|
||||
#### P0-1 命令注入(`window.focus` 的 `title`)—— 而且它是**静默**的
|
||||
|
||||
原实现把 `JSON.stringify(title)` 直接插进 PowerShell 的**双引号**串。PS 里 `\` 不是转义符,
|
||||
双引号串还会做变量插值与 `$(...)` 展开 —— 于是 `title` 里的 `"; <命令>; "` 被当代码执行。
|
||||
|
||||
真机实测里最险的一点:**接口返回的是「正常业务错误」**(`E_EXECUTION_FAILED: 未找到匹配的窗口`),
|
||||
而 payload 已经把文件创建出来了 —— 调用方从返回值里完全看不出异常。
|
||||
|
||||
- 新增 `apps/agent/src/util/quote.ts`:`psLit` / `shLit` 作**单一真源**
|
||||
(PS 用 `''` 转义、sh 用 `'\''`),另加 `assertSingleQuotedLiteral()` 与裸参前提校验
|
||||
`isSafeBareWindowsPathArg()`(用于 `-File <path>` 这种不能用引号的位置)。
|
||||
- 全仓注入点收敛到 `psLit`/`shLit`:`window.focus` / `uiaFind` / `ocrFind` /
|
||||
`system.ts` 的计划任务参数 / `ps-helper.ts` 的 `-File` 裸拼 等 **14 处**;
|
||||
原有的「双引号」断言同步**翻转**为单引号 —— 旧断言的方向本就是错的(CI 断言会过期且方向可能反)。
|
||||
- 新增 `tests/unit/quote.test.mjs`:既有行为断言,也有**源码形态守卫**
|
||||
(禁止 `${JSON.stringify(` 拼进 PS 模板、禁止再手写 `replace(/'/g,"''")`)。
|
||||
形态守卫带 `stripComments()` —— 否则注释里**讲解漏洞**的文字会被当成犯案。
|
||||
|
||||
#### P0-2 ACL 省略 `clients` → 每条 invoke 崩掉被控端
|
||||
|
||||
`{"acl":{"default_effect":"deny"}}`(没写 `clients`)会让 `aclPolicy.clients.find(...)` 抛错,
|
||||
而这段**落在 `if (hasAcl)` 块之外**、无条件执行 —— 配置少写一个字段,整个被控端即不可用。
|
||||
|
||||
⚠️ 值得记一笔:**静态审查当时判它是「fail-open 放行」,实测是崩溃**(后果更严重)。
|
||||
静态审查必须配实测 —— 这是本轮最直接的一条教训。
|
||||
|
||||
#### 崩溃链的共同根因:没有 `unhandledRejection` 兜底
|
||||
|
||||
`ws.on('message')` 用 `void handleMessage()` 起 Promise 且**没有 `.catch`**,
|
||||
而全仓没有任何 `process.on('unhandledRejection')` —— Node ≥22 遇到未处理的 rejection **直接终止进程**。
|
||||
`verifyNonce` 有 try/catch,而 psk 默认路径走的 `verifyHmac` 没有。于是两条畸形报文就够了:
|
||||
`hello {protocol:123}` 与 `auth {hmac:1}` 都能在**认证之前**把被控端打死。
|
||||
|
||||
- `apps/agent/src/index.ts` 新增进程级兜底:日志每次都打、**审计按 10s 限流**
|
||||
(否则畸形报文可高频刷爆 `audit.log`)。
|
||||
- 明确**不**退出进程:源头已有校验与 `.catch`,可用性优先(取舍写在注释里)。
|
||||
|
||||
#### 其余 15 条
|
||||
|
||||
| 级别 | 缺陷 | 修法 |
|
||||
|---|---|---|
|
||||
| P1 | `clients: []` → **fail-open 全放行** | 判据改为「对象是否存在」而非「数组长度」 |
|
||||
| P1 | 畸形 hello / 畸形 auth → 崩且**免认证** | 控制面报文类型校验 + 上述进程级兜底 |
|
||||
| P1 | macOS `screen.find` 的 text 拼 bash → `$(...)` 实测执行 | 走 `shLit` |
|
||||
| P1 | 控制台审计筛选项与协议 enum 漂移(除 `invoke` 外全 `E_PARAM_INVALID`) | 新增 `packages/protocol/src/audit-types.ts` 作**唯一真源**,manifest enum 由它派生,并加编译期双向守卫 |
|
||||
| P2 | `monitor.start` 的 target 注入;monitor id 路径穿越(读/删) | 走 `psLit` + 统一过 `guardPath` |
|
||||
| P2 | `keys{}` + `client_id:"constructor"` 崩 | 改 `Object.prototype.hasOwnProperty.call`(原型链取值) |
|
||||
| P2 | 事件订阅跨控制端可见/可删;`event.watch` 绕开 `fs_roots` | 有状态资源落 owner + 统一路径白名单 |
|
||||
| P2 | `screen.capture` 的 region 无上界 | `assertRegionSane()`,并**提前到枚举显示器之前**(fail fast) |
|
||||
| P2 | `ipMatchesCidr('8.8.8.8','10.0.0.0/')` → `true`(空掩码被 `Number('') === 0` 当成 `/0`) | 掩码解析加严,补 `/0` 与 `/32` 对照组用例 |
|
||||
| P3 | CLI `--timeout` 静默失效 | `parseArgs` 补声明(`strict:false` 下未声明选项被当 boolean,值掉进 `positionals`,双重危害) |
|
||||
| P3 | 拒绝类事件不写审计 | 补 `request.rejected` / `agent.error` 两类事件 |
|
||||
|
||||
#### 回归
|
||||
|
||||
`test:unit` **312/312**(+21)、`test:e2e` **33/33**(+3 组硬化用例)、`test:rust` **11/11**。
|
||||
e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场景独占端口 + 独立临时 `NODEAGENT_HOME`** ——
|
||||
首版复用了同一端口,结果连上了上一块的残留实例,把对照组误判成「实现有缺陷」。
|
||||
|
||||
### 新增 —— 控制台单实例保护(症状:Windows 端起了两个被控端)
|
||||
|
||||
真机观察到两桩现象:① Windows 上同时跑着**两个被控端**;② **旧的服务端还在运行时,控制台起不来**。
|
||||
根因是同一个:控制台**没有单实例锁**,再启动一次就是第二个进程 → 第二个 `AgentManager`
|
||||
→ 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 `na-gui` 计划任务指向
|
||||
测试部署目录,也在抢同一个端口。
|
||||
|
||||
- 引入 `tauri-plugin-single-instance`(**必须注册为第一个插件**)。
|
||||
- 第二次启动**不是**静默退出,而是「聚焦既有窗口 + 发 `na-second-instance` 事件」让前端刷新状态。
|
||||
这一点是刻意为之:自启动的实例是**无窗口**的,若第二次启动静默退出,用户双击图标只会看到
|
||||
「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(`AGENT_ONLY_FLAG`)时跳过显示。
|
||||
- 端口占用的报错补上「怎么找出占用者」:`netstat -ano | findstr :<port>` /
|
||||
`lsof -nP -iTCP:<port> -sTCP:LISTEN`,并新增一条单测。
|
||||
|
||||
### 修复 —— 发版脚本漏改 Cargo 清单(版本号会分叉)
|
||||
|
||||
`release.mjs patch --dry-run` 实测:只改 **8 个 `package.json` + 1 个 `tauri.conf.json`**,
|
||||
而 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里**同样存着 2.0.2** —— 发版后必然滞留,
|
||||
与 `tauri.conf.json` 悄悄分叉,且**不报错**(Tauri 打包以 `tauri.conf.json` 为准)。
|
||||
|
||||
- 版本写入抽成 `scripts/lib/version-write.mjs`(纯函数、可测),`release.mjs` 调用它。
|
||||
- `Cargo.toml` **只在 `[package]` 段**改(非 package 段里也可能有独占一行的 `version = "…"`);
|
||||
`Cargo.lock` **按 `[[package]]` 块 + crate 名定位**(全文盲替换会改坏 `thiserror 2.0.21` 这类依赖版本)。
|
||||
- `release.mjs` 的「脏文件白名单」改为取自**实际改写结果**(`changed`),不再手写文件名列表 ——
|
||||
手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的(静默推走改动)。
|
||||
- 新增 `tests/unit/version-write.test.mjs`(9 条):在临时目录铺**真实文件**并 `git init` / `git add`
|
||||
(`versionTargets` 走 `git ls-files`,不真进索引就会假绿),跑完**读回文件**断言;
|
||||
另含一条**仓库版本号一致性守卫**,发版漏项会直接变红。
|
||||
- 注入 4 处故障实测:编排漏 Cargo / `Cargo.toml` 全文盲替换 / `Cargo.lock` 按位置盲改 /
|
||||
仓库版本分叉 —— **全部变红**,还原后全绿。
|
||||
|
||||
> ⚠️ 本批**含被控端变更**(`apps/agent/src/**` 十余个文件),按版本纪律,发版时
|
||||
> `pnpm release patch`(→ **v2.0.3**)**必须**执行,否则 `agent.mjs` 会谎报一个并不包含这些修复的版本。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] —— 2026-10-11
|
||||
|
||||
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
|
||||
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
|
||||
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
|
||||
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
|
||||
|
||||
### 新增
|
||||
|
||||
- **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`)
|
||||
—— 控制台的 sidecar 是 `.mjs`,此前只能在装了 Node 的机器上运行(`resolve_node` 回退系统 node)。
|
||||
现由 `scripts/prepare-console.mjs` 把 Node 运行时准备为 Tauri resource 随包分发。
|
||||
实测:`.app` **90.8 MiB**(含运行时 86MB)/`.dmg` **33.78 MiB**(分发件,仍低于 40MB 目标)。
|
||||
作为对照,本机实测 Electron 运行时本体(`Electron Framework.framework`)为 207MB ——
|
||||
即 Tauri + Node 的运行时开销不到 Electron 的一半。
|
||||
- **`apps/console/scripts/verify-bundle.mjs`** —— 验证「打包产物能否自举」:真 spawn 打包内的 node
|
||||
去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。
|
||||
|
||||
### 修复 / 规避(两处实测踩出来的约束)
|
||||
|
||||
- **macOS 上 `strip` 会使 Mach-O 代码签名失效 → 内核直接 `SIGKILL`(exit 137)**,
|
||||
且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。
|
||||
实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
|
||||
故准备脚本在 strip 之后**必须** adhoc 重签(省 20% 体积值得,但这步不能少)。
|
||||
- **Node 运行时不得声明进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**
|
||||
(`tauri-build` 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 `cargo check/test`
|
||||
在 build.rs 阶段直接失败。一律走 `tauri build --config` 注入。
|
||||
|
||||
### 修复 —— 被控端打包(ARM64)
|
||||
|
||||
- **`nodeagent-win-arm64.zip` 里装的其实是 x64 的 `node.exe`**。
|
||||
`pack.mjs` 下载 Node 运行时的缓存文件名是固定的 `node-runtime.zip`(**不含架构**):
|
||||
`release.mjs` 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用
|
||||
→ **两份公开包内的 `node.exe` 字节完全相同**(sha256 同为 `fdddbf45…`,PE Machine = `0x8664`)。
|
||||
后果极隐蔽:该包在 ARM Windows 上**能启动、能 listen**,但 **TLS 握手不完成**
|
||||
(控制端只见 `E_TIMEOUT`)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。
|
||||
修法:① 缓存文件名带上架构(`node-runtime-<arch>.zip`);
|
||||
② 解包后**断言 `node.exe` 的 PE Machine 与 `--arch` 一致**(缓存串包 / 手工塞错文件当场红)。
|
||||
- **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)——
|
||||
此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**,
|
||||
故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。
|
||||
- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)——
|
||||
`release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构,
|
||||
不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽,
|
||||
这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive`
|
||||
(Windows runner 上没有 `unzip`),另留 CLI 自检入口:
|
||||
`node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>`(已注入故障实测会红)。
|
||||
|
||||
### 真机实测(ARM64 Windows 虚拟机)
|
||||
|
||||
- **arm64 包**:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→
|
||||
`system.info` / `status` / `process.list` / `service.list` / `shell.exec` /
|
||||
`audit verify` 全部正常,`arch` 报告为 `arm64`(原生,非模拟层)。
|
||||
- **x64 包(对照,同机同参)**:`connect` 报 `E_TIMEOUT` —— **复现**了「能 listen 但握手不完成」。
|
||||
→ 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
|
||||
- 已知边界:被控端若以 **SYSTEM(会话 0)** 手动启动,`screen.capture` 等图形能力会失败
|
||||
(会话 0 无交互桌面)。正式安装路径不受影响 —— `install.ps1` 的计划任务 principal
|
||||
用的是登录用户 + `LogonType Interactive`,注释已明写「图形能力可用」。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] —— 2026-10-11
|
||||
|
||||
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`control.ps1` 的 `$scheme:` 被当 drive 引用** —— PowerShell **解析期错误 → 整个脚本不可用**
|
||||
(stop / start / status 全废,且只报一行语法错、极易误判为别的问题)。改用 `${scheme}`;
|
||||
并新增守卫扫全仓 `.ps1` 的 `$ident:` 形式(含注入故障验证)。
|
||||
- **连接保活缺 pong 检测** —— 心跳只 `ping()` 而不检查 pong,且 ws 库默认不会因「ping 无 pong」断开;
|
||||
客户端进程已死(TCP 未发 FIN)时,连接会**永久占用 `max_connections` 名额**,
|
||||
叠加「有任务/订阅时清扫保守跳过」的策略 → **会把正常控制端挡在外面**(真机实测把自己锁住了)。
|
||||
现对静默超过 `idle_timeout_ms * 2` 的连接 `terminate()`,并让 pong 也算「活跃」。
|
||||
- **v26 自重启任务清理阈值过粗** —— 原用「早于 1 小时」,频繁 restart(间隔 < 1h)时旧任务照样累积;
|
||||
改为「**早于本 agent 启动时刻**」——语义精确:凡在**我启动之前**运行的任务,必然不是我的宿主。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`scripts/pack.mjs --arch <x64|arm64>`** —— Windows 包分架构。**x64 包在 ARM Windows 上协议层不可用**
|
||||
(能启动、能 listen 8765,但 **TLS 握手不完成** —— Prism 模拟下异常,现象极具误导性);
|
||||
ARM 机器须用 arm64 包。`release.mjs` 随之**同时产出两个架构**,`latest.json` 新增 `zip_arm64` 字段
|
||||
(**只增不减**,旧控制端忽略即可)。
|
||||
- **`scripts/verify-b2.mjs`** —— v23 连接层三防护的真机验证脚本(`idle` / `maxconn` / `ban` 三模式),
|
||||
全部走真实连接而非 mock。
|
||||
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用 Parallels Win11 ARM 补验证的完整清单
|
||||
(含实测结果、能补/不能补矩阵、8 条排查项)。
|
||||
|
||||
### 真机验证状态(本版实测)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| A 组(`verify.mjs` 17 项) | 15 通过 / 0 失败 / 1 跳过 |
|
||||
| v1.5 四项(字符串热键/预设别名/长按/后台投递) | 全过(`route=post` 实测命中 138 窗口) |
|
||||
| v23 三防护 | 三场景全过(封禁 / 上限 / 空闲断开) |
|
||||
| v26 自重启清理 | 验证并改进(阈值改为 agent 启动时刻) |
|
||||
| 锚点端到端 | 全过(含云盘链外留存) |
|
||||
|
||||
## [v2.0.0] —— 2026-10-10
|
||||
|
||||
> 自 `v1.6.0` 起累计 37 个提交。**能力数 43 → 51**,单测 195 → 273,端到端 30。
|
||||
> 之所以直接跳 v2.0.0:两个新方向(语义可断言 / 状态可回看)+ 安全批次四连 + 五处
|
||||
> 只有真机能暴露的严重缺陷修复,量级远超一次 MINOR。
|
||||
>
|
||||
> **兼容性**:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数,
|
||||
> 返回结构只增不减(遵循 `docs/VERSIONING.md` 的四条铁律)。
|
||||
>
|
||||
> **验证状态**:本版发布时被控端整机离线,`v1.5.0` 的预设别名/长按/后台投递/热键序列
|
||||
> 与 `v2.0.0` 的自重启任务清理策略**当时未经真机验证**(后者已在本地单测锁定判定条件)。
|
||||
>
|
||||
> ✅ **2026-10-11 已补齐**:在 Parallels 27 + Win11 ARM 虚拟机上完成真机验证 ——
|
||||
> A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略**验证并改进**
|
||||
> (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。
|
||||
> 并**顺带发现并修复 5 个真问题**(含 `control.ps1` 解析期错误、连接保活缺 pong 检测等)。
|
||||
> 详见 `docs/VM-VERIFICATION.md`。
|
||||
|
||||
### 新增:语义可断言(v1.6.0 / v2.0.0)
|
||||
|
||||
- **`gui.await`** —— 等条件成立再返回。四类条件(`window` / `control` / `process` / `file`)
|
||||
× 两种状态(`present` / `absent`),复用既有能力,零新增平台探测代码。
|
||||
超时是**正常返回**(`satisfied:false` + `note`),不抛异常;轮询期单次异常不视为失败。
|
||||
- **`screen.find` 返回 UIA 属性** —— `enabled` / `value` / `selected` / `toggle`。
|
||||
每项单独 try/catch,**拿不到就留 `null` 绝不猜**(Electron、游戏 UI 常给不出值)。
|
||||
- **`where` 属性谓词** —— 如 `{"enabled":true}`「等一个可用的按钮」,
|
||||
比「等一个叫某名字的按钮」更贴近真实意图。仅 UIA 引擎生效,
|
||||
OCR/图像引擎会忽略并**带回 `note`**,不让调用方误以为过滤过了。
|
||||
- **`any_of` 组合** —— 任一命中即算,命中时回报是哪个子条件。
|
||||
例:`[{window:"安装完成"},{control:"错误"}]` = 「装成功或报错,先出现的算」。
|
||||
- CLI:`nodeagent await`;MCP:`na_await`。
|
||||
|
||||
### 新增:状态可回看(v2.0.0)
|
||||
|
||||
- **`log.query`** —— 日志**在被控端侧过滤**后只回匹配行(`pattern` / `level` / `since` /
|
||||
`offset` / `limit` / `tail`)。流式逐行读,几万行日志也不整份拉回。
|
||||
- **`monitor.start` / `report` / `stop` / `list` / `delete`** —— 定时采样并落盘。
|
||||
四类源:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行)/
|
||||
`metric`(CPU·内存)。**摘要直接给结论**:断了几次、最长断多久、值域。
|
||||
- CLI:`nodeagent monitor` / `log`;MCP:`na_monitor` / `na_log`。
|
||||
|
||||
### 新增:安全加固(v2.0.0)
|
||||
|
||||
- **连接层三防护**:
|
||||
- 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
|
||||
- 连接上限(默认 8)+ 空闲断开(默认 30 分钟;有任务或订阅在跑时保守不踢)
|
||||
- 状态从 `WeakMap` 改 `Map`(需要 size 与遍历),关闭时手动清理
|
||||
- **安装期密钥改 stdin** —— `install.cmd` 用管道喂 `-KeyFromStdin`,密钥不再出现在命令行
|
||||
(同机其他用户无法从进程命令行读到)。
|
||||
- **零信任优先安装** —— 侦测到 `client-acl.json`(含控制端公钥)即走 `ed25519` 并写入
|
||||
`acl.default_effect="deny"`;否则保持 `psk` 但输出**醒目安全告警**(说明 `client_id`
|
||||
自报的固有风险 + 三步切换指引)。
|
||||
- **审计链外部锚定** —— `system.audit.head`(链头可读)+ `system.audit.anchor`(锚点追加落盘)。
|
||||
锚点记四元组 `{entries, head_hash, head_ts, rotated_segments}`:只记哈希会把**日志轮转
|
||||
误判成篡改**。锚点文件只追加 —— 可覆盖的话,攻击者重写链后再刷新锚点即可抹痕。
|
||||
|
||||
### 修复(五处只有真机能暴露的严重缺陷)
|
||||
|
||||
- **`install.cmd` 双击首装即炸** —— `for /f` 调 PowerShell 时 `^|` 转义漏进 PS,导致
|
||||
ParseError。**真实用户第一次安装必然踩到**(PSK.txt 已不在分发包内)。
|
||||
- **`pack.mjs` 三连** —— `spawn node_modules/.bin/esbuild` 在 pnpm 布局下 ENOENT;
|
||||
以及 `zip` / `ls -la` / `du -sh` 在 Windows 上都不存在。已改为 esbuild JS API +
|
||||
平台分流(Windows 用 `Compress-Archive`)+ Node 原生 API。
|
||||
- **`where` 的 PowerShell 注入语法错** —— 连修两轮:JSON 字面量 ✗ → `\"` 反斜杠转义 ✗
|
||||
(PS 5.1 不认)→ **PS 单引号字面量** ✓。症状是「带 where 一律窗口操作失败,不带则正常」。
|
||||
- **`screen.find` 的 where-only 被自己入口校验拦下** —— 只改了一半(`gui.await` 侧放开、
|
||||
`screen.find` 侧没动)。
|
||||
- **`parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记** —— UTC 被当本地时间解析,**整体偏 8 小时**,
|
||||
跨时区日志判断「何时断的」会错(单测抓到)。
|
||||
|
||||
### 修复(其他)
|
||||
|
||||
- **自重启在非管理员身份下失败** —— `-RunLevel Highest` 被系统拒绝(v7 起的既有缺陷)。
|
||||
现改为两级降级:先试 Highest,失败则不指定 Principal(继承当前权限级别)。
|
||||
修复后 `deploy` 首次能一次走通完整闭环。
|
||||
- **`deploy` 备份无限堆积** —— `.bak-<时间戳>` → 固定名 `.bak` + 清理历史(真机 17 → 2 个文件)。
|
||||
- **自重启任务 Running 僵尸** —— 清理条件加「`LastRunTime` 早于 1 小时」余量
|
||||
(不能简单清所有 Running:那会连当前 agent 的宿主一起杀)。
|
||||
- Windows CI 四连:`URL.pathname` 跨平台路径、`shell: cmd` 套多行 PowerShell、
|
||||
5.1 执行的内联脚本含中文、产物包断言方向反了(`PSK.txt` 从「必含」改「不得含」)。
|
||||
|
||||
### 安全 / 隐私
|
||||
|
||||
- 源码与测试里的真机常量脱敏(机器名、内网地址)—— 保留「为什么」的教训,去掉具体实况。
|
||||
- 能力调用新增审计类型 `session.idle_close`。
|
||||
|
||||
### 文档
|
||||
|
||||
- `README` 能力总览补三行、数字同步至 51 项能力 / 41 个 MCP 工具 / 269 单测。
|
||||
- 新增章节:`gui.await` 属性谓词与组合、状态采样、审计链外部锚定。
|
||||
- `docs/SECURITY.md` 新增 §4.7(零信任优先安装)/ §4.8(审计锚定),
|
||||
§5「还没做的」清单**至此全部完成**。
|
||||
- `docs/DEVELOPMENT.md` 能力表补全(此前停在 19 项)。
|
||||
- `docs/RETROSPECTIVE.md` 加「短板已全部解决」对照表(保留原判断不改写)。
|
||||
- `docs/ROADMAP.md` 台账刷新,并新增「真机验证抓到的 8 个真 bug」表。
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] —— 2026-10-07
|
||||
|
||||
### 新增
|
||||
|
||||
- `gui.await` 等待条件能力(第 43 项)。
|
||||
|
||||
### 说明
|
||||
|
||||
- 本版**未经真机验证**即发布(当时被控端离线);相关验证已于 2026-10-09 ~ 10-10 补齐。
|
||||
|
||||
## [v1.5.0] —— 2026-10-07
|
||||
|
||||
### 新增
|
||||
|
||||
- `input.key.press` 参数面扩展:字符串热键(`hotkey: "ctrl+shift+esc"`)、约 50 个预设别名、
|
||||
长按 `hold_ms`、后台定向投递 `route=post` + `target_pid`。
|
||||
- 按键表补齐:左右侧修饰键、小键盘(与数字键分开)、OEM 符号键(解锁 `win+d` / `ctrl++` /
|
||||
`win+.`)、IME 键、浏览器键、系统键。
|
||||
|
||||
### 说明
|
||||
|
||||
- 本版同样**未经真机验证**即发布;2026-10-10 已验证字符串热键与单键投递,其余项待被控端在线后补齐。
|
||||
@@ -27,11 +27,148 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
|
||||
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
|
||||
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
|
||||
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
|
||||
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
|
||||
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
|
||||
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
|
||||
| **GUI 等待** | `gui.await` | 等条件成立再返回(窗口/元素/进程/文件 × 出现/消失);支持**属性谓词 `where`** 与 **`any_of` 组合** |
|
||||
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
|
||||
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
|
||||
|
||||
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
|
||||
## 快捷键(hotkey)控制 —— v1.5.0
|
||||
|
||||
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
|
||||
|
||||
```bash
|
||||
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
|
||||
nodeagent --node win key press "ctrl+shift+esc"
|
||||
nodeagent --node win key press "win+d" # 显示桌面
|
||||
nodeagent --node win key press "alt+tab" # 切换应用
|
||||
|
||||
# ② 热键序列
|
||||
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
|
||||
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
|
||||
|
||||
# ③ 预设语义名(约 50 个,免记键位)
|
||||
nodeagent --node win key press copy # = ctrl+c
|
||||
nodeagent --node win key press task_manager # = ctrl+shift+esc
|
||||
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
|
||||
|
||||
# ④ 老数组形式
|
||||
nodeagent --node win key press ctrl c
|
||||
```
|
||||
|
||||
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
|
||||
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
|
||||
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
|
||||
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
|
||||
|
||||
**进阶参数**:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
|
||||
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
|
||||
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
|
||||
|
||||
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
|
||||
|
||||
| 类别 | 预设名 |
|
||||
|---|---|
|
||||
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
|
||||
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
|
||||
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
|
||||
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
|
||||
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
|
||||
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
|
||||
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
|
||||
|
||||
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
|
||||
|
||||
## 等待条件 gui.await —— v1.6.0
|
||||
|
||||
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
|
||||
|
||||
```bash
|
||||
# 等界面元素出现(最多 30s)
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
|
||||
# 等它消失
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
|
||||
# 等窗口 / 进程 / 文件
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
|
||||
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
|
||||
```
|
||||
|
||||
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
|
||||
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
|
||||
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
|
||||
|
||||
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
|
||||
|
||||
### 属性谓词与组合(v2.0.0)
|
||||
|
||||
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
|
||||
|
||||
```bash
|
||||
# 等一个「可用」的按钮(不指定文字)
|
||||
nodeagent --node win await --condition control --where '{"enabled":true}'
|
||||
# 等值里含「已」的元素
|
||||
nodeagent --node win await --condition control --where '{"value":"*已*"}' --timeout 15000
|
||||
# 组合:装成功 或 报错,先出现的算
|
||||
nodeagent --node win await --any_of '[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]'
|
||||
```
|
||||
|
||||
| 属性 | 来源 | 拿不到时 |
|
||||
|---|---|---|
|
||||
| `enabled` | `IsEnabled` | — |
|
||||
| `value` | `ValuePattern` | `null`(**不猜**) |
|
||||
| `selected` | `SelectionItemPattern` | `null` |
|
||||
| `toggle` | `TogglePattern` | `null` |
|
||||
|
||||
> 属性只在 **UIA 引擎**下可读(Windows)。走 OCR/图像模板时 `where` 被忽略并**带回 `note`**,
|
||||
> 不会让调用方误以为「按属性过滤过了」。macOS 无 UIA 等价物,`where` 不可用(会明确报错提示用 `text`)。
|
||||
|
||||
---
|
||||
|
||||
## 状态采样:日志过滤与定时采样 —— v2.0.0
|
||||
|
||||
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
|
||||
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
|
||||
|
||||
```bash
|
||||
# 日志在被控端侧过滤,只回匹配行(几万行日志也不整份拉回)
|
||||
nodeagent --node win log --path "C:\app\app.log" --level ERROR --limit 20
|
||||
nodeagent --node win log --path "C:\app\app.log" --pattern "timeout|refused" --tail
|
||||
|
||||
# 连续采样 5 分钟,事后回看「断过几次、最长断多久」
|
||||
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
|
||||
nodeagent --node win monitor report probe
|
||||
nodeagent --node win monitor stop probe
|
||||
```
|
||||
|
||||
采样源四类:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行输出)/
|
||||
`metric`(CPU·内存)。样本落 `<数据目录>/monitors/<id>.jsonl`,**摘要直接给结论**。
|
||||
|
||||
---
|
||||
|
||||
## 审计链外部锚定 —— v2.0.0
|
||||
|
||||
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
|
||||
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
|
||||
|
||||
```bash
|
||||
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
|
||||
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
```
|
||||
|
||||
**锚点记四元组** `{entries, head_hash, head_ts, rotated_segments}`,不能只记哈希 ——
|
||||
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
|
||||
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
|
||||
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -42,9 +179,15 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build # 全部 workspace(protocol/client/agent/cli/mcp/hub + 桌面控制台前端)
|
||||
pnpm test:unit # 单元测试(协议纯函数 / 清单守护 / 编码脚本守卫 / 契约守卫)
|
||||
pnpm test:e2e # 端到端(起真实被控端跑全链路)
|
||||
pnpm test:rust # Rust 控制端客户端(需 cargo)
|
||||
```
|
||||
|
||||
> 桌面控制台是 Tauri 应用,**打包**需另进目录:`cd apps/console && pnpm tauri build`。
|
||||
> 仅前端产物则 `pnpm build` 已覆盖。
|
||||
|
||||
### 1. Windows 侧(被控端)
|
||||
|
||||
**方式 A:安装包(推荐,免装 Node.js)**
|
||||
@@ -168,11 +311,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ 控制端 (macOS) │ ◄──── JSON-RPC 2.0 ────► │ 被控端 (Windows) │
|
||||
│ CLI · MCP Server │ over WebSocket │ Agent(开机自启) │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
│ │
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 控制端 (macOS / Windows) │
|
||||
│ ① 桌面控制台(Tauri 2) ② CLI ③ MCP │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
JSON-RPC 2.0 over WebSocket
|
||||
│
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 被控端 Agent(Windows 常驻 / 开机自启) │
|
||||
│ 同时也是「本地被控端」,供本机控制台直连 │
|
||||
└──────────────────────────────────────────┘
|
||||
└──────────── 三种链路可选 ──────────────────────┘
|
||||
① 局域网直连 ② UDP 广播自动发现 ③ Hub 中转(跨网段/公网)
|
||||
```
|
||||
@@ -185,6 +334,66 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
| 审计 | JSONL 落盘 + 轮转 + 参数脱敏 + 查询能力 |
|
||||
| 传输 | TLS(自签证书可用 `--insecure` 跳过校验,鉴权另有保障) |
|
||||
|
||||
### 桌面控制台(Tauri 2)
|
||||
|
||||
`apps/console` —— 跨端图形控制台,**主控与被控一体**:本机也可作为被控端,
|
||||
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
|
||||
|
||||
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
|
||||
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
|
||||
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
|
||||
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
|
||||
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
|
||||
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
|
||||
|
||||
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
|
||||
「简约白」与「高级灰」,侧栏底部一键切换。
|
||||
|
||||
| 页面 | 能力 |
|
||||
|---|---|
|
||||
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
|
||||
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
|
||||
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
|
||||
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
|
||||
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
|
||||
| 进程与服务 | 进程表(CPU/内存/已运行)+ Windows 服务表 |
|
||||
| 软件 | `app.list` / `app.install`(winget 静默安装,超时放大到 10 分钟) |
|
||||
| 文件 | 浏览 / 预览 / 写入(走分块能力,天然支持大文件) |
|
||||
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
|
||||
|
||||
```bash
|
||||
pnpm -r build # 含 console 前端
|
||||
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
|
||||
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
|
||||
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
|
||||
```
|
||||
|
||||
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
|
||||
|
||||
| 架构 | runner | 安装包 | 实测体积 |
|
||||
|---|---|---|---|
|
||||
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
|
||||
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
|
||||
|
||||
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
|
||||
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
|
||||
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
|
||||
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
|
||||
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
|
||||
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
|
||||
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
|
||||
|
||||
> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。
|
||||
|
||||
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
|
||||
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
|
||||
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
|
||||
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
|
||||
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -427,47 +636,56 @@ nodeagent/
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── docs/ # 项目文档(含 PRD)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
| 阶段 | 目标 | 状态 |
|
||||
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
|
||||
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
|
||||
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
|
||||
|
||||
| 阶段(开发批次) | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
|
||||
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
|
||||
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
|
||||
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [产品需求文档(PRD)](./docs/PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
|
||||
|
||||
## 关键决策
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.4.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -2,6 +2,13 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { agentDir } from './config.js';
|
||||
import {
|
||||
judgeAnchors,
|
||||
parseAnchorsFromJsonl,
|
||||
AUDIT_TYPES,
|
||||
type AnchorComparison,
|
||||
type AnchorRecord,
|
||||
} from '@nodeagent/protocol';
|
||||
|
||||
export type AuditType =
|
||||
| 'agent.start'
|
||||
@@ -17,7 +24,39 @@ export type AuditType =
|
||||
/** v21:来源不在 allow_from 被拒 */
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update';
|
||||
| 'agent.update'
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close'
|
||||
/**
|
||||
* v2.0.3:**被拒的请求**统一留痕。
|
||||
*
|
||||
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
|
||||
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
|
||||
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
|
||||
*/
|
||||
| 'request.rejected'
|
||||
/**
|
||||
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
|
||||
*
|
||||
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
|
||||
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
|
||||
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
|
||||
* 混在同一类里,事后无法区分。
|
||||
*/
|
||||
| 'agent.error';
|
||||
|
||||
/**
|
||||
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
|
||||
* `AUDIT_TYPES` **双向一致**。
|
||||
*
|
||||
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
|
||||
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
|
||||
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
|
||||
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
|
||||
*/
|
||||
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
||||
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
|
||||
void _auditTypeInSyncWithProtocol;
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
export interface AuditEntry {
|
||||
@@ -332,3 +371,120 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
|
||||
collected.sort((a, b) => a.ts - b.ts);
|
||||
return { entries: collected.slice(-limit), total, file: p };
|
||||
}
|
||||
|
||||
|
||||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||||
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
|
||||
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
|
||||
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
|
||||
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
|
||||
//
|
||||
// ## 记什么(关键:不能只记哈希)
|
||||
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
|
||||
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
|
||||
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
|
||||
|
||||
export interface AuditHead {
|
||||
/** 当前链可见条目数(含轮转段) */
|
||||
entries: number;
|
||||
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
|
||||
head_hash: string | null;
|
||||
/** 末条时间戳 */
|
||||
head_ts: number | null;
|
||||
/** 存在的轮转段数(.log.1 … .log.20) */
|
||||
rotated_segments: number;
|
||||
file: string;
|
||||
file_bytes: number;
|
||||
computed_at: number;
|
||||
}
|
||||
|
||||
/** 计算当前审计链的头部信息(只读)。 */
|
||||
export function computeAuditHead(): AuditHead {
|
||||
const p = auditFilePath();
|
||||
let rotated = 0;
|
||||
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
|
||||
|
||||
const entries = readAuditAll();
|
||||
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
|
||||
let fileBytes = 0;
|
||||
try {
|
||||
fileBytes = statSync(p).size;
|
||||
} catch {
|
||||
/* 文件可能尚不存在(还没写过审计) */
|
||||
}
|
||||
return {
|
||||
entries: entries.length,
|
||||
head_hash: last?.hash ?? null,
|
||||
head_ts: last?.ts ?? null,
|
||||
rotated_segments: rotated,
|
||||
file: p,
|
||||
file_bytes: fileBytes,
|
||||
computed_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||||
|
||||
/** 锚点文件路径(默认与审计同目录)。 */
|
||||
export function anchorFilePath(custom?: string): string {
|
||||
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
|
||||
}
|
||||
|
||||
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
|
||||
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||||
file: string;
|
||||
record: AnchorRecord;
|
||||
total_lines: number;
|
||||
} {
|
||||
const head = computeAuditHead();
|
||||
const file = anchorFilePath(opts.path);
|
||||
const rec: AnchorRecord = {
|
||||
ts: Date.now(),
|
||||
entries: head.entries,
|
||||
head_hash: head.head_hash,
|
||||
head_ts: head.head_ts,
|
||||
rotated_segments: head.rotated_segments,
|
||||
...(opts.note ? { note: opts.note } : {}),
|
||||
};
|
||||
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
|
||||
let total = 0;
|
||||
try {
|
||||
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
|
||||
} catch {
|
||||
total = 1;
|
||||
}
|
||||
return { file, record: rec, total_lines: total };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
*
|
||||
* 判定逻辑(三条):
|
||||
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
|
||||
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
|
||||
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
|
||||
*/
|
||||
export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||||
const file = anchorFilePath(customPath);
|
||||
if (!existsSync(file)) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||||
}
|
||||
let anchors: AnchorRecord[] | null = null;
|
||||
try {
|
||||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||||
}
|
||||
if (!anchors) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||||
return judgeAnchors(computeAuditHead(), anchors);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -108,7 +109,9 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
const target = id ? `--id ${quote(id)} --exact` : `${quote(pkg)}`;
|
||||
// ⚠️ package / id 来自控制端,且命令经 `powershell.exe -Command` 执行
|
||||
// → 必须用 PS 单引号字面量(旧的 `"…"` + `\"` 写法可被 `;` / `$(…)` 逃逸,同类 P0)。
|
||||
const target = id ? `--id ${psLit(id)} --exact` : `${psLit(pkg)}`;
|
||||
const flags = [
|
||||
silent ? '--silent' : '',
|
||||
'--accept-package-agreements',
|
||||
@@ -134,10 +137,6 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return `"${s.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function extractVersion(output: string): string | null {
|
||||
const m = /(\d+\.\d+(?:\.\d+)*(?:-\w+)?)/.exec(output);
|
||||
return m ? m[1]! : null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -132,7 +133,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
|
||||
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
|
||||
async function runMac(script: string): Promise<string> {
|
||||
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
|
||||
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
|
||||
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
|
||||
const r = await execCommand({ command: `osascript -e ${shLit(script)}`, timeoutMs: 15_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* gui.await —— 等待条件成立(v1.6.0)。
|
||||
*
|
||||
* ## 为什么需要它(复盘三大短板之「语义」)
|
||||
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
|
||||
* 问题不是"找不到元素"(screen.find 已解决),而是**操作之后无法断言界面到了预期状态**。
|
||||
* 有了本能力,"点了安装 → 等待『完成』按钮出现" 从"睡 3 秒碰运气"变成**可验证的等待**:
|
||||
*
|
||||
* await control(text:"完成", timeout:30s) → click(found) → await absent(text:"安装中")
|
||||
*
|
||||
* ## 实现要点
|
||||
* 不写任何新的平台探测代码 —— 四个条件全部**复用既有能力**(window.list /
|
||||
* screen.find / process.list / fs.stat),因此 Windows/macOS 行为与它们完全一致。
|
||||
* 轮询期间异常一律视为"尚未命中"并记录 last_error,避免单次抖动整句失败;
|
||||
* 但超时未命中时会把它带回,方便判断是"真的没有"还是"每次都在报错"。
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { windowList } from './window.js';
|
||||
import { screenFind } from './window.js';
|
||||
import { processList } from './system.js';
|
||||
import { fsStat } from './fs.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
type Condition = 'window' | 'control' | 'process' | 'file';
|
||||
|
||||
const CONDITIONS: readonly Condition[] = ['window', 'control', 'process', 'file'];
|
||||
|
||||
/** 判断一次底层调用是否"命中"(兼容各能力不同的返回形态)。 */
|
||||
function isHit(result: unknown): boolean {
|
||||
if (result === null || result === undefined) return false;
|
||||
if (typeof result === 'boolean') return result;
|
||||
const r = result as Record<string, unknown>;
|
||||
if (r['found'] === true) return true;
|
||||
if (r['exists'] === true) return true;
|
||||
for (const key of ['windows', 'processes', 'matches', 'elements']) {
|
||||
const arr = r[key];
|
||||
if (Array.isArray(arr) && arr.length > 0) return true;
|
||||
}
|
||||
if (Array.isArray(result) && result.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 按条件组装底层调用的参数(只透传该条件支持的字段)。 */
|
||||
function buildInnerArgs(condition: Condition, args: Args): Args {
|
||||
const num = (k: string): number | undefined => {
|
||||
const v = args[k];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
};
|
||||
switch (condition) {
|
||||
case 'window': {
|
||||
const inner: Args = {};
|
||||
const title = (args['title'] as string | undefined) ?? (args['pattern'] as string | undefined);
|
||||
if (title) inner['title_pattern'] = title;
|
||||
const limit = num('limit');
|
||||
if (limit !== undefined) inner['limit'] = limit;
|
||||
return inner;
|
||||
}
|
||||
case 'control': {
|
||||
// 透传 screen.find 的全部相关参数(method=image 时也一样走轮询)
|
||||
const inner: Args = {};
|
||||
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
|
||||
if (args[k] !== undefined) inner[k] = args[k];
|
||||
}
|
||||
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
|
||||
const where = args['where'];
|
||||
if (where !== undefined) {
|
||||
if (typeof where !== 'object' || where === null || Array.isArray(where)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'where 必须是对象,如 {"enabled":true}');
|
||||
}
|
||||
const allowed = ['enabled', 'selected', 'value', 'toggle'];
|
||||
const bad = Object.keys(where as Record<string, unknown>).filter((k) => !allowed.includes(k));
|
||||
if (bad.length > 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `where 只支持 ${allowed.join('/')}`, { got: bad });
|
||||
}
|
||||
inner['where'] = where;
|
||||
}
|
||||
if (inner['text'] === undefined && where === undefined) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'control 条件需要 text 或 where 之一');
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
case 'process': {
|
||||
const inner: Args = {};
|
||||
const name = args['process'] as string | undefined;
|
||||
if (name) inner['filter'] = { name_pattern: name };
|
||||
const limit = num('limit');
|
||||
if (limit !== undefined) inner['limit'] = limit;
|
||||
return inner;
|
||||
}
|
||||
case 'file': {
|
||||
const p = args['path'] as string | undefined;
|
||||
if (!p) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'condition=file 需要 path', {
|
||||
hint: '如 {"condition":"file","path":"C:\\\\Windows\\\\Temp\\\\setup.log"}',
|
||||
});
|
||||
}
|
||||
return { path: p };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 条件 → 底层能力(本进程内直接调用,省去 RPC 往返开销)。 */
|
||||
function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
|
||||
switch (condition) {
|
||||
case 'window':
|
||||
return (a) => windowList(a);
|
||||
case 'control':
|
||||
return (a) => screenFind(a);
|
||||
case 'process':
|
||||
return (a) => processList(a);
|
||||
case 'file':
|
||||
return (a) => fsStat(a);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
*
|
||||
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
|
||||
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
|
||||
* 而通用布尔表达式会把 schema 与求值都复杂化,收益不成比例。
|
||||
* 若真需要"两个都必须成立",上层连调两次 await 更直白。
|
||||
*/
|
||||
function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; label: string }> {
|
||||
const raw = args['any_of'];
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 需为非空数组,每项为一个条件对象');
|
||||
}
|
||||
if (raw.length > 8) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 最多 8 个条件', { got: raw.length });
|
||||
}
|
||||
return raw.map((item, i) => {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const condition = obj['condition'] as Condition | undefined;
|
||||
if (!condition || !CONDITIONS.includes(condition)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `any_of[${i}].condition 不支持: ${String(condition)}`, {
|
||||
allowed: CONDITIONS,
|
||||
});
|
||||
}
|
||||
// 每项复用主条件的构造逻辑(同等校验),顶层键名直接透传
|
||||
const inner = buildInnerArgs(condition, obj);
|
||||
const label =
|
||||
condition === 'control' ? String(obj['text'] ?? JSON.stringify(obj['where'] ?? ''))
|
||||
: condition === 'window' ? String(obj['title'] ?? obj['pattern'] ?? '')
|
||||
: condition === 'process' ? String(obj['process'] ?? '')
|
||||
: String(obj['path'] ?? '');
|
||||
return { condition, inner, label };
|
||||
});
|
||||
}
|
||||
|
||||
export async function guiAwait(args: Args): Promise<unknown> {
|
||||
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
|
||||
const condition = args['condition'] as Condition | undefined;
|
||||
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
|
||||
allowed: CONDITIONS,
|
||||
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
|
||||
});
|
||||
}
|
||||
const state = (args['state'] as string | undefined) ?? 'present';
|
||||
if (state !== 'present' && state !== 'absent') {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 state: ${state}`, {
|
||||
allowed: ['present', 'absent'],
|
||||
});
|
||||
}
|
||||
const timeoutMs = Math.max(0, Math.min(60_000, (args['timeout_ms'] as number | undefined) ?? 5000));
|
||||
const intervalMs = Math.max(50, Math.min(5000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
let lastHit = false;
|
||||
let lastError: string | null = null;
|
||||
|
||||
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
|
||||
if (anyOf) {
|
||||
for (;;) {
|
||||
attempts += 1;
|
||||
let hitLabel: string | null = null;
|
||||
lastError = null;
|
||||
for (const c of anyOf) {
|
||||
try {
|
||||
const r = await innerProbe(c.condition)(c.inner);
|
||||
if (isHit(r)) { hitLabel = c.label; break; }
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
const satisfied = (state === 'present' ? hitLabel !== null : hitLabel === null) as boolean;
|
||||
if (satisfied) {
|
||||
return {
|
||||
satisfied: true,
|
||||
condition: 'any_of',
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: state,
|
||||
hit: hitLabel ?? undefined,
|
||||
note: hitLabel ? `命中:${hitLabel}` : '所有子条件均已消失',
|
||||
};
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
return {
|
||||
satisfied: false,
|
||||
condition: 'any_of',
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: lastHit ? 'present' : 'absent',
|
||||
last_error: lastError ?? undefined,
|
||||
note: `等待 ${timeoutMs}ms,${anyOf.length} 个条件均未${state === 'present' ? '命中' : '消失'}`,
|
||||
};
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// 能走到这里说明没走 any_of 分支,而进入函数时的校验已保证 condition 合法;
|
||||
// TS 无法跨分支收窄,这里显式断言一次(buildInnerArgs / innerProbe 内部还会各自校验)。
|
||||
const single = condition as Condition;
|
||||
const innerArgs = buildInnerArgs(single, args);
|
||||
const probe = innerProbe(single);
|
||||
|
||||
for (;;) {
|
||||
attempts += 1;
|
||||
try {
|
||||
const result = await probe(innerArgs);
|
||||
lastHit = isHit(result);
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
// 轮询期异常不代表条件不成立(可能正忙于启动)——记下再试
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
lastHit = false;
|
||||
}
|
||||
const satisfied = state === 'present' ? lastHit : !lastHit;
|
||||
if (satisfied) {
|
||||
return {
|
||||
satisfied: true,
|
||||
condition,
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: state,
|
||||
note:
|
||||
state === 'absent'
|
||||
? '条件已消失(连续一次未命中即视为消失;如需更稳可加大 interval_ms)'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
return {
|
||||
satisfied: false,
|
||||
condition,
|
||||
state,
|
||||
elapsed_ms: Date.now() - startedAt,
|
||||
attempts,
|
||||
last_seen: lastHit ? 'present' : 'absent',
|
||||
last_error: lastError ?? undefined,
|
||||
note:
|
||||
state === 'present'
|
||||
? `等待 ${timeoutMs}ms 仍未出现${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`
|
||||
: `等待 ${timeoutMs}ms 仍未消失${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`,
|
||||
};
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { agentDir } from '../config.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
@@ -317,7 +318,15 @@ async function ensureOcrBin(): Promise<string> {
|
||||
export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
assertDarwin('screen.find');
|
||||
const text = args['text'] as string;
|
||||
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
|
||||
if (!text) {
|
||||
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
|
||||
// 明确说清原因,别让调用方以为是参数写错。
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
|
||||
{ platform: 'darwin', engine: 'ocr' },
|
||||
);
|
||||
}
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
@@ -336,7 +345,7 @@ export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
try {
|
||||
for (;;) {
|
||||
const cap = await execCommand({
|
||||
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
|
||||
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (cap.exit_code !== 0) {
|
||||
@@ -351,8 +360,10 @@ export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
const scale =
|
||||
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
|
||||
|
||||
// ⚠️ `text` 来自控制端,`bin`/`shot` 是内部路径 —— 一律 shLit 包成 shell 单引号字面量。
|
||||
// 曾用 JSON.stringify(双引号)→ `$(touch /tmp/p)` 会被 bash 执行(P1 D-01,实测复现)。
|
||||
const r = await execCommand({
|
||||
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
|
||||
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
openSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
@@ -26,24 +27,70 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
let allowedRoots: string[] = [];
|
||||
|
||||
export function setFsRoots(roots: string[] | undefined): void {
|
||||
allowedRoots = (roots ?? []).map((r) => resolve(r));
|
||||
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
|
||||
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
|
||||
}
|
||||
|
||||
function guardPath(input: string): string {
|
||||
/**
|
||||
* 尽力取真实路径(解析符号链接)。
|
||||
*
|
||||
* - 路径存在 → 直接 `realpathSync`
|
||||
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
|
||||
*
|
||||
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
|
||||
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
|
||||
*/
|
||||
function realPathBestEffort(abs: string): string {
|
||||
try {
|
||||
return realpathSync.native(abs);
|
||||
} catch {
|
||||
/* 继续向上找 */
|
||||
}
|
||||
const parts: string[] = [];
|
||||
let cur = abs;
|
||||
for (;;) {
|
||||
const parent = dirname(cur);
|
||||
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
|
||||
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
|
||||
try {
|
||||
const base = realpathSync.native(parent);
|
||||
return parts.length > 0 ? join(base, ...parts) : base;
|
||||
} catch {
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径白名单守卫(v5)。
|
||||
*
|
||||
* ⚠️ 两道防线,缺一不可:
|
||||
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
|
||||
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
|
||||
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
|
||||
* (Windows 文件系统不区分大小写)。
|
||||
*/
|
||||
export function guardPath(input: string): string {
|
||||
if (typeof input !== 'string' || input.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
|
||||
}
|
||||
const abs = resolve(input);
|
||||
if (allowedRoots.length === 0) return abs;
|
||||
|
||||
const ok = allowedRoots.some((root) => {
|
||||
if (abs === root) return true;
|
||||
const real = realPathBestEffort(abs);
|
||||
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
|
||||
const target = norm(real);
|
||||
|
||||
const ok = allowedRoots.some((rootRaw) => {
|
||||
const root = norm(rootRaw);
|
||||
if (target === root) return true;
|
||||
const prefix = root.endsWith(sep) ? root : root + sep;
|
||||
return abs.startsWith(prefix);
|
||||
return target.startsWith(prefix);
|
||||
});
|
||||
if (!ok) {
|
||||
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
|
||||
allowed_roots: allowedRoots,
|
||||
resolved: real,
|
||||
});
|
||||
}
|
||||
return abs;
|
||||
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
|
||||
if (append) {
|
||||
appendFileSync(p, buf);
|
||||
} else {
|
||||
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
|
||||
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
|
||||
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
|
||||
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
|
||||
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
|
||||
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
writeFileSync(tmp, buf);
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CapabilityNames } from '@nodeagent/protocol';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, agentRestart } from './system.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
|
||||
import { appList, appInstall } from './app.js';
|
||||
import { screenInfo, screenCapture } from './screen.js';
|
||||
import { windowList, windowFocus, screenFind } from './window.js';
|
||||
@@ -13,10 +13,21 @@ import { audioGet, audioSet } from './audio.js';
|
||||
import { agentUpdate } from './selfupdate.js';
|
||||
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
|
||||
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
|
||||
import { guiAwait } from './await.js';
|
||||
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
|
||||
|
||||
export interface HandlerContext {
|
||||
/** 连接标识(事件订阅归属 / 断开清理) */
|
||||
owner: string;
|
||||
/**
|
||||
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
|
||||
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
|
||||
*
|
||||
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
|
||||
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
|
||||
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
|
||||
*/
|
||||
clientId?: string;
|
||||
/** 向该连接推送事件 */
|
||||
emit: (evt: unknown) => void;
|
||||
}
|
||||
@@ -52,10 +63,22 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.MouseDrag]: mouseDrag,
|
||||
[CapabilityNames.KeyType]: keyType,
|
||||
[CapabilityNames.KeyPress]: keyPress,
|
||||
// v1.6.0 等待条件(把盲试变为可断言)
|
||||
[CapabilityNames.GuiAwait]: guiAwait,
|
||||
// v2.0.0 状态采样(方向二)
|
||||
[CapabilityNames.LogQuery]: logQuery,
|
||||
[CapabilityNames.MonitorStart]: monitorStart,
|
||||
[CapabilityNames.MonitorReport]: monitorReport,
|
||||
[CapabilityNames.MonitorStop]: monitorStop,
|
||||
[CapabilityNames.MonitorList]: monitorList,
|
||||
[CapabilityNames.MonitorDelete]: monitorDelete,
|
||||
// v3+ 审计
|
||||
[CapabilityNames.AuditList]: auditList,
|
||||
// v11 审计防篡改
|
||||
[CapabilityNames.AuditVerify]: auditVerify,
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// v13 成功指标
|
||||
[CapabilityNames.Metrics]: metricsReport,
|
||||
// v5 文件传输
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
|
||||
*
|
||||
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
|
||||
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
|
||||
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
|
||||
export const VK_MAP: Record<string, number> = {
|
||||
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
|
||||
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
|
||||
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
capslock: 0x14, printscreen: 0x2c,
|
||||
// 系统/编辑键补充
|
||||
pause: 0x13, break: 0x13,
|
||||
apps: 0x5d, menu: 0x5d,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
|
||||
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
|
||||
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
|
||||
|
||||
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
|
||||
//
|
||||
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
|
||||
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
|
||||
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
|
||||
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
|
||||
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
|
||||
media_stop: 0xb2, stop: 0xb2,
|
||||
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
|
||||
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
|
||||
media_fast_forward: 0xb4, fast_forward: 0xb4,
|
||||
media_rewind: 0xb7, rewind: 0xb7,
|
||||
media_eject: 0xb2,
|
||||
volume_mute: 0xad, mute: 0xad,
|
||||
volume_down: 0xae,
|
||||
volume_up: 0xaf,
|
||||
|
||||
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
|
||||
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
|
||||
browser_favorites: 0xab, browser_home: 0xac,
|
||||
|
||||
// ---- 左右侧修饰键(v1.5.0)----
|
||||
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
|
||||
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
|
||||
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
|
||||
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
|
||||
|
||||
// ---- 小键盘(v1.5.0)----
|
||||
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
|
||||
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
|
||||
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
|
||||
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
|
||||
numlock: 0x90, scrolllock: 0x91,
|
||||
numpad_enter: 0x0d,
|
||||
|
||||
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
|
||||
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
|
||||
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
|
||||
oem_1: 0xba, // : ;
|
||||
oem_2: 0xbf, // / ?
|
||||
oem_3: 0xc0, // ` ~
|
||||
oem_4: 0xdb, // [ {
|
||||
oem_5: 0xdc, // \ |
|
||||
oem_6: 0xdd, // ] }
|
||||
oem_7: 0xde, // ' "
|
||||
oem_8: 0xdf,
|
||||
oem_minus: 0xbd, // - _
|
||||
oem_plus: 0xbb, // + =
|
||||
oem_comma: 0xbc, // , <
|
||||
oem_period: 0xbe,// . >
|
||||
|
||||
// ---- 系统/IME 杂项(v1.5.0)----
|
||||
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
|
||||
separator: 0x6c, oem_clear: 0xfe,
|
||||
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
|
||||
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
|
||||
};
|
||||
|
||||
/** 键名 → 虚拟键码。 */
|
||||
export function toVk(key: string): number {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
|
||||
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
|
||||
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
|
||||
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体键名 → APPCOMMAND 取值。
|
||||
*
|
||||
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
|
||||
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
|
||||
*
|
||||
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
|
||||
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
|
||||
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
|
||||
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
|
||||
*/
|
||||
export const MEDIA_COMMANDS: Record<string, number> = {
|
||||
media_play: 14, play: 14,
|
||||
media_pause: 1, pause: 1,
|
||||
media_play_pause: 12, play_pause: 12,
|
||||
media_stop: 15, stop: 15,
|
||||
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
|
||||
media_next: 7, next_track: 7, next: 7,
|
||||
media_fast_forward: 3, fast_forward: 3,
|
||||
media_rewind: 4, rewind: 4,
|
||||
media_close: 10, close: 10,
|
||||
};
|
||||
|
||||
export interface PressBodyOptions {
|
||||
/** 序列中相邻和弦之间的间隔毫秒 */
|
||||
gapMs: number;
|
||||
/** 按下后保持毫秒数(长按) */
|
||||
holdMs: number;
|
||||
/** route=post 时的目标进程 PID */
|
||||
targetPid: number;
|
||||
/** 投放路由 */
|
||||
route: 'foreground' | 'post';
|
||||
}
|
||||
|
||||
/** 判断整条序列是否都是媒体键。 */
|
||||
export function isAllMedia(chords: string[][]): boolean {
|
||||
return (
|
||||
chords.length > 0 &&
|
||||
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成媒体键(WM_APPCOMMAND)投放脚本。
|
||||
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
|
||||
*/
|
||||
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
const pid = Math.max(0, Math.trunc(opts.targetPid));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
|
||||
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前台注入(SendInput Vk)脚本。
|
||||
*
|
||||
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
|
||||
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
|
||||
*/
|
||||
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const chord = chords[i]!;
|
||||
if (chord.length === 0) continue;
|
||||
const vks = chord.map(toVk);
|
||||
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
|
||||
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
|
||||
// 逆序释放,保证组合键正确
|
||||
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成后台定向投递(PostMessage)脚本。
|
||||
*
|
||||
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
|
||||
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
|
||||
*/
|
||||
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
|
||||
const body: string[] = [];
|
||||
const pid = Math.max(1, Math.trunc(opts.targetPid));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const vks = chords[i]!.map(toVk);
|
||||
if (vks.length === 0) continue;
|
||||
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
|
||||
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
|
||||
// 逆序释放
|
||||
const rel = [...vks].reverse().join(', ');
|
||||
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
|
||||
if (i < chords.length - 1 && opts.gapMs > 0) {
|
||||
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
|
||||
import {
|
||||
isAllMedia,
|
||||
buildMediaBody,
|
||||
buildForegroundBody,
|
||||
buildPostBody,
|
||||
} from './input-script.js';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -128,22 +135,116 @@ public static class NAInput {
|
||||
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
|
||||
SendInput(1, u, SIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
|
||||
///
|
||||
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
|
||||
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
|
||||
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
|
||||
///
|
||||
/// 用途:远程控制主流媒体播放器(音乐 / 视频)或浏览器视频,且**窗口最小化
|
||||
/// 或被全屏独占应用遮挡时依然有效**(实测:精简模式的播放器被全屏游戏压住时该通道可用)。
|
||||
/// </summary>
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
private delegate bool EnumProc(IntPtr h, IntPtr p);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
|
||||
/// <summary>
|
||||
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
|
||||
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
|
||||
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
|
||||
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
|
||||
/// (部分播放器会创建数十个辅助窗口,只投主窗口常常无效)。
|
||||
/// </summary>
|
||||
public static int MediaCommand(int appCommand, int targetPid) {
|
||||
int sent = 0;
|
||||
if (targetPid == 0) {
|
||||
// HWND_BROADCAST:广播给所有顶层窗口
|
||||
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
|
||||
return sent;
|
||||
}
|
||||
var handles = new System.Collections.Generic.List<IntPtr>();
|
||||
EnumWindows(delegate(IntPtr h, IntPtr p) {
|
||||
uint wpid;
|
||||
GetWindowThreadProcessId(h, out wpid);
|
||||
if (wpid == (uint)targetPid) handles.Add(h);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
foreach (var h in handles) {
|
||||
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
///
|
||||
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
|
||||
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
|
||||
///
|
||||
/// ⚠️ 已知局限(务必告知调用方):
|
||||
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
|
||||
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
|
||||
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
|
||||
/// </summary>
|
||||
public static int PostChord(ushort[] vks, int targetPid, bool up) {
|
||||
int sent = 0;
|
||||
if (vks == null || vks.Length == 0) return 0;
|
||||
var handles = new System.Collections.Generic.List<IntPtr>();
|
||||
EnumWindows(delegate(IntPtr h, IntPtr p) {
|
||||
uint wpid;
|
||||
GetWindowThreadProcessId(h, out wpid);
|
||||
if (wpid == (uint)targetPid) handles.Add(h);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
uint msg = up ? 0x0101u : 0x0100u;
|
||||
foreach (var h in handles) {
|
||||
foreach (var vk in vks) {
|
||||
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
}`;
|
||||
|
||||
/** 组装:加类型定义 + 具体操作。 */
|
||||
function psScript(body: string[]): string {
|
||||
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
|
||||
'\n',
|
||||
);
|
||||
/**
|
||||
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
|
||||
*
|
||||
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
|
||||
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
|
||||
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
|
||||
*/
|
||||
export function buildInputPsScript(body: string[]): string {
|
||||
return [
|
||||
`$ErrorActionPreference = 'Stop'`,
|
||||
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
|
||||
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
|
||||
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
|
||||
// 请求 (100,100) 实际落到 (200,200),请求 (552,1489) 实际落到 (1104,2019)。
|
||||
PS_DPI_AWARE,
|
||||
`$code = @'`,
|
||||
CS_CODE,
|
||||
`'@`,
|
||||
`Add-Type -TypeDefinition $code`,
|
||||
...body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
|
||||
const r = await execCommand({ command: psScript(body), timeoutMs });
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 400),
|
||||
});
|
||||
}
|
||||
return r.stdout ?? '';
|
||||
}
|
||||
|
||||
// ---------------- 鼠标 ----------------
|
||||
@@ -201,9 +302,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
await runPs(body);
|
||||
|
||||
// 返回实际点击位置
|
||||
// 返回实际点击位置。
|
||||
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
|
||||
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
|
||||
const pos = await execCommand({
|
||||
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
|
||||
@@ -256,29 +359,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
|
||||
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
|
||||
}
|
||||
|
||||
// ---------------- 键盘 ----------------
|
||||
const VK_MAP: Record<string, number> = {
|
||||
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
|
||||
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
|
||||
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
capslock: 0x14, printscreen: 0x2c,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
|
||||
};
|
||||
|
||||
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
|
||||
function toVk(key: string): number {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
|
||||
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
|
||||
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
|
||||
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function keyType(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.type');
|
||||
requireWindows('input.key.type');
|
||||
@@ -303,35 +383,96 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.press');
|
||||
requireWindows('input.key.press');
|
||||
|
||||
// v10.2:支持三种语义
|
||||
// 1) keys —— 单个和弦(如 ["ctrl","c"])
|
||||
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
|
||||
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
|
||||
const sequence = args['sequence'] as string[][] | undefined;
|
||||
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
|
||||
const keys = args['keys'] as string[] | undefined;
|
||||
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
|
||||
let expanded;
|
||||
try {
|
||||
expanded = expandChords({
|
||||
hotkey: args['hotkey'] as string | undefined,
|
||||
hotkeys: args['hotkeys'] as string[] | undefined,
|
||||
preset: args['preset'] as string | undefined,
|
||||
presets: args['presets'] as string[] | undefined,
|
||||
keys: args['keys'] as string[] | undefined,
|
||||
sequence: args['sequence'] as string[][] | undefined,
|
||||
repeat: args['repeat'] as number | undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const chords = expanded.chords;
|
||||
const presets = expanded.presets;
|
||||
|
||||
const chords: string[][] = sequence?.length
|
||||
? sequence
|
||||
: keys?.length
|
||||
? Array.from({ length: repeat }, () => keys)
|
||||
: [];
|
||||
if (chords.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
|
||||
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
|
||||
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
|
||||
const route = (args['route'] as string | undefined) ?? 'foreground';
|
||||
if (route !== 'foreground' && route !== 'post') {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
|
||||
}
|
||||
const pid = (args['target_pid'] as number) ?? 0;
|
||||
if (route === 'post' && pid <= 0) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
|
||||
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
|
||||
);
|
||||
}
|
||||
|
||||
const body: string[] = [];
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
|
||||
for (let i = 0; i < chords.length; i += 1) {
|
||||
const chord = chords[i]!;
|
||||
if (chord.length === 0) continue;
|
||||
const vks = chord.map(toVk);
|
||||
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
|
||||
// 逆序释放,保证组合键正确
|
||||
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
|
||||
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
|
||||
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
|
||||
//
|
||||
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
|
||||
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
|
||||
const allMedia = isAllMedia(chords);
|
||||
if (allMedia) {
|
||||
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
|
||||
// 只投主窗口常常无效 —— 实测踩过);否则广播。
|
||||
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
|
||||
await runPs(mediaBody);
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
channel: 'appcommand',
|
||||
target_pid: pid || null,
|
||||
};
|
||||
}
|
||||
await runPs(body);
|
||||
|
||||
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
if (route === 'post') {
|
||||
let sent = 0;
|
||||
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
|
||||
let total = 0;
|
||||
for (const line of out.split('\n')) {
|
||||
const m = /(\d+)/.exec(line.trim());
|
||||
if (m) total += Number(m[1]);
|
||||
}
|
||||
sent = total;
|
||||
return {
|
||||
pressed: true,
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
channel: 'postmessage',
|
||||
route,
|
||||
target_pid: pid,
|
||||
sent_windows: sent,
|
||||
note:
|
||||
sent === 0
|
||||
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
|
||||
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
|
||||
'无效时改用 route=foreground 并确保目标窗口可见',
|
||||
};
|
||||
}
|
||||
|
||||
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
|
||||
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
|
||||
return {
|
||||
@@ -339,5 +480,9 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
keys: chords.length === 1 ? chords[0] : undefined,
|
||||
chords,
|
||||
times: chords.length,
|
||||
presets,
|
||||
via: expanded.via,
|
||||
hold_ms: holdMs || undefined,
|
||||
route,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* log.query(v2.0.0,方向二「状态」第一块)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
|
||||
* 几万行以上。`fs.read` 虽有 8MB 上限,但"整份拉回再筛"既慢又占带宽 ——
|
||||
* 过滤必须发生在 **Windows 侧**。
|
||||
*
|
||||
* ## 实现要点
|
||||
* - 流式逐行读(readline),**不全量载入内存**;扫到 limit 条即停
|
||||
* - 三级过滤:`pattern`(正则,大小写不敏感)/ `level`(ERROR|WARN|INFO|DEBUG,
|
||||
* 命中行内含 `[LEVEL]` 或 `LEVEL:` 即算)/ `since`(Unix ms,按行首时间戳或
|
||||
* 文件 mtime 粗筛)
|
||||
* - 路径仍走 `fs.guardPath` 的白名单(fs_roots 非空即受限)—— 不新开安全面
|
||||
* - 单行超长保护:超过 4KB 的行截断后再匹配,避免一行巨型 JSON 拖垮内存
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { statSync } from 'node:fs';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { guardPath } from './fs.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
const MAX_LINE = 4096;
|
||||
const MAX_SCAN = 2_000_000; // 单次查询最多扫 200 万行,防失控文件拖死 agent
|
||||
|
||||
export interface LogLine {
|
||||
/** 行号(从 1 开始)—— 回看时便于对照原文件 */
|
||||
n: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogQueryResult {
|
||||
path: string;
|
||||
matched: LogLine[];
|
||||
/** 实际扫描过的行数(用于判断"是扫完了还是被 limit 截住") */
|
||||
scanned: number;
|
||||
/** 是否因达到 MAX_SCAN 提前结束 */
|
||||
truncated: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 从行里推断日志级别(找不到返回 null,不猜)。 */
|
||||
function detectLevel(line: string): string | null {
|
||||
const m = /\[(ERROR|WARN|WARNING|INFO|DEBUG|TRACE)\]|\b(ERROR|WARN|INFO|DEBUG|TRACE):/i.exec(line);
|
||||
if (!m) return null;
|
||||
const raw = (m[1] ?? m[2] ?? '').toUpperCase();
|
||||
return raw === 'WARNING' ? 'WARN' : raw;
|
||||
}
|
||||
|
||||
export async function logQuery(args: Args): Promise<LogQueryResult> {
|
||||
const p = guardPath(args['path'] as string);
|
||||
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 100));
|
||||
const offset = Math.max(0, (args['offset'] as number | undefined) ?? 0);
|
||||
const patternStr = args['pattern'] as string | undefined;
|
||||
const level = (args['level'] as string | undefined)?.toUpperCase();
|
||||
const since = args['since'] as number | undefined;
|
||||
const tail = (args['tail'] as boolean | undefined) ?? false;
|
||||
|
||||
let re: RegExp | null = null;
|
||||
if (patternStr) {
|
||||
try {
|
||||
re = new RegExp(patternStr, 'i');
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `pattern 不是合法正则: ${patternStr}`, {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (level && !['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].includes(level)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 level: ${level}`, {
|
||||
allowed: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
|
||||
});
|
||||
}
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = statSync(p);
|
||||
} catch {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件不存在或不可读: ${p}`);
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是普通文件: ${p}`);
|
||||
}
|
||||
// since 粗筛:文件在 since 之前就没改过,必然没有更新的行 —— 省一次全扫
|
||||
if (since !== undefined && st.mtimeMs < since) {
|
||||
return { path: p, matched: [], scanned: 0, truncated: false, note: '文件 mtime 早于 since,未扫描' };
|
||||
}
|
||||
|
||||
// tail 模式:保留最后 limit 条命中的环形缓冲(边扫边丢旧的)
|
||||
const ring: LogLine[] = [];
|
||||
let matched = 0;
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
const rl = createInterface({
|
||||
input: createReadStream(p, { encoding: 'utf8' }),
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
for await (const rawLine of rl) {
|
||||
scanned += 1;
|
||||
if (scanned > MAX_SCAN) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let line = rawLine;
|
||||
if (line.length > MAX_LINE) line = `${line.slice(0, MAX_LINE)}…[截断]`;
|
||||
if (re && !re.test(line)) continue;
|
||||
if (level && detectLevel(line) !== level) continue;
|
||||
// since 精确筛:只在行首能解出时间戳时才按时间比(解不出就放行,交由调用方判断)
|
||||
if (since !== undefined) {
|
||||
const ts = parseLeadingTs(line);
|
||||
if (ts !== null && ts < since) continue;
|
||||
}
|
||||
matched += 1;
|
||||
if (tail) {
|
||||
ring.push({ n: scanned, text: line });
|
||||
if (ring.length > limit) ring.shift();
|
||||
} else {
|
||||
if (matched > offset && matched <= offset + limit) ring.push({ n: scanned, text: line });
|
||||
if (matched > offset + limit) break; // 已取够,提前收工
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: p,
|
||||
matched: ring,
|
||||
scanned,
|
||||
truncated,
|
||||
...(truncated ? { note: `扫描超过 ${MAX_SCAN} 行上限,结果可能不完整` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 行首时间戳 → Unix ms(支持 ISO 与常见 `[2026-10-07 12:34:56]` 形态;解不出返回 null)。
|
||||
*
|
||||
* ⚠️ 必须**连同时区后缀一起解析**:只截取到秒会丢掉 `Z` / `+08:00`,
|
||||
* 让 UTC 时间被当本地时间读 —— 实测会整体偏 8 小时(真机日志跨时区时判断"何时断的"就错了)。
|
||||
*/
|
||||
export function parseLeadingTs(line: string): number | null {
|
||||
const m = /^\[?(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)/.exec(line);
|
||||
if (!m) return null;
|
||||
// 无时区信息时按本地时间解析(与 `[2026-10-07 10:00:12]` 这类本地日志的语义一致);
|
||||
// 带 Z / ±hh:mm 时 Date.parse 自行按该时区处理 —— 关键是**别把后缀截掉**。
|
||||
const iso = m[1]!.replace(/\//g, '-').replace(' ', 'T');
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
*
|
||||
* ## 为什么需要(复盘 §四)
|
||||
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
|
||||
* `event.watch` 解决「有事件时通知」,没解决「**持续记录,事后能查**」。
|
||||
*
|
||||
* ## 设计
|
||||
* - 四类采样源:`port`(连通性 + 延迟)/ `process`(存活 + PID 列表)/
|
||||
* `command`(退出码 + 首行输出)/ `metric`(CPU/内存)
|
||||
* - 落盘 `<数据目录>/monitors/<id>.jsonl`(一行一样本,追加写 + 立即 flush 语义)
|
||||
* - `monitor.report` 读回并给摘要:min/max/均值、断线次数、最长中断 —— 直接回答
|
||||
* 「5 分钟里断过几次、最长断了多久」
|
||||
* - 并发上限 8 个;单文件 10 万行封顶(防写满磁盘)
|
||||
* - 采样循环 `unref()`,不阻止 agent 退出;`monitor.stop` 或进程退出即停
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { join, resolve as resolvePath, sep } from 'node:path';
|
||||
import { homedir, totalmem, freemem, cpus } from 'node:os';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { logQuery, type LogLine } from './log.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
export type MonitorSource = 'port' | 'process' | 'command' | 'metric';
|
||||
|
||||
interface Sample {
|
||||
ts: number;
|
||||
/** 各源自定义字段 */
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface MonitorRecord {
|
||||
id: string;
|
||||
source: MonitorSource;
|
||||
target: string;
|
||||
intervalMs: number;
|
||||
createdAt: number;
|
||||
samples: number;
|
||||
file: string;
|
||||
timer: ReturnType<typeof setInterval>;
|
||||
/** 首次采样失败时记下,避免每次 tick 都刷错误 */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
const monitors = new Map<string, MonitorRecord>();
|
||||
const MAX_MONITORS = 8;
|
||||
const MAX_SAMPLES_PER_FILE = 100_000;
|
||||
|
||||
function dataDir(): string {
|
||||
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
|
||||
}
|
||||
|
||||
function monitorDir(): string {
|
||||
const d = join(dataDir(), 'monitors');
|
||||
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
|
||||
*
|
||||
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
|
||||
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
|
||||
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
|
||||
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
|
||||
*/
|
||||
function safeMonitorId(raw: unknown): string {
|
||||
const id = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
|
||||
got: id,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
|
||||
function monitorFile(id: string): string {
|
||||
const dir = resolvePath(monitorDir());
|
||||
const file = resolvePath(dir, `${id}.jsonl`);
|
||||
if (!file.startsWith(dir + sep)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// ---------------- 采样器 ----------------
|
||||
|
||||
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
|
||||
function samplePort(host: string, port: number, timeoutMs: number): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const sock = connect({ host, port });
|
||||
const done = (up: boolean, err?: string): void => {
|
||||
sock.destroy();
|
||||
resolve({ ts: Date.now(), up, ms: Date.now() - started, ...(err ? { error: err } : {}) });
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.once('connect', () => done(true));
|
||||
sock.once('timeout', () => done(false, 'timeout'));
|
||||
sock.once('error', (e) => done(false, String(e.message ?? e)));
|
||||
});
|
||||
}
|
||||
|
||||
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
|
||||
function sampleProcess(name: string): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
|
||||
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
|
||||
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
|
||||
// 交给 JS 过滤(不再 grep)。
|
||||
const collect = (stdout: string, err: Error | null): void => {
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !/^INFO:/i.test(l));
|
||||
const pids = lines
|
||||
.map((l) => {
|
||||
const m = /^(\d+)/.exec(l);
|
||||
return m ? Number(m[1]) : null;
|
||||
})
|
||||
.filter((x): x is number => x !== null);
|
||||
resolve({
|
||||
ts: Date.now(),
|
||||
alive: pids.length > 0,
|
||||
pids,
|
||||
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
|
||||
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
|
||||
collect(stdout ?? '', err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
|
||||
const needle = name.toLowerCase();
|
||||
const filtered = (stdout ?? '')
|
||||
.split('\n')
|
||||
.filter((l) => l.toLowerCase().includes(needle))
|
||||
.join('\n');
|
||||
collect(filtered, err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** command:退出码 + 输出首行(用于"跑个探针命令"这类自定义采样)。 */
|
||||
function sampleCommand(command: string, timeoutMs: number): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { timeout: timeoutMs }, (err, stdout, stderr) => {
|
||||
const first = (stdout || stderr || '').split('\n')[0]?.slice(0, 500) ?? '';
|
||||
resolve({
|
||||
ts: Date.now(),
|
||||
exit_code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
||||
first_line: first,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** metric:CPU(两次采样差分)+ 内存。 */
|
||||
async function sampleMetric(): Promise<Sample> {
|
||||
const a = cpus();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const b = cpus();
|
||||
const idle = (c: typeof a): number =>
|
||||
c.reduce((s, x) => s + x.times.idle + x.times.irq, 0) / c.length;
|
||||
const total = (c: typeof a): number =>
|
||||
c.reduce((s, x) => s + Object.values(x.times).reduce((p, q) => p + q, 0), 0) / c.length;
|
||||
const idleDiff = idle(b) - idle(a);
|
||||
const totalDiff = total(b) - total(a);
|
||||
const cpu = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 10000) / 100 : 0;
|
||||
const mem = totalmem();
|
||||
const free = freemem();
|
||||
return { ts: Date.now(), cpu_pct: cpu, mem_pct: Math.round(((mem - free) / mem) * 10000) / 100 };
|
||||
}
|
||||
|
||||
async function takeSample(rec: MonitorRecord): Promise<Sample> {
|
||||
switch (rec.source) {
|
||||
case 'port': {
|
||||
const [host, portStr] = rec.target.split(':');
|
||||
return samplePort(host || '127.0.0.1', Number(portStr), Math.min(rec.intervalMs, 5000));
|
||||
}
|
||||
case 'process':
|
||||
return sampleProcess(rec.target);
|
||||
case 'command':
|
||||
return sampleCommand(rec.target, Math.min(rec.intervalMs * 2, 20_000));
|
||||
case 'metric':
|
||||
return sampleMetric();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 能力实现 ----------------
|
||||
|
||||
export async function monitorStart(args: Args): Promise<unknown> {
|
||||
const source = args['source'] as MonitorSource | undefined;
|
||||
if (!source || !['port', 'process', 'command', 'metric'].includes(source)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 source: ${String(source)}`, {
|
||||
allowed: ['port', 'process', 'command', 'metric'],
|
||||
hint: 'port 需 target="host:port";process 需 target=进程名;command 需 target=命令;metric 不需要 target',
|
||||
});
|
||||
}
|
||||
const intervalMs = Math.max(500, Math.min(600_000, (args['interval_ms'] as number | undefined) ?? 2000));
|
||||
const target = (args['target'] as string | undefined) ?? '';
|
||||
if (source !== 'metric' && !target) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `source=${source} 需要 target`);
|
||||
}
|
||||
if (source === 'port' && !/^.+:\d+$/.test(target)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'port 采样的 target 应为 "host:port"', { got: target });
|
||||
}
|
||||
if (monitors.size >= MAX_MONITORS) {
|
||||
throw new CapabilityError(ErrorCodes.RATE_LIMITED, `并发监控上限 ${MAX_MONITORS} 个`, {
|
||||
running: [...monitors.keys()],
|
||||
});
|
||||
}
|
||||
|
||||
const id =
|
||||
(args['id'] as string | undefined)?.trim() ||
|
||||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
|
||||
}
|
||||
|
||||
const file = monitorFile(id);
|
||||
const rec: MonitorRecord = {
|
||||
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
|
||||
timer: setInterval(() => { void tick(rec); }, intervalMs),
|
||||
lastError: null,
|
||||
};
|
||||
monitors.set(id, rec);
|
||||
// 立即采第一针(调用方往往想马上知道"现在通不通"),不额外等一个 interval
|
||||
void tick(rec);
|
||||
return { id, source, target, interval_ms: intervalMs, file, started_at: rec.createdAt };
|
||||
}
|
||||
|
||||
async function tick(rec: MonitorRecord): Promise<void> {
|
||||
try {
|
||||
const s = await takeSample(rec);
|
||||
rec.lastError = null;
|
||||
appendFileSync(rec.file, `${JSON.stringify(s)}\n`, 'utf8');
|
||||
rec.samples += 1;
|
||||
// 行数封顶:超过就从头截断(保留后半),防止磁盘写满
|
||||
if (rec.samples > MAX_SAMPLES_PER_FILE) {
|
||||
const lines = readFileSync(rec.file, 'utf8').trim().split('\n');
|
||||
writeFileSync(rec.file, `${lines.slice(-MAX_SAMPLES_PER_FILE).join('\n')}\n`, 'utf8');
|
||||
rec.samples = MAX_SAMPLES_PER_FILE;
|
||||
}
|
||||
} catch (err) {
|
||||
rec.lastError = err instanceof Error ? err.message : String(err);
|
||||
// 采样失败也记一行("查不到"本身也是信息),但不无限刷盘
|
||||
if (rec.samples < MAX_SAMPLES_PER_FILE) {
|
||||
appendFileSync(rec.file, `${JSON.stringify({ ts: Date.now(), error: rec.lastError })}\n`, 'utf8');
|
||||
rec.samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorReport(args: Args): Promise<unknown> {
|
||||
const id = safeMonitorId(args['id']);
|
||||
const rec = monitors.get(id);
|
||||
const file = monitorFile(id);
|
||||
if (!rec && !existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
|
||||
}
|
||||
const since = args['since'] as number | undefined;
|
||||
const limit = Math.max(1, Math.min(20_000, (args['limit'] as number | undefined) ?? 5000));
|
||||
|
||||
const samples: Sample[] = [];
|
||||
let truncated = false;
|
||||
if (existsSync(file)) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const s = JSON.parse(line) as Sample;
|
||||
if (since !== undefined && s.ts < since) continue;
|
||||
samples.push(s);
|
||||
} catch {
|
||||
/* 半行(写入中断)跳过 */
|
||||
}
|
||||
}
|
||||
if (samples.length > limit) {
|
||||
samples.splice(0, samples.length - limit);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
running: rec !== undefined,
|
||||
source: rec?.source ?? 'unknown',
|
||||
target: rec?.target ?? '',
|
||||
interval_ms: rec?.intervalMs,
|
||||
file,
|
||||
samples: samples.length,
|
||||
truncated,
|
||||
last_error: rec?.lastError ?? undefined,
|
||||
summary: summarize(samples, rec?.source ?? 'port'),
|
||||
series: samples,
|
||||
};
|
||||
}
|
||||
|
||||
/** 摘要:把样本压成"人话"—— 断几次、最长多久、值域。 */
|
||||
function summarize(samples: Sample[], source: MonitorSource): Record<string, unknown> {
|
||||
if (samples.length === 0) return { n: 0 };
|
||||
const base: Record<string, unknown> = { n: samples.length };
|
||||
const ts = samples.map((s) => s.ts);
|
||||
base.window_ms = Math.max(...ts) - Math.min(...ts);
|
||||
|
||||
if (source === 'port') {
|
||||
const ups = samples.filter((s) => s.up === true);
|
||||
base.up_count = ups.length;
|
||||
base.down_count = samples.length - ups.length;
|
||||
const lat = samples.map((s) => Number(s.ms ?? 0)).filter((x) => Number.isFinite(x));
|
||||
if (lat.length) {
|
||||
base.ms_min = Math.min(...lat);
|
||||
base.ms_max = Math.max(...lat);
|
||||
base.ms_avg = Math.round((lat.reduce((a, b) => a + b, 0) / lat.length) * 100) / 100;
|
||||
}
|
||||
// 最长连续中断
|
||||
let run = 0;
|
||||
let worst = 0;
|
||||
for (const s of samples) {
|
||||
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
|
||||
}
|
||||
base.longest_outage_samples = worst;
|
||||
} else if (source === 'process') {
|
||||
base.alive_count = samples.filter((s) => s.alive === true).length;
|
||||
base.dead_count = samples.filter((s) => s.alive !== true).length;
|
||||
let run = 0;
|
||||
let worst = 0;
|
||||
for (const s of samples) {
|
||||
if (s.alive === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
|
||||
}
|
||||
base.longest_dead_samples = worst;
|
||||
} else if (source === 'command') {
|
||||
const codes = samples.map((s) => Number(s.exit_code ?? -1));
|
||||
base.ok_count = codes.filter((c) => c === 0).length;
|
||||
base.fail_count = codes.filter((c) => c !== 0).length;
|
||||
base.exit_codes_seen = [...new Set(codes)];
|
||||
} else {
|
||||
const cpu = samples.map((s) => Number(s.cpu_pct ?? 0));
|
||||
const mem = samples.map((s) => Number(s.mem_pct ?? 0));
|
||||
if (cpu.length) {
|
||||
base.cpu_min = Math.min(...cpu); base.cpu_max = Math.max(...cpu);
|
||||
base.cpu_avg = Math.round((cpu.reduce((a, b) => a + b, 0) / cpu.length) * 100) / 100;
|
||||
}
|
||||
if (mem.length) {
|
||||
base.mem_min = Math.min(...mem); base.mem_max = Math.max(...mem);
|
||||
base.mem_avg = Math.round((mem.reduce((a, b) => a + b, 0) / mem.length) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function monitorStop(args: Args): Promise<unknown> {
|
||||
const id = safeMonitorId(args['id']);
|
||||
const rec = monitors.get(id);
|
||||
if (!rec) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
|
||||
}
|
||||
clearInterval(rec.timer);
|
||||
monitors.delete(id);
|
||||
const report = (await monitorReport({ id })) as { samples: number; summary: Record<string, unknown> };
|
||||
return { id, stopped: true, samples: report.samples, summary: report.summary, file: rec.file };
|
||||
}
|
||||
|
||||
export async function monitorList(_args: Args): Promise<unknown> {
|
||||
const running = [...monitors.values()].map((r) => ({
|
||||
id: r.id, source: r.source, target: r.target, interval_ms: r.intervalMs,
|
||||
samples: r.samples, started_at: r.createdAt, file: r.file, last_error: r.lastError ?? undefined,
|
||||
}));
|
||||
const dir = monitorDir();
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = (await import('node:fs')).readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
||||
} catch {
|
||||
/* 目录不存在即无历史 */
|
||||
}
|
||||
return {
|
||||
running,
|
||||
running_count: running.length,
|
||||
history_files: files.map((f) => f.replace(/\.jsonl$/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
|
||||
export async function monitorDelete(args: Args): Promise<unknown> {
|
||||
const id = safeMonitorId(args['id']);
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
|
||||
}
|
||||
const file = monitorFile(id);
|
||||
if (!existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
|
||||
}
|
||||
rmSync(file, { force: true });
|
||||
return { id, deleted: true, file };
|
||||
}
|
||||
|
||||
// log.query 与 monitor 同属"状态"方向,放在同一能力文件里导出
|
||||
export { logQuery };
|
||||
export type { LogLine };
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { psLit as psLitShared, shLit } from '../util/quote.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
import { extractPsError } from '../util/ps-helper.js';
|
||||
@@ -59,7 +60,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
|
||||
const path = join(dir, `${tag}-${Date.now()}.ps1`);
|
||||
writeFileSync(path, withBom(script), 'utf8');
|
||||
return execCommand({
|
||||
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
|
||||
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -68,7 +69,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
|
||||
* 生成的 .ps1 **必须带 UTF-8 BOM**。
|
||||
*
|
||||
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
|
||||
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
|
||||
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
|
||||
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
|
||||
* 所有落盘的 PowerShell 脚本一律经此函数。
|
||||
*/
|
||||
@@ -83,17 +84,17 @@ export function withBom(content: string): string {
|
||||
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
|
||||
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
|
||||
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
|
||||
*
|
||||
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
|
||||
*/
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
export const psLit = psLitShared;
|
||||
|
||||
/**
|
||||
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
|
||||
*
|
||||
* ⚠️ 两个真机教训都固化在这里:
|
||||
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
|
||||
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
|
||||
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
|
||||
*/
|
||||
@@ -173,7 +174,7 @@ export interface IfaceSnapshot {
|
||||
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$a = ${JSON.stringify(iface)}
|
||||
$a = ${psLit(iface)}
|
||||
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
|
||||
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
|
||||
@@ -210,7 +211,7 @@ $o | ConvertTo-Json -Compress -Depth 4
|
||||
|
||||
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
|
||||
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
|
||||
const a = JSON.stringify(snap.alias);
|
||||
const a = psLit(snap.alias);
|
||||
const L = psLit(logPath);
|
||||
const lines = [
|
||||
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
|
||||
@@ -229,23 +230,23 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
|
||||
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
|
||||
for (const addr of snap.ipv4) {
|
||||
lines.push(
|
||||
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${psLit(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
|
||||
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
|
||||
const keep = snap.ipv4.map((x) => psLit(x.ip)).join(',');
|
||||
lines.push(` $keep = @(${keep})`);
|
||||
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
|
||||
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
|
||||
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
|
||||
if (snap.gateway[0]) {
|
||||
lines.push(
|
||||
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (snap.dns.length > 0) {
|
||||
lines.push(
|
||||
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(` W 'restored: static'`);
|
||||
@@ -260,14 +261,14 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
|
||||
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
|
||||
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
|
||||
if (-not $c) { Write-Output '{}'; return }
|
||||
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
|
||||
$o = [ordered]@{ interface = ${psLit(iface)} }
|
||||
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
|
||||
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
|
||||
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
|
||||
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
|
||||
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$dhcp = Get-NetIPInterface -InterfaceAlias ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
|
||||
$o | ConvertTo-Json -Compress
|
||||
`;
|
||||
@@ -436,7 +437,7 @@ export async function netApply(args: Args): Promise<unknown> {
|
||||
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
|
||||
*/
|
||||
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
|
||||
const a = JSON.stringify(iface);
|
||||
const a = psLit(iface);
|
||||
if (mode === 'command') {
|
||||
const cmd = args['command'] as string | undefined;
|
||||
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
|
||||
@@ -459,7 +460,7 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
}
|
||||
const prefix = maskToPrefix(mask);
|
||||
const dns = (args['dns'] as string[] | undefined) ?? [];
|
||||
const ipLit = JSON.stringify(ip);
|
||||
const ipLit = psLit(ip);
|
||||
const lines = [
|
||||
`$ErrorActionPreference='Continue'`,
|
||||
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
|
||||
@@ -477,12 +478,12 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
];
|
||||
if (gateway) {
|
||||
lines.push(
|
||||
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (dns.length > 0) {
|
||||
lines.push(
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(`Write-Output 'static applied'`);
|
||||
@@ -495,10 +496,10 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
|
||||
*/
|
||||
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
|
||||
const a = JSON.stringify(iface);
|
||||
const rb = JSON.stringify(rollbackScriptPath);
|
||||
const a = psLit(iface);
|
||||
const rb = psLit(rollbackScriptPath);
|
||||
const cond = expectIp
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${psLit(expectIp)} }).Count -gt 0`
|
||||
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
|
||||
return [
|
||||
``,
|
||||
@@ -551,7 +552,7 @@ async function posixCommandApply(
|
||||
'utf8',
|
||||
);
|
||||
const launched = await execCommand({
|
||||
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
|
||||
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -109,9 +110,10 @@ export async function screenRecord(args: Args): Promise<unknown> {
|
||||
let height = region?.height ?? 0;
|
||||
|
||||
if (!region) {
|
||||
// ⚠️ 这里必须先设 DPI 感知,`VirtualScreen` 才是**物理**尺寸(见 util/dpi.ts)。
|
||||
// 统一走 PS_DPI_AWARE,避免各能力各写一份 P/Invoke 壳而漂移。
|
||||
const probe = await execCommand({
|
||||
command:
|
||||
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -TypeDefinition \'using System.Runtime.InteropServices; public class NADPIR { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'; [void][NADPIR]::SetProcessDPIAware(); $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)',
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const nums = probe.stdout.trim().split(',').map(Number);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -25,22 +27,37 @@ const MAX_CAPTURE_BYTES = 40 * 1024 * 1024;
|
||||
|
||||
// ---------------- screen.info ----------------
|
||||
|
||||
/**
|
||||
* listDisplays 下发给 PowerShell 的脚本。
|
||||
*
|
||||
* 导出**仅供单测断言**(检查 DPI 感知确实注入且位于坐标 API 之前)——
|
||||
* 真机 2026-10-12 的「缺省整屏只截到左上 1/4」就是这里漏了 DPI 感知,
|
||||
* 源码扫描式守卫抓不到(文件里别处也引用了 PS_DPI_AWARE)。
|
||||
*/
|
||||
export function buildListDisplaysScript(): string {
|
||||
return [
|
||||
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
|
||||
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
|
||||
//(200% 缩放下 3600×2020 的桌面报成 1800×1010)。真机 2026-10-12 实证。
|
||||
PS_DPI_AWARE,
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)。
|
||||
// 注意:设了 DPI 感知后 Bounds 已是**物理**像素,scale 仅作参考信息保留。
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function listDisplays(): Promise<Display[]> {
|
||||
if (IS_WINDOWS) {
|
||||
const script = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
const r = await execCommand({ command: script, timeoutMs: 30_000 });
|
||||
const r = await execCommand({ command: buildListDisplaysScript(), timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '读取显示器信息失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -106,27 +123,61 @@ export async function screenCapture(args: Args): Promise<unknown> {
|
||||
region: args['region'] as CaptureOpts['region'],
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ S-01(v2.0.3):region 的合法性检查必须放在**枚举显示器之前**。
|
||||
*
|
||||
* 两道闸的由来:被控端会照 region 直接 `New-Object Bitmap $w,$h`(32bpp ≈ 4×w×h 字节),
|
||||
* 一个 100000×100000 的请求足以吃光内存(≈40GB)→ 进程被杀。
|
||||
* 收敛为「单边 ≤ 16384(常见位图/纹理上限)+ 总像素 ≤ 32M(≈128MB)」。
|
||||
*
|
||||
* 放在枚举之前有两个理由:① 校验不依赖显示器信息,没理由先付出枚举成本;
|
||||
* ② 真要挡住的就是这种请求 —— 让它在任何副作用之前就失败(fail fast)。
|
||||
*/
|
||||
if (opts.region) assertRegionSane(opts.region);
|
||||
|
||||
const displays = await listDisplays();
|
||||
const display = displays[opts.display_id] ?? displays[0];
|
||||
if (!display) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `显示器 ${opts.display_id} 不存在`);
|
||||
}
|
||||
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移。
|
||||
// 缺省值也要过同一道闸:显示器自报异常尺寸时同样不能放行。
|
||||
const region = opts.region ?? { x: display.x, y: display.y, width: display.width, height: display.height };
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
assertRegionSane(region);
|
||||
|
||||
if (IS_WINDOWS) return captureWindows(opts, region);
|
||||
return capturePosix(opts, region);
|
||||
}
|
||||
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
/** region 上界校验(S-01)。缺省值与显式传入走**同一份**判据,避免两处漂移。 */
|
||||
function assertRegionSane(region: NonNullable<CaptureOpts['region']>): void {
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
const MAX_EDGE = 16384;
|
||||
const MAX_PIXELS = 33_554_432;
|
||||
if (region.width > MAX_EDGE || region.height > MAX_EDGE || region.width * region.height > MAX_PIXELS) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`region 过大:单边须 ≤ ${MAX_EDGE} 且总像素 ≤ ${MAX_PIXELS}(当前 ${region.width}×${region.height})`,
|
||||
{ region, max_edge: MAX_EDGE, max_pixels: MAX_PIXELS },
|
||||
);
|
||||
}
|
||||
if (![region.x, region.y, region.width, region.height].every((n) => Number.isFinite(n))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 必须都是有限数字', { region });
|
||||
}
|
||||
}
|
||||
|
||||
/** captureWindows 下发给 PowerShell 的脚本(导出**仅供单测断言**,同 buildListDisplaysScript)。 */
|
||||
export function buildCaptureScript(
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const script = [
|
||||
opts: { scale: number; format: string; quality: number },
|
||||
): string {
|
||||
return [
|
||||
// ⚠️ 与 listDisplays 同理:region 是物理像素,进程也必须是 DPI 感知,
|
||||
// 否则「region 语义(物理)」与「进程坐标观(逻辑)」两套口径并存。
|
||||
PS_DPI_AWARE,
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
`$x = ${region.x}; $y = ${region.y}; $w = ${region.width}; $h = ${region.height}`,
|
||||
@@ -157,8 +208,17 @@ async function captureWindows(
|
||||
'$ms.Dispose(); $bmp.Dispose()',
|
||||
'Write-Output ("{0}|{1}|{2}" -f [Convert]::ToBase64String($bytes), $bmpW, $bmpH)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const r = await execCommand({ command: script, timeoutMs: 60_000, maxOutputBytes: MAX_CAPTURE_BYTES });
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const r = await execCommand({
|
||||
command: buildCaptureScript(region, opts),
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '截屏失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -197,7 +257,7 @@ async function capturePosix(
|
||||
|
||||
// macOS 用 screencapture;区域用 -R x,y,w,h
|
||||
const cmd =
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${JSON.stringify(file)}`;
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${shLit(file)}`;
|
||||
const r = await execCommand({ command: cmd, timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '当前平台截屏不可用(需要 macOS 屏幕录制权限)', {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { guardPath } from './fs.js';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
@@ -6,10 +7,11 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { currentCertFingerprint } from '../certs.js';
|
||||
import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -55,7 +57,6 @@ async function isAdmin(): Promise<boolean> {
|
||||
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
|
||||
declare const __AGENT_VERSION__: string | undefined;
|
||||
declare const __BUILD_COMMIT__: string | undefined;
|
||||
declare const __BUILD_TIME__: string | undefined;
|
||||
|
||||
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
|
||||
function configAllowFrom(): string[] | null {
|
||||
@@ -67,10 +68,9 @@ function configAllowFrom(): string[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
function injected(name: 'version' | 'commit' | 'time'): string {
|
||||
function injected(name: 'version' | 'commit'): string {
|
||||
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
|
||||
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
|
||||
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
}
|
||||
|
||||
const STARTED_AT = Date.now();
|
||||
@@ -118,7 +118,9 @@ export function buildInfo(): {
|
||||
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
|
||||
version: injected('version'),
|
||||
commit: injected('commit'),
|
||||
built_at: injected('time'),
|
||||
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
|
||||
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
|
||||
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
|
||||
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
};
|
||||
@@ -526,15 +528,38 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
const TASK = `nodeagent-selfrestart-${Date.now()}`;
|
||||
const ps = [
|
||||
`$ErrorActionPreference='SilentlyContinue'`,
|
||||
// 清理历史 selfrestart 任务(保留 Running 的 —— 那正是当前 agent 的宿主)
|
||||
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' | Where-Object { $_.State -ne 'Running' } | Unregister-ScheduledTask -Confirm:$false`,
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
|
||||
// 清理历史 selfrestart 任务。
|
||||
//
|
||||
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
|
||||
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
|
||||
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
|
||||
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
|
||||
//
|
||||
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
|
||||
//
|
||||
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
|
||||
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
|
||||
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
|
||||
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
|
||||
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
|
||||
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
|
||||
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
|
||||
`$cut = $agentStart`,
|
||||
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
|
||||
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
|
||||
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
|
||||
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
|
||||
// ⚠️ 必须用最高权限:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
|
||||
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
|
||||
// 否则会出现「新进程起来了但端口被占(EADDRINUSE)」。
|
||||
`$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest -LogonType Interactive`,
|
||||
`Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Principal $p -Force | Out-Null`,
|
||||
// ② 降级:**非管理员身份下 Highest 会被系统拒绝**(真机 2026-10-09 坐实:
|
||||
// agent 以普通用户运行时,deploy/restart 一律报"注册重启任务失败 exit=1")。
|
||||
// 此时不指定 -Principal —— 默认继承当前用户权限级别(Limited),
|
||||
// 注册能成功,且同权限下 taskkill 自己完全够用。
|
||||
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Force | Out-Null }`,
|
||||
// 校验注册真的成功(SilentlyContinue 会吞错,必须显式确认)
|
||||
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Write-Output 'register-failed'; exit 1 }`,
|
||||
`Start-ScheduledTask -TaskName '${TASK}'`,
|
||||
@@ -600,6 +625,31 @@ export async function auditVerify(_args: Args): Promise<unknown> {
|
||||
return verifyAudit();
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:返回审计链的链头(只读)。
|
||||
*
|
||||
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
|
||||
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
|
||||
*/
|
||||
export async function auditHead(args: Args): Promise<unknown> {
|
||||
const head = computeAuditHead();
|
||||
if (args['compare'] !== true) return head;
|
||||
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
*
|
||||
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
|
||||
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
|
||||
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
|
||||
*/
|
||||
export async function auditAnchor(args: Args): Promise<unknown> {
|
||||
const raw = args['path'] as string | undefined;
|
||||
const path = raw ? guardPath(raw) : undefined;
|
||||
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
|
||||
}
|
||||
|
||||
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
|
||||
export async function metricsReport(args: Args): Promise<unknown> {
|
||||
const since = args['since'] as number | undefined;
|
||||
|
||||
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
|
||||
|
||||
// ---------------- system.task.list ----------------
|
||||
|
||||
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
export function runningTaskCount(): number {
|
||||
let n = 0;
|
||||
for (const t of tasks.values()) if (t.state === 'running') n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function taskList(_args: Args): Promise<unknown> {
|
||||
return {
|
||||
tasks: [...tasks.values()].map((t) => ({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
export const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 图像模板匹配预加载段(ZNCC 粗到精)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
export const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*
|
||||
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
|
||||
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
|
||||
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
|
||||
*/
|
||||
export const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function global:Await($WinRtTask, $ResultType) {
|
||||
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
$script:ocrReady = $true
|
||||
} catch {
|
||||
$script:ocrReady = $false
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* UIA 控件树预加载段(含 Where 谓词与属性读取)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
|
||||
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
|
||||
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
|
||||
*/
|
||||
export const UIA_ASSEMBLY_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
|
||||
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
|
||||
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
|
||||
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
|
||||
function Get-UiaProps($e) {
|
||||
$enabled = $true
|
||||
try { $enabled = [bool]$e.Current.IsEnabled } catch {}
|
||||
$value = $null
|
||||
try {
|
||||
$vp = $e.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
|
||||
if ($vp -ne $null) { $value = $vp.Current.Value }
|
||||
} catch {}
|
||||
$selected = $null
|
||||
try {
|
||||
$sp = $e.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
||||
if ($sp -ne $null) { $selected = [bool]$sp.Current.IsSelected }
|
||||
} catch {}
|
||||
$toggle = $null
|
||||
try {
|
||||
$tp = $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)
|
||||
if ($tp -ne $null) { $toggle = "$($tp.Current.ToggleState)" }
|
||||
} catch {}
|
||||
return @{ enabled = $enabled; value = $value; selected = $selected; toggle = $toggle }
|
||||
}
|
||||
|
||||
function Test-UiaWhere($props, $where) {
|
||||
if ($where -eq $null) { return $true }
|
||||
foreach ($k in $where.Keys) {
|
||||
$want = $where[$k]
|
||||
switch ($k) {
|
||||
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
|
||||
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
|
||||
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
|
||||
default { } # 未知键忽略(Node 侧已白名单校验)
|
||||
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct, $where) {
|
||||
$cond = [System.Windows.Automation.Condition]::TrueCondition
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomitionElement]::ControlTypeProperty, $ctObj)
|
||||
}
|
||||
}
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$scanned = 0
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
$scanned++
|
||||
if ($scanned -gt 20000) { break }
|
||||
try {
|
||||
$nm = $e.Current.Name
|
||||
if ([string]::IsNullOrEmpty($nm)) { continue }
|
||||
if ($text -ne '' -and $nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
$props = Get-UiaProps $e
|
||||
if (-not (Test-UiaWhere $props $where)) { continue }
|
||||
[void]$out.Add([pscustomobject]@{
|
||||
name = $nm
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
enabled = $props.enabled
|
||||
value = $props.value
|
||||
selected = $props.selected
|
||||
toggle = $props.toggle
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
return ,$out
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
export const WIN32_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public class NAWin32 {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
@@ -1,11 +1,19 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
|
||||
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
|
||||
import { LIST_BODY } from './win-ps/body-list.js';
|
||||
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
|
||||
import { OCR_PRELUDE } from './win-ps/prelude-ocr.js';
|
||||
import { IMAGE_MATCH_PRELUDE } from './win-ps/prelude-image.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
function toArray<T>(v: T | T[] | null | undefined): T[] {
|
||||
@@ -80,381 +88,11 @@ function extractJson<T>(raw: string): T | null {
|
||||
}
|
||||
|
||||
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
|
||||
const WIN32_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public class NAWin32 {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
/**
|
||||
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
|
||||
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
|
||||
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
|
||||
*/
|
||||
const UIA_ASSEMBLY_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
|
||||
$cond = [System.Windows.Automation.Condition]::TrueCondition
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
|
||||
}
|
||||
}
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$scanned = 0
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
$scanned++
|
||||
if ($scanned -gt 20000) { break }
|
||||
try {
|
||||
$nm = $e.Current.Name
|
||||
if ([string]::IsNullOrEmpty($nm)) { continue }
|
||||
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
[void]$out.Add([pscustomobject]@{
|
||||
name = $nm
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
return ,$out
|
||||
}
|
||||
`;
|
||||
|
||||
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
|
||||
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*/
|
||||
const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
|
||||
[void][NADPI]::SetProcessDPIAware()
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function global:Await($WinRtTask, $ResultType) {
|
||||
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
$script:ocrReady = $true
|
||||
} catch {
|
||||
$script:ocrReady = $false
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
|
||||
/**
|
||||
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
|
||||
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
|
||||
@@ -483,7 +121,17 @@ export function warmupWindowHelper(): void {
|
||||
}, 1500).unref?.();
|
||||
}
|
||||
|
||||
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
|
||||
/**
|
||||
* 常驻助手的预加载段。
|
||||
*
|
||||
* ⚠️ `PS_DPI_AWARE` **必须排在最前**,且不能放进 `OCR_PRELUDE` 的 try/catch 里 ——
|
||||
* 那段 catch 是为了「缺 WinRT 组件时只让 OCR 退化」,但它同时也会把
|
||||
* `SetProcessDPIAware` 一起吞掉;一旦吞掉,助手退回 DPI 不感知,
|
||||
* `GetWindowRect`(window.list)就与 UIA / 截图的口径不一致了。
|
||||
* DPI 感知是**坐标口径前提**,不是可退化项。
|
||||
*/
|
||||
export const WIN_HELPER_PRELUDE = `${PS_DPI_AWARE}
|
||||
${WIN32_PRELUDE}
|
||||
${UIA_ASSEMBLY_PRELUDE}
|
||||
${OCR_PRELUDE}
|
||||
${IMAGE_MATCH_PRELUDE}`;
|
||||
@@ -570,14 +218,18 @@ export async function windowFocus(args: Args): Promise<unknown> {
|
||||
/**
|
||||
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
|
||||
* 未找到时改为输出 `{"found":false}` 并 return。
|
||||
*
|
||||
* ⚠️⚠️ `title` / `hwnd` 来自控制端,**必须**用 `psLit` 包成 PS 单引号字面量。
|
||||
* 曾用 `JSON.stringify` → 双引号串 + `\"` 被 PS 解析成「字面反斜杠 + 结束引号」
|
||||
* → 任意命令执行(P0 W-01,真机实测「返回正常业务错误但命令已执行」)。
|
||||
*/
|
||||
function focusBody(title: string | undefined, hwnd: string | undefined): string {
|
||||
return `
|
||||
$h = [IntPtr]::Zero
|
||||
$want = ${JSON.stringify(hwnd ?? '')}
|
||||
$want = ${psLit(hwnd ?? '')}
|
||||
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
|
||||
else {
|
||||
$re = ${JSON.stringify(title ?? '')}
|
||||
$re = ${psLit(title ?? '')}
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$found = [IntPtr]::Zero
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
@@ -664,11 +316,26 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
|
||||
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
|
||||
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
|
||||
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
|
||||
const hasWhere = (() => {
|
||||
const w = args['where'];
|
||||
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
|
||||
})();
|
||||
if (!wantImage && text.length === 0 && !hasWhere) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'text 与 where 至少给一个(method=image 时用 template)',
|
||||
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
|
||||
);
|
||||
}
|
||||
const windowTitle = args['window'] as string | undefined;
|
||||
const controlType = args['control_type'] as string | undefined;
|
||||
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
|
||||
const whereArg = args['where'] as Record<string, unknown> | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
|
||||
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
|
||||
@@ -686,17 +353,39 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
// ---------- UIA 引擎 ----------
|
||||
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
const ct = controlType ?? '';
|
||||
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
|
||||
const body = `
|
||||
$text = ${JSON.stringify(text)}
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
$ct = ${psLit(controlType ?? '')}
|
||||
$winTitle = ${psLit(windowTitle ?? '')}
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
|
||||
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
|
||||
# (PS 用反引号),依然 ParseError,整份助手脚本失败
|
||||
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
|
||||
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
|
||||
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
|
||||
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
|
||||
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
|
||||
$where = $null
|
||||
if ($whereJson -ne '') {
|
||||
$where = @{}
|
||||
try {
|
||||
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
|
||||
} catch {
|
||||
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
|
||||
}
|
||||
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
|
||||
}
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
if ($winTitle -ne '') {
|
||||
# 指定窗口:直接在该窗口子树内找
|
||||
$w = Get-WinByTitle $winTitle
|
||||
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
|
||||
$out = Find-UiaByText $w $text $limit $ct
|
||||
$out = Find-UiaByText $w $text $limit $ct $where
|
||||
} else {
|
||||
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
|
||||
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
|
||||
@@ -704,9 +393,9 @@ if ($winTitle -ne '') {
|
||||
try {
|
||||
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
|
||||
} catch {}
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
|
||||
if ($out.Count -eq 0) {
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
|
||||
}
|
||||
}
|
||||
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
|
||||
@@ -730,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
|
||||
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
|
||||
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
|
||||
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
|
||||
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
|
||||
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
|
||||
const q = psLit;
|
||||
|
||||
// 截图区域:显式 region 优先,否则整块虚拟屏
|
||||
const capX = regionArg ? regionArg.x : 'VSX';
|
||||
@@ -801,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
|
||||
$yv = ${regionArg ? regionArg.y : -1}
|
||||
$wv = ${regionArg ? regionArg.width : -1}
|
||||
$hv = ${regionArg ? regionArg.height : -1}
|
||||
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
|
||||
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
|
||||
$x = 0; $y = 0
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$w = $vs.Width; $h = $vs.Height
|
||||
@@ -861,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
||||
if ($null -eq $engine) { Write-Output '[]'; return }
|
||||
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
|
||||
|
||||
$text = ${JSON.stringify(text)}
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
|
||||
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
|
||||
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
|
||||
$textPlain = (${psLit(text)}).Replace(' ','')
|
||||
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
foreach ($line in $result.Lines) {
|
||||
@@ -954,10 +644,22 @@ $out | ConvertTo-Json -Compress
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
|
||||
const whereIgnored = hasWhere && last.engine !== 'uia';
|
||||
return {
|
||||
matches: last.matches,
|
||||
engine: last.engine,
|
||||
waited_ms: Date.now() - startedAt,
|
||||
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
|
||||
...(whereIgnored
|
||||
? {
|
||||
note:
|
||||
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
|
||||
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
|
||||
where_applied: false,
|
||||
}
|
||||
: {}),
|
||||
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,19 @@ export interface AgentConfig {
|
||||
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
|
||||
*/
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
|
||||
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
|
||||
*/
|
||||
security?: {
|
||||
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
|
||||
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
|
||||
/** 最大并发连接数(默认 8) */
|
||||
max_connections?: number;
|
||||
/** 空闲断开(默认 30 分钟;0 = 关闭) */
|
||||
idle_timeout_ms?: number;
|
||||
};
|
||||
/** v3+:审计日志配置 */
|
||||
audit?: {
|
||||
/** 是否启用(默认 true) */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { watch, type FSWatcher } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
// ⚠️ E-03:file watcher 必须与 fs.* 用**同一套**路径白名单守卫(否则 fs_roots 形同虚设)
|
||||
import { guardPath } from './capabilities/fs.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -30,6 +32,8 @@ interface WatchRecord {
|
||||
id: string;
|
||||
kind: 'file' | 'process' | 'net';
|
||||
owner: string; // 连接标识,断开时清理
|
||||
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
|
||||
clientId: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
count: number;
|
||||
@@ -186,10 +190,23 @@ function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
|
||||
export interface WatchContext {
|
||||
/** 连接标识(用于断开时清理) */
|
||||
owner: string;
|
||||
/** 调用方 client_id(跨控制端隔离;缺省视为匿名,彼此可见) */
|
||||
clientId?: string;
|
||||
/** 该连接的推送出口 */
|
||||
emit: (evt: WatchEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该 watcher 是否属于 caller。
|
||||
*
|
||||
* 兼容性:`clientId` 为空(匿名 / 老客户端)时**不做隔离** —— 但只要有 client_id,
|
||||
* 隔离就必须生效:否则 B 控制端能看到甚至 `event.unwatch` 掉 A 的订阅(E-01/E-02 实测)。
|
||||
*/
|
||||
function ownedBy(rec: WatchRecord, callerId: string): boolean {
|
||||
if (!callerId) return true;
|
||||
return !rec.clientId || rec.clientId === callerId;
|
||||
}
|
||||
|
||||
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
|
||||
const kind = args['kind'] as 'file' | 'process' | 'net';
|
||||
if (!['file', 'process', 'net'].includes(kind)) {
|
||||
@@ -202,6 +219,7 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
id,
|
||||
kind,
|
||||
owner: ctx.owner,
|
||||
clientId: ctx.clientId ?? '',
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
count: 0,
|
||||
@@ -210,8 +228,12 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
};
|
||||
|
||||
if (kind === 'file') {
|
||||
const path = args['path'] as string | undefined;
|
||||
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
|
||||
const raw = args['path'] as string | undefined;
|
||||
if (!raw) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
|
||||
// ⚠️ E-03(实测复现):file watcher **必须**过 `guardPath`。
|
||||
// 原来 `fs.read` 会被 `fs_roots` 正确拦下,而 `event.watch` 用同一路径却畅通无阻
|
||||
// —— 白名单形同虚设(可通过事件流观察白名单外的目录变动)。
|
||||
const path = guardPath(raw);
|
||||
const recursive = args['recursive'] === true;
|
||||
rec.dispose = startFileWatch(rec, path, recursive, pattern);
|
||||
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
|
||||
@@ -227,18 +249,27 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
return { watch_id: id, kind, description: rec.description };
|
||||
}
|
||||
|
||||
export async function eventUnwatch(args: Args): Promise<unknown> {
|
||||
/** 取消订阅。**只有创建者(同 client_id)能取消** —— 否则任意控制端可掐掉别人的监控。 */
|
||||
export async function eventUnwatch(args: Args, ctx?: WatchContext): Promise<unknown> {
|
||||
const id = args['watch_id'] as string;
|
||||
const rec = watches.get(id);
|
||||
if (!rec) return { removed: false };
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
if (!ownedBy(rec, callerId)) {
|
||||
// 明确回报,而不是假装成功(也便于审计追查越权尝试)
|
||||
return { removed: false, reason: `订阅 ${id} 属于其他控制端,无权取消` };
|
||||
}
|
||||
rec.dispose();
|
||||
watches.delete(id);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
export async function eventList(_args: Args): Promise<unknown> {
|
||||
/** 列出订阅:**只列自己的**(含匿名创建的)。 */
|
||||
export async function eventList(_args: Args, ctx?: WatchContext): Promise<unknown> {
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
const mine = [...watches.values()].filter((w) => ownedBy(w, callerId));
|
||||
return {
|
||||
watches: [...watches.values()].map((w) => ({
|
||||
watches: mine.map((w) => ({
|
||||
watch_id: w.id,
|
||||
kind: w.kind,
|
||||
description: w.description,
|
||||
@@ -249,18 +280,39 @@ export async function eventList(_args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function eventPoll(args: Args): Promise<unknown> {
|
||||
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
|
||||
export async function eventPoll(args: Args, ctx?: WatchContext): Promise<unknown> {
|
||||
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
|
||||
const since = (args['since'] as number | undefined) ?? 0;
|
||||
const watchId = args['watch_id'] as string | undefined;
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
|
||||
if (watchId) {
|
||||
const rec = watches.get(watchId);
|
||||
if (rec && !ownedBy(rec, callerId)) {
|
||||
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
|
||||
}
|
||||
}
|
||||
let list = buffer.filter((e) => e.seq > since);
|
||||
if (watchId) list = list.filter((e) => e.watch_id === watchId);
|
||||
if (watchId) {
|
||||
list = list.filter((e) => e.watch_id === watchId);
|
||||
} else if (callerId) {
|
||||
const allowed = new Set(
|
||||
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
|
||||
);
|
||||
list = list.filter((e) => allowed.has(e.watch_id));
|
||||
}
|
||||
const sliced = list.slice(0, limit);
|
||||
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
|
||||
return { events: sliced, next_cursor: nextCursor, dropped };
|
||||
}
|
||||
|
||||
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
|
||||
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
export function watchCount(): number {
|
||||
return watches.size;
|
||||
}
|
||||
|
||||
export function disposeWatchesByOwner(owner: string): number {
|
||||
let n = 0;
|
||||
for (const [id, rec] of watches) {
|
||||
|
||||
@@ -18,6 +18,63 @@ function listLocalIps(): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2.0.3:进程级最后一道防线(崩溃链兜底)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
|
||||
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
|
||||
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
|
||||
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
|
||||
//
|
||||
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
|
||||
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
|
||||
//
|
||||
// ## 为什么**不**退出进程
|
||||
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
|
||||
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
|
||||
// 代价(可能带着不一致的状态继续跑)由两点对冲:
|
||||
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
|
||||
//
|
||||
// ## 为什么要限流审计
|
||||
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
|
||||
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
|
||||
|
||||
let procErrCount = 0;
|
||||
let lastProcErrAuditAt = 0;
|
||||
/** 两条 `agent.error` 审计之间的最小间隔 */
|
||||
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
|
||||
|
||||
function reportProcessError(kind: string, detail: string): void {
|
||||
procErrCount += 1;
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
|
||||
lastProcErrAuditAt = now;
|
||||
audit({
|
||||
type: 'agent.error',
|
||||
status: 'failed',
|
||||
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
|
||||
});
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
reportProcessError(
|
||||
'未处理的 Promise 拒绝',
|
||||
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
|
||||
);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err: unknown) => {
|
||||
reportProcessError(
|
||||
'未捕获异常',
|
||||
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
||||
);
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { config, isNew } = loadAgentConfig();
|
||||
initAudit({
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner } from './events.js';
|
||||
import { disposeWatchesByOwner, watchCount } from './events.js';
|
||||
import { runningTaskCount } from './capabilities/task.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -47,6 +48,8 @@ interface ConnState {
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -61,15 +64,37 @@ type LogLevel = 'debug' | 'info' | 'warn';
|
||||
export interface AgentCore {
|
||||
/** 在给定连接上提供被控端服务(连接已建立)。 */
|
||||
attach(ws: WebSocket, remote: string): void;
|
||||
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
banRemainingMs(remote: string): number;
|
||||
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
states: Map<WebSocket, ConnState>;
|
||||
}
|
||||
|
||||
export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const capabilityRegistry = createCapabilityRegistry(cfg);
|
||||
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
|
||||
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
|
||||
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
|
||||
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
|
||||
const hasAcl = Boolean(cfg.acl?.clients?.length);
|
||||
/**
|
||||
* ACL 策略归一化。
|
||||
*
|
||||
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
|
||||
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
|
||||
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
|
||||
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
|
||||
*/
|
||||
const aclPolicy = {
|
||||
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
|
||||
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
|
||||
};
|
||||
/**
|
||||
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
|
||||
*
|
||||
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
|
||||
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
|
||||
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
|
||||
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
|
||||
*/
|
||||
const hasAcl = Boolean(cfg.acl);
|
||||
|
||||
/** 按调用方的滑动窗口限速(每分钟)。 */
|
||||
const rateBuckets = new Map<string, number[]>();
|
||||
@@ -93,7 +118,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
};
|
||||
|
||||
const states = new WeakMap<WebSocket, ConnState>();
|
||||
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
|
||||
const states = new Map<WebSocket, ConnState>();
|
||||
|
||||
const send = (ws: WebSocket, msg: unknown): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
@@ -105,14 +132,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
|
||||
const params = req.params as HelloParams | undefined;
|
||||
const proto = params?.protocol ?? '';
|
||||
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
|
||||
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
|
||||
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
|
||||
const rawProto = params?.protocol;
|
||||
if (typeof rawProto !== 'string' || rawProto === '') {
|
||||
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
|
||||
got: typeof rawProto,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const proto = rawProto;
|
||||
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `协议版本不兼容: 客户端 ${proto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
|
||||
return;
|
||||
}
|
||||
state.nonce = generateNonce();
|
||||
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
|
||||
state.clientId = params?.client_id ?? null;
|
||||
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
|
||||
// 新一轮握手开始:清除上一轮的认证态与授权
|
||||
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
|
||||
state.authenticated = false;
|
||||
@@ -122,15 +172,32 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
function handleAuth(ws: WebSocket, state: ConnState, req: RpcRequest): void {
|
||||
const params = req.params as AuthParams | undefined;
|
||||
const failOut = (msg: string, hint?: string): void => {
|
||||
const ban = recordAuthFailure(state.remote);
|
||||
audit({ type: 'auth.failure', client_id: params?.client_id ?? '?', remote: state.remote, reason: msg });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, msg, {
|
||||
...(hint ? { hint } : {}),
|
||||
...(ban.banned ? { retry_after_ms: ban.waitMs } : {}),
|
||||
});
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
};
|
||||
|
||||
if (!state.nonce || Date.now() > state.nonceExpiresAt) {
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '挑战已过期,请重新握手');
|
||||
log('warn', '握手失败:挑战已过期');
|
||||
failOut('挑战已过期,请重新握手');
|
||||
return;
|
||||
}
|
||||
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
|
||||
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
|
||||
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
|
||||
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
|
||||
if (params?.nonce !== state.nonce) {
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'nonce 不匹配');
|
||||
log('warn', '握手失败:nonce 不匹配');
|
||||
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
|
||||
return;
|
||||
}
|
||||
const clientId = params.client_id ?? '?';
|
||||
const clientId = typeof params.client_id === 'string' ? params.client_id : '?';
|
||||
let authorized: string[] | null = null;
|
||||
let keyKindUsed = 'ed25519';
|
||||
|
||||
@@ -138,18 +205,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
|
||||
if (!client?.pubkey) {
|
||||
log('warn', `鉴权失败(调用方未注册)client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '调用方未在 ACL 中注册' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, `调用方未在 ACL 中注册: ${clientId}`, {
|
||||
hint: '在被控端 acl.clients 中加入该 client_id 与公钥',
|
||||
});
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
|
||||
return;
|
||||
}
|
||||
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
|
||||
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? params.signature : '')) {
|
||||
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: 'Ed25519 验签失败' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'Ed25519 签名校验失败');
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
|
||||
return;
|
||||
}
|
||||
authorized = authorizedCapabilities(
|
||||
@@ -160,22 +221,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
} else {
|
||||
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
|
||||
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
|
||||
const scopedKey = cfg.keys?.[clientId];
|
||||
//
|
||||
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
|
||||
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
|
||||
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
|
||||
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
|
||||
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
|
||||
const keyForClient = scopedKey ?? cfg.key;
|
||||
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
|
||||
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
|
||||
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
|
||||
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
|
||||
let hmacOk = false;
|
||||
try {
|
||||
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
|
||||
} catch (err) {
|
||||
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
|
||||
hmacOk = false;
|
||||
}
|
||||
if (!hmacOk) {
|
||||
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
|
||||
audit({
|
||||
type: 'auth.failure',
|
||||
client_id: clientId,
|
||||
remote: state.remote,
|
||||
reason: scopedKey
|
||||
? '该 client_id 的专属密钥校验失败'
|
||||
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
failOut('预共享密钥校验失败', scopedKey
|
||||
? '该 client_id 有专属密钥,需用那把 key'
|
||||
: '用错了 key,或该 client_id 应登记到 keys{}');
|
||||
return;
|
||||
}
|
||||
keyKindUsed = keyKind;
|
||||
@@ -183,6 +250,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
state.nonceExpiresAt = 0;
|
||||
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
state.authenticated = true;
|
||||
state.clientId = clientId;
|
||||
state.authorized = authorized;
|
||||
@@ -211,11 +279,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未完成认证即调用能力',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
|
||||
return;
|
||||
}
|
||||
const params = req.params as InvokeParams | undefined;
|
||||
const name = params?.capability ?? '';
|
||||
const name = typeof params?.capability === 'string' ? params.capability : '';
|
||||
|
||||
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
|
||||
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
|
||||
@@ -243,6 +317,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
|
||||
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
|
||||
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
|
||||
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
|
||||
|
||||
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
|
||||
@@ -306,6 +381,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const cap = findCapability(name);
|
||||
const handler = capabilityRegistry[name];
|
||||
if (!cap || !handler) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `不支持的能力: ${name}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
|
||||
return;
|
||||
}
|
||||
@@ -313,6 +396,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const args = (params?.args ?? {}) as Record<string, unknown>;
|
||||
const errors = validate(args, cap.params_schema);
|
||||
if (errors.length > 0) {
|
||||
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
|
||||
return;
|
||||
}
|
||||
@@ -324,6 +416,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
|
||||
const data = await handler(finalArgs, {
|
||||
owner: state.connId,
|
||||
clientId: state.clientId ?? '',
|
||||
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
|
||||
});
|
||||
const duration = Date.now() - started;
|
||||
@@ -370,10 +463,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
try {
|
||||
req = JSON.parse(text) as RpcRequest;
|
||||
} catch {
|
||||
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
|
||||
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
|
||||
return;
|
||||
}
|
||||
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
|
||||
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: states.get(ws)?.remote,
|
||||
status: 'denied',
|
||||
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
|
||||
});
|
||||
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
|
||||
return;
|
||||
}
|
||||
@@ -392,6 +493,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
return;
|
||||
case Methods.Capabilities:
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未认证即拉取能力清单',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
|
||||
return;
|
||||
}
|
||||
@@ -401,10 +508,60 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
sendResult(ws, req.id, { pong: true, ts: Date.now() });
|
||||
return;
|
||||
default:
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `未知方法: ${req.method}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
|
||||
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
|
||||
interface BanState { fails: number; bannedUntil: number }
|
||||
const authBan = new Map<string, BanState>();
|
||||
const banMax = Math.max(1, cfg.security?.auth_ban?.max_attempts ?? 5);
|
||||
const banBaseMs = Math.max(1000, cfg.security?.auth_ban?.ban_ms ?? 600_000);
|
||||
const banWhitelist = (cfg.security?.auth_ban?.whitelist ?? []).map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
/** 归一化来源 IP:剥离 IPv4-mapped 前缀(::ffff:1.2.3.4 → 1.2.3.4),否则同一来源会被当成两个键。 */
|
||||
const normIp = (addr: string): string => addr.replace(/^::ffff:/i, '');
|
||||
|
||||
/** 该来源还有多久解封(0 = 不在封禁期)。白名单来源永不封禁。 */
|
||||
function banRemainingMs(remote: string): number {
|
||||
const ip = normIp(remote);
|
||||
if (banWhitelist.includes(ip)) return 0;
|
||||
const st = authBan.get(ip);
|
||||
if (!st) return 0;
|
||||
const left = st.bannedUntil - Date.now();
|
||||
return left > 0 ? left : 0; // 已过期则不挡(计数保留,继续指数退避)
|
||||
}
|
||||
|
||||
/** 记录一次握手失败;返回本次是否**新触发**封禁(好让错误响应带上 retry_after_ms)。 */
|
||||
function recordAuthFailure(remote: string): { banned: boolean; waitMs: number } {
|
||||
const ip = normIp(remote);
|
||||
if (banWhitelist.includes(ip)) return { banned: false, waitMs: 0 };
|
||||
const prev = authBan.get(ip) ?? { fails: 0, bannedUntil: 0 };
|
||||
const fails = prev.fails + 1;
|
||||
if (fails < banMax) {
|
||||
authBan.set(ip, { fails, bannedUntil: 0 });
|
||||
return { banned: false, waitMs: 0 };
|
||||
}
|
||||
// 第 N 次失败起:ban = base × 2^(N - max),封顶 24h
|
||||
const power = Math.min(10, fails - banMax);
|
||||
const waitMs = Math.min(86_400_000, banBaseMs * 2 ** power);
|
||||
authBan.set(ip, { fails, bannedUntil: Date.now() + waitMs });
|
||||
return { banned: true, waitMs };
|
||||
}
|
||||
|
||||
function recordAuthSuccess(remote: string): void {
|
||||
const ip = normIp(remote);
|
||||
if (authBan.delete(ip)) log('info', `握手失败计数已清零(来源 ${ip} 本次认证成功)`);
|
||||
}
|
||||
|
||||
function attachConnection(ws: WebSocket, remote: string): void {
|
||||
log('info', `新连接: ${remote}`);
|
||||
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -416,20 +573,50 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
authorized: null,
|
||||
remote,
|
||||
connId,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
|
||||
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
|
||||
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
|
||||
ws.on('pong', () => {
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
});
|
||||
ws.on('message', (raw: RawData) => {
|
||||
void handleMessage(ws, raw.toString());
|
||||
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
|
||||
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
|
||||
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
|
||||
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
|
||||
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
|
||||
try {
|
||||
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
|
||||
} catch {
|
||||
/* 连接可能已关闭 */
|
||||
}
|
||||
});
|
||||
});
|
||||
ws.on('close', () => {
|
||||
log('info', `连接关闭: ${remote}`);
|
||||
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
|
||||
const n = disposeWatchesByOwner(connId);
|
||||
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
|
||||
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
});
|
||||
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
|
||||
}
|
||||
|
||||
return { attach: attachConnection };
|
||||
return {
|
||||
attach: attachConnection,
|
||||
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
banRemainingMs,
|
||||
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
|
||||
states,
|
||||
};
|
||||
}
|
||||
|
||||
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
|
||||
@@ -449,6 +636,11 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
|
||||
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
|
||||
// v23③:空闲断开阈值(0 = 关闭)
|
||||
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
|
||||
|
||||
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
|
||||
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
|
||||
wss.on('connection', (ws, req) => {
|
||||
@@ -459,13 +651,53 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
ws.close(1008, 'source not allowed');
|
||||
return;
|
||||
}
|
||||
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
|
||||
const bannedLeft = core.banRemainingMs(remote);
|
||||
if (bannedLeft > 0) {
|
||||
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
|
||||
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
|
||||
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
|
||||
return;
|
||||
}
|
||||
// v23②:连接数上限
|
||||
if (core.states.size >= maxConnections) {
|
||||
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
|
||||
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
|
||||
ws.close(1013, 'too many connections');
|
||||
return;
|
||||
}
|
||||
core.attach(ws, remote);
|
||||
});
|
||||
|
||||
// WebSocket 层保活:30s ping,探测死连接
|
||||
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
|
||||
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
|
||||
// 连接会一直挂在 states 里,占用 max_connections 名额。
|
||||
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
|
||||
const st = core.states.get(ws);
|
||||
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
|
||||
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.ping();
|
||||
}
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
|
||||
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
|
||||
if (runningTaskCount() > 0 || watchCount() > 0) return;
|
||||
const now = Date.now();
|
||||
for (const [ws, st] of core.states) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
const idleFor = now - st.lastActiveAt;
|
||||
if (idleFor < idleTimeoutMs) continue;
|
||||
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
|
||||
ws.close(1000, 'idle timeout');
|
||||
}
|
||||
}, 30_000);
|
||||
heartbeat.unref();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
*
|
||||
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
|
||||
* **同时存在两套坐标系**,而且互不提醒:
|
||||
*
|
||||
* | 调用 | 该进程看到的 |
|
||||
* |---|---|
|
||||
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 3600×2020 → 报 1800×1010) |
|
||||
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
|
||||
* | `CopyFromScreen(0,0,1800,1010)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
|
||||
* | `SetCursorPos(552,1489)` | 落点实际在物理 (1104, 2978)(被系统 ×2) |
|
||||
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
|
||||
*
|
||||
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
|
||||
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
|
||||
* 「找得到、点不准」,而且不报任何错。
|
||||
*
|
||||
* ## 用法
|
||||
*
|
||||
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
|
||||
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
|
||||
*
|
||||
* ## 真机实证(2026-10-12)
|
||||
*
|
||||
* Parallels Win11 ARM 虚拟机,主机缩放 200%,物理 3600×2020:
|
||||
* - 缺省 `screen.capture` 只拿到 1800×1010(= 屏幕左上 1/4),且 `screen.info` 报 1800×1010
|
||||
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
|
||||
* - `input.mouse.move 552,1489` → 实际落点 **(1104, 2019)**(截断到屏幕底部)
|
||||
* - 补上本段后:`screen.capture` 拿到完整 3600×2020;`screen.find` 的物理坐标
|
||||
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
|
||||
*
|
||||
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
|
||||
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
|
||||
*/
|
||||
export const PS_DPI_AWARE = [
|
||||
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
|
||||
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
|
||||
`[void][NADpiAware]::SetProcessDPIAware()`,
|
||||
].join('\n');
|
||||
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execCommand } from './exec.js';
|
||||
import { isSafeBareWindowsPathArg } from './quote.js';
|
||||
|
||||
/**
|
||||
* PowerShell 常驻助手(v14)。
|
||||
@@ -438,8 +439,18 @@ export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout:
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-ps-'));
|
||||
const scriptPath = join(dir, 'run.ps1');
|
||||
writeFileSync(scriptPath, `\uFEFF${prelude}\n${body}`, 'utf8');
|
||||
// ⚠️ 这里**只能裸拼**路径:`-File` 的引号由 powershell.exe 自己的命令行解析器处理,
|
||||
// 塞 PS 单引号字面量进去不保证被剥掉。既然裸拼,就必须**显式**确认路径里没有
|
||||
// 会改变解析的字符(引号/$/&/|/^/…)—— 正常它由 mkdtempSync 生成,但 TMP 被设成
|
||||
// 怪路径时前提就破了,而且破了也是静默的(与 W-01 同类)。破前提就当场报错。
|
||||
if (!isSafeBareWindowsPathArg(scriptPath)) {
|
||||
throw new Error(
|
||||
`PowerShell 临时脚本路径含可能改变命令行解析的字符,拒绝拼接:${JSON.stringify(scriptPath)}` +
|
||||
'(请检查 TMP/TEMP 或用户目录名)',
|
||||
);
|
||||
}
|
||||
const r = await execCommand({
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${JSON.stringify(scriptPath)}`,
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${scriptPath}`,
|
||||
timeoutMs,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 跨平台「安全字面量」工具 —— 把不可信字符串安全地嵌进 shell / PowerShell 脚本。
|
||||
*
|
||||
* ## 为什么必须有这个文件
|
||||
*
|
||||
* 本项目真机踩到过 **P0 级静默 RCE**:把 `JSON.stringify(title)` 直接插进 PowerShell
|
||||
* 双引号串,payload `x"; New-Item …; #` 就执行了任意命令 —— 而接口还返回「正常的业务错误」,
|
||||
* 调用方完全看不出异常(见 `window.ts` 的历史注释与 bug 报告 W-01)。
|
||||
*
|
||||
* 两条铁律(违反任一条都会重新引入漏洞):
|
||||
*
|
||||
* 1. **PowerShell 里 `\` 不是转义符**(那是 C/JS 的规则)。JSON 产出的 `\"` 会被 PS 解析成
|
||||
* 「字面反斜杠 + 字符串结束引号」,后面的内容就成了新语句。
|
||||
* 2. **PS 双引号串会做变量插值与子表达式展开**(`$x`、`$(...)`、反引号),
|
||||
* 而**单引号串什么都不展开** —— 这是唯一正确的选择。
|
||||
*
|
||||
* 因此:**任何**嵌入 PowerShell 的不可信值都必须走 `psLit()`;
|
||||
* 嵌入 bash/sh 的走 `shLit()`;嵌进命令行参数数组的(`spawn(file, args)`)根本不需要引用,
|
||||
* 那是首选方案,不要拼字符串。
|
||||
*/
|
||||
|
||||
/** PowerShell 单引号字面量:内部单引号翻倍,其余字符(含 `$`、反引号、反斜杠)全部原样。 */
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** PowerShell 数组字面量:`@('a','b')`。空数组产出 `@()`。 */
|
||||
export function psLitArray(values: readonly string[]): string {
|
||||
return `@(${values.map(psLit).join(',')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX shell 单引号字面量:把 `'` 拆成 `'\''`(闭合 → 转义单引号 → 重开)。
|
||||
* 单引号内不会有变量展开、命令替换或反斜杠转义。
|
||||
*/
|
||||
export function shLit(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单引号字面量的形状(供调用侧自检):首尾是 `'`,且内部**没有落单的单引号**。
|
||||
*
|
||||
* 原理:正确转义后内部每个 `'` 都成对(`''`),把 `''` 全部消去后不应再残留任何 `'`。
|
||||
* 只做形状检查,不替代真机验证。
|
||||
*/
|
||||
export function assertSingleQuotedLiteral(lit: string): boolean {
|
||||
if (!lit.startsWith("'") || !lit.endsWith("'")) return false;
|
||||
return !lit.slice(1, -1).replace(/''/g, '').includes("'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 原生命令行参数的「裸传」前提校验。
|
||||
*
|
||||
* 有些位置**不能用** `psLit()`:例如 `powershell.exe -File <path>` —— 这一段的引号由
|
||||
* `powershell.exe` 自己的命令行解析器处理,塞 PS 单引号字面量进去不保证被剥掉。
|
||||
* 这种位置只能把路径**裸着**拼进命令行,于是必须保证它**本身含不了任何会改变语义的字符**。
|
||||
*
|
||||
* 真例(v2.0.3 审查发现,与 W-01 同类):`util/ps-helper.ts` 把 `mkdtempSync` 产出的临时脚本
|
||||
* 路径直接拼进 `-File`。那条路径由本进程生成、正常含不了引号,但**这是隐含前提**——
|
||||
* 一旦 `TMP`/`TEMP` 被设成怪路径(或用户目录名含特殊字符),前提就破了,而且是静默的。
|
||||
* 故把前提**显式化**:不满足就当场报错,而不是把可疑字符串拼进命令行。
|
||||
*/
|
||||
export function isSafeBareWindowsPathArg(v: string): boolean {
|
||||
if (v === '' || v.includes('\0')) return false;
|
||||
// 引号/反引号/$/&/|/</>/(/)/%/^/!/换行:任何一个都能改变 cmd.exe 或 PS 的解析
|
||||
if (/["'`$&|<>()%^!\r\n\t]/.test(v)) return false;
|
||||
// 结尾是单个反斜杠会在双引号包裹时吃掉闭引号(cmd 的经典怪癖)
|
||||
return !/\\$/.test(v);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.4.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -57,7 +57,9 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
const profile: NodeProfile = {
|
||||
host,
|
||||
port,
|
||||
tls: !process.argv.includes('--no-tls'),
|
||||
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
|
||||
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
|
||||
tls: !opts.noTls,
|
||||
insecure: opts.insecure,
|
||||
key: opts.key ?? '',
|
||||
auth_mode: authMode,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
HOTKEY_PRESETS,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
@@ -61,7 +62,15 @@ export async function cmdScreen(opts: Options): Promise<void> {
|
||||
export async function cmdScreenshot(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.format) args['format'] = opts.format;
|
||||
if (opts.scale) args['scale'] = Number(opts.scale);
|
||||
if (opts.scale) {
|
||||
const s = Number(opts.scale);
|
||||
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
|
||||
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
|
||||
if (!Number.isFinite(s) || s <= 0 || s > 1) {
|
||||
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
|
||||
}
|
||||
args['scale'] = s;
|
||||
}
|
||||
if (opts.region) {
|
||||
const nums = opts.region.split(',').map(Number);
|
||||
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
|
||||
@@ -163,19 +172,57 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
|
||||
// v1.5.0:三种用法(一次一种)
|
||||
// nodeagent key press "ctrl+shift+esc" 字符串热键
|
||||
// nodeagent key press copy 预设名
|
||||
// nodeagent key press ctrl c 老形式(空格分隔的各键)
|
||||
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
|
||||
const args: Record<string, unknown> = {};
|
||||
let positionalsEcho = positionals;
|
||||
if (positionals.length === 1) {
|
||||
const one = positionals[0]!;
|
||||
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
|
||||
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
|
||||
if (spec) {
|
||||
args['preset'] = one.trim().toLowerCase();
|
||||
} else {
|
||||
args['hotkey'] = one;
|
||||
}
|
||||
} else {
|
||||
args['keys'] = positionals;
|
||||
}
|
||||
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
// v1.5.0 新增可选参数
|
||||
if (opts.hold) args['hold_ms'] = Number(opts.hold);
|
||||
if (opts.route) args['route'] = opts.route;
|
||||
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
|
||||
if (opts.pid) args['target_pid'] = Number(opts.pid);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => {
|
||||
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
|
||||
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
|
||||
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
|
||||
// "Cannot read properties of undefined (reading 'join')")
|
||||
// 故优先用请求参数回显,缺失时再退回 chords 展开。
|
||||
const r = d as { keys?: string[]; chords?: string[][]; times?: number };
|
||||
const r = d as {
|
||||
keys?: string[]; chords?: string[][]; times?: number; via?: string;
|
||||
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
|
||||
target_pid?: number | null; note?: string;
|
||||
};
|
||||
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
|
||||
const label = r.keys?.length
|
||||
? r.keys.join('+')
|
||||
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
|
||||
: shown || positionalsEcho.join('+');
|
||||
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
|
||||
console.log(`✓ 已按下 ${label}${times}`);
|
||||
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
|
||||
const via =
|
||||
r.channel === 'appcommand'
|
||||
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
|
||||
: r.channel === 'postmessage'
|
||||
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
|
||||
: '';
|
||||
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -185,6 +232,64 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v1.6.0 等待条件(gui.await) ----------
|
||||
|
||||
/**
|
||||
* 等待一个条件成立再返回(只读低危)。
|
||||
*
|
||||
* nodeagent await --condition control --text "完成" --timeout 30000
|
||||
* nodeagent await --condition window --title "安装程序"
|
||||
* nodeagent await --condition process --process setup.exe
|
||||
* nodeagent await --condition file --path "C:\\log.txt" --state absent
|
||||
*
|
||||
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
|
||||
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
|
||||
*/
|
||||
export async function cmdAwait(opts: Options): Promise<void> {
|
||||
const condition = opts.condition as string | undefined;
|
||||
if (!condition) {
|
||||
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
|
||||
}
|
||||
const args: Record<string, unknown> = { condition };
|
||||
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
for (const k of ['where', 'any_of']) {
|
||||
const raw = (opts as unknown as Record<string, string | undefined>)[k];
|
||||
if (raw !== undefined) {
|
||||
try {
|
||||
args[k] = JSON.parse(raw);
|
||||
} catch {
|
||||
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.text !== undefined) args['text'] = opts.text;
|
||||
if (opts.title !== undefined) args['title'] = opts.title;
|
||||
if (opts.process !== undefined) args['process'] = opts.process;
|
||||
if (opts.path !== undefined) args['path'] = opts.path;
|
||||
if (opts.state !== undefined) args['state'] = opts.state;
|
||||
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
satisfied: boolean; condition: string; state: string;
|
||||
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
|
||||
};
|
||||
const mark = r.satisfied ? '✓' : '✗';
|
||||
const label = r.condition === 'control' ? String(args['text'] ?? '')
|
||||
: r.condition === 'window' ? String(args['title'] ?? '')
|
||||
: r.condition === 'process' ? String(args['process'] ?? '')
|
||||
: String(args['path'] ?? '');
|
||||
console.log(
|
||||
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
|
||||
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
|
||||
);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v3+ 审计 ----------
|
||||
|
||||
/** v11:审计链完整性校验。 */
|
||||
@@ -273,7 +273,10 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
const uploadInfo = await withClient(async (c) => {
|
||||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||||
const backup = `${target}.bak-${Date.now()}`;
|
||||
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
|
||||
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
|
||||
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
|
||||
const backup = `${target}.bak`;
|
||||
let backupPath = '';
|
||||
try {
|
||||
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
|
||||
@@ -329,6 +332,23 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
offset += part.length;
|
||||
}
|
||||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
|
||||
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
|
||||
try {
|
||||
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
|
||||
const cleanup = isWin
|
||||
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
|
||||
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
|
||||
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
|
||||
const left = Number((r.data?.stdout ?? '').trim());
|
||||
if (r.status === 'ok' && Number.isFinite(left)) {
|
||||
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
|
||||
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
|
||||
}
|
||||
} catch {
|
||||
/* 清理是附加项,失败不影响部署结果 */
|
||||
}
|
||||
return { backupPath };
|
||||
});
|
||||
|
||||
@@ -375,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
|
||||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -620,3 +640,115 @@ export async function cmdClip(opts: Options): Promise<void> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||||
|
||||
/**
|
||||
* 状态采样与回看。
|
||||
*
|
||||
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
|
||||
* nodeagent monitor report <id>
|
||||
* nodeagent monitor stop <id>
|
||||
* nodeagent monitor list
|
||||
* nodeagent monitor delete <id>
|
||||
*/
|
||||
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
|
||||
switch (action) {
|
||||
case 'start': {
|
||||
const source = opts.source;
|
||||
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
|
||||
const args: Record<string, unknown> = { source };
|
||||
if (opts.target !== undefined) args['target'] = opts.target;
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
if (positional[0]) args['id'] = positional[0];
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
|
||||
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
|
||||
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
|
||||
console.log(` 样本文件:${r.file}`);
|
||||
console.log(` 回看:nodeagent monitor report ${r.id}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'report': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
|
||||
const args: Record<string, unknown> = { id };
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
running: boolean; source: string; target: string; samples: number;
|
||||
summary: Record<string, number>;
|
||||
};
|
||||
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
|
||||
// 摘要直接说人话(这是本能力的主要价值)
|
||||
const sm = r.summary;
|
||||
if (sm.down_count !== undefined) {
|
||||
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
|
||||
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
|
||||
}
|
||||
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
|
||||
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
|
||||
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
|
||||
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'stop': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor stop <id>');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
|
||||
const r = d as { id: string; samples: number; file: string };
|
||||
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'list': {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
|
||||
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
|
||||
console.log(`运行中 ${r.running_count} 个:`);
|
||||
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
|
||||
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'delete': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
|
||||
}
|
||||
}
|
||||
|
||||
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
|
||||
export async function cmdLog(opts: Options): Promise<void> {
|
||||
const logPath = opts.path;
|
||||
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
|
||||
const args: Record<string, unknown> = { path: logPath };
|
||||
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
|
||||
if (opts.level !== undefined) args['level'] = opts.level;
|
||||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
|
||||
if (opts.tail === true) args['tail'] = true;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
|
||||
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
|
||||
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
|
||||
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,54 @@ export async function cmdRestart(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 审计链外部锚定 ----------
|
||||
|
||||
/**
|
||||
* 打印审计链头(可选与最近锚点比对)。
|
||||
*
|
||||
* nodeagent audit head [--compare]
|
||||
*/
|
||||
export async function cmdAuditHead(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.compare === true) args['compare'] = true;
|
||||
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
entries: number; head_hash: string | null; head_ts: number | null;
|
||||
rotated_segments: number; file_bytes: number;
|
||||
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
|
||||
};
|
||||
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
|
||||
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
|
||||
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
|
||||
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
|
||||
if (d.comparison) {
|
||||
const k = d.comparison.ok ? '✓' : '✗';
|
||||
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一个锚点到锚点文件。
|
||||
*
|
||||
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
|
||||
*/
|
||||
export async function cmdAuditAnchor(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
|
||||
if (opts.note !== undefined) args['note'] = opts.note;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
|
||||
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
|
||||
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
|
||||
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdAuditVerify(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
|
||||
|
||||
@@ -48,6 +48,13 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent macro validate <file.json> | macro init [文件]
|
||||
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
|
||||
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
|
||||
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
|
||||
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
|
||||
command 取退出码/metric 取 CPU·内存
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
|
||||
nodeagent monitor stop|list|delete <id>
|
||||
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
|
||||
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
|
||||
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
|
||||
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
|
||||
@@ -72,6 +79,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent mouse scroll <delta>
|
||||
nodeagent key type "<文本>" [--interval 10]
|
||||
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
|
||||
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
|
||||
--pid 定向到该进程的全部顶层窗口;省略则广播。
|
||||
窗口被遮挡/最小化/游戏全屏时依然有效)
|
||||
|
||||
通用选项:
|
||||
--node <设备名> 本次命令临时指定目标设备(不改变 current)
|
||||
|
||||
@@ -34,13 +34,13 @@ import { HELP } from './help.js';
|
||||
import { parseOptions } from './options.js';
|
||||
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
|
||||
import {
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
|
||||
} from './cmd/system.js';
|
||||
import {
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks,
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
|
||||
} from './cmd/ops.js';
|
||||
import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
|
||||
import { cmdNet } from './cmd/net.js';
|
||||
import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js';
|
||||
@@ -60,6 +60,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ `--help` 是**选项**、不是位置参数。
|
||||
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
|
||||
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
|
||||
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
|
||||
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
|
||||
if (opts.help) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'connect': {
|
||||
const host = positionals[0];
|
||||
@@ -187,6 +197,14 @@ async function main(): Promise<void> {
|
||||
case 'events':
|
||||
await cmdEvents(opts);
|
||||
return;
|
||||
case 'monitor': {
|
||||
const [action, ...rest2] = positionals;
|
||||
await cmdMonitor(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'log':
|
||||
await cmdLog(opts);
|
||||
return;
|
||||
case 'macro': {
|
||||
const sub = positionals[0];
|
||||
await cmdMacro(sub, positionals[1], opts);
|
||||
@@ -268,6 +286,8 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
case 'audit':
|
||||
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
|
||||
else if (positionals[0] === 'head') await cmdAuditHead(opts);
|
||||
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
|
||||
else await cmdAudit(opts);
|
||||
return;
|
||||
case 'discover':
|
||||
@@ -304,6 +324,10 @@ async function main(): Promise<void> {
|
||||
await cmdKey(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'await': {
|
||||
await cmdAwait(opts);
|
||||
return;
|
||||
}
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
|
||||
@@ -38,6 +38,11 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest,
|
||||
options: {
|
||||
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
|
||||
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
|
||||
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
|
||||
help: { type: 'boolean', default: false },
|
||||
'no-tls': { type: 'boolean', default: false },
|
||||
json: { type: 'boolean', default: false },
|
||||
insecure: { type: 'boolean', default: false },
|
||||
port: { type: 'string' },
|
||||
@@ -54,6 +59,24 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: { type: 'string' },
|
||||
duration: { type: 'string' },
|
||||
interval: { type: 'string' },
|
||||
tail: { type: 'boolean' },
|
||||
compare: { type: 'boolean' },
|
||||
'anchor-path': { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
level: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
where: { type: 'string' },
|
||||
any_of: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
process: { type: 'string' },
|
||||
state: { type: 'string' },
|
||||
condition: { type: 'string' },
|
||||
// 媒体键定向投递的目标进程 PID(v1.2)
|
||||
pid: { type: 'string' },
|
||||
hold: { type: 'string' },
|
||||
route: { type: 'string' },
|
||||
'auth-mode': { type: 'string' },
|
||||
since: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
@@ -62,7 +85,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
'discovery-port': { type: 'string' },
|
||||
node: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
recursive: { type: 'boolean', default: false },
|
||||
pattern: { type: 'string' },
|
||||
'create-dirs': { type: 'boolean', default: false },
|
||||
@@ -72,6 +94,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
delay: { type: 'string' },
|
||||
// v10 后台任务 / 剪贴板
|
||||
'timeout-ms': { type: 'string' },
|
||||
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
|
||||
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
|
||||
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
|
||||
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
|
||||
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
|
||||
timeout: { type: 'string' },
|
||||
kill: { type: 'boolean', default: false },
|
||||
offset: { type: 'string' },
|
||||
set: { type: 'string' },
|
||||
@@ -109,6 +137,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
});
|
||||
return {
|
||||
opts: {
|
||||
help: Boolean(values['help']),
|
||||
noTls: Boolean(values['no-tls']),
|
||||
json: Boolean(values['json']),
|
||||
insecure: Boolean(values['insecure']),
|
||||
port: values['port'] as string | undefined,
|
||||
@@ -123,6 +153,23 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: values['button'] as string | undefined,
|
||||
duration: values['duration'] as string | undefined,
|
||||
interval: values['interval'] as string | undefined,
|
||||
tail: values['tail'] as boolean | undefined,
|
||||
compare: values['compare'] as boolean | undefined,
|
||||
anchorPath: values['anchor-path'] as string | undefined,
|
||||
note: values['note'] as string | undefined,
|
||||
level: values['level'] as string | undefined,
|
||||
target: values['target'] as string | undefined,
|
||||
source: values['source'] as string | undefined,
|
||||
where: values['where'] as string | undefined,
|
||||
any_of: values['any_of'] as string | undefined,
|
||||
condition: values['condition'] as string | undefined,
|
||||
state: values['state'] as string | undefined,
|
||||
process: values['process'] as string | undefined,
|
||||
text: values['text'] as string | undefined,
|
||||
title: values['title'] as string | undefined,
|
||||
pid: values['pid'] as string | undefined,
|
||||
hold: values['hold'] as string | undefined,
|
||||
route: values['route'] as string | undefined,
|
||||
authMode: values['auth-mode'] as string | undefined,
|
||||
since: values['since'] as string | undefined,
|
||||
type: values['type'] as string | undefined,
|
||||
@@ -131,7 +178,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
discoveryPort: values['discovery-port'] as string | undefined,
|
||||
node: values['node'] as string | undefined,
|
||||
delay: values['delay'] as string | undefined,
|
||||
timeoutMs: values['timeout-ms'] as string | undefined,
|
||||
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
|
||||
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
|
||||
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
|
||||
kill: Boolean(values['kill']),
|
||||
offset: values['offset'] as string | undefined,
|
||||
set: values['set'] as string | undefined,
|
||||
@@ -164,7 +213,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
seconds: values['seconds'] as string | undefined,
|
||||
intervalMs: values['interval-ms'] as string | undefined,
|
||||
name: values['name'] as string | undefined,
|
||||
note: values['note'] as string | undefined,
|
||||
recursive: Boolean(values['recursive']),
|
||||
pattern: values['pattern'] as string | undefined,
|
||||
createDirs: Boolean(values['create-dirs']),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// CLI 共享类型与常量(从 index.ts 拆出)
|
||||
/** 已解析的常用选项。 */
|
||||
export interface Options {
|
||||
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
|
||||
help: boolean;
|
||||
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
|
||||
noTls: boolean;
|
||||
json: boolean;
|
||||
insecure: boolean;
|
||||
port?: string;
|
||||
@@ -16,10 +20,38 @@ export interface Options {
|
||||
button?: string;
|
||||
duration?: string;
|
||||
interval?: string;
|
||||
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
|
||||
/** 通用选项:文本 / 标题 */
|
||||
text?: string;
|
||||
title?: string;
|
||||
/** v2.0.0 审计锚定 */
|
||||
compare?: boolean;
|
||||
anchorPath?: string;
|
||||
note?: string;
|
||||
/** v2.0.0 状态采样 */
|
||||
source?: string;
|
||||
target?: string;
|
||||
pattern?: string;
|
||||
level?: string;
|
||||
since?: string;
|
||||
offset?: string;
|
||||
tail?: boolean;
|
||||
/** v2.0.0 gui.await 扩展 */
|
||||
where?: string;
|
||||
any_of?: string;
|
||||
/** v1.6.0 gui.await */
|
||||
condition?: string;
|
||||
state?: string;
|
||||
path?: string;
|
||||
process?: string;
|
||||
/** v1.5.0 快捷键扩展:长按毫秒 */
|
||||
hold?: string;
|
||||
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
|
||||
route?: string;
|
||||
pid?: string;
|
||||
/** v3:认证模式 psk | ed25519 */
|
||||
authMode?: string;
|
||||
/** v3+ 审计查询 */
|
||||
since?: string;
|
||||
type?: string;
|
||||
clientId?: string;
|
||||
/** v4 发现 */
|
||||
@@ -28,10 +60,8 @@ export interface Options {
|
||||
/** v5 多设备 */
|
||||
node?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
/** v5 文件 */
|
||||
recursive?: boolean;
|
||||
pattern?: string;
|
||||
createDirs?: boolean;
|
||||
/** v6 Hub */
|
||||
hubToken?: string;
|
||||
@@ -41,7 +71,6 @@ export interface Options {
|
||||
/** v10 后台任务 / 剪贴板 */
|
||||
timeoutMs?: string;
|
||||
kill?: boolean;
|
||||
offset?: string;
|
||||
set?: string;
|
||||
/** v11 剪贴板图片 */
|
||||
imageFile?: string;
|
||||
@@ -50,7 +79,6 @@ export interface Options {
|
||||
/** v11 多设备并发 */
|
||||
nodes?: string;
|
||||
/** v11 部署目标路径 */
|
||||
path?: string;
|
||||
/** v12 事件订阅 */
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nodeagent 控制台</title>
|
||||
<script>
|
||||
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
|
||||
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
|
||||
//
|
||||
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
|
||||
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
|
||||
// 改调色板底色时记得同步这两行。
|
||||
try {
|
||||
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
|
||||
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
|
||||
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
|
||||
} catch (e) {
|
||||
/* localStorage 不可用(隐私模式)时走默认底色即可 */
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"sidecar:dev": "node sidecar/main.mjs",
|
||||
"sidecar:build": "node sidecar/build.mjs",
|
||||
"prepare:node": "node ../../scripts/prepare-console.mjs",
|
||||
"verify:bundle": "node scripts/verify-bundle.mjs",
|
||||
"bundle": "pnpm sidecar:build && pnpm --filter @nodeagent/agent build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.3.1",
|
||||
"@nodeagent/client": "workspace:*",
|
||||
"@nodeagent/protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 验证「控制台的打包产物能否自举」—— 即打包内的 Node 运行时能否跑起打包内的 sidecar。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/verify-bundle.mjs # 自动定位打包产物(macOS)
|
||||
* node scripts/verify-bundle.mjs --app <x.app>
|
||||
* node scripts/verify-bundle.mjs --runtime # 只用 prepare 出的 runtime + sidecar.dist
|
||||
*
|
||||
* 为什么需要它
|
||||
* ----------
|
||||
* `pnpm bundle` 成功、`tauri build` 报告 Finished,**不代表产物能跑**:
|
||||
* 2026-10-11 实测踩到 —— macOS 上 `strip` 会使 Mach-O 代码签名失效,
|
||||
* 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,没有任何有用报错。
|
||||
* 这类问题只存在于「打包产物」中:源码与单测都正常,只有真跑一遍产物才会暴露。
|
||||
* 本脚本把那次人工排查固化成一条命令。
|
||||
*
|
||||
* 它同时覆盖三件事(缺一不可):
|
||||
* ① 打包内的 node 真能执行(签名 / strip / 可执行位都对)
|
||||
* ② 打包内的 sidecar.mjs 真能被它加载(bundle 完整、无缺依赖)
|
||||
* ③ 两者之间的行分隔 JSON-RPC 通道真的通
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // apps/console/scripts
|
||||
const consoleDir = resolve(here, '..'); // apps/console
|
||||
const isWin = process.platform === 'win32';
|
||||
const NODE_NAME = isWin ? 'node.exe' : 'node';
|
||||
|
||||
const arg = (n) => {
|
||||
const i = process.argv.indexOf(`--${n}`);
|
||||
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
||||
? process.argv[i + 1]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** 定位打包产物(macOS:bundle/macos/*.app;Windows:无 .app,回落到 runtime)。 */
|
||||
function findApp() {
|
||||
const explicit = arg('app');
|
||||
if (explicit) return resolve(explicit);
|
||||
const dir = join(consoleDir, 'src-tauri/target/release/bundle/macos');
|
||||
if (existsSync(dir)) {
|
||||
const app = readdirSync(dir).find((f) => f.endsWith('.app'));
|
||||
if (app) return join(dir, app);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nodeBin;
|
||||
let sidecar;
|
||||
let agentScript;
|
||||
let source;
|
||||
|
||||
const app = process.argv.includes('--runtime') ? undefined : findApp();
|
||||
if (app) {
|
||||
// ⚠️ Tauri 的 resource 映射 `"runtime/node": "node"` 使产物落在 Contents/Resources/node
|
||||
// —— 必须与 src-tauri/src/sidecar.rs 的 resolve_node/resolve_sidecar 保持一致;
|
||||
// `"runtime/agent.mjs": "agent.mjs"` 则落在 Contents/Resources/agent.mjs,
|
||||
// 与 src-tauri/src/agent.rs 的 resolve_bundled_agent 一致。
|
||||
const res = join(app, 'Contents/Resources');
|
||||
nodeBin = join(res, NODE_NAME);
|
||||
sidecar = join(res, 'sidecar.mjs');
|
||||
agentScript = join(res, 'agent.mjs');
|
||||
source = `打包产物 ${app}`;
|
||||
} else {
|
||||
nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`);
|
||||
sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs');
|
||||
agentScript = join(consoleDir, 'src-tauri/runtime/agent.mjs');
|
||||
source = '准备产物(runtime + sidecar/dist)';
|
||||
}
|
||||
|
||||
console.log(`=== 验证控制台产物自举 ===`);
|
||||
console.log(` 来源:${source}`);
|
||||
|
||||
for (const [label, p] of [
|
||||
['Node 运行时', nodeBin],
|
||||
['sidecar', sidecar],
|
||||
['被控端 agent.mjs', agentScript],
|
||||
]) {
|
||||
if (!existsSync(p)) {
|
||||
console.error(`✗ 缺少 ${label}:${p}`);
|
||||
console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ✓ ${label}:${p}`);
|
||||
}
|
||||
|
||||
// ---------- ③ 打包内的 node 必须能真正**解析**打包内的 agent.mjs ----------
|
||||
// 只查「文件存在」是不够的:截断、半写、依赖缺失都会让文件在、却加载不了,
|
||||
// 而那要到用户点「启动」时才炸。`node --check` 只做语法/模块解析、不执行,
|
||||
// 无副作用、不占端口,适合流水线。
|
||||
{
|
||||
const r = spawnSync(nodeBin, ['--check', agentScript], { encoding: 'utf8' });
|
||||
if (r.status !== 0) {
|
||||
console.error('\n✗ 打包内的 node 无法解析 agent.mjs —— 被控端资源已损坏');
|
||||
console.error(' ' + (r.stderr || r.stdout || '').trim().split('\n').slice(0, 4).join('\n '));
|
||||
console.error(' 提示:重跑 `pnpm prepare:node`(会重新 esbuild 打包被控端)。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✓ 打包内 node 可解析 agent.mjs(语法/模块解析通过)');
|
||||
}
|
||||
|
||||
// ---------- ①②③ 合一的实测:真 spawn,真握手 ----------
|
||||
const child = spawn(nodeBin, [sidecar], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (d) => (out += d));
|
||||
child.stderr.on('data', (d) => (err += d));
|
||||
|
||||
// 用一个只读、无副作用的方法:未连接时应回 {"connected":false}
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'state', params: {} }) + '\n');
|
||||
|
||||
const done = (code) => {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
/* 已退出 */
|
||||
}
|
||||
process.exit(code);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
let parsed = null;
|
||||
const line = out.split('\n').find((l) => l.trim().startsWith('{'));
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
/* 下面统一报错 */
|
||||
}
|
||||
|
||||
if (err.trim()) {
|
||||
console.log(' sidecar stderr:' + err.trim().split('\n').slice(0, 3).join(' | '));
|
||||
}
|
||||
|
||||
if (parsed && parsed.id === 1 && parsed.result && 'connected' in parsed.result) {
|
||||
console.log(
|
||||
`\n✓ 自举成功:打包内 node(${process.version} 级)真跑起打包内 sidecar` +
|
||||
`(connected=${parsed.result.connected})`,
|
||||
);
|
||||
done(0);
|
||||
} else {
|
||||
console.error('\n✗ 未收到预期的 JSON-RPC 响应 —— 产物可能无法自举');
|
||||
console.error(' stdout:' + (out.trim().slice(0, 300) || '(空)'));
|
||||
console.error(
|
||||
' 若 node 直接运行即被 SIGKILL(137):macOS 上 strip 破坏了代码签名,' +
|
||||
'需 adhoc 重签(见 scripts/prepare-console.mjs 的 ② 段注释)。',
|
||||
);
|
||||
done(1);
|
||||
}
|
||||
}, 4000);
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
|
||||
*
|
||||
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
|
||||
*
|
||||
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
|
||||
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
|
||||
* 用 API 则从模块解析走,与包管理器布局无关。
|
||||
*
|
||||
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
|
||||
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
|
||||
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
|
||||
*/
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
const outIdx = argv.indexOf('--out');
|
||||
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(here, 'main.mjs')],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
|
||||
external: ['node:*'],
|
||||
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
|
||||
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
|
||||
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
|
||||
banner: {
|
||||
js:
|
||||
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
|
||||
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
|
||||
'const require = __naCreateRequire(import.meta.url);',
|
||||
},
|
||||
legalComments: 'none',
|
||||
minify: false, // 保持可读:出问题时能直接看堆栈
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
|
||||
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
|
||||
*
|
||||
* ## 为什么需要它(选项 D:Tauri + Node sidecar)
|
||||
* console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
|
||||
* 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
|
||||
* **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
|
||||
*
|
||||
* ## 协议(行分隔 JSON,每条消息一行)
|
||||
* 请求(Rust → 本进程):
|
||||
* {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
|
||||
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
|
||||
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
|
||||
* {"jsonrpc":"2.0","id":4,"method":"state"}
|
||||
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?}}
|
||||
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}}
|
||||
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
|
||||
* 通知(无 id,本进程主动推):
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
|
||||
*
|
||||
* ## 纪律
|
||||
* - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
|
||||
* - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { NodeAgentClient } from '@nodeagent/client';
|
||||
|
||||
/** @type {NodeAgentClient | null} */
|
||||
let client = null;
|
||||
/** 最近一次连接参数(重连/状态查询用)。 */
|
||||
let lastOptions = null;
|
||||
|
||||
// ---------------- 输出(唯一写 stdout 的地方) ----------------
|
||||
|
||||
function send(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
function ok(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result: result ?? null });
|
||||
}
|
||||
|
||||
function fail(id, code, message, data) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
|
||||
}
|
||||
|
||||
function notify(kind, payload) {
|
||||
send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
|
||||
}
|
||||
|
||||
/** 日志走 stderr(stdout 归协议专用)。 */
|
||||
function log(level, message) {
|
||||
process.stderr.write(`[sidecar] ${level} ${message}\n`);
|
||||
}
|
||||
|
||||
// ---------------- 状态快照 ----------------
|
||||
|
||||
function stateSnapshot() {
|
||||
if (!client) return { connected: false };
|
||||
return {
|
||||
connected: true,
|
||||
capabilities: client.listCapabilities(),
|
||||
authorized: client.listAuthorized(),
|
||||
agent_version: client.getAgentVersion() ?? null,
|
||||
agent_build: client.getAgentBuild() ?? null,
|
||||
peer_cert_fp: client.getPeerCertFingerprint(),
|
||||
client_id: lastOptions?.clientId ?? null,
|
||||
auth_mode: lastOptions?.authMode ?? 'psk',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 方法实现 ----------------
|
||||
|
||||
/**
|
||||
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
|
||||
*
|
||||
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
|
||||
* 到两位数的补丁号时会误判「无更新」。
|
||||
*/
|
||||
function cmpVersion(a, b) {
|
||||
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
||||
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (d !== 0) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
|
||||
async connect(params) {
|
||||
if (!params?.url || !params?.key) {
|
||||
throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
|
||||
}
|
||||
// 已有连接先干净关闭,避免两条 ws 并存
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
/* 关不掉也无妨,下面会覆盖 */
|
||||
}
|
||||
}
|
||||
lastOptions = {
|
||||
url: String(params.url),
|
||||
clientId: String(params.client_id ?? 'console'),
|
||||
authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
|
||||
};
|
||||
|
||||
client = new NodeAgentClient({
|
||||
url: lastOptions.url,
|
||||
key: String(params.key),
|
||||
clientId: lastOptions.clientId,
|
||||
insecure: params.insecure !== false,
|
||||
...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
|
||||
...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
|
||||
...(params.private_key ? { privateKey: String(params.private_key) } : {}),
|
||||
...(params.hub ? { hub: params.hub } : {}),
|
||||
autoReconnect: params.auto_reconnect !== false,
|
||||
|
||||
// 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
|
||||
onStateChange: (state) => notify('state', { state }),
|
||||
onLog: (message) => notify('log', { message }),
|
||||
onEvent: (event) => notify('agent_event', { event }),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
|
||||
return stateSnapshot();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (client) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (err) {
|
||||
log('warn', `关闭连接时异常:${String(err)}`);
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
return { connected: false };
|
||||
},
|
||||
|
||||
async invoke(params) {
|
||||
// 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
|
||||
if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
|
||||
if (!params?.capability) {
|
||||
throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
|
||||
}
|
||||
try {
|
||||
// 正常路径:被控端返回 InvokeResult(status: ok|failed)
|
||||
return await client.invoke(
|
||||
String(params.capability),
|
||||
(params.args ?? {}),
|
||||
typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
// ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
|
||||
// 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
|
||||
// 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
|
||||
return {
|
||||
status: 'failed',
|
||||
error: {
|
||||
name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
...(err?.data !== undefined ? { data: err.data } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
|
||||
*
|
||||
* ## 为什么放在 sidecar 而不是 Rust 壳
|
||||
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
|
||||
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
|
||||
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
|
||||
*
|
||||
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
|
||||
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
|
||||
*/
|
||||
async check_update(params) {
|
||||
const consoleVersion = String(params?.console_version ?? '0.0.0');
|
||||
const url =
|
||||
params?.url ??
|
||||
'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json';
|
||||
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), 20_000);
|
||||
let meta;
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
meta = await res.json();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), {
|
||||
data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const latest = String(meta?.version ?? '');
|
||||
if (!latest) {
|
||||
throw new Error('latest.json 缺少 version 字段(发版产物异常)');
|
||||
}
|
||||
const agent = meta?.agent ?? {};
|
||||
return {
|
||||
console_version: consoleVersion,
|
||||
latest_version: latest,
|
||||
has_update: cmpVersion(latest, consoleVersion) > 0,
|
||||
published_at: meta?.built_at ?? null,
|
||||
commit: meta?.commit ?? null,
|
||||
capabilities: meta?.capabilities ?? null,
|
||||
agent: {
|
||||
url: agent.url ?? null,
|
||||
sha256: agent.sha256 ?? null,
|
||||
bytes: agent.bytes ?? null,
|
||||
},
|
||||
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
|
||||
source: url,
|
||||
checked_at: Date.now(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件并做 sha256 校验(校验不通过**不落盘**,避免半成品污染数据目录)。
|
||||
* 供「更新本机被控端」使用。
|
||||
*/
|
||||
async download(params) {
|
||||
const url = String(params?.url ?? '');
|
||||
const expect = String(params?.sha256 ?? '').toLowerCase();
|
||||
const dest = String(params?.dest ?? '');
|
||||
if (!url || !dest) {
|
||||
throw Object.assign(new Error('download 需要 url 与 dest'), { code: -32602 });
|
||||
}
|
||||
if (!/^[0-9a-f]{12,64}$/.test(expect)) {
|
||||
throw Object.assign(
|
||||
new Error('download 需要 sha256(12~64 位十六进制)—— 不校验哈希等于接受任意内容'),
|
||||
{ code: -32602 },
|
||||
);
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), 300_000);
|
||||
let buf;
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
buf = Buffer.from(await res.arrayBuffer());
|
||||
} catch (err) {
|
||||
throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const got = createHash('sha256').update(buf).digest('hex');
|
||||
if (got !== expect && !got.startsWith(expect)) {
|
||||
throw Object.assign(new Error('哈希校验失败,已丢弃(未写入任何文件)'), {
|
||||
data: { expected: expect.slice(0, 12), actual: got.slice(0, 12), bytes: buf.length },
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buf);
|
||||
// 写后复核:防止截断/半写(磁盘满等)
|
||||
const written = createHash('sha256')
|
||||
.update(await readFile(dest))
|
||||
.digest('hex');
|
||||
if (written !== got) {
|
||||
await unlink(dest).catch(() => {});
|
||||
throw new Error('写入后校验不一致,已删除(请检查磁盘空间)');
|
||||
}
|
||||
return { path: dest, sha256: got, bytes: buf.length };
|
||||
},
|
||||
|
||||
state: () => stateSnapshot(),
|
||||
};
|
||||
|
||||
// ---------------- 主循环 ----------------
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const text = line.trim();
|
||||
if (!text) return;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(text);
|
||||
} catch {
|
||||
fail(null, -32700, 'JSON 解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id = null, method, params } = req ?? {};
|
||||
const fn = methods[method];
|
||||
if (typeof fn !== 'function') {
|
||||
fail(id, -32601, `未知方法: ${String(method)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
|
||||
Promise.resolve()
|
||||
.then(() => fn(params ?? {}))
|
||||
.then((result) => ok(id, result))
|
||||
.catch((err) => {
|
||||
const code = typeof err?.code === 'number' ? err.code : -32603;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('error', `${String(method)} 失败:${msg}`);
|
||||
fail(id, code, msg, err?.data);
|
||||
});
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
// Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
|
||||
methods.disconnect();
|
||||
log('info', 'stdin 关闭,sidecar 退出');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('error', `未捕获异常:${err?.stack ?? String(err)}`);
|
||||
});
|
||||
process.on('unhandledRejection', (err) => {
|
||||
log('error', `未处理的 rejection:${String(err)}`);
|
||||
});
|
||||
|
||||
log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
|
||||
@@ -0,0 +1,14 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
||||
# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库)
|
||||
# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。
|
||||
/runtime/
|
||||
|
||||
# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释)
|
||||
/tauri.bundle.gen.json
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.3"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "nodeagent_console_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
|
||||
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
|
||||
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
|
||||
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,856 @@
|
||||
//! 本机被控端(agent)进程管理器 —— 让控制台能「启动 / 停止 / 重启」本机被控端,
|
||||
//! 用户全程**不必碰命令行**。
|
||||
//!
|
||||
//! ## 关键设计(每条都是踩出来的)
|
||||
//!
|
||||
//! 1. **脚本必须在数据目录里跑,不能在 app bundle 里跑。**
|
||||
//! macOS 的 `.app` 是签名保护的只读资源 —— 自更新写不进去;且开机自启需要**稳定路径**
|
||||
//! (用户挪动 / 重装 / 删除 app 不该带走被控端)。故首次运行时把内置的 `agent.mjs`
|
||||
//! **播种**到 `<数据目录>/runtime/agent.mjs`,此后一律跑这一份。
|
||||
//!
|
||||
//! 2. **绝不覆盖「已自更新」的副本。**
|
||||
//! 用 `<数据目录>/runtime/agent.seed.json` 记「播种时的应用版本」;只有**应用版本变了**
|
||||
//! 才重新播种。否则控制台每次启动都会把被控端(可能已自更新到更新版)**降级**回随包版本 ——
|
||||
//! 这类「悄悄降级」不会报任何错,只是功能莫名回退。
|
||||
//!
|
||||
//! 3. **Windows 必须 `CREATE_NO_WINDOW`。**
|
||||
//! 否则每次 spawn `node.exe` 都会弹一个黑色控制台窗口。本项目的目标之一正是
|
||||
//! 「不再打开命令行窗口」,故这一步不可省。
|
||||
//!
|
||||
//! 4. **退出监控用轮询 `try_wait`,不把 `Child` 移出去。**
|
||||
//! 若把 `Child` 交给 `wait()` 的 task,`stop()` 就再也拿不到它去 kill 了。
|
||||
//! 轮询 800ms 代价可忽略,换来 stop 随时可用。
|
||||
//!
|
||||
//! ## 可测试性
|
||||
//! 本模块**不依赖 Tauri GUI** —— 单测直接拉起真实的 node 子进程,验证
|
||||
//! 「启动 → 捕获日志 → 停止 → 状态收敛」全链路。
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
/// 日志环形缓冲上限(行)。够回看一次启动/一次故障,又不至于吃内存。
|
||||
const LOG_CAP: usize = 4000;
|
||||
|
||||
/// 被控端状态机取值(前后端共用字面量)。
|
||||
pub mod state {
|
||||
pub const STOPPED: &str = "stopped";
|
||||
pub const STARTING: &str = "starting";
|
||||
pub const RUNNING: &str = "running";
|
||||
pub const STOPPING: &str = "stopping";
|
||||
pub const CRASHED: &str = "crashed";
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogLine {
|
||||
/// 全局单调递增序号(环形缓冲淘汰旧行后仍然递增 —— 前端靠它做增量拉取)
|
||||
pub seq: u64,
|
||||
/// 毫秒时间戳(UTC)
|
||||
pub ts: u64,
|
||||
/// `stdout` / `stderr` / `system`(本管理器自己产生的说明行)
|
||||
pub stream: String,
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
fn new(seq: u64, stream: &str, line: impl Into<String>) -> Self {
|
||||
Self {
|
||||
seq,
|
||||
ts: now_ms(),
|
||||
stream: stream.to_string(),
|
||||
line: line.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理器向外推送的事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentEvent {
|
||||
Log(LogLine),
|
||||
/// 状态变化(取值见 `state::*`)
|
||||
State(String),
|
||||
}
|
||||
|
||||
pub type AgentEmit = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 数据目录:优先 `NODEAGENT_HOME`(跨平台统一),否则 `~/.nodeagent`。
|
||||
///
|
||||
/// ⚠️ Windows 认 `USERPROFILE`、不认 `HOME` —— 与被控端 `apps/agent/src/config.ts`
|
||||
/// 的 `agentDir()` 必须保持一致,否则「控制台显示的数据目录」与「被控端实际用的」
|
||||
/// 会是两个地方。
|
||||
pub fn agent_data_dir() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_HOME") {
|
||||
if !p.trim().is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let home = std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".nodeagent")
|
||||
}
|
||||
|
||||
/// 被控端入口脚本(数据目录里的那一份)。
|
||||
pub fn agent_script_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("runtime").join("agent.mjs")
|
||||
}
|
||||
|
||||
/// 播种标记文件。
|
||||
fn seed_marker_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("runtime").join("agent.seed.json")
|
||||
}
|
||||
|
||||
/// 比较 `x.y.z` 版本号(数值逐段比,不做字符串比 —— 否则 `2.0.10 < 2.0.9`)。
|
||||
pub fn cmp_version(a: &str, b: &str) -> i32 {
|
||||
let pa: Vec<u32> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
let pb: Vec<u32> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
for i in 0..pa.len().max(pb.len()) {
|
||||
let d = pa.get(i).copied().unwrap_or(0) as i64 - pb.get(i).copied().unwrap_or(0) as i64;
|
||||
if d != 0 {
|
||||
return if d > 0 { 1 } else { -1 };
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// 读出「数据目录里当前脚本所对应的版本」(无标记则视为 `0.0.0`)。
|
||||
pub fn installed_agent_version(data_dir: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(seed_marker_path(data_dir)).ok()?;
|
||||
let v: Value = serde_json::from_str(&text).ok()?;
|
||||
v.get("version").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
/// 记录「数据目录里当前脚本的版本」。
|
||||
///
|
||||
/// 播种与被控端自更新(**含控制台发起的更新**)都要写它 —— 这是防止
|
||||
/// 「控制台启动时把已更新的被控端降级回随包版本」的唯一依据。
|
||||
pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> Result<(), String> {
|
||||
let marker = seed_marker_path(data_dir);
|
||||
if let Some(dir) = marker.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
|
||||
}
|
||||
std::fs::write(
|
||||
&marker,
|
||||
format!(
|
||||
"{{\n \"version\": \"{}\",\n \"source\": \"{}\",\n \"at\": {}\n}}\n",
|
||||
version,
|
||||
source,
|
||||
now_ms()
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("写入播种标记失败:{e}"))
|
||||
}
|
||||
|
||||
/// 定位随包(或开发期)的内置 `agent.mjs`。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` →
|
||||
/// 开发期的 `apps/agent/dist/index.js`。
|
||||
pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("agent.mjs");
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 开发期:src-tauri → apps/agent/dist/index.js
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../agent/dist/index.js");
|
||||
if dev.exists() {
|
||||
return Ok(dev);
|
||||
}
|
||||
Err("找不到内置 agent.mjs(先跑 `pnpm -r build`,或用 NODEAGENT_CONSOLE_AGENT 指定)".to_string())
|
||||
}
|
||||
|
||||
/// 把内置被控端**播种**到数据目录。
|
||||
///
|
||||
/// **只在「数据目录里的脚本版本 < 随包版本」时才覆盖** —— 这条规则防的是两类静默故障:
|
||||
/// - 控制台每次启动都覆盖 → 把**已自更新**的被控端**降级**回随包版本(功能莫名回退,不报错);
|
||||
/// - 应用被降级安装后,反过来把更新的被控端覆盖掉。
|
||||
///
|
||||
/// 返回实际使用的脚本路径。
|
||||
pub fn seed_agent_script(
|
||||
bundled: &Path,
|
||||
data_dir: &Path,
|
||||
app_version: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target = agent_script_path(data_dir);
|
||||
|
||||
if target.exists() {
|
||||
let installed = installed_agent_version(data_dir).unwrap_or_else(|| "0.0.0".to_string());
|
||||
if cmp_version(app_version, &installed) <= 0 {
|
||||
// 数据目录里的版本不低于随包版本(可能是自更新来的)→ 不动它
|
||||
return Ok(target);
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(target.parent().unwrap_or(data_dir))
|
||||
.map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
|
||||
std::fs::copy(bundled, &target).map_err(|e| {
|
||||
format!(
|
||||
"播种被控端脚本失败({} → {}):{e}",
|
||||
bundled.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
// 保留可执行位(POSIX;Windows 无此概念)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
mark_installed_version(data_dir, app_version, "bundled")?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// TCP 探测端口是否已被监听(判断「是否已有实例在跑」)。
|
||||
///
|
||||
/// 只做一次 TCP 连接,不跑协议 —— 快、无副作用,足够回答「这个端口有没有人在听」。
|
||||
pub fn port_in_use(port: u16) -> bool {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
TcpStream::connect_timeout(&addr, Duration::from_millis(400)).is_ok()
|
||||
}
|
||||
|
||||
/// 读被控端配置(`<数据目录>/agent.json`)里与控制台有关的部分。
|
||||
///
|
||||
/// 只读、不创建 —— 配置由被控端自己在首次启动时生成(含随机密钥)。
|
||||
pub fn read_agent_config(data_dir: &Path) -> Value {
|
||||
let p = data_dir.join("agent.json");
|
||||
let (node_id, port, key, auth_mode, tls, exists) = match std::fs::read_to_string(&p) {
|
||||
Ok(text) => {
|
||||
// 剥离 BOM:Windows 上由 PowerShell 写出的 JSON 可能带 BOM
|
||||
let parsed: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or(json!({}));
|
||||
(
|
||||
parsed.get("node_id").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
parsed.get("port").and_then(Value::as_u64).unwrap_or(8765),
|
||||
parsed.get("key").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
parsed.get("auth_mode").and_then(Value::as_str).unwrap_or("psk").to_string(),
|
||||
parsed.get("tls").and_then(Value::as_bool).unwrap_or(true),
|
||||
true,
|
||||
)
|
||||
}
|
||||
Err(_) => (String::new(), 8765, String::new(), "psk".to_string(), true, false),
|
||||
};
|
||||
json!({
|
||||
"data_dir": data_dir.to_string_lossy(),
|
||||
"config_path": p.to_string_lossy(),
|
||||
"config_exists": exists,
|
||||
"node_id": node_id,
|
||||
"port": port,
|
||||
"tls": tls,
|
||||
"auth_mode": auth_mode,
|
||||
// 密钥回传给本机 UI 属预期(用户要用它连接自己的机器);
|
||||
// 它本来就在用户本机的 600 文件里,不构成额外暴露面。
|
||||
"key": key,
|
||||
"port_in_use": port_in_use(port as u16),
|
||||
"url": format!("ws://127.0.0.1:{port}"),
|
||||
})
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
state: String,
|
||||
child: Option<Child>,
|
||||
pid: Option<u32>,
|
||||
started_at: Option<u64>,
|
||||
restarts: u32,
|
||||
last_exit: Option<String>,
|
||||
}
|
||||
|
||||
/// 本机被控端管理器。
|
||||
pub struct AgentManager {
|
||||
inner: tokio::sync::Mutex<Inner>,
|
||||
/// 环形日志缓冲。用 `Arc<Mutex<..>>` 是为了让日志泵任务能**同时**入缓冲与推事件,
|
||||
/// 而不必持有整个管理器(否则自引用,Arc 循环)。
|
||||
logs: Arc<Mutex<VecDeque<LogLine>>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
emit: AgentEmit,
|
||||
/// 脚本与 node 路径(start 时注入,便于 status 展示)
|
||||
paths: Mutex<Option<(PathBuf, PathBuf, PathBuf)>>, // (node, script, data_dir)
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
pub fn new(emit: AgentEmit) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: tokio::sync::Mutex::new(Inner {
|
||||
state: state::STOPPED.to_string(),
|
||||
child: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
restarts: 0,
|
||||
last_exit: None,
|
||||
}),
|
||||
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))),
|
||||
seq: Arc::new(AtomicU64::new(1)),
|
||||
emit,
|
||||
paths: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// 追加一行日志:入环形缓冲 + 推事件。
|
||||
pub fn push_log(&self, stream: &str, text: impl Into<String>) {
|
||||
let entry = LogLine::new(self.next_seq(), stream, text);
|
||||
{
|
||||
let mut logs = self.logs.lock().unwrap();
|
||||
if logs.len() >= LOG_CAP {
|
||||
logs.pop_front();
|
||||
}
|
||||
logs.push_back(entry.clone());
|
||||
}
|
||||
(self.emit)(AgentEvent::Log(entry));
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.seq.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn set_state(&self, s: &str) {
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.state == s {
|
||||
return;
|
||||
}
|
||||
inner.state = s.to_string();
|
||||
}
|
||||
(self.emit)(AgentEvent::State(s.to_string()));
|
||||
}
|
||||
|
||||
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
|
||||
pub async fn snapshot(&self) -> Value {
|
||||
let inner = self.inner.lock().await;
|
||||
let paths = self.paths.lock().unwrap().clone();
|
||||
let (node, script, data_dir) = match paths {
|
||||
Some((n, s, d)) => (
|
||||
n.to_string_lossy().to_string(),
|
||||
s.to_string_lossy().to_string(),
|
||||
d.to_string_lossy().to_string(),
|
||||
),
|
||||
None => (String::new(), String::new(), agent_data_dir().to_string_lossy().to_string()),
|
||||
};
|
||||
json!({
|
||||
"state": inner.state,
|
||||
"pid": inner.pid,
|
||||
"started_at": inner.started_at,
|
||||
"uptime_ms": inner.started_at.map(|t| now_ms().saturating_sub(t)),
|
||||
"restarts": inner.restarts,
|
||||
"last_exit": inner.last_exit,
|
||||
"node": node,
|
||||
"script": script,
|
||||
"data_dir": data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取日志(`since` 为上次拿到的最大 `seq`,0 表示取全部缓冲)。
|
||||
pub fn logs_since(&self, since: u64) -> Value {
|
||||
let logs = self.logs.lock().unwrap();
|
||||
let total = self.seq.load(Ordering::SeqCst).saturating_sub(1);
|
||||
let lines: Vec<Value> = logs
|
||||
.iter()
|
||||
.filter(|l| l.seq > since)
|
||||
.map(|l| json!({ "seq": l.seq, "ts": l.ts, "stream": l.stream, "line": l.line }))
|
||||
.collect();
|
||||
json!({ "lines": lines, "total": total })
|
||||
}
|
||||
|
||||
pub fn clear_logs(&self) {
|
||||
self.logs.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// 启动被控端。
|
||||
///
|
||||
/// 接收者写成 `self: &Arc<Self>`(而非 `&self`)是**必需**的:启动后要挂一个
|
||||
/// **自持引用**的监控任务(崩溃自动重启要能再次调用 `start`),而 `&self` 拿不到 `Arc`。
|
||||
///
|
||||
/// `auto_restart`:进程意外退出时是否自动拉起(「后台运行」的语义要求)。
|
||||
pub async fn start(
|
||||
self: &Arc<Self>,
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
data_dir: &Path,
|
||||
auto_restart: bool,
|
||||
) -> Result<Value, String> {
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
if inner.child.is_some() {
|
||||
return Err("被控端已在运行".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明,
|
||||
// 而不是让用户看到一个 `EADDRINUSE` 堆栈。
|
||||
//
|
||||
// ⚠️ 这里是症状 B(「应用端无法启动」)的**现场**:真机上曾有陈旧计划任务拉起的
|
||||
// 被控端占着 8765,控制台自己的被控端起不来。端口检查本身是对的,
|
||||
// 但只给一句「已被占用」用户无从下手 —— 故补上「怎么找出占用者」。
|
||||
let cfg = read_agent_config(data_dir);
|
||||
let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
|
||||
if port_in_use(port) {
|
||||
let hint = if cfg!(windows) {
|
||||
format!(
|
||||
"netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq <PID>\" 看是谁"
|
||||
)
|
||||
} else {
|
||||
format!("lsof -nP -iTCP:{port} -sTCP:LISTEN")
|
||||
};
|
||||
return Err(format!(
|
||||
"端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
|
||||
或另一个控制台实例)。\n\
|
||||
排查占用者:{hint}\n\
|
||||
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
|
||||
data_dir.join("agent.json").display()
|
||||
));
|
||||
}
|
||||
|
||||
*self.paths.lock().unwrap() = Some((node.to_path_buf(), script.to_path_buf(), data_dir.to_path_buf()));
|
||||
self.set_state(state::STARTING).await;
|
||||
self.push_log(
|
||||
"system",
|
||||
format!(
|
||||
"启动被控端:{} {}(数据目录 {})",
|
||||
node.display(),
|
||||
script.display(),
|
||||
data_dir.display()
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(node);
|
||||
cmd.arg(script)
|
||||
.current_dir(data_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀
|
||||
|
||||
// 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析)
|
||||
cmd.env("NODEAGENT_HOME", data_dir);
|
||||
|
||||
// ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动被控端失败({}):{e}", node.display()))?;
|
||||
|
||||
let pid = child.id();
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.child = Some(child);
|
||||
inner.pid = pid;
|
||||
inner.started_at = Some(now_ms());
|
||||
}
|
||||
self.set_state(state::RUNNING).await;
|
||||
|
||||
// 日志泵:既入环形缓冲(供前端「拉取历史」),也推事件(供前端「实时滚动」)
|
||||
if let Some(out) = stdout {
|
||||
spawn_line_pump(out, "stdout", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
|
||||
}
|
||||
if let Some(err) = stderr {
|
||||
spawn_line_pump(err, "stderr", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
|
||||
}
|
||||
|
||||
// 退出监控 + 自动重启
|
||||
AgentManager::spawn_monitor(
|
||||
self,
|
||||
node.to_path_buf(),
|
||||
script.to_path_buf(),
|
||||
data_dir.to_path_buf(),
|
||||
auto_restart,
|
||||
);
|
||||
|
||||
Ok(json!({ "pid": pid, "state": state::RUNNING }))
|
||||
}
|
||||
|
||||
/// 停止被控端(幂等)。
|
||||
pub async fn stop(&self) -> Result<Value, String> {
|
||||
self.set_state(state::STOPPING).await;
|
||||
let child = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.child.take()
|
||||
};
|
||||
let Some(mut child) = child else {
|
||||
self.set_state(state::STOPPED).await;
|
||||
return Ok(json!({ "state": state::STOPPED, "note": "本来就未运行" }));
|
||||
};
|
||||
let pid = child.id();
|
||||
self.push_log("system", format!("停止被控端(pid {})…", pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into())));
|
||||
|
||||
// Windows:用 taskkill /T 连子进程树一起收;POSIX:直接 kill 本进程。
|
||||
let killed = if cfg!(windows) {
|
||||
match pid {
|
||||
Some(p) => {
|
||||
let out = Command::new("taskkill")
|
||||
.args(["/PID", &p.to_string(), "/T", "/F"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) => o.status.success(),
|
||||
Err(_) => child.kill().await.is_ok(),
|
||||
}
|
||||
}
|
||||
None => child.kill().await.is_ok(),
|
||||
}
|
||||
} else {
|
||||
child.kill().await.is_ok()
|
||||
};
|
||||
|
||||
let _ = child.wait().await;
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.pid = None;
|
||||
inner.started_at = None;
|
||||
inner.last_exit = Some(if killed { "stopped by console".into() } else { "stop failed".into() });
|
||||
}
|
||||
self.set_state(state::STOPPED).await;
|
||||
self.push_log("system", "被控端已停止");
|
||||
|
||||
// 端口释放确认(如被控端刚触发了「受控重启」,可能留下继承者)
|
||||
let data_dir = self
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|(_, _, d)| d.clone())
|
||||
.unwrap_or_else(agent_data_dir);
|
||||
let port = read_agent_config(&data_dir).get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
|
||||
for _ in 0..12 {
|
||||
if !port_in_use(port) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
let still = port_in_use(port);
|
||||
if still {
|
||||
self.push_log(
|
||||
"system",
|
||||
format!("⚠️ 端口 {port} 仍未释放 —— 可能存在继承进程(如受控重启产生的后继),请检查"),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "state": state::STOPPED, "port_released": !still }))
|
||||
}
|
||||
|
||||
/// 重启 = 停止 + 启动。
|
||||
pub async fn restart(
|
||||
self: &Arc<Self>,
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
data_dir: &Path,
|
||||
auto_restart: bool,
|
||||
) -> Result<Value, String> {
|
||||
self.push_log("system", "重启被控端…");
|
||||
let _ = self.stop().await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
self.start(node, script, data_dir, auto_restart).await
|
||||
}
|
||||
|
||||
/// 退出监控:轮询 `try_wait`(**不把 Child 移出去**,否则 stop() 就杀不到它了)。
|
||||
fn spawn_monitor(
|
||||
me: &Arc<Self>,
|
||||
node: PathBuf,
|
||||
script: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
auto_restart: bool,
|
||||
) {
|
||||
let me = Arc::clone(me);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let mut inner = me.inner.lock().await;
|
||||
let Some(child) = inner.child.as_mut() else {
|
||||
return; // 已被 stop() 取走 → 结束监控
|
||||
};
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let code = status.code();
|
||||
inner.child = None;
|
||||
inner.pid = None;
|
||||
inner.started_at = None;
|
||||
inner.last_exit = Some(format!("exit code {:?}", code));
|
||||
drop(inner);
|
||||
|
||||
me.push_log("system", format!("被控端已退出(exit code {:?})", code));
|
||||
me.set_state(state::CRASHED).await;
|
||||
|
||||
if !auto_restart {
|
||||
return;
|
||||
}
|
||||
// 自动重启:退避 2s,超 5 次则放弃并留在 crashed(避免无限重启风暴)
|
||||
let n = {
|
||||
let mut inner = me.inner.lock().await;
|
||||
inner.restarts += 1;
|
||||
inner.restarts
|
||||
};
|
||||
if n > 5 {
|
||||
me.push_log("system", "自动重启已达上限(5 次),停止拉起。请查看上方日志定位原因。");
|
||||
return;
|
||||
}
|
||||
me.push_log("system", format!("将在 2 秒后自动重启(第 {n} 次)…"));
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let _ = me.stop().await;
|
||||
if let Err(e) = me.start(&node, &script, &data_dir, true).await {
|
||||
me.push_log("system", format!("自动重启失败:{e}"));
|
||||
me.set_state(state::CRASHED).await;
|
||||
return;
|
||||
}
|
||||
// start() 已重新挂上监控,本任务结束
|
||||
return;
|
||||
}
|
||||
Ok(None) => { /* 仍在运行 */ }
|
||||
Err(e) => {
|
||||
me.push_log("system", format!("监控被控端失败:{e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐行读一个管道:入环形缓冲 + 推日志事件。
|
||||
fn spawn_line_pump<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
emit: AgentEmit,
|
||||
logs: Arc<Mutex<VecDeque<LogLine>>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(text)) = lines.next_line().await {
|
||||
let entry = LogLine::new(seq.fetch_add(1, Ordering::SeqCst), stream, text);
|
||||
{
|
||||
let mut buf = logs.lock().unwrap();
|
||||
if buf.len() >= LOG_CAP {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
}
|
||||
emit(AgentEvent::Log(entry));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单测:真跑 node 子进程
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn node_bin() -> PathBuf {
|
||||
crate::sidecar::resolve_node(None).expect("需要 node 才能跑被控端管理单测")
|
||||
}
|
||||
|
||||
/// 造一个「打印两行、然后常驻」的假被控端。
|
||||
fn fake_agent(dir: &Path) -> PathBuf {
|
||||
let p = dir.join("fake-agent.mjs");
|
||||
std::fs::write(
|
||||
&p,
|
||||
"console.log('READY-LINE');\nconsole.error('ERR-LINE');\nsetInterval(()=>{}, 1000);\n",
|
||||
)
|
||||
.unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("na-agent-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动应捕获日志且状态为running() {
|
||||
let dir = tmp_dir("start");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.expect("应能启动");
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
|
||||
|
||||
// 等日志泵读到两行
|
||||
let mut collected = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let logs = m.logs_since(0);
|
||||
collected = logs["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["line"].as_str().unwrap_or("").to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if collected.contains("READY-LINE") && collected.contains("ERR-LINE") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(collected.contains("READY-LINE"), "应捕获 stdout,实际:{collected}");
|
||||
assert!(collected.contains("ERR-LINE"), "应捕获 stderr,实际:{collected}");
|
||||
|
||||
let r = m.stop().await.expect("应能停止");
|
||||
assert_eq!(r["state"], json!(state::STOPPED));
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 停止后应可再次启动() {
|
||||
let dir = tmp_dir("restart");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.unwrap();
|
||||
m.stop().await.unwrap();
|
||||
m.start(&node_bin(), &script, &dir, false).await.expect("停止后应能重新启动");
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
|
||||
m.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 重复启动应被拒绝而不是起两个进程() {
|
||||
let dir = tmp_dir("dup");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.unwrap();
|
||||
let err = m.start(&node_bin(), &script, &dir, false).await;
|
||||
assert!(err.is_err(), "已在运行时再次启动应被拒绝");
|
||||
assert!(err.unwrap_err().contains("已在运行"), "错误信息应说明原因");
|
||||
m.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 端口被外部占用时应给出可执行的错误说明() {
|
||||
let dir = tmp_dir("port-busy");
|
||||
let script = fake_agent(&dir);
|
||||
// 占住一个空闲端口,扮演「手工安装的 / 计划任务拉起的另一个被控端」
|
||||
// (真机症状 B:陈旧计划任务 na-gui 占着 8765,控制台自己的被控端起不来)
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("应能绑一个空闲端口");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::fs::write(
|
||||
dir.join("agent.json"),
|
||||
format!("{{\"node_id\":\"t\",\"port\":{port},\"key\":\"k\",\"tls\":true}}"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
let err = m
|
||||
.start(&node_bin(), &script, &dir, false)
|
||||
.await
|
||||
.expect_err("端口被占时应拒绝启动");
|
||||
assert!(err.contains("已被占用"), "应说明端口被占用,实际:{err}");
|
||||
assert!(err.contains(&port.to_string()), "错误里应带上具体端口号,实际:{err}");
|
||||
// 关键:必须给出「怎么找出占用者」的可执行线索,否则用户无从下手
|
||||
assert!(
|
||||
err.contains("lsof") || err.contains("netstat"),
|
||||
"应给出排查占用者的命令,实际:{err}"
|
||||
);
|
||||
// 端口检查失败不该改动状态、更不该留下进程
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 进程意外退出应转为crashed() {
|
||||
let dir = tmp_dir("crash");
|
||||
let p = dir.join("crash-agent.mjs");
|
||||
std::fs::write(&p, "console.log('BYE');\nprocess.exit(3);\n").unwrap();
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &p, &dir, false).await.unwrap();
|
||||
let mut seen = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
seen = m.snapshot().await["state"].as_str().unwrap_or("").to_string();
|
||||
if seen == state::CRASHED {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, state::CRASHED, "进程退出后状态应为 crashed");
|
||||
assert!(
|
||||
m.snapshot().await["last_exit"].as_str().unwrap_or("").contains("3"),
|
||||
"应记录退出码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 播种应只在应用版本变化时覆盖() {
|
||||
let dir = tmp_dir("seed");
|
||||
let bundled = dir.join("bundled.mjs");
|
||||
std::fs::write(&bundled, "// v1\n").unwrap();
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let t = seed_agent_script(&bundled, &data, "2.0.0").unwrap();
|
||||
assert!(t.exists());
|
||||
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v1\n");
|
||||
|
||||
// 模拟「被控端自更新」改了副本
|
||||
std::fs::write(&t, "// self-updated v9\n").unwrap();
|
||||
// 同版本再播种 → 不应覆盖
|
||||
seed_agent_script(&bundled, &data, "2.0.0").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&t).unwrap(),
|
||||
"// self-updated v9\n",
|
||||
"同版本播种不得覆盖已自更新的副本(否则是静默降级)"
|
||||
);
|
||||
|
||||
// 应用升级 → 应重新播种
|
||||
std::fs::write(&bundled, "// v2\n").unwrap();
|
||||
seed_agent_script(&bundled, &data, "2.1.0").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v2\n", "应用版本变化后应重新播种");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 播种不得把更新过的被控端降级() {
|
||||
let dir = tmp_dir("seed-down");
|
||||
let bundled = dir.join("bundled.mjs");
|
||||
std::fs::write(&bundled, "// bundled 2.0.3\n").unwrap();
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
// 模拟:被控端已通过控制台更新到 2.1.0
|
||||
std::fs::create_dir_all(data.join("runtime")).unwrap();
|
||||
std::fs::write(agent_script_path(&data), "// console-updated 2.1.0\n").unwrap();
|
||||
mark_installed_version(&data, "2.1.0", "console").unwrap();
|
||||
|
||||
seed_agent_script(&bundled, &data, "2.0.3").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(agent_script_path(&data)).unwrap(),
|
||||
"// console-updated 2.1.0\n",
|
||||
"随包版本更低时**不得**覆盖数据目录里更新的被控端(那是静默降级)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 版本比较应按数值而非字符串() {
|
||||
assert_eq!(cmp_version("2.0.10", "2.0.9"), 1, "2.0.10 应大于 2.0.9");
|
||||
assert_eq!(cmp_version("2.0.2", "2.0.2"), 0);
|
||||
assert_eq!(cmp_version("2.1.0", "2.0.99"), 1);
|
||||
assert_eq!(cmp_version("2.0", "2.0.1"), -1, "缺段按 0 处理");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
|
||||
//!
|
||||
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 node
|
||||
//!
|
||||
//! 两个候选方案,各有硬伤:
|
||||
//! - **直接自启 `node agent.mjs`**:Windows 上会弹一个黑色控制台窗口(schtasks 无法指定
|
||||
//! `CREATE_NO_WINDOW`),且进程无人托管 —— 崩了不会拉起、也无法从界面停止。
|
||||
//! - **自启本控制台(`--agent-only`)**:本应用是 GUI 子系统程序,**不会有控制台窗口**;
|
||||
//! 且它本来就负责「托管被控端」(启动/停止/自动重启/日志),语义完全对齐。
|
||||
//!
|
||||
//! 故选后者。`--agent-only` 模式下应用不显示主窗口,直接进托盘并拉起被控端。
|
||||
//!
|
||||
//! ## 平台差异
|
||||
//! | 平台 | 机制 | 位置 |
|
||||
//! |---|---|---|
|
||||
//! | macOS | LaunchAgent(用户级,无需提权) | `~/Library/LaunchAgents/<label>.plist` |
|
||||
//! | Windows | 计划任务(登录时触发,Interactive 登录类型) | 任务名 `<label>` |
|
||||
//!
|
||||
//! Windows 用**登录用户 + Interactive** 而非 SYSTEM:被控端的图形能力(截图 / 键鼠 / 窗口)
|
||||
//! 必须跑在交互式会话里,SYSTEM(会话 0)没有桌面 —— 这一点在真机验证时已实证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// 自启项标识(macOS 的 launchd label / Windows 的计划任务名)。
|
||||
const LABEL: &str = "cn.jkooe.nodeagent-console";
|
||||
|
||||
/// 仅服务模式命令行参数(无窗口、直接进托盘并拉起被控端)。
|
||||
pub const AGENT_ONLY_FLAG: &str = "--agent-only";
|
||||
|
||||
/// 当前可执行文件路径。
|
||||
fn app_exe() -> Result<PathBuf, String> {
|
||||
std::env::current_exe().map_err(|e| format!("取当前程序路径失败:{e}"))
|
||||
}
|
||||
|
||||
/// macOS 上 LaunchAgent plist 的位置。
|
||||
fn macos_plist_path() -> Result<PathBuf, String> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or("拿不到 HOME")?;
|
||||
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
|
||||
}
|
||||
|
||||
/// 生成 macOS LaunchAgent plist。
|
||||
///
|
||||
/// `RunAtLoad` 保证登录即拉起;**不用** `KeepAlive` —— 让「退出」按钮能真正退出,
|
||||
/// 用户想恢复就重新登录或再开应用。进程级别的崩溃自愈由控制台的 `AgentManager` 负责,
|
||||
/// 不该在这里再叠一层(两层自愈会互相打架)。
|
||||
fn macos_plist(exe: &Path) -> String {
|
||||
let exe = xml_escape(&exe.to_string_lossy());
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- 由 nodeagent 控制台生成 —— 请勿手工编辑(界面上的「开机自启」开关会覆写本文件) -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{exe}</string>
|
||||
<string>{AGENT_ONLY_FLAG}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 当前用户的 uid(macOS 的 `launchctl bootstrap gui/<uid>` 需要它)。
|
||||
///
|
||||
/// 用 `id -u` 现取,**不硬编码 501** —— 501 只是首个本地用户的常见值,
|
||||
/// 多用户机器上并不成立。
|
||||
fn current_uid() -> Option<String> {
|
||||
let out = Command::new("id").arg("-u").output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS:加载 / 卸载 LaunchAgent。
|
||||
///
|
||||
/// 新版 macOS 推荐 `launchctl bootstrap gui/<uid>`,旧版用 `load -w`;两条都试,
|
||||
/// 任一路径成功即算成功(不同 macOS 版本上可用的那条不一样)。
|
||||
fn macos_launchctl(plist: &Path, enable: bool) -> Result<String, String> {
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let uid = current_uid();
|
||||
let domain = uid.map(|u| format!("gui/{u}"));
|
||||
|
||||
let attempts: Vec<Vec<String>> = if enable {
|
||||
let mut v = vec![vec!["load".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootstrap".into(), d.clone(), plist.to_string_lossy().to_string()]);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![vec!["unload".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootout".into(), format!("{d}/{LABEL}")]);
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
for args in attempts {
|
||||
match Command::new("launchctl").args(&args).output() {
|
||||
Ok(o) if o.status.success() => return Ok(format!("launchctl {} 成功", args[0])),
|
||||
Ok(o) => notes.push(format!(
|
||||
"launchctl {} 失败:{}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
)),
|
||||
Err(e) => notes.push(format!("launchctl {} 无法执行:{e}", args[0])),
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
Err(notes.join(";"))
|
||||
} else {
|
||||
// 卸载失败不致命:plist 已删,下次登录不会再起
|
||||
Ok(format!("(卸载命令未全部成功,但配置已移除){}", notes.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows:注册 / 注销计划任务。
|
||||
///
|
||||
/// 用 PowerShell 的 `New-ScheduledTask*` + `Register-ScheduledTask` 而非 `schtasks /TR` ——
|
||||
/// 后者要拼一层引号转义(路径含空格时尤其容易出错),前者用参数对象,无转义问题。
|
||||
fn windows_schtasks(exe: &Path, enable: bool) -> Result<String, String> {
|
||||
if enable {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
$exe = '{exe}'
|
||||
$action = New-ScheduledTaskAction -Execute $exe -Argument '{AGENT_ONLY_FLAG}'
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
$user = "$env:USERDOMAIN\$env:USERNAME"
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName '{LABEL}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Output ('OK ' + $user)"#,
|
||||
exe = exe.display().to_string().replace('\'', "''"),
|
||||
);
|
||||
run_powershell(&script)
|
||||
} else {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
Unregister-ScheduledTask -TaskName '{LABEL}' -Confirm:$false
|
||||
Write-Output 'OK removed'"#
|
||||
);
|
||||
run_powershell(&script)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_powershell(script: &str) -> Result<String, String> {
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
.map_err(|e| format!("无法执行 PowerShell:{e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(if stderr.is_empty() { stdout } else { stderr })
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启是否已启用。
|
||||
pub fn is_enabled() -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.exists()).unwrap_or(false)
|
||||
} else if cfg!(windows) {
|
||||
Command::new("schtasks")
|
||||
.args(["/Query", "/TN", LABEL])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取自启状态的完整快照(供界面展示)。
|
||||
pub fn status() -> Value {
|
||||
let exe = app_exe();
|
||||
let supported = cfg!(any(target_os = "macos", windows));
|
||||
let where_ = if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else if cfg!(windows) {
|
||||
format!("计划任务 {LABEL}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
json!({
|
||||
"enabled": is_enabled(),
|
||||
"supported": supported,
|
||||
"location": where_,
|
||||
"app_exe": exe.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"mode": AGENT_ONLY_FLAG,
|
||||
"note": if supported {
|
||||
"开机自启将以「仅服务模式」拉起本控制台:不显示窗口、直接进托盘并托管本机被控端。"
|
||||
} else {
|
||||
"当前平台暂不支持——请在系统设置里自行添加登录项。"
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 开启 / 关闭开机自启。
|
||||
pub fn set(enabled: bool) -> Result<Value, String> {
|
||||
if !cfg!(any(target_os = "macos", windows)) {
|
||||
return Err("当前平台暂不支持自动配置开机自启".to_string());
|
||||
}
|
||||
let exe = app_exe()?;
|
||||
|
||||
let detail = if cfg!(target_os = "macos") {
|
||||
let plist = macos_plist_path()?;
|
||||
if enabled {
|
||||
if let Some(dir) = plist.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("创建 LaunchAgents 目录失败:{e}"))?;
|
||||
}
|
||||
std::fs::write(&plist, macos_plist(&exe)).map_err(|e| format!("写入 plist 失败:{e}"))?;
|
||||
macos_launchctl(&plist, true)?
|
||||
} else {
|
||||
if plist.exists() {
|
||||
std::fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败:{e}"))?;
|
||||
}
|
||||
macos_launchctl(&plist, false)?
|
||||
}
|
||||
} else {
|
||||
windows_schtasks(&exe, enabled)?
|
||||
};
|
||||
|
||||
let mut snap = status();
|
||||
snap["detail"] = json!(detail);
|
||||
Ok(snap)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! ## 结构(2026-10 起:sidecar 方案)
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
|
||||
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
|
||||
//!
|
||||
//! ## command 面
|
||||
//! 连接类(签名与语义从未变过):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//!
|
||||
//! 本机服务类(2026-10-11 新增):
|
||||
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
|
||||
//! - `agent_logs` / `agent_logs_clear`
|
||||
//! - `autostart_status` / `autostart_set`
|
||||
//! - `check_update` / `apply_update`
|
||||
//! - `settings_get` / `settings_set` / `system_info`
|
||||
//!
|
||||
//! ## 事件面
|
||||
//! - 连接:`na-state` / `na-log` / `na-event`
|
||||
//! - 本机服务:`na-agent-state` / `na-agent-log`
|
||||
//!
|
||||
//! ## 启动模式
|
||||
//! - 默认:显示主窗口
|
||||
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
|
||||
//! 这样登录自启也不会有任何窗口弹出来)
|
||||
//!
|
||||
//! ## 单实例(v2.0.3)
|
||||
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
|
||||
|
||||
mod agent;
|
||||
mod autostart;
|
||||
mod settings;
|
||||
mod sidecar;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use agent::{AgentEvent, AgentManager};
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
///
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
|
||||
capabilities: Mutex<Value>,
|
||||
agent_version: Mutex<Option<String>>,
|
||||
conn_state: Mutex<String>,
|
||||
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
|
||||
agent: Mutex<Option<Arc<AgentManager>>>,
|
||||
settings: Mutex<settings::Settings>,
|
||||
/// 控制台自身配置目录(设置存这里)
|
||||
config_dir: PathBuf,
|
||||
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn agent(&self) -> Result<Arc<AgentManager>, String> {
|
||||
self.agent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
|
||||
}
|
||||
|
||||
fn settings(&self) -> settings::Settings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
|
||||
#[derive(serde::Serialize)]
|
||||
struct ConnectInfo {
|
||||
capabilities: Value,
|
||||
agent_version: Option<String>,
|
||||
authorized: Option<Value>,
|
||||
peer_cert_fp: Option<String>,
|
||||
auth_mode: String,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
/// 本机被控端的三条路径。
|
||||
struct AgentPaths {
|
||||
node: PathBuf,
|
||||
script: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// 解析(并在需要时播种)本机被控端所需路径。
|
||||
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())?;
|
||||
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
|
||||
let data_dir = agent::agent_data_dir();
|
||||
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
|
||||
Ok(AgentPaths { node, script, data_dir })
|
||||
}
|
||||
|
||||
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
|
||||
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
|
||||
let mut guard = state.sidecar.lock().await;
|
||||
if let Some(sc) = guard.as_ref() {
|
||||
return Ok(Arc::clone(sc));
|
||||
}
|
||||
|
||||
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())?;
|
||||
let script = sidecar::resolve_sidecar(resource_dir.as_deref())?;
|
||||
|
||||
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
|
||||
let app_events = app.clone();
|
||||
let on_event = Arc::new(move |evt: SidecarEvent| match evt {
|
||||
SidecarEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", s);
|
||||
}
|
||||
SidecarEvent::Log(msg) => {
|
||||
let _ = app_events.emit("na-log", msg);
|
||||
}
|
||||
SidecarEvent::Event(params) => {
|
||||
let _ = app_events.emit("na-event", params);
|
||||
}
|
||||
SidecarEvent::Other(raw) => {
|
||||
let _ = app_events.emit("na-log", format!("[sidecar] 未知通知: {raw}"));
|
||||
}
|
||||
});
|
||||
|
||||
let sc = Sidecar::spawn(&node, &script, on_event)?;
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*guard = Some(Arc::clone(&sc));
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 连接类 command(签名与语义保持不变)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
key: String,
|
||||
client_id: Option<String>,
|
||||
insecure: Option<bool>,
|
||||
) -> Result<ConnectInfo, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let client_id = client_id.unwrap_or_else(|| "console".to_string());
|
||||
|
||||
let snap = sc
|
||||
.request(
|
||||
"connect",
|
||||
json!({
|
||||
"url": url,
|
||||
"key": key,
|
||||
"client_id": client_id,
|
||||
"insecure": insecure.unwrap_or(true),
|
||||
}),
|
||||
30,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
|
||||
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
|
||||
*state.capabilities.lock().unwrap() = caps.clone();
|
||||
*state.agent_version.lock().unwrap() = ver.clone();
|
||||
*state.conn_state.lock().unwrap() = "connected".to_string();
|
||||
|
||||
Ok(ConnectInfo {
|
||||
capabilities: caps,
|
||||
agent_version: ver,
|
||||
authorized: snap.get("authorized").cloned(),
|
||||
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
|
||||
auth_mode: snap
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("psk")
|
||||
.to_string(),
|
||||
client_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
|
||||
if let Some(sc) = sc {
|
||||
sc.request("disconnect", json!({}), 10).await?;
|
||||
}
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*state.capabilities.lock().unwrap() = json!([]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
|
||||
#[tauri::command]
|
||||
async fn invoke_capability(
|
||||
state: State<'_, AppState>,
|
||||
capability: String,
|
||||
args: Option<Value>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<Value, String> {
|
||||
let sc = {
|
||||
state
|
||||
.sidecar
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
|
||||
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
|
||||
params["timeout_ms"] = json!(ms);
|
||||
}
|
||||
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
|
||||
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
|
||||
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
|
||||
}
|
||||
|
||||
/// 当前连接的能力清单(读缓存,保持同步签名)。
|
||||
#[tauri::command]
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
Ok(state.capabilities.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
|
||||
#[tauri::command]
|
||||
fn get_state(state: State<'_, AppState>) -> String {
|
||||
state.conn_state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(被控端)command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
|
||||
#[tauri::command]
|
||||
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
|
||||
let mgr = state.agent.lock().unwrap().clone();
|
||||
let snap = match &mgr {
|
||||
Some(m) => m.snapshot().await,
|
||||
None => json!({ "state": agent::state::STOPPED }),
|
||||
};
|
||||
|
||||
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
|
||||
let data_dir = snap
|
||||
.get("data_dir")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(agent::agent_data_dir);
|
||||
let cfg = agent::read_agent_config(&data_dir);
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
Ok(json!({
|
||||
"manager": snap,
|
||||
"config": cfg,
|
||||
"autostart": autostart::status(),
|
||||
"settings": settings::to_json(&state.settings()),
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"installed_agent_version": agent::installed_agent_version(&data_dir),
|
||||
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
|
||||
#[tauri::command]
|
||||
async fn agent_start(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 停止本机被控端(幂等)。
|
||||
#[tauri::command]
|
||||
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
state.agent()?.stop().await
|
||||
}
|
||||
|
||||
/// 重启本机被控端。
|
||||
#[tauri::command]
|
||||
async fn agent_restart(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.restart(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取本机服务日志(增量:传上次拿到的最大 `seq`)。
|
||||
#[tauri::command]
|
||||
fn agent_logs(state: State<'_, AppState>, since: Option<u64>) -> Result<Value, String> {
|
||||
Ok(state.agent()?.logs_since(since.unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// 清空日志缓冲。
|
||||
#[tauri::command]
|
||||
fn agent_logs_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.agent()?.clear_logs();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开机自启状态。
|
||||
#[tauri::command]
|
||||
fn autostart_status() -> Value {
|
||||
autostart::status()
|
||||
}
|
||||
|
||||
/// 开关开机自启。
|
||||
#[tauri::command]
|
||||
fn autostart_set(enabled: bool) -> Result<Value, String> {
|
||||
autostart::set(enabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 更新
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 检查更新:比对最新 Release 与本控制台版本,并附上本机被控端已安装版本。
|
||||
#[tauri::command]
|
||||
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let mut snap = sc
|
||||
.request(
|
||||
"check_update",
|
||||
json!({ "console_version": state.app_version }),
|
||||
45,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 本机被控端的版本(数据目录里那份),供界面区分「应用要更新」与「被控端要更新」
|
||||
let data_dir = agent::agent_data_dir();
|
||||
let installed_version = agent::installed_agent_version(&data_dir).unwrap_or_default();
|
||||
let latest = snap
|
||||
.get("latest_version")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let agent_has_update =
|
||||
!installed_version.is_empty() && agent::cmp_version(&latest, &installed_version) > 0;
|
||||
snap["installed_agent_version"] = json!(installed_version);
|
||||
snap["agent_has_update"] = json!(agent_has_update);
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。
|
||||
#[tauri::command]
|
||||
async fn apply_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
sha256: String,
|
||||
version: String,
|
||||
) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let mgr = state.agent()?;
|
||||
|
||||
// ① 下载到暂存文件(**校验通过才落盘**;不直接覆盖在跑的脚本)
|
||||
let staged = paths
|
||||
.data_dir
|
||||
.join("runtime")
|
||||
.join(format!("agent.mjs.staged-{}", std::process::id()));
|
||||
let dl = sc
|
||||
.request(
|
||||
"download",
|
||||
json!({
|
||||
"url": url,
|
||||
"sha256": sha256,
|
||||
"dest": staged.to_string_lossy(),
|
||||
}),
|
||||
320,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ② 停被控端(Windows 上正被 node 打开的文件不能重命名/替换)
|
||||
let was_running = mgr.snapshot().await["state"] == json!(agent::state::RUNNING);
|
||||
if was_running {
|
||||
mgr.stop().await?;
|
||||
}
|
||||
|
||||
// ③ 原子替换:先改名旧文件做备份,再落新文件
|
||||
let backup = paths.script.with_extension(format!("mjs.bak-{}", now_secs()));
|
||||
let replace = (|| -> Result<(), String> {
|
||||
if paths.script.exists() {
|
||||
std::fs::rename(&paths.script, &backup)
|
||||
.map_err(|e| format!("备份现有脚本失败:{e}"))?;
|
||||
}
|
||||
std::fs::rename(&staged, &paths.script).map_err(|e| {
|
||||
// 失败则回滚
|
||||
let _ = std::fs::rename(&backup, &paths.script);
|
||||
format!("替换脚本失败(已回滚):{e}")
|
||||
})
|
||||
})();
|
||||
if let Err(e) = replace {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// ④ 记版本:这是「控制台启动时不要把已更新的被控端降级」的依据
|
||||
agent::mark_installed_version(&paths.data_dir, &version, "console")?;
|
||||
mgr.push_log(
|
||||
"system",
|
||||
format!("已更新本机被控端到 v{version}(备份:{})", backup.display()),
|
||||
);
|
||||
|
||||
// ⑤ 按需重新拉起
|
||||
let mut restarted = false;
|
||||
if was_running || state.settings().auto_start_agent {
|
||||
match mgr
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, state.settings().auto_restart)
|
||||
.await
|
||||
{
|
||||
Ok(_) => restarted = true,
|
||||
Err(e) => mgr.push_log("system", format!("更新后启动失败:{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"updated": true,
|
||||
"version": version,
|
||||
"bytes": dl.get("bytes").cloned().unwrap_or(json!(0)),
|
||||
"sha256": dl.get("sha256").cloned().unwrap_or(json!("")),
|
||||
"backup": backup.to_string_lossy(),
|
||||
"restarted": restarted,
|
||||
}))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 设置与系统信息
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 读设置。
|
||||
#[tauri::command]
|
||||
fn settings_get(state: State<'_, AppState>) -> Value {
|
||||
settings::to_json(&state.settings())
|
||||
}
|
||||
|
||||
/// 合并式写设置(只传要改的字段)。
|
||||
#[tauri::command]
|
||||
fn settings_set(state: State<'_, AppState>, patch: Value) -> Result<Value, String> {
|
||||
let next = settings::merge(&state.config_dir, &patch)?;
|
||||
*state.settings.lock().unwrap() = next.clone();
|
||||
Ok(settings::to_json(&next))
|
||||
}
|
||||
|
||||
/// 控制台自身信息(版本、数据目录、平台),供「关于」与故障上报使用。
|
||||
#[tauri::command]
|
||||
fn system_info(app: AppHandle, state: State<'_, AppState>) -> Value {
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
// 先取 node 路径,再把 resource_dir 消费成字符串 —— 顺序反了会「借用已移动的值」
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"agent_data_dir": agent::agent_data_dir().to_string_lossy(),
|
||||
"resource_dir": resource_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"platform": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"node": node,
|
||||
"agent_only": std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 托盘与启动模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 显示主窗口(托盘菜单与第二次启动都用它)。
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在后台拉起被控端(忽略错误 —— 失败详情在日志里)。
|
||||
fn spawn_start_agent(app: &AppHandle, auto_restart: bool) {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
let mgr = match state.agent() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("[console] 被控端管理器不可用:{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if mgr.snapshot().await["state"] == json!(agent::state::RUNNING) {
|
||||
return;
|
||||
}
|
||||
match resolve_agent_paths(&app2, &state) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = mgr.start(&p.node, &p.script, &p.data_dir, auto_restart).await {
|
||||
mgr.push_log("system", format!("自动启动失败:{e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => mgr.push_log("system", format!("自动启动失败(路径解析):{e}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let start = MenuItem::with_id(app, "agent_start", "启动本机被控端", true, None::<&str>)?;
|
||||
let stop = MenuItem::with_id(app, "agent_stop", "停止本机被控端", true, None::<&str>)?;
|
||||
let restart = MenuItem::with_id(app, "agent_restart", "重启本机被控端", true, None::<&str>)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show, &sep1, &start, &stop, &restart, &sep2, &quit])?;
|
||||
|
||||
let mut builder = TrayIconBuilder::with_id("na-tray")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("nodeagent 控制台")
|
||||
.on_menu_event(|app, event| {
|
||||
let id = event.id().as_ref().to_string();
|
||||
match id.as_str() {
|
||||
"show" => show_main(app),
|
||||
"agent_start" => {
|
||||
let ar = app.state::<AppState>().settings().auto_restart;
|
||||
spawn_start_agent(app, ar);
|
||||
}
|
||||
"agent_stop" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
let _ = mgr.stop().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"agent_restart" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
if let Ok(p) = resolve_agent_paths(&app2, &state) {
|
||||
let ar = state.settings().auto_restart;
|
||||
let _ = mgr.restart(&p.node, &p.script, &p.data_dir, ar).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// 左键单击托盘图标 → 唤出主窗口(符合习惯)
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
builder = builder.icon(icon.clone());
|
||||
}
|
||||
builder.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单实例
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么必须有(真机缺陷,2026-10-13)
|
||||
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
|
||||
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
|
||||
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
|
||||
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
|
||||
//
|
||||
// 后果按先后顺序分两种,正是用户报的两个症状:
|
||||
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
|
||||
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
|
||||
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
|
||||
//
|
||||
// ## 为什么不能简单「已有实例就退出」
|
||||
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
|
||||
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
|
||||
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
|
||||
//
|
||||
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
|
||||
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
|
||||
|
||||
/// 第二次启动时执行(在**已有实例**的进程里)。
|
||||
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
|
||||
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
|
||||
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
|
||||
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
|
||||
return;
|
||||
}
|
||||
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
|
||||
show_main(app);
|
||||
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
|
||||
// 前端没监听也无害。
|
||||
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
on_second_instance(app, argv);
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
let app_version = handle.package_info().version.to_string();
|
||||
let loaded = settings::load(&config_dir);
|
||||
|
||||
// 事件桥接:被控端管理器 → Tauri event
|
||||
let app_events = handle.clone();
|
||||
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
|
||||
AgentEvent::Log(l) => {
|
||||
let _ = app_events.emit("na-agent-log", l);
|
||||
}
|
||||
AgentEvent::State(s) => {
|
||||
let _ = app_events.emit("na-agent-state", s);
|
||||
}
|
||||
});
|
||||
|
||||
let manager = AgentManager::new(emit);
|
||||
manager.push_log(
|
||||
"system",
|
||||
format!(
|
||||
"控制台 v{app_version} 就绪({} / {})",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
),
|
||||
);
|
||||
|
||||
app.manage(AppState {
|
||||
sidecar: tokio::sync::Mutex::new(None),
|
||||
capabilities: Mutex::new(json!([])),
|
||||
agent_version: Mutex::new(None),
|
||||
conn_state: Mutex::new("未连接".to_string()),
|
||||
agent: Mutex::new(Some(Arc::clone(&manager))),
|
||||
settings: Mutex::new(loaded.clone()),
|
||||
config_dir,
|
||||
app_version,
|
||||
});
|
||||
|
||||
build_tray(&handle)?;
|
||||
|
||||
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
|
||||
if agent_only {
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
|
||||
}
|
||||
|
||||
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
|
||||
if loaded.auto_start_agent {
|
||||
spawn_start_agent(&handle, loaded.auto_restart);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
let close_to_tray = state.settings().close_to_tray;
|
||||
if close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 连接
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state,
|
||||
// 本机服务
|
||||
agent_status,
|
||||
agent_start,
|
||||
agent_stop,
|
||||
agent_restart,
|
||||
agent_logs,
|
||||
agent_logs_clear,
|
||||
autostart_status,
|
||||
autostart_set,
|
||||
// 更新
|
||||
check_update,
|
||||
apply_update,
|
||||
// 设置与信息
|
||||
settings_get,
|
||||
settings_set,
|
||||
system_info,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
|
||||
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state = app.state::<AppState>();
|
||||
let sc = tauri::async_runtime::block_on(async {
|
||||
state.sidecar.lock().await.as_ref().map(Arc::clone)
|
||||
});
|
||||
if let Some(sc) = sc {
|
||||
tauri::async_runtime::block_on(async {
|
||||
sc.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
nodeagent_console_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 控制台自身的偏好设置(持久化到磁盘)。
|
||||
//!
|
||||
//! ## 为什么设置要在 Rust 侧存一份
|
||||
//! 前端有 `localStorage`,本来够用。但**「仅服务模式」(`--agent-only`,开机自启用)下
|
||||
//! 窗口是隐藏的** —— 此时仍需要知道「要不要拉起被控端」「崩了要不要自动重启」。
|
||||
//! 若只存 localStorage,就得靠隐藏的 webview 跑 JS 来驱动,既不直观也不可靠。
|
||||
//! 故由 Rust 侧持有唯一真相,前端只做读写。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// 应用启动时自动拉起本机被控端(默认开 —— 这正是「打开就有服务」的期望)
|
||||
pub auto_start_agent: bool,
|
||||
/// 被控端意外退出时自动重启(默认开 —— 「后台运行」的应有之义)
|
||||
pub auto_restart: bool,
|
||||
/// 点窗口关闭按钮时最小化到托盘而非退出(默认开 —— 「后台运行」)
|
||||
pub close_to_tray: bool,
|
||||
/// 连接本机被控端时是否自动接受自签证书(默认开;关掉就得手工钉指纹)
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_start_agent: true,
|
||||
auto_restart: true,
|
||||
close_to_tray: true,
|
||||
insecure: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_path(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join("console.json")
|
||||
}
|
||||
|
||||
pub fn load(config_dir: &Path) -> Settings {
|
||||
let p = settings_path(config_dir);
|
||||
match std::fs::read_to_string(&p) {
|
||||
Ok(text) => {
|
||||
// 剥离 BOM(Windows 上由 PowerShell 写出的 JSON 可能带 BOM)
|
||||
serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or_default()
|
||||
}
|
||||
Err(_) => Settings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(config_dir: &Path, s: &Settings) -> Result<(), String> {
|
||||
std::fs::create_dir_all(config_dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
|
||||
let text = serde_json::to_string_pretty(s).map_err(|e| format!("序列化设置失败:{e}"))?;
|
||||
std::fs::write(settings_path(config_dir), format!("{text}\n"))
|
||||
.map_err(|e| format!("写入设置失败:{e}"))
|
||||
}
|
||||
|
||||
/// 把前端传来的「部分字段」合并进当前设置(未提供的字段保持原值)。
|
||||
pub fn merge(config_dir: &Path, patch: &Value) -> Result<Settings, String> {
|
||||
let mut cur = load(config_dir);
|
||||
let mut raw = serde_json::to_value(&cur).map_err(|e| e.to_string())?;
|
||||
if let (Some(obj), Some(p)) = (raw.as_object_mut(), patch.as_object()) {
|
||||
for (k, v) in p {
|
||||
// 类型不匹配的键直接忽略(前端传 `undefined` 序列化成 null 是常见情形)
|
||||
if obj.get(k).map(|old| std::mem::discriminant(old) == std::mem::discriminant(v)).unwrap_or(false) {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
cur = serde_json::from_value(raw).map_err(|e| format!("设置字段类型不合法:{e}"))?;
|
||||
save(config_dir, &cur)?;
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
pub fn to_json(s: &Settings) -> Value {
|
||||
serde_json::to_value(s).unwrap_or_else(|_| json!({}))
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Node sidecar 管理器 —— 启动 `apps/console/sidecar/main.mjs` 并做 JSON-RPC 转发。
|
||||
//!
|
||||
//! ## 为什么是 sidecar(而不是 Rust 版 client)
|
||||
//! 原设计让本壳自己实现一份 client,与 TS 版(`@nodeagent/client`)**两套协议实现并存** ——
|
||||
//! 改了一处漏另一处时,构建过、单测过,只在真机暴露。sidecar 让本壳只做
|
||||
//! **进程管理 + 消息转发**,协议实现只剩 TS 一份。
|
||||
//!
|
||||
//! ## 协议(行分隔 JSON,见 apps/console/sidecar/main.mjs 顶部注释)
|
||||
//! 出(本壳 → sidecar):`{"jsonrpc":"2.0","id":N,"method":...,"params":{...}}`
|
||||
//! 入(sidecar → 本壳):
|
||||
//! - 响应:`{"jsonrpc":"2.0","id":N,"result"|"error":...}`
|
||||
//! - 通知:`{"jsonrpc":"2.0","method":"event","params":{"kind":...,...}}`
|
||||
//!
|
||||
//! ## 可测试性(本项目纪律:不能只 `cargo check`)
|
||||
//! 本模块的 `spawn` / `request` / 事件回调**都能在 `cargo test` 里真跑** ——
|
||||
//! 单测直接拉起真实的 node 进程,不依赖 Tauri GUI。GUI 那层才需要人工验证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SidecarEvent {
|
||||
/// 连接状态变化:connected / reconnecting / closed
|
||||
State(String),
|
||||
/// 日志一行
|
||||
Log(String),
|
||||
/// 被控端主动推送的事件(v12 `event.*`)
|
||||
Event(Value),
|
||||
/// sidecar 上来的未知通知(保留原始形态,便于排查)
|
||||
Other(Value),
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
|
||||
|
||||
/// 一个已启动的 sidecar 进程。
|
||||
pub struct Sidecar {
|
||||
child: Mutex<Option<Child>>,
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
pending: Pending,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
/// 启动 sidecar 并开始读取其 stdout。
|
||||
///
|
||||
/// `on_event`:sidecar 的通知回调(本壳里用于桥接成 Tauri event)。
|
||||
pub fn spawn(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
|
||||
) -> Result<Arc<Self>, String> {
|
||||
let mut child = Command::new(node)
|
||||
.arg(script)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true) // app 退出时不留孤儿进程
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("sidecar stdin 不可用")?;
|
||||
let stdout = child.stdout.take().ok_or("sidecar stdout 不可用")?;
|
||||
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
|
||||
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// stdout:逐行读,分派「响应」与「通知」
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let text = line.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(msg) = serde_json::from_str::<Value>(text) else {
|
||||
// 混入非 JSON 行说明协议被破坏 —— 记日志但不崩
|
||||
eprintln!("[sidecar] 非 JSON 输出:{}", &text[..text.len().min(200)]);
|
||||
continue;
|
||||
};
|
||||
|
||||
// 响应:有 id
|
||||
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(&id) {
|
||||
let out = if let Some(err) = msg.get("error") {
|
||||
Err(err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("sidecar 返回错误")
|
||||
.to_string())
|
||||
} else {
|
||||
Ok(msg.get("result").cloned().unwrap_or(Value::Null))
|
||||
};
|
||||
let _ = tx.send(out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通知:method == "event"
|
||||
if msg.get("method").and_then(Value::as_str) == Some("event") {
|
||||
let p = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
let evt = match kind {
|
||||
"state" => SidecarEvent::State(
|
||||
p.get("state").and_then(Value::as_str).unwrap_or("closed").to_string(),
|
||||
),
|
||||
"log" => SidecarEvent::Log(
|
||||
p.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
),
|
||||
"agent_event" => {
|
||||
SidecarEvent::Event(p.get("event").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
_ => SidecarEvent::Other(p),
|
||||
};
|
||||
on_event(evt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// stderr:sidecar 的日志 → 本壳 stderr(保持 stdout 干净)
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
eprintln!("{}", line);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
child: Mutex::new(Some(child)),
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
|
||||
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin
|
||||
.write_all(format!("{msg}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("写入 sidecar 失败:{e}"))?;
|
||||
stdin.flush().await.map_err(|e| format!("flush sidecar 失败:{e}"))?;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => Err("sidecar 通道已关闭".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().unwrap().remove(&id);
|
||||
Err(format!("{method} 超时({timeout_secs}s)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 关掉 sidecar(app 退出或显式 disconnect 时调用)。
|
||||
pub async fn shutdown(&self) {
|
||||
let _ = self.request("disconnect", json!({}), 5).await;
|
||||
// ⚠️ 必须**先把 child 取出来、让锁离开作用域**,再 await kill()。
|
||||
// 若写成 `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`,
|
||||
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
|
||||
// 且真会阻塞 executor)。
|
||||
let child = self.child.lock().unwrap().take();
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 解析 node 可执行文件。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
|
||||
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
|
||||
if !p.is_empty() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 系统 node(开发期)
|
||||
for cand in ["node", "node.exe"] {
|
||||
if which(cand).is_some() {
|
||||
return Ok(PathBuf::from(cand));
|
||||
}
|
||||
}
|
||||
Err("找不到 node 可执行文件(可用 NODEAGENT_CONSOLE_NODE 指定)".to_string())
|
||||
}
|
||||
|
||||
/// 极简 which(避免为一个函数引依赖)。
|
||||
fn which(cmd: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let full = dir.join(cmd);
|
||||
if full.is_file() {
|
||||
return Some(full);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 sidecar 脚本。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
|
||||
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
|
||||
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("sidecar.mjs");
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 开发期:src-tauri → apps/console/sidecar/dist/sidecar.mjs
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../sidecar/dist/sidecar.mjs");
|
||||
if dev.exists() {
|
||||
return Ok(dev);
|
||||
}
|
||||
Err("找不到 sidecar.mjs(先跑 `pnpm --filter nodeagent-console sidecar:build`,\
|
||||
或用 NODEAGENT_CONSOLE_SIDECAR 指定)"
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单测:真跑 sidecar 进程(不依赖 Tauri GUI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// 开发期的 sidecar 源码(不依赖打包产物,pnpm 的 node_modules 已能解析其依赖)。
|
||||
fn dev_sidecar_src() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/main.mjs")
|
||||
}
|
||||
|
||||
fn node_bin() -> PathBuf {
|
||||
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_state_请求应返回未连接() {
|
||||
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&seen);
|
||||
let sc = Sidecar::spawn(
|
||||
&node_bin(),
|
||||
&dev_sidecar_src(),
|
||||
Arc::new(move |_e| {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
)
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let r = sc.request("state", json!({}), 15).await.expect("state 应成功");
|
||||
assert_eq!(r["connected"], json!(false), "未连接时 connected 应为 false");
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未知方法应返回错误而不是崩溃() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("no_such_method", json!({}), 15).await;
|
||||
assert!(err.is_err(), "未知方法应返回错误");
|
||||
|
||||
// 关键:进程没崩,后续请求仍可用
|
||||
let ok = sc.request("state", json!({}), 15).await.expect("sidecar 不应因错误退出");
|
||||
assert_eq!(ok["connected"], json!(false));
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未连接时_invoke_应返回明确错误() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("invoke", json!({"capability": "system.info"}), 15).await;
|
||||
match err {
|
||||
Err(msg) => assert!(msg.contains("尚未连接"), "错误信息应提示未连接,实际:{msg}"),
|
||||
Ok(v) => panic!("未连接时 invoke 应失败,实际返回:{v}"),
|
||||
}
|
||||
sc.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.3",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "nodeagent 控制台",
|
||||
"width": 1100,
|
||||
"height": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from "vue";
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
|
||||
import AppShell from "./components/AppShell.vue";
|
||||
import { cssVars } from "./theme";
|
||||
import { useThemeStore } from "./stores/theme";
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
/**
|
||||
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
|
||||
*
|
||||
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
|
||||
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
|
||||
* 不报错、类型也抓不到)。
|
||||
*
|
||||
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
|
||||
* 在「高级灰」下白得刺眼。
|
||||
*/
|
||||
watchEffect(() => {
|
||||
const { palette, name } = theme.preset;
|
||||
const root = document.documentElement;
|
||||
for (const [k, v] of Object.entries(cssVars(palette))) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
root.style.colorScheme = palette.scheme;
|
||||
root.dataset.theme = name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:theme="theme.preset.base"
|
||||
:theme-overrides="theme.preset.overrides"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
>
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<app-shell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AgentActionResult,
|
||||
AgentLogPage,
|
||||
AgentStatus,
|
||||
ApplyUpdateResult,
|
||||
AutostartStatus,
|
||||
CapabilityDescriptor,
|
||||
ConnectInfo,
|
||||
ConsoleSettings,
|
||||
ConsoleSystemInfo,
|
||||
InvokeResult,
|
||||
UpdateInfo,
|
||||
} from "../types";
|
||||
|
||||
/** 建立连接并握手。 */
|
||||
export function connect(url: string, key: string, clientId = "console", insecure = true) {
|
||||
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
|
||||
}
|
||||
|
||||
/** 断开连接(停自动重连)。 */
|
||||
export function disconnect() {
|
||||
return invoke<void>("disconnect");
|
||||
}
|
||||
|
||||
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
|
||||
export function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
|
||||
}
|
||||
|
||||
/** 当前连接的能力清单。 */
|
||||
export function getCapabilities() {
|
||||
return invoke<CapabilityDescriptor[]>("get_capabilities");
|
||||
}
|
||||
|
||||
/** 当前连接状态字符串。 */
|
||||
export function getState() {
|
||||
return invoke<string>("get_state");
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力调用失败(业务层失败,非传输层失败)。
|
||||
*
|
||||
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
|
||||
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
|
||||
*/
|
||||
export class CapabilityFailure extends Error {
|
||||
readonly capability: string;
|
||||
readonly errorName: string;
|
||||
readonly data?: unknown;
|
||||
|
||||
constructor(capability: string, errorName: string, message: string, data?: unknown) {
|
||||
super(message);
|
||||
this.name = "CapabilityFailure";
|
||||
this.capability = capability;
|
||||
this.errorName = errorName;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
|
||||
export function isUnsupportedPlatform(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
|
||||
}
|
||||
|
||||
/** 能力被 ACL 拒绝。 */
|
||||
export function isAclDenied(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
|
||||
}
|
||||
|
||||
/** 能力被调用限速拦截。 */
|
||||
export function isRateLimited(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
|
||||
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
|
||||
*/
|
||||
export async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
|
||||
export function agentStatus() {
|
||||
return invoke<AgentStatus>("agent_status");
|
||||
}
|
||||
|
||||
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
|
||||
export function agentStart(autoRestart?: boolean) {
|
||||
return invoke<AgentActionResult>("agent_start", { autoRestart });
|
||||
}
|
||||
|
||||
/** 停止本机被控端(幂等)。 */
|
||||
export function agentStop() {
|
||||
return invoke<AgentActionResult>("agent_stop");
|
||||
}
|
||||
|
||||
/** 重启本机被控端。 */
|
||||
export function agentRestart(autoRestart?: boolean) {
|
||||
return invoke<AgentActionResult>("agent_restart", { autoRestart });
|
||||
}
|
||||
|
||||
/** 增量取日志(传上次拿到的最大 seq)。 */
|
||||
export function agentLogs(since = 0) {
|
||||
return invoke<AgentLogPage>("agent_logs", { since });
|
||||
}
|
||||
|
||||
/** 清空日志缓冲。 */
|
||||
export function agentLogsClear() {
|
||||
return invoke<void>("agent_logs_clear");
|
||||
}
|
||||
|
||||
/** 开机自启状态。 */
|
||||
export function autostartStatus() {
|
||||
return invoke<AutostartStatus>("autostart_status");
|
||||
}
|
||||
|
||||
/** 开关开机自启。 */
|
||||
export function autostartSet(enabled: boolean) {
|
||||
return invoke<AutostartStatus>("autostart_set", { enabled });
|
||||
}
|
||||
|
||||
/** 检查更新(比对最新 Release)。 */
|
||||
export function checkUpdate() {
|
||||
return invoke<UpdateInfo>("check_update");
|
||||
}
|
||||
|
||||
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
|
||||
export function applyUpdate(url: string, sha256: string, version: string) {
|
||||
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
|
||||
}
|
||||
|
||||
/** 读设置。 */
|
||||
export function settingsGet() {
|
||||
return invoke<ConsoleSettings>("settings_get");
|
||||
}
|
||||
|
||||
/** 合并式写设置(只传要改的字段)。 */
|
||||
export function settingsSet(patch: Partial<ConsoleSettings>) {
|
||||
return invoke<ConsoleSettings>("settings_set", { patch });
|
||||
}
|
||||
|
||||
/** 控制台自身信息。 */
|
||||
export function systemInfo() {
|
||||
return invoke<ConsoleSystemInfo>("system_info");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutSider,
|
||||
NLayoutHeader,
|
||||
NLayoutContent,
|
||||
NMenu,
|
||||
NTag,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NTooltip,
|
||||
NRadioGroup,
|
||||
NRadioButton,
|
||||
type MenuOption,
|
||||
} from "naive-ui";
|
||||
import { navRoutes, type NavMeta } from "../router";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import { useAgentStore } from "../stores/agent";
|
||||
import { useThemeStore } from "../stores/theme";
|
||||
import { THEME_LIST } from "../theme";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const conn = useConnStore();
|
||||
const agent = useAgentStore();
|
||||
const theme = useThemeStore();
|
||||
const themeName = computed({
|
||||
get: () => theme.name,
|
||||
set: (v) => theme.set(v),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
conn.bind();
|
||||
agent.bind();
|
||||
});
|
||||
|
||||
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
|
||||
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
|
||||
key: r.name as string,
|
||||
label: (r.meta as NavMeta).title,
|
||||
}));
|
||||
|
||||
const activeKey = computed(() => (route.name as string) ?? "local");
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
|
||||
const stateType = computed(() => {
|
||||
if (conn.state === "connected") return "success" as const;
|
||||
if (conn.state === "reconnecting") return "warning" as const;
|
||||
return "error" as const;
|
||||
});
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (conn.state === "connected") return "已连接";
|
||||
if (conn.state === "reconnecting") return "重连中";
|
||||
return "未连接";
|
||||
});
|
||||
|
||||
/** 从侧栏快速启停本机服务(不必切页面)。 */
|
||||
async function quickToggle() {
|
||||
if (agent.running) await agent.stop();
|
||||
else await agent.start();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100vh">
|
||||
<n-layout-sider bordered :width="200" :native-scrollbar="false">
|
||||
<div class="brand">
|
||||
<div class="brand-title">nodeagent</div>
|
||||
<div class="brand-sub">桌面控制台</div>
|
||||
</div>
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
|
||||
<div class="sider-foot">
|
||||
<div class="foot-label">主题</div>
|
||||
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
|
||||
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
|
||||
{{ t.label }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
<div class="foot-sep"></div>
|
||||
|
||||
<div class="foot-row">
|
||||
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
|
||||
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
|
||||
</div>
|
||||
<n-button
|
||||
size="tiny"
|
||||
block
|
||||
:loading="agent.busy"
|
||||
:disabled="agent.busy || agent.externalInstance"
|
||||
@click="quickToggle"
|
||||
>
|
||||
{{ agent.running ? "停止服务" : "启动服务" }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar">
|
||||
<n-space align="center">
|
||||
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
|
||||
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
|
||||
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-space align="center">
|
||||
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="info" round>仅服务模式</n-tag>
|
||||
</template>
|
||||
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
|
||||
</n-tooltip>
|
||||
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
|
||||
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
|
||||
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
|
||||
html,
|
||||
body {
|
||||
background: var(--na-bg);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.brand {
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--na-brand);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: var(--na-text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
|
||||
:deep(.n-layout-sider-scroll-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sider-foot {
|
||||
margin-top: auto;
|
||||
padding: 12px 14px 16px;
|
||||
border-top: 1px solid var(--na-border);
|
||||
}
|
||||
.foot-label {
|
||||
font-size: 11px;
|
||||
color: var(--na-text-3);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.foot-sep {
|
||||
height: 1px;
|
||||
background: var(--na-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.theme-switch :deep(.n-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
.theme-switch :deep(.n-radio-button__label) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.foot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 路由表。hash history 便于 Tauri 打包后稳定工作。 */
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
// 刻意不设 icon 字段:应用内一律不使用图标,导航以纯文字呈现。
|
||||
// (原因见 CHANGELOG [未发布]:emoji 图标在非系统字体栈的渲染器里会退化成豆腐块,
|
||||
// 而跨平台「哪套字体栈一定带 emoji」并不成立 —— 与其赌字形,不如不用。)
|
||||
export interface NavMeta {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const routes = [
|
||||
// 默认落在「本机服务」—— 这是「打开应用就要能启动/停止/重启」的落点,
|
||||
// 而不是先让用户去填一个连接地址。
|
||||
{ path: "/", redirect: "/local" },
|
||||
{
|
||||
path: "/local",
|
||||
name: "local",
|
||||
component: () => import("../views/LocalServiceView.vue"),
|
||||
meta: { title: "本机服务" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
name: "logs",
|
||||
component: () => import("../views/LogsView.vue"),
|
||||
meta: { title: "服务日志" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/nodes",
|
||||
name: "nodes",
|
||||
component: () => import("../views/NodesView.vue"),
|
||||
meta: { title: "设备管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/overview",
|
||||
name: "overview",
|
||||
component: () => import("../views/OverviewView.vue"),
|
||||
meta: { title: "状态总览" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/terminal",
|
||||
name: "terminal",
|
||||
component: () => import("../views/TerminalView.vue"),
|
||||
meta: { title: "命令执行" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/processes",
|
||||
name: "processes",
|
||||
component: () => import("../views/ProcessesView.vue"),
|
||||
meta: { title: "进程与服务" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/software",
|
||||
name: "software",
|
||||
component: () => import("../views/SoftwareView.vue"),
|
||||
meta: { title: "软件管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "files",
|
||||
component: () => import("../views/FilesView.vue"),
|
||||
meta: { title: "文件管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
name: "audit",
|
||||
component: () => import("../views/AuditView.vue"),
|
||||
meta: { title: "审计日志" } satisfies NavMeta,
|
||||
},
|
||||
];
|
||||
|
||||
export const navRoutes = routes.filter((r) => "name" in r);
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 本机服务 store —— 托管**本机**被控端(启动 / 停止 / 重启 / 自启 / 更新 / 设置)。
|
||||
*
|
||||
* 与 `conn` store 的分工:`conn` 管「连到某台被控端」(远程控制),
|
||||
* 本 store 管「本机这台被控端本身的进程与配置」。二者都连本机时是同一个实例,
|
||||
* 但关注点不同 —— 本 store 不依赖连接是否成功(进程没起也能启动它)。
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "../api";
|
||||
import { useConnStore } from "./conn";
|
||||
import type {
|
||||
AgentLogLine,
|
||||
AgentState,
|
||||
AgentStatus,
|
||||
ApplyUpdateResult,
|
||||
ConsoleSettings,
|
||||
ConsoleSystemInfo,
|
||||
UpdateInfo,
|
||||
} from "../types";
|
||||
|
||||
/** 状态 → 展示文案与配色。 */
|
||||
export const STATE_META: Record<AgentState, { label: string; type: "success" | "warning" | "error" | "default" }> = {
|
||||
running: { label: "运行中", type: "success" },
|
||||
starting: { label: "启动中", type: "warning" },
|
||||
stopping: { label: "停止中", type: "warning" },
|
||||
stopped: { label: "已停止", type: "default" },
|
||||
crashed: { label: "已异常退出", type: "error" },
|
||||
};
|
||||
|
||||
export const useAgentStore = defineStore("agent", () => {
|
||||
const status = ref<AgentStatus | null>(null);
|
||||
const sysInfo = ref<ConsoleSystemInfo | null>(null);
|
||||
const update = ref<UpdateInfo | null>(null);
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const bound = ref(false);
|
||||
|
||||
const process = computed(() => status.value?.manager ?? null);
|
||||
const state = computed<AgentState>(() => process.value?.state ?? "stopped");
|
||||
const meta = computed(() => STATE_META[state.value] ?? STATE_META.stopped);
|
||||
const settings = computed<ConsoleSettings>(
|
||||
() => status.value?.settings ?? { auto_start_agent: true, auto_restart: true, close_to_tray: true, insecure: true },
|
||||
);
|
||||
const running = computed(() => state.value === "running");
|
||||
/** 端口被占但本管理器没在跑 → 已有「非托管」的实例 */
|
||||
const externalInstance = computed(
|
||||
() => !running.value && (status.value?.config.port_in_use ?? false),
|
||||
);
|
||||
|
||||
/** 包一层:统一置 busy / 收集错误,避免每个动作都写一遍 try-finally。 */
|
||||
async function run<T>(fn: () => Promise<T>): Promise<T | null> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const r = await fn();
|
||||
await refresh();
|
||||
return r;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新总览(状态 + 配置 + 自启 + 设置)。 */
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await api.agentStatus();
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
return status.value;
|
||||
}
|
||||
|
||||
async function loadSystemInfo() {
|
||||
sysInfo.value = await api.systemInfo();
|
||||
return sysInfo.value;
|
||||
}
|
||||
|
||||
/** 绑定后端事件(应用启动时调用一次):进程状态变化由推送驱动,不轮询。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<string>("na-agent-state", () => {
|
||||
void refresh();
|
||||
});
|
||||
// v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。
|
||||
// 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。
|
||||
await listen<{ argv?: string[] }>("na-second-instance", () => {
|
||||
void refresh();
|
||||
});
|
||||
await refresh();
|
||||
void loadSystemInfo();
|
||||
}
|
||||
|
||||
const start = () => run(() => api.agentStart());
|
||||
const stop = () => run(() => api.agentStop());
|
||||
const restart = () => run(() => api.agentRestart());
|
||||
const clearLogs = () => api.agentLogsClear();
|
||||
|
||||
async function setAutostart(enabled: boolean) {
|
||||
return run(() => api.autostartSet(enabled));
|
||||
}
|
||||
|
||||
async function patchSettings(patch: Partial<ConsoleSettings>) {
|
||||
return run(() => api.settingsSet(patch));
|
||||
}
|
||||
|
||||
/** 检查更新。 */
|
||||
async function checkUpdate() {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
update.value = await api.checkUpdate();
|
||||
return update.value;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。 */
|
||||
async function applyUpdate(): Promise<ApplyUpdateResult | null> {
|
||||
const u = update.value;
|
||||
const url = u?.agent?.url;
|
||||
const sha = u?.agent?.sha256;
|
||||
if (!u || !url || !sha) {
|
||||
error.value = "最新版本信息不完整(缺少下载地址或哈希),无法更新";
|
||||
return null;
|
||||
}
|
||||
const r = await run(() => api.applyUpdate(url, sha, u.latest_version));
|
||||
if (r) await checkUpdate();
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
|
||||
async function connectLocal() {
|
||||
const cfg = status.value?.config;
|
||||
if (!cfg) return null;
|
||||
const conn = useConnStore();
|
||||
return conn.connect(cfg.url, cfg.key);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
sysInfo,
|
||||
update,
|
||||
busy,
|
||||
error,
|
||||
process,
|
||||
state,
|
||||
meta,
|
||||
settings,
|
||||
running,
|
||||
externalInstance,
|
||||
bind,
|
||||
refresh,
|
||||
loadSystemInfo,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
clearLogs,
|
||||
setAutostart,
|
||||
patchSettings,
|
||||
checkUpdate,
|
||||
applyUpdate,
|
||||
connectLocal,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 日志 store —— 本机服务日志的实时流 + 增量拉取。
|
||||
*
|
||||
* 设计要点:**事件推 + 序号去重**。若只靠事件,页面重新挂载后会丢掉历史;
|
||||
* 若只靠轮询,则看不到实时的那一行。故两条路并用,用 `seq` 去重。
|
||||
*/
|
||||
export const useLogsStore = defineStore("logs", () => {
|
||||
const lines = ref<AgentLogLine[]>([]);
|
||||
const maxSeq = ref(0);
|
||||
const paused = ref(false);
|
||||
const bound = ref(false);
|
||||
/** 内存里保留的最大行数(与 Rust 侧环形缓冲上限对齐,避免界面无限膨胀) */
|
||||
const CAP = 4000;
|
||||
|
||||
function append(rows: AgentLogLine[]) {
|
||||
const fresh = rows.filter((r) => r.seq > maxSeq.value);
|
||||
if (fresh.length === 0) return;
|
||||
lines.value.push(...fresh);
|
||||
maxSeq.value = Math.max(maxSeq.value, ...fresh.map((r) => r.seq));
|
||||
if (lines.value.length > CAP) lines.value.splice(0, lines.value.length - CAP);
|
||||
}
|
||||
|
||||
/** 拉取历史(首次进页面 / 清空后重建)。 */
|
||||
async function load() {
|
||||
const page = await api.agentLogs(0);
|
||||
lines.value = [];
|
||||
maxSeq.value = 0;
|
||||
append(page.lines);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** 绑定实时日志事件。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<AgentLogLine>("na-agent-log", (e) => {
|
||||
if (paused.value) return;
|
||||
append([e.payload]);
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
await api.agentLogsClear();
|
||||
lines.value = [];
|
||||
maxSeq.value = 0;
|
||||
}
|
||||
|
||||
/** 导出为文本(在浏览器侧生成,不额外调后端)。 */
|
||||
function toText(filtered?: AgentLogLine[]): string {
|
||||
const src = filtered ?? lines.value;
|
||||
return src
|
||||
.map((l) => `${new Date(l.ts).toISOString()} [${l.stream}] ${l.line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function downloadText(text: string, filename: string) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
return { lines, maxSeq, paused, bind, load, clear, append, toText, downloadText };
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** 连接状态 store:连接/断开、能力清单、元信息、日志与被控端事件。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "../api";
|
||||
import type { ConnectInfo, InvokeResult, SystemInfo } from "../types";
|
||||
|
||||
export interface AgentEvent {
|
||||
ts: number;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
|
||||
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
|
||||
*/
|
||||
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
|
||||
|
||||
export const useConnStore = defineStore("conn", () => {
|
||||
const state = ref<string>("closed");
|
||||
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
|
||||
const key = ref(localStorage.getItem("na.key") || "");
|
||||
const info = ref<ConnectInfo | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const events = ref<AgentEvent[]>([]);
|
||||
const connecting = ref(false);
|
||||
const bound = ref(false);
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' | ... (连接后自动探测) */
|
||||
const nodeOs = ref<string | null>(null);
|
||||
|
||||
const connected = computed(() => state.value === "connected" && !!info.value);
|
||||
const capabilities = computed(() => info.value?.capabilities ?? []);
|
||||
/** 被控端能力名集合,便于按名判存在性。 */
|
||||
const capabilityNames = computed(() => new Set(capabilities.value.map((c) => c.name)));
|
||||
const isWindows = computed(() => nodeOs.value === "Windows");
|
||||
/** 平台门控能力当前是否可用(未探测到平台时按「乐观可用」处理)。 */
|
||||
const platformReady = computed(() => nodeOs.value === null || isWindows.value);
|
||||
|
||||
function pushLog(msg: string) {
|
||||
logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
||||
if (logs.value.length > 500) logs.value.shift();
|
||||
}
|
||||
|
||||
/** 绑定后端事件(应用启动时调用一次)。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<string>("na-log", (e) => pushLog(e.payload));
|
||||
await listen<string>("na-state", (e) => {
|
||||
state.value = e.payload;
|
||||
pushLog(`状态 → ${e.payload}`);
|
||||
});
|
||||
await listen<unknown>("na-event", (e) => {
|
||||
events.value.unshift({ ts: Date.now(), payload: e.payload });
|
||||
if (events.value.length > 200) events.value.pop();
|
||||
pushLog(`被控端事件:${JSON.stringify(e.payload)}`);
|
||||
});
|
||||
state.value = await api.getState();
|
||||
}
|
||||
|
||||
/** 探测被控端平台(`system.info.os`)——决定 Windows 专属能力是否可用。 */
|
||||
async function loadPlatform() {
|
||||
try {
|
||||
const i = await api.ok<SystemInfo>("system.info", { fields: ["os"] });
|
||||
nodeOs.value = i.os ?? null;
|
||||
pushLog(`被控端平台:${nodeOs.value ?? "未知"}`);
|
||||
return nodeOs.value;
|
||||
} catch (e) {
|
||||
pushLog(`平台探测失败:${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(u = url.value, k = key.value) {
|
||||
connecting.value = true;
|
||||
url.value = u;
|
||||
key.value = k;
|
||||
pushLog(`正在连接 ${u} ...`);
|
||||
try {
|
||||
const i = await api.connect(u, k, "console");
|
||||
info.value = i;
|
||||
localStorage.setItem("na.url", u);
|
||||
localStorage.setItem("na.key", k);
|
||||
pushLog(`握手成功:${i.capabilities.length} 项能力,agent ${i.agent_version ?? "?"}`);
|
||||
// 握手后立刻探测平台,供各页面事前门控。
|
||||
await loadPlatform();
|
||||
return i;
|
||||
} catch (e) {
|
||||
pushLog(`连接失败:${e}`);
|
||||
throw e;
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await api.disconnect();
|
||||
info.value = null;
|
||||
nodeOs.value = null;
|
||||
pushLog("已断开");
|
||||
}
|
||||
|
||||
/** 泛型调用;连接断开时抛错。 */
|
||||
async function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<InvokeResult<T>> {
|
||||
if (!connected.value) throw new api.CapabilityFailure(capability, "E_NODE_OFFLINE", "未连接");
|
||||
const t0 = performance.now();
|
||||
const res = await api.call<T>(capability, args, timeoutMs);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
if (res.status === "ok") {
|
||||
pushLog(`${capability} → ok (${ms}ms)`);
|
||||
} else {
|
||||
pushLog(`${capability} → 失败:[${res.error?.name}] ${res.error?.message ?? "未知错误"}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功,直接返回 data。
|
||||
* 失败抛 `CapabilityFailure`(携带 `errorName`),便于页面按错误名分支
|
||||
* ——如 `isUnsupportedPlatform(e)` 判平台不支持。
|
||||
*/
|
||||
async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new api.CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
url,
|
||||
key,
|
||||
info,
|
||||
logs,
|
||||
events,
|
||||
connecting,
|
||||
connected,
|
||||
capabilities,
|
||||
capabilityNames,
|
||||
nodeOs,
|
||||
isWindows,
|
||||
platformReady,
|
||||
bind,
|
||||
loadPlatform,
|
||||
connect,
|
||||
disconnect,
|
||||
call,
|
||||
ok,
|
||||
pushLog,
|
||||
clearLogs,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 设备列表 store:本地保存的被控端配置(名称 / 地址 / PSK),CRUD + 持久化。 */
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export interface NodeProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useNodeStore = defineStore("node", () => {
|
||||
const nodes = ref<NodeProfile[]>(JSON.parse(localStorage.getItem("na.nodes") || "[]"));
|
||||
const currentId = ref<string | null>(localStorage.getItem("na.current"));
|
||||
|
||||
const current = computed(() => nodes.value.find((n) => n.id === currentId.value) ?? null);
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem("na.nodes", JSON.stringify(nodes.value));
|
||||
localStorage.setItem("na.current", currentId.value ?? "");
|
||||
}
|
||||
|
||||
function add(profile: Omit<NodeProfile, "id">): NodeProfile {
|
||||
const node: NodeProfile = { id: crypto.randomUUID(), ...profile };
|
||||
nodes.value.push(node);
|
||||
persist();
|
||||
return node;
|
||||
}
|
||||
|
||||
function update(id: string, patch: Partial<NodeProfile>) {
|
||||
const idx = nodes.value.findIndex((n) => n.id === id);
|
||||
if (idx >= 0) {
|
||||
nodes.value[idx] = { ...nodes.value[idx], ...patch };
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
nodes.value = nodes.value.filter((n) => n.id !== id);
|
||||
if (currentId.value === id) currentId.value = null;
|
||||
persist();
|
||||
}
|
||||
|
||||
function setCurrent(id: string | null) {
|
||||
currentId.value = id;
|
||||
persist();
|
||||
}
|
||||
|
||||
return { nodes, currentId, current, add, update, remove, setCurrent };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
THEMES,
|
||||
THEME_STORAGE_KEY,
|
||||
normalizeThemeName,
|
||||
type ThemeName,
|
||||
} from "../theme";
|
||||
|
||||
/**
|
||||
* 主题选择。
|
||||
*
|
||||
* 只存 **localStorage**,不落 Rust 侧 settings:那是「仅服务模式」(无窗口、
|
||||
* 常驻托盘)也要生效的偏好,而主题是纯图形偏好 —— 没有窗口时毫无意义。
|
||||
* 放 localStorage 可避免为一件纯前端的事去动 Rust 命令面与契约守卫。
|
||||
*/
|
||||
export const useThemeStore = defineStore("theme", () => {
|
||||
const name = ref<ThemeName>(read());
|
||||
|
||||
const preset = computed(() => THEMES[name.value]);
|
||||
const isWhite = computed(() => name.value === "white");
|
||||
|
||||
function read(): ThemeName {
|
||||
try {
|
||||
return normalizeThemeName(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
} catch {
|
||||
// 隐私模式等场景下 localStorage 可能不可用 —— 不该因此启动失败
|
||||
return "white";
|
||||
}
|
||||
}
|
||||
|
||||
function set(next: ThemeName) {
|
||||
name.value = normalizeThemeName(next);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
set(name.value === "white" ? "gray" : "white");
|
||||
}
|
||||
|
||||
watch(
|
||||
name,
|
||||
(v) => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, v);
|
||||
} catch {
|
||||
/* 忽略:存不下只影响下次启动的默认值 */
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { name, preset, isWhite, set, toggle };
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
type GlobalTheme,
|
||||
type GlobalThemeOverrides,
|
||||
} from "naive-ui";
|
||||
|
||||
/**
|
||||
* 控制台只有两套主题,且都**只用中性灰阶** —— 不含彩色品牌色。
|
||||
*
|
||||
* 「简约白」= 白底 + 浅灰分线,靠留白与层次而不是颜色区分区域。
|
||||
* 「高级灰」= 低饱和冷灰底 + 浅灰字,同样只用灰阶。
|
||||
*
|
||||
* 之所以真的把两套都做出来、而不是只留一套浅色:深色在长时间盯日志/终端时
|
||||
* 更省眼(运维场景常态),浅色在白天与投屏时更清楚。两套共用同一份灰阶逻辑
|
||||
* (只是明暗反转),故维护成本不翻倍。
|
||||
*/
|
||||
export type ThemeName = "white" | "gray";
|
||||
|
||||
export const THEME_STORAGE_KEY = "nodeagent.console.theme";
|
||||
|
||||
/**
|
||||
* 调色板。**这是全应用唯一一份色值定义**:
|
||||
* 既喂给 naive-ui 的 themeOverrides,也由 App.vue 注入成 `--na-*` CSS 变量
|
||||
* 供自定义样式使用。两处各写一份必然漂移(本项目吃过这类亏),故只此一处。
|
||||
*/
|
||||
export interface Palette {
|
||||
/** 页面底色 */
|
||||
bg: string;
|
||||
/** 侧栏底色(与 bg 拉开一档层次,而不是靠边框) */
|
||||
sider: string;
|
||||
/** 卡片 / 浮层底色 */
|
||||
card: string;
|
||||
/** 内嵌区:日志框、代码块、表头 */
|
||||
sunken: string;
|
||||
border: string;
|
||||
borderStrong: string;
|
||||
text1: string;
|
||||
text2: string;
|
||||
text3: string;
|
||||
hover: string;
|
||||
/** 主色位(naive-ui 的 primary)—— 刻意用中性色,不用彩色 */
|
||||
accent: string;
|
||||
accentHover: string;
|
||||
accentPressed: string;
|
||||
/** 主色底之上的文字色 */
|
||||
accentText: string;
|
||||
/** 品牌字(侧栏站点标题),不再用青绿 */
|
||||
brand: string;
|
||||
/** 语义色:统一降饱和,保证「白 + 灰」的底色观感 */
|
||||
ok: string;
|
||||
warn: string;
|
||||
err: string;
|
||||
info: string;
|
||||
/** 语义色的浅底(用于 Tag / 告警块) */
|
||||
okSoft: string;
|
||||
warnSoft: string;
|
||||
errSoft: string;
|
||||
infoSoft: string;
|
||||
tooltipBg: string;
|
||||
tooltipText: string;
|
||||
scrollbar: string;
|
||||
scrollbarHover: string;
|
||||
shadow: string;
|
||||
/** 原生控件(滚动条、表单控件)的明暗 */
|
||||
scheme: "light" | "dark";
|
||||
}
|
||||
|
||||
/** 简约白:白为主,灰为辅。层次靠灰阶(页底 < 侧栏 < 卡片),不靠颜色。 */
|
||||
const WHITE: Palette = {
|
||||
// 页底刻意不是 #ffffff:卡片也是白的,两者同色就只能靠 1px 分线撑层次,
|
||||
// 整页会发"糊"。压一档浅灰后卡片自然浮起来,且仍是「白」为主。
|
||||
bg: "#f4f4f5",
|
||||
sider: "#fafafa",
|
||||
card: "#ffffff",
|
||||
sunken: "#f5f5f6",
|
||||
border: "#e8e8ea",
|
||||
borderStrong: "#d6d6da",
|
||||
text1: "#1a1a1c",
|
||||
text2: "#55555c",
|
||||
text3: "#8e8e97",
|
||||
hover: "#eeeeef",
|
||||
accent: "#1f1f23",
|
||||
accentHover: "#33333a",
|
||||
accentPressed: "#0d0d10",
|
||||
accentText: "#ffffff",
|
||||
brand: "#1a1a1c",
|
||||
ok: "#4f8768",
|
||||
warn: "#9c7c39",
|
||||
err: "#b4635c",
|
||||
info: "#5b789a",
|
||||
okSoft: "#eef4f0",
|
||||
warnSoft: "#f7f3e9",
|
||||
errSoft: "#f8efee",
|
||||
infoSoft: "#eef2f7",
|
||||
tooltipBg: "#2b2b30",
|
||||
tooltipText: "#f2f2f3",
|
||||
scrollbar: "#d6d6da",
|
||||
scrollbarHover: "#bcbcc2",
|
||||
shadow: "0 1px 2px rgba(16, 16, 20, 0.05)",
|
||||
scheme: "light",
|
||||
};
|
||||
|
||||
/** 高级灰:雾霾冷灰调。低饱和、低对比,但保证正文可读。 */
|
||||
const GRAY: Palette = {
|
||||
bg: "#1d1f22",
|
||||
sider: "#181a1d",
|
||||
card: "#24262a",
|
||||
sunken: "#1a1c1f",
|
||||
border: "#32353a",
|
||||
borderStrong: "#41454c",
|
||||
text1: "#dcdee2",
|
||||
text2: "#a2a6ae",
|
||||
text3: "#71757c",
|
||||
hover: "#2b2e33",
|
||||
accent: "#dcdee2",
|
||||
accentHover: "#eef0f2",
|
||||
accentPressed: "#c6c9ce",
|
||||
accentText: "#1d1f22",
|
||||
brand: "#e6e8eb",
|
||||
ok: "#83a891",
|
||||
warn: "#b99a5c",
|
||||
err: "#cc8b82",
|
||||
info: "#8098b5",
|
||||
okSoft: "#262f2a",
|
||||
warnSoft: "#2f2c22",
|
||||
errSoft: "#312625",
|
||||
infoSoft: "#252b33",
|
||||
tooltipBg: "#3a3e44",
|
||||
tooltipText: "#e8eaed",
|
||||
scrollbar: "#3a3e44",
|
||||
scrollbarHover: "#4a4f56",
|
||||
shadow: "0 1px 2px rgba(0, 0, 0, 0.35)",
|
||||
scheme: "dark",
|
||||
};
|
||||
|
||||
/**
|
||||
* 调色板 → `--na-*` CSS 变量。
|
||||
*
|
||||
* 名字写死而不是自动 kebab 化:自动转换一旦改字段名,CSS 侧会**静默**失配
|
||||
* (变量没人定义 → 颜色落回继承值,不报错、类型也抓不到)。写死则改字段时
|
||||
* 至少能在这里一眼看到。
|
||||
*/
|
||||
export function cssVars(p: Palette): Record<string, string> {
|
||||
return {
|
||||
"--na-bg": p.bg,
|
||||
"--na-sider": p.sider,
|
||||
"--na-card": p.card,
|
||||
"--na-sunken": p.sunken,
|
||||
"--na-border": p.border,
|
||||
"--na-border-strong": p.borderStrong,
|
||||
"--na-text-1": p.text1,
|
||||
"--na-text-2": p.text2,
|
||||
"--na-text-3": p.text3,
|
||||
"--na-hover": p.hover,
|
||||
"--na-accent": p.accent,
|
||||
"--na-accent-text": p.accentText,
|
||||
"--na-brand": p.brand,
|
||||
"--na-ok": p.ok,
|
||||
"--na-warn": p.warn,
|
||||
"--na-err": p.err,
|
||||
"--na-info": p.info,
|
||||
"--na-tooltip-bg": p.tooltipBg,
|
||||
"--na-tooltip-text": p.tooltipText,
|
||||
"--na-scrollbar": p.scrollbar,
|
||||
"--na-scrollbar-hover": p.scrollbarHover,
|
||||
"--na-shadow": p.shadow,
|
||||
};
|
||||
}
|
||||
|
||||
function overridesOf(p: Palette): GlobalThemeOverrides {
|
||||
return {
|
||||
common: {
|
||||
// —— 主色:中性灰。naive-ui 默认主色是绿色,留着它整套配色立刻"不简单"。
|
||||
// 主按钮、开关激活态、进度条、勾选框都吃这一个值。
|
||||
primaryColor: p.accent,
|
||||
primaryColorHover: p.accentHover,
|
||||
primaryColorPressed: p.accentPressed,
|
||||
primaryColorSuppl: p.accentHover,
|
||||
|
||||
// —— 语义色降饱和(仍保留:运维工具里"运行中/出错"必须一眼可辨,
|
||||
// 全部灰化会导致误读;故只在徽章与告警这类小面积上出现)
|
||||
successColor: p.ok,
|
||||
successColorHover: p.ok,
|
||||
successColorPressed: p.ok,
|
||||
successColorSuppl: p.ok,
|
||||
warningColor: p.warn,
|
||||
warningColorHover: p.warn,
|
||||
warningColorPressed: p.warn,
|
||||
warningColorSuppl: p.warn,
|
||||
errorColor: p.err,
|
||||
errorColorHover: p.err,
|
||||
errorColorPressed: p.err,
|
||||
errorColorSuppl: p.err,
|
||||
infoColor: p.info,
|
||||
infoColorHover: p.info,
|
||||
infoColorPressed: p.info,
|
||||
infoColorSuppl: p.info,
|
||||
|
||||
textColorBase: p.text1,
|
||||
textColor1: p.text1,
|
||||
textColor2: p.text2,
|
||||
textColor3: p.text3,
|
||||
placeholderColor: p.text3,
|
||||
placeholderColorDisabled: p.text3,
|
||||
iconColor: p.text3,
|
||||
iconColorHover: p.text2,
|
||||
|
||||
dividerColor: p.border,
|
||||
borderColor: p.border,
|
||||
closeIconColor: p.text3,
|
||||
|
||||
bodyColor: p.bg,
|
||||
cardColor: p.card,
|
||||
modalColor: p.card,
|
||||
popoverColor: p.card,
|
||||
tableColor: p.card,
|
||||
inputColor: p.sunken,
|
||||
inputColorDisabled: p.sunken,
|
||||
tagColor: p.sunken,
|
||||
actionColor: p.sunken,
|
||||
avatarColor: p.sunken,
|
||||
codeColor: p.sunken,
|
||||
tabColor: p.sunken,
|
||||
tableHeaderColor: p.sunken,
|
||||
hoverColor: p.hover,
|
||||
tableColorHover: p.hover,
|
||||
tableColorStriped: p.sunken,
|
||||
pressedColor: p.hover,
|
||||
progressRailColor: p.sunken,
|
||||
railColor: p.sunken,
|
||||
scrollbarColor: p.scrollbar,
|
||||
scrollbarColorHover: p.scrollbarHover,
|
||||
|
||||
borderRadius: "6px",
|
||||
borderRadiusSmall: "4px",
|
||||
},
|
||||
|
||||
Layout: {
|
||||
color: p.bg,
|
||||
siderColor: p.sider,
|
||||
headerColor: p.bg,
|
||||
siderBorderColor: p.border,
|
||||
headerBorderColor: p.border,
|
||||
textColor: p.text1,
|
||||
},
|
||||
|
||||
Menu: {
|
||||
color: p.sider,
|
||||
itemTextColor: p.text2,
|
||||
itemTextColorHover: p.text1,
|
||||
itemTextColorActive: p.text1,
|
||||
itemTextColorActiveHover: p.text1,
|
||||
itemTextColorChildActive: p.text1,
|
||||
itemColorHover: p.hover,
|
||||
itemColorActive: p.hover,
|
||||
itemColorActiveHover: p.hover,
|
||||
itemColorActiveCollapsed: p.hover,
|
||||
itemIconColor: p.text3,
|
||||
itemIconColorHover: p.text2,
|
||||
itemIconColorActive: p.text1,
|
||||
groupTextColor: p.text3,
|
||||
arrowColor: p.text3,
|
||||
borderColorHorizontal: p.border,
|
||||
borderRadius: "6px",
|
||||
},
|
||||
|
||||
Card: {
|
||||
color: p.card,
|
||||
colorModal: p.card,
|
||||
colorPopover: p.card,
|
||||
colorEmbedded: p.sunken,
|
||||
textColor: p.text1,
|
||||
titleTextColor: p.text1,
|
||||
borderColor: p.border,
|
||||
actionColor: p.sunken,
|
||||
titleFontWeight: "600",
|
||||
boxShadow: p.shadow,
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
thColor: p.sunken,
|
||||
thColorHover: p.hover,
|
||||
tdColor: p.card,
|
||||
tdColorHover: p.hover,
|
||||
tdColorStriped: p.sunken,
|
||||
borderColor: p.border,
|
||||
thTextColor: p.text2,
|
||||
tdTextColor: p.text2,
|
||||
thFontWeight: "600",
|
||||
loadingColor: p.accent,
|
||||
},
|
||||
|
||||
Input: {
|
||||
color: p.card,
|
||||
colorFocus: p.card,
|
||||
colorDisabled: p.sunken,
|
||||
textColor: p.text1,
|
||||
textColorDisabled: p.text3,
|
||||
placeholderColor: p.text3,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderHover: `1px solid ${p.borderStrong}`,
|
||||
borderFocus: `1px solid ${p.accent}`,
|
||||
borderDisabled: `1px solid ${p.border}`,
|
||||
boxShadowFocus: `0 0 0 2px ${p.hover}`,
|
||||
caretColor: p.accent,
|
||||
iconColor: p.text3,
|
||||
clearColor: p.text3,
|
||||
clearColorHover: p.text2,
|
||||
},
|
||||
|
||||
Alert: {
|
||||
color: p.sunken,
|
||||
border: `1px solid ${p.border}`,
|
||||
titleTextColor: p.text1,
|
||||
contentTextColor: p.text2,
|
||||
iconColor: p.text3,
|
||||
borderInfo: `1px solid ${p.info}`,
|
||||
colorInfo: p.infoSoft,
|
||||
titleTextColorInfo: p.text1,
|
||||
contentTextColorInfo: p.text2,
|
||||
iconColorInfo: p.info,
|
||||
borderSuccess: `1px solid ${p.ok}`,
|
||||
colorSuccess: p.okSoft,
|
||||
titleTextColorSuccess: p.text1,
|
||||
contentTextColorSuccess: p.text2,
|
||||
iconColorSuccess: p.ok,
|
||||
borderWarning: `1px solid ${p.warn}`,
|
||||
colorWarning: p.warnSoft,
|
||||
titleTextColorWarning: p.text1,
|
||||
contentTextColorWarning: p.text2,
|
||||
iconColorWarning: p.warn,
|
||||
borderError: `1px solid ${p.err}`,
|
||||
colorError: p.errSoft,
|
||||
titleTextColorError: p.text1,
|
||||
contentTextColorError: p.text2,
|
||||
iconColorError: p.err,
|
||||
},
|
||||
|
||||
Tag: {
|
||||
color: p.sunken,
|
||||
textColor: p.text2,
|
||||
border: "1px solid transparent",
|
||||
colorBordered: p.sunken,
|
||||
borderRadius: "5px",
|
||||
colorPrimary: p.accent,
|
||||
textColorPrimary: p.accentText,
|
||||
borderPrimary: "1px solid transparent",
|
||||
colorInfo: p.infoSoft,
|
||||
textColorInfo: p.info,
|
||||
borderInfo: `1px solid ${p.info}`,
|
||||
colorSuccess: p.okSoft,
|
||||
textColorSuccess: p.ok,
|
||||
borderSuccess: `1px solid ${p.ok}`,
|
||||
colorWarning: p.warnSoft,
|
||||
textColorWarning: p.warn,
|
||||
borderWarning: `1px solid ${p.warn}`,
|
||||
colorError: p.errSoft,
|
||||
textColorError: p.err,
|
||||
borderError: `1px solid ${p.err}`,
|
||||
},
|
||||
|
||||
Divider: {
|
||||
color: p.border,
|
||||
textColor: p.text3,
|
||||
},
|
||||
|
||||
Progress: {
|
||||
railColor: p.sunken,
|
||||
fillColor: p.accent,
|
||||
fillColorSuccess: p.ok,
|
||||
fillColorWarning: p.warn,
|
||||
fillColorError: p.err,
|
||||
fillColorInfo: p.info,
|
||||
textColorLineInner: p.accentText,
|
||||
textColorLineOuter: p.text2,
|
||||
},
|
||||
|
||||
Switch: {
|
||||
railColor: p.borderStrong,
|
||||
railColorActive: p.accent,
|
||||
buttonColor: p.card,
|
||||
iconColor: p.text3,
|
||||
loadingColor: p.text3,
|
||||
},
|
||||
|
||||
Statistic: {
|
||||
labelTextColor: p.text3,
|
||||
valueTextColor: p.text1,
|
||||
valuePrefixTextColor: p.text2,
|
||||
valueSuffixTextColor: p.text2,
|
||||
},
|
||||
|
||||
Empty: {
|
||||
textColor: p.text3,
|
||||
iconColor: p.text3,
|
||||
extraTextColor: p.text3,
|
||||
},
|
||||
|
||||
Spin: {
|
||||
color: p.accent,
|
||||
textColor: p.text2,
|
||||
},
|
||||
|
||||
Popover: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
dividerColor: p.border,
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
color: p.tooltipBg,
|
||||
textColor: p.tooltipText,
|
||||
},
|
||||
|
||||
Modal: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
},
|
||||
|
||||
List: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
borderColor: p.border,
|
||||
},
|
||||
|
||||
Collapse: {
|
||||
titleTextColor: p.text1,
|
||||
textColor: p.text2,
|
||||
dividerColor: p.border,
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
labelTextColor: p.text1,
|
||||
color: p.card,
|
||||
border: `1px solid ${p.borderStrong}`,
|
||||
borderChecked: `1px solid ${p.accent}`,
|
||||
colorChecked: p.accent,
|
||||
checkMarkColor: p.accentText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemePreset {
|
||||
name: ThemeName;
|
||||
label: string;
|
||||
desc: string;
|
||||
base: GlobalTheme;
|
||||
palette: Palette;
|
||||
overrides: GlobalThemeOverrides;
|
||||
}
|
||||
|
||||
export const THEMES: Record<ThemeName, ThemePreset> = {
|
||||
white: {
|
||||
name: "white",
|
||||
label: "简约白",
|
||||
desc: "白底浅灰分线",
|
||||
base: lightTheme,
|
||||
palette: WHITE,
|
||||
overrides: overridesOf(WHITE),
|
||||
},
|
||||
gray: {
|
||||
name: "gray",
|
||||
label: "高级灰",
|
||||
desc: "雾霾冷灰低对比",
|
||||
base: darkTheme,
|
||||
palette: GRAY,
|
||||
overrides: overridesOf(GRAY),
|
||||
},
|
||||
};
|
||||
|
||||
export const THEME_LIST: ThemePreset[] = [THEMES.white, THEMES.gray];
|
||||
|
||||
/** 越界 / 脏数据一律落回默认主题,避免启动即白屏。 */
|
||||
export function normalizeThemeName(v: unknown): ThemeName {
|
||||
return v === "gray" ? "gray" : "white";
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
|
||||
|
||||
/** 能力描述(被控端声明的能力清单条目)。 */
|
||||
export interface CapabilityDescriptor {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "high" | string;
|
||||
params_schema: JsonSchema;
|
||||
returns_schema?: JsonSchema;
|
||||
}
|
||||
|
||||
/** JSON Schema 子集。 */
|
||||
export interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
items?: JsonSchema;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** connect command 返回的连接元信息。 */
|
||||
export interface ConnectInfo {
|
||||
capabilities: CapabilityDescriptor[];
|
||||
agent_version: string | null;
|
||||
authorized: string[] | null;
|
||||
peer_cert_fp: string | null;
|
||||
auth_mode: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
/** 能力执行失败的错误信息。 */
|
||||
export interface InvokeError {
|
||||
name: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/** 能力执行结果(业务层)。 */
|
||||
export interface InvokeResult<T = unknown> {
|
||||
status: "ok" | "failed";
|
||||
data?: T;
|
||||
error?: InvokeError;
|
||||
}
|
||||
|
||||
/** 连接状态。 */
|
||||
export type ConnState = "connected" | "reconnecting" | "closed";
|
||||
|
||||
// ---------- 各能力的返回结构 ----------
|
||||
|
||||
/** 被控端构建信息(v18 构建指纹 + 运行状态)。 */
|
||||
export interface BuildInfo {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
commit?: string;
|
||||
built_at?: string;
|
||||
/** 运行中 agent 脚本的 sha256(v21 证书/脚本交叉核对) */
|
||||
cert_sha256?: string;
|
||||
bytes?: number;
|
||||
mtime_ms?: number;
|
||||
node?: string;
|
||||
started_at?: number;
|
||||
uptime_ms?: number;
|
||||
}
|
||||
|
||||
/** PowerShell 常驻助手状态(v15)。 */
|
||||
export interface PsHelperInfo {
|
||||
spawned?: boolean;
|
||||
hits?: number;
|
||||
failures?: number;
|
||||
consecutiveFailures?: number;
|
||||
avg_ms?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** v21 来源网段访问控制。 */
|
||||
export interface NetworkInfo {
|
||||
/** null = **未配置 = 放行全部**(应尽快收敛) */
|
||||
allow_from?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
hostname?: string;
|
||||
/** 被控端平台:'Windows' | 'macOS' | 'Linux' —— **平台门控能力的判断依据** */
|
||||
os?: string;
|
||||
os_version?: string;
|
||||
arch?: string;
|
||||
cpu_model?: string;
|
||||
cpu_cores?: number;
|
||||
memory_total?: number;
|
||||
uptime_sec?: number;
|
||||
is_admin?: boolean;
|
||||
pid?: number;
|
||||
agent_home?: string;
|
||||
/** 被控端入口脚本路径,供一键升级定位 */
|
||||
agent_script?: string;
|
||||
/** 被控端 Node 可执行文件路径(字段名是 `node_path`,没有独立的 `node`) */
|
||||
node_path?: string;
|
||||
ps_helper?: PsHelperInfo;
|
||||
network?: NetworkInfo;
|
||||
build?: BuildInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 磁盘(对齐 manifest system.status.disks 契约)。
|
||||
* 注意:**没有 `used` 字段**,已用量需自行算 `total - free`。
|
||||
*/
|
||||
export interface DiskInfo {
|
||||
drive?: string;
|
||||
total?: number;
|
||||
free?: number;
|
||||
used_pct?: number;
|
||||
}
|
||||
|
||||
/** 网络适配器(对齐 manifest system.status.net 契约)。 */
|
||||
export interface NetInfo {
|
||||
adapter?: string;
|
||||
ip?: string;
|
||||
up?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu_pct?: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
memory_pct?: number;
|
||||
disks?: DiskInfo[];
|
||||
net?: NetInfo[];
|
||||
}
|
||||
|
||||
/** 进程项(对齐 manifest system.process.list 契约)。 */
|
||||
export interface ProcessEntry {
|
||||
pid: number;
|
||||
name: string;
|
||||
/** CPU 占用百分比(非 `cpu`) */
|
||||
cpu_pct?: number;
|
||||
/** 常驻内存字节数(非 `memory`) */
|
||||
memory_bytes?: number;
|
||||
started_at?: number;
|
||||
}
|
||||
|
||||
/** 服务项(对齐 manifest system.service.list 契约)。 */
|
||||
export interface ServiceEntry {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
/** 运行状态字段名是 `state`,不是 `status` */
|
||||
state?: string;
|
||||
start_type?: string;
|
||||
}
|
||||
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir" | "other";
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface FsListResult {
|
||||
entries: FsEntry[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface FsReadResult {
|
||||
data: string;
|
||||
encoding: string;
|
||||
offset: number;
|
||||
bytes: number;
|
||||
total_bytes: number;
|
||||
eof: boolean;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
/** 已装软件项(对齐 manifest app.list 契约)。 */
|
||||
export interface AppEntry {
|
||||
name: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
/** 'registry' | 'uwp' | 'winget' 等来源标记 */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** 审计事件类别(对齐 apps/agent/src/audit.ts 的 `AuditType`)。 */
|
||||
export type AuditType =
|
||||
| "agent.start"
|
||||
| "agent.stop"
|
||||
| "auth.success"
|
||||
| "auth.failure"
|
||||
| "invoke"
|
||||
| "acl.denied"
|
||||
| "capability.disabled"
|
||||
| "rate.limited"
|
||||
| "net.change"
|
||||
| "net.deny"
|
||||
| "agent.update"
|
||||
| "session.idle_close"
|
||||
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
|
||||
| "request.rejected"
|
||||
| "agent.error";
|
||||
|
||||
export interface AuditEntry {
|
||||
ts?: number;
|
||||
type?: AuditType | string;
|
||||
client_id?: string;
|
||||
remote?: string;
|
||||
capability?: string;
|
||||
status?: "ok" | "failed" | "denied";
|
||||
duration_ms?: number;
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
|
||||
args_digest?: string;
|
||||
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
/** v11 防篡改:上一条哈希(首条为 "genesis") */
|
||||
prev?: string;
|
||||
/** v11 防篡改:本条哈希 */
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export interface AuditListResult {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/** 审计链首个异常位置(`ok:false` 时存在)。 */
|
||||
export interface AuditBrokenAt {
|
||||
file?: string;
|
||||
line?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AuditVerifyResult {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
/** 无链字段的历史条目数(跳过校验) */
|
||||
legacy?: number;
|
||||
/** 是**对象**而非数字 */
|
||||
broken_at?: AuditBrokenAt | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 被控端进程状态机取值(与 Rust `agent::state` 一一对应)。 */
|
||||
export type AgentState = "stopped" | "starting" | "running" | "stopping" | "crashed";
|
||||
|
||||
/** 本机被控端进程快照。 */
|
||||
export interface AgentProcess {
|
||||
state: AgentState;
|
||||
pid?: number | null;
|
||||
started_at?: number | null;
|
||||
uptime_ms?: number | null;
|
||||
/** 自动重启已尝试次数 */
|
||||
restarts?: number;
|
||||
last_exit?: string | null;
|
||||
/** 实际使用的 node 可执行文件 */
|
||||
node?: string;
|
||||
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
|
||||
script?: string;
|
||||
data_dir?: string;
|
||||
}
|
||||
|
||||
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
|
||||
export interface AgentConfigInfo {
|
||||
data_dir: string;
|
||||
config_path: string;
|
||||
config_exists: boolean;
|
||||
node_id: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: "psk" | "ed25519" | string;
|
||||
/** PSK 明文(本机展示用;它本就在用户自己的 600 权限文件里) */
|
||||
key: string;
|
||||
/** 端口是否已被监听(可能是一个非本控制台托管的实例) */
|
||||
port_in_use: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AutostartStatus {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
/** 落地位置(plist 路径 / 计划任务名) */
|
||||
location: string;
|
||||
app_exe: string;
|
||||
/** 自启时传给应用的模式参数 */
|
||||
mode: string;
|
||||
note: string;
|
||||
/** 本次操作详情(开关后返回) */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleSettings {
|
||||
auto_start_agent: boolean;
|
||||
auto_restart: boolean;
|
||||
close_to_tray: boolean;
|
||||
insecure: boolean;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
manager: AgentProcess;
|
||||
config: AgentConfigInfo;
|
||||
autostart: AutostartStatus;
|
||||
settings: ConsoleSettings;
|
||||
app_version: string;
|
||||
config_dir: string;
|
||||
/** 数据目录里那份脚本的版本(被控端自更新或控制台更新后写入) */
|
||||
installed_agent_version?: string | null;
|
||||
/** 随包内置的 agent.mjs 路径 */
|
||||
bundled_agent?: string;
|
||||
}
|
||||
|
||||
export interface AgentActionResult {
|
||||
state?: AgentState;
|
||||
pid?: number | null;
|
||||
note?: string;
|
||||
port_released?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentLogLine {
|
||||
seq: number;
|
||||
ts: number;
|
||||
/** stdout / stderr / system */
|
||||
stream: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface AgentLogPage {
|
||||
lines: AgentLogLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
console_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
agent_has_update?: boolean;
|
||||
installed_agent_version?: string;
|
||||
published_at?: string | null;
|
||||
commit?: string | null;
|
||||
capabilities?: number | null;
|
||||
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
|
||||
release_url: string;
|
||||
source: string;
|
||||
checked_at: number;
|
||||
}
|
||||
|
||||
export interface ApplyUpdateResult {
|
||||
updated: boolean;
|
||||
version: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
backup: string;
|
||||
restarted: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleSystemInfo {
|
||||
app_version: string;
|
||||
config_dir: string;
|
||||
agent_data_dir: string;
|
||||
resource_dir: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
node: string;
|
||||
/** 是否以「仅服务模式」启动(开机自启时为 true) */
|
||||
agent_only: boolean;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 通用格式化工具。 */
|
||||
|
||||
export function formatBytes(n?: number | null): string {
|
||||
if (n == null || Number.isNaN(n)) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(sec?: number | null): string {
|
||||
if (sec == null || Number.isNaN(sec)) return "-";
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d) return `${d} 天 ${h} 小时`;
|
||||
if (h) return `${h} 小时 ${m} 分`;
|
||||
return `${m} 分`;
|
||||
}
|
||||
|
||||
export function formatTime(ms?: number | null): string {
|
||||
if (!ms) return "-";
|
||||
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatPct(v?: number | null): string {
|
||||
if (v == null || Number.isNaN(v)) return "-";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NText,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NAlert,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
|
||||
import { formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const entries = ref<AuditEntry[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(100);
|
||||
const typeFilter = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const verify = ref<AuditVerifyResult | null>(null);
|
||||
|
||||
/**
|
||||
* 审计筛选项(取值必须是 `@nodeagent/protocol` 里 `AUDIT_FILTER_TYPES` 的一员)。
|
||||
*
|
||||
* ⚠️ C-01(v2.0.3 修):这里原本列了 11 个**细粒度**取值(`auth.success`、`acl.denied`…),
|
||||
* 而协议 manifest 的 enum 只有 `['invoke','auth','acl','agent']` → 被控端 schema 校验把
|
||||
* 除 `invoke` 外的**全部**取值判成 `E_PARAM_INVALID`:下拉框形同虚设,且**不报类型错**。
|
||||
*
|
||||
* 改用**粗粒度前缀**:被控端按 `entry.type.startsWith(query)` 过滤,所以
|
||||
* `agent` 一次覆盖 `agent.start/stop/update/error`,`request` 覆盖新增的 `request.rejected`。
|
||||
* 覆盖面更全(每个审计类型都能被至少一项筛到),下拉框也不必膨胀成二十几项。
|
||||
* 防漂移由 `tests/unit/audit-filter.test.mjs` 钉死(正反双向都比对真源)。
|
||||
*/
|
||||
const typeOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "调用 invoke", value: "invoke" },
|
||||
{ label: "认证 auth", value: "auth" },
|
||||
{ label: "ACL 拒绝 acl", value: "acl" },
|
||||
{ label: "能力被禁 capability", value: "capability" },
|
||||
{ label: "限速 rate", value: "rate" },
|
||||
{ label: "网络 net", value: "net" },
|
||||
{ label: "agent 生命周期 agent", value: "agent" },
|
||||
{ label: "会话 session", value: "session" },
|
||||
{ label: "被拒请求 request", value: "request" },
|
||||
];
|
||||
|
||||
const cols: DataTableColumns<AuditEntry> = [
|
||||
{ title: "时间", key: "ts", width: 180, render: (r) => formatTime(r.ts) },
|
||||
{
|
||||
title: "类型",
|
||||
key: "type",
|
||||
width: 150,
|
||||
render: (r) => {
|
||||
const t = r.type ?? "-";
|
||||
const kind = t.includes("denied") || t.includes("failure") || t.includes("deny") ? "error" : "default";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => t });
|
||||
},
|
||||
},
|
||||
{ title: "调用方", key: "client_id", width: 110 },
|
||||
{ title: "来源", key: "remote", width: 120 },
|
||||
{ title: "能力", key: "capability", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
render: (r) => {
|
||||
if (!r.status) return "-";
|
||||
const kind = r.status === "ok" ? "success" : "error";
|
||||
return h(NTag, { size: "small", type: kind }, { default: () => r.status });
|
||||
},
|
||||
},
|
||||
{ title: "耗时", key: "duration_ms", width: 90, render: (r) => (r.duration_ms != null ? `${r.duration_ms}ms` : "-") },
|
||||
{
|
||||
title: "参数摘要",
|
||||
key: "args_digest",
|
||||
width: 170,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.args_digest ?? "-",
|
||||
},
|
||||
{
|
||||
title: "说明",
|
||||
key: "reason",
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => r.reason || r.error || "-",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const args: Record<string, unknown> = { limit: limit.value };
|
||||
if (typeFilter.value) args.type = typeFilter.value;
|
||||
const r = await conn.ok<AuditListResult>("system.audit.list", args);
|
||||
entries.value = r.entries ?? [];
|
||||
total.value = r.total ?? entries.value.length;
|
||||
} catch (e) {
|
||||
msg.error(`加载审计失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runVerify() {
|
||||
try {
|
||||
verify.value = await conn.ok<AuditVerifyResult>("system.audit.verify", {});
|
||||
} catch (e) {
|
||||
msg.error(`校验失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJsonl() {
|
||||
const text = entries.value.map((e) => JSON.stringify(e)).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
msg.success(`已复制 ${entries.value.length} 条到剪贴板`);
|
||||
} catch {
|
||||
msg.warning("剪贴板不可用");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<span>审计日志</span>
|
||||
<n-text depth="3" style="font-size: 12px">共 {{ total }} 条</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-select
|
||||
v-model:value="typeFilter"
|
||||
:options="typeOptions"
|
||||
size="small"
|
||||
style="width: 190px"
|
||||
@update:value="load"
|
||||
/>
|
||||
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
|
||||
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
|
||||
<n-button size="small" @click="runVerify">完整性校验</n-button>
|
||||
<n-button size="small" @click="exportJsonl">导出</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert
|
||||
v-if="verify"
|
||||
:type="verify.ok ? 'success' : 'error'"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
|
||||
<template v-if="verify.legacy"> · 跳过 {{ verify.legacy }} 条历史条目</template>
|
||||
<template v-if="verify.broken_at">
|
||||
· 首个断点:{{ verify.broken_at.file }} 第 {{ verify.broken_at.line }} 行
|
||||
<template v-if="verify.broken_at.reason">({{ verify.broken_at.reason }})</template>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
|
||||
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NModal,
|
||||
NTag,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
|
||||
import { formatBytes, formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const cwd = ref("");
|
||||
const entries = ref<FsEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const truncated = ref(false);
|
||||
|
||||
const previewOpen = ref(false);
|
||||
const previewName = ref("");
|
||||
const previewText = ref("");
|
||||
const previewMeta = ref("");
|
||||
|
||||
const writeOpen = ref(false);
|
||||
const wName = ref("");
|
||||
const wContent = ref("");
|
||||
const wAppend = ref(false);
|
||||
|
||||
const cols: DataTableColumns<FsEntry> = [
|
||||
{
|
||||
title: "名称",
|
||||
key: "name",
|
||||
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
|
||||
},
|
||||
{ title: "类型", key: "type", width: 80 },
|
||||
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
|
||||
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
|
||||
];
|
||||
|
||||
async function list(path: string) {
|
||||
if (!path) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await conn.ok<FsListResult>("fs.list", { path });
|
||||
entries.value = (r.entries ?? []).slice().sort((a, b) => {
|
||||
if (a.type === "dir" && b.type !== "dir") return -1;
|
||||
if (a.type !== "dir" && b.type === "dir") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
truncated.value = !!r.truncated;
|
||||
cwd.value = path;
|
||||
} catch (e) {
|
||||
msg.error(`列目录失败:${e}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parentOf(p: string): string {
|
||||
const s = p.replace(/[\\/]+$/, "");
|
||||
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
if (idx < 0) return s;
|
||||
if (idx === 0) return s.slice(0, 1); // POSIX 根
|
||||
const r = s.slice(0, idx);
|
||||
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
|
||||
}
|
||||
|
||||
function onRowClick(row: FsEntry) {
|
||||
if (row.type === "dir") list(row.path);
|
||||
else preview(row);
|
||||
}
|
||||
|
||||
async function preview(row: FsEntry) {
|
||||
try {
|
||||
const r = await conn.ok<FsReadResult>("fs.read", {
|
||||
path: row.path,
|
||||
encoding: "utf8",
|
||||
max_bytes: 262144,
|
||||
});
|
||||
previewName.value = row.name;
|
||||
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
|
||||
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
|
||||
previewOpen.value = true;
|
||||
} catch (e) {
|
||||
msg.error(`读取失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openWrite() {
|
||||
wName.value = "";
|
||||
wContent.value = "";
|
||||
wAppend.value = false;
|
||||
writeOpen.value = true;
|
||||
}
|
||||
|
||||
function doWrite() {
|
||||
if (!wName.value) return;
|
||||
const sep = cwd.value.includes("\\") ? "\\" : "/";
|
||||
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
|
||||
dialog.warning({
|
||||
title: "确认写入被控端",
|
||||
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
|
||||
positiveText: "写入",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => writeTo(path),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTo(path: string) {
|
||||
try {
|
||||
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
|
||||
msg.success(`已写入 ${path}`);
|
||||
writeOpen.value = false;
|
||||
list(cwd.value);
|
||||
} catch (e) {
|
||||
msg.error(`写入失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureStart() {
|
||||
try {
|
||||
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
|
||||
let start = info.agent_home ?? "";
|
||||
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
|
||||
list(start);
|
||||
} catch {
|
||||
list(conn.info ? "/" : "");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) ensureStart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small">
|
||||
<n-space align="center" :wrap="false">
|
||||
<n-input
|
||||
v-model:value="cwd"
|
||||
size="small"
|
||||
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
|
||||
style="flex: 1"
|
||||
@keyup.enter="list(cwd)"
|
||||
/>
|
||||
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
|
||||
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
|
||||
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
|
||||
</n-space>
|
||||
<div class="path">
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
当前:{{ cwd || "-" }} · {{ entries.length }} 项
|
||||
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" style="margin-top: 16px">
|
||||
<n-empty :show-icon="false" v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="cols"
|
||||
:data="entries"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="520"
|
||||
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
|
||||
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
|
||||
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
|
||||
</n-modal>
|
||||
|
||||
<!-- 新建/写入 -->
|
||||
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
|
||||
<n-input
|
||||
v-model:value="wContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 16 }"
|
||||
placeholder="文件内容(utf8)"
|
||||
/>
|
||||
<n-space align="center">
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="wAppend ? 'warning' : 'default'"
|
||||
checkable
|
||||
:checked="wAppend"
|
||||
@update:checked="(v: boolean) => (wAppend = v)"
|
||||
>
|
||||
追加模式
|
||||
</n-tag>
|
||||
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.path {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preview {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--na-sunken);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 本机服务 —— 控制台托管的**本机被控端**(默认首页)。
|
||||
*
|
||||
* 目标:用户**完全不碰命令行** —— 打开应用即可启动 / 停止 / 重启被控端、
|
||||
* 开关开机自启、查日志、检查更新。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDivider,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useAgentStore, useLogsStore } from "../stores/agent";
|
||||
import { formatDuration } from "../utils/format";
|
||||
|
||||
const agent = useAgentStore();
|
||||
const logs = useLogsStore();
|
||||
const msg = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const revealKey = ref(false);
|
||||
const copied = ref<string | null>(null);
|
||||
|
||||
const cfg = computed(() => agent.status?.config ?? null);
|
||||
const proc = computed(() => agent.process);
|
||||
const busy = computed(() => agent.busy);
|
||||
|
||||
/** 最近 6 行日志(完整日志在「服务日志」页)。 */
|
||||
const recent = computed(() => logs.lines.slice(-6));
|
||||
|
||||
async function copy(text: string, what: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = what;
|
||||
msg.success(`已复制${what}`);
|
||||
setTimeout(() => (copied.value = null), 1500);
|
||||
} catch {
|
||||
msg.error("复制失败(系统剪贴板不可用,可手工选中复制)");
|
||||
}
|
||||
}
|
||||
|
||||
async function doAction(fn: () => Promise<unknown>, name: string) {
|
||||
const r = await fn();
|
||||
if (agent.error) msg.error(`${name}失败:${agent.error}`);
|
||||
else if (r) msg.success(`${name}完成`);
|
||||
}
|
||||
|
||||
async function doAutostart(v: boolean) {
|
||||
const r = await agent.setAutostart(v);
|
||||
if (agent.error) msg.error(`设置开机自启失败:${agent.error}`);
|
||||
else msg.success(v ? `已开启开机自启(${agent.status?.autostart.location})` : "已关闭开机自启");
|
||||
return r;
|
||||
}
|
||||
|
||||
async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_agent", v: boolean) {
|
||||
await agent.patchSettings({ [key]: v } as never);
|
||||
if (agent.error) msg.error(`保存设置失败:${agent.error}`);
|
||||
}
|
||||
|
||||
async function doCheckUpdate() {
|
||||
const u = await agent.checkUpdate();
|
||||
if (agent.error) msg.error(`检查更新失败:${agent.error}`);
|
||||
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
|
||||
}
|
||||
|
||||
async function doApplyUpdate() {
|
||||
const r = await agent.applyUpdate();
|
||||
if (agent.error) msg.error(`更新失败:${agent.error}`);
|
||||
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
|
||||
}
|
||||
|
||||
async function doConnectLocal() {
|
||||
try {
|
||||
await agent.connectLocal();
|
||||
msg.success("已连接本机被控端");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await agent.bind();
|
||||
await logs.bind();
|
||||
});
|
||||
|
||||
function fmtTime(ts?: number | null) {
|
||||
return ts ? new Date(ts).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
/** 打开 Release 页面(模板里不能直接用 `window`,故走 setup 作用域的函数)。 */
|
||||
function openRelease() {
|
||||
const url = agent.update?.release_url;
|
||||
if (url) window.open(url, "_blank");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-spin :show="busy && !agent.status">
|
||||
<div class="wrap">
|
||||
<n-alert v-if="agent.error" type="error" style="margin-bottom: 14px">
|
||||
{{ agent.error }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 非托管实例提示:端口被占但不是本控制台起的 -->
|
||||
<n-alert
|
||||
v-if="agent.externalInstance"
|
||||
type="warning"
|
||||
style="margin-bottom: 14px"
|
||||
title="检测到已有一个被控端在运行(非本控制台托管)"
|
||||
>
|
||||
端口 {{ cfg?.port }} 已被监听。它可能是手工安装的(`install.cmd` / 计划任务)。
|
||||
本控制台无法停止它;如要用本控制台管理,请先停掉它,或直接点「连接本机」复用它。
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
|
||||
<!-- ① 服务状态与操作 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="本机被控端" size="small">
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-tag :type="agent.meta.type" size="medium" round>{{ agent.meta.label }}</n-tag>
|
||||
<n-text v-if="proc?.pid" depth="3" style="font-size: 12px">PID {{ proc.pid }}</n-text>
|
||||
<n-text v-if="proc?.uptime_ms" depth="3" style="font-size: 12px">
|
||||
· 已运行 {{ formatDuration(Math.round((proc.uptime_ms ?? 0) / 1000)) }}
|
||||
</n-text>
|
||||
<n-text v-if="proc?.restarts" depth="3" style="font-size: 12px">
|
||||
· 自动重启 {{ proc.restarts }} 次
|
||||
</n-text>
|
||||
</n-space>
|
||||
|
||||
<n-space style="margin-bottom: 14px">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="agent.running || agent.externalInstance"
|
||||
@click="doAction(() => agent.start(), '启动')"
|
||||
>
|
||||
启动
|
||||
</n-button>
|
||||
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.stop(), '停止')">
|
||||
停止
|
||||
</n-button>
|
||||
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.restart(), '重启')">
|
||||
重启
|
||||
</n-button>
|
||||
<n-button :disabled="!cfg" quaternary @click="doConnectLocal">连接本机</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-alert v-if="proc?.last_exit" type="default" style="margin-bottom: 12px">
|
||||
上次退出:{{ proc.last_exit }}
|
||||
</n-alert>
|
||||
|
||||
<div class="kv"><span>节点 ID</span><b>{{ cfg?.node_id || "-" }}</b></div>
|
||||
<div class="kv"><span>监听地址</span><b>{{ cfg?.url || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>预共享密钥</span>
|
||||
<b class="mono">
|
||||
{{ revealKey ? cfg?.key || "-" : "•".repeat(Math.min(cfg?.key?.length ?? 0, 16)) || "-" }}
|
||||
<n-button text size="tiny" style="margin-left: 6px" @click="revealKey = !revealKey">
|
||||
{{ revealKey ? "隐藏" : "显示" }}
|
||||
</n-button>
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
:disabled="!cfg?.key"
|
||||
@click="copy(cfg?.key ?? '', '密钥')"
|
||||
>
|
||||
复制
|
||||
</n-button>
|
||||
</b>
|
||||
</div>
|
||||
<div class="kv"><span>认证模式</span><b>{{ cfg?.auth_mode || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>数据目录</span>
|
||||
<b class="mono ellipsis" :title="cfg?.data_dir">{{ cfg?.data_dir || "-" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>运行脚本</span>
|
||||
<b class="mono ellipsis" :title="proc?.script">{{ proc?.script || "-" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>被控端版本</span>
|
||||
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ② 行为设置 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="运行方式" size="small">
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>开机自启</div>
|
||||
<div class="hint">
|
||||
{{ agent.status?.autostart.note }}
|
||||
</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.status?.autostart.enabled ?? false"
|
||||
:disabled="!agent.status?.autostart.supported || busy"
|
||||
:loading="busy"
|
||||
@update:value="doAutostart"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>后台运行(关闭窗口时最小化到托盘)</div>
|
||||
<div class="hint">关闭后,点窗口的关闭按钮将直接退出应用并停止被控端</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.close_to_tray"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('close_to_tray', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>启动应用时自动拉起被控端</div>
|
||||
<div class="hint">关闭后需手工点「启动」</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.auto_start_agent"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('auto_start_agent', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>被控端异常退出时自动重启</div>
|
||||
<div class="hint">连续失败 5 次后停止重试并保留现场日志</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.auto_restart"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('auto_restart', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="kv"><span>自启落地位置</span><b class="mono ellipsis">{{ agent.status?.autostart.location || "-" }}</b></div>
|
||||
<div class="kv"><span>自启方式</span><b class="mono">仅服务模式(无窗口)</b></div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ③ 更新 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="更新" size="small">
|
||||
<div class="kv"><span>控制台版本</span><b>v{{ agent.status?.app_version || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>被控端版本</span>
|
||||
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>最新版本</span>
|
||||
<b>
|
||||
{{ agent.update ? `v${agent.update.latest_version}` : "(未检查)" }}
|
||||
<n-tag
|
||||
v-if="agent.update?.has_update"
|
||||
type="warning"
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
>
|
||||
有更新
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-else-if="agent.update"
|
||||
type="success"
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
>
|
||||
已是最新
|
||||
</n-tag>
|
||||
</b>
|
||||
</div>
|
||||
<div v-if="agent.update?.published_at" class="kv">
|
||||
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
|
||||
</div>
|
||||
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button size="small" :loading="busy" @click="doCheckUpdate">检查更新</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="doApplyUpdate">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!agent.update?.agent_has_update || busy"
|
||||
>
|
||||
更新被控端
|
||||
</n-button>
|
||||
</template>
|
||||
将下载最新被控端并替换本机正在使用的那份(自动备份旧文件),随后重启服务。确定?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<div class="hint" style="margin-top: 10px">
|
||||
「更新被控端」只替换本机被控端脚本(架构无关的 JS)。控制台自身(桌面应用)请从
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
:disabled="!agent.update"
|
||||
@click="openRelease"
|
||||
>
|
||||
Release 页面
|
||||
</n-button>
|
||||
下载安装包。
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ④ 日志预览 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="最近日志" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button text size="tiny" @click="logs.load()">刷新</n-button>
|
||||
<n-button text size="tiny" @click="router.push({ name: 'logs' })">查看全部</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-if="recent.length" class="logs">
|
||||
<div v-for="l in recent" :key="l.seq" class="log-line" :class="l.stream">
|
||||
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<span class="txt">{{ l.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-text v-else depth="3" style="font-size: 13px">暂无日志</n-text>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-text depth="3" style="font-size: 12px; display: block; margin-top: 18px">
|
||||
提示:关闭窗口后控制台会缩到托盘继续运行 —— 从托盘菜单可显示窗口或退出。
|
||||
</n-text>
|
||||
</template>
|
||||
若不想后台常驻,可在「运行方式」里关闭「后台运行」
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.kv > span {
|
||||
opacity: 0.6;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.kv > b {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.logs {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-line .ts {
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.log-line.stderr .txt {
|
||||
color: var(--na-err);
|
||||
}
|
||||
.log-line.system .txt {
|
||||
color: var(--na-info);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 服务日志 —— 本机被控端(agent)的标准输出/错误与系统消息。
|
||||
*
|
||||
* 实时性靠后端事件推送(`na-agent-log`),历史靠 `agent_logs` 增量拉取;
|
||||
* 两条路用 `seq` 去重,故既不会丢历史、也不会重复。
|
||||
*/
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useLogsStore } from "../stores/agent";
|
||||
|
||||
const logs = useLogsStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const keyword = ref("");
|
||||
const streams = ref<string[]>(["stdout", "stderr", "system"]);
|
||||
const autoScroll = ref(true);
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const filtered = computed(() =>
|
||||
logs.lines.filter((l) => {
|
||||
if (!streams.value.includes(l.stream)) return false;
|
||||
if (!keyword.value) return true;
|
||||
return l.line.toLowerCase().includes(keyword.value.toLowerCase());
|
||||
}),
|
||||
);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!autoScroll.value) return;
|
||||
await nextTick();
|
||||
const el = boxRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// 新日志到达时自动滚到底(仅在未暂停且开启自动滚动时)
|
||||
watch(() => logs.lines.length, scrollToBottom);
|
||||
|
||||
onMounted(async () => {
|
||||
await logs.bind();
|
||||
await scrollToBottom();
|
||||
});
|
||||
|
||||
function clear() {
|
||||
void logs.clear();
|
||||
msg.success("已清空日志缓冲");
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
const text = logs.toText(filtered.value);
|
||||
logs.downloadText(text, `nodeagent-console-${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
||||
msg.success(`已导出 ${filtered.value.length} 行`);
|
||||
}
|
||||
|
||||
function streamType(s: string) {
|
||||
if (s === "stderr") return "error" as const;
|
||||
if (s === "system") return "success" as const;
|
||||
return "default" as const;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<n-card size="small" title="本机服务日志">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
{{ filtered.length }} / {{ logs.lines.length }} 行(缓冲上限 4000)
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-space align="center" style="margin-bottom: 12px" :wrap="true">
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
placeholder="过滤关键字…"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<n-checkbox-group v-model:value="streams">
|
||||
<n-space align="center">
|
||||
<n-checkbox value="stdout" label="标准输出" />
|
||||
<n-checkbox value="stderr" label="标准错误" />
|
||||
<n-checkbox value="system" label="系统消息" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
<n-space align="center">
|
||||
<n-text depth="3" style="font-size: 12px">自动滚动</n-text>
|
||||
<n-switch v-model:value="autoScroll" size="small" />
|
||||
</n-space>
|
||||
<n-space align="center">
|
||||
<n-text depth="3" style="font-size: 12px">暂停接收</n-text>
|
||||
<n-switch v-model:value="logs.paused" size="small" />
|
||||
</n-space>
|
||||
<n-button size="small" @click="logs.load()">重新拉取</n-button>
|
||||
<n-button size="small" @click="scrollToBottom">滚到底部</n-button>
|
||||
<n-button size="small" @click="exportLogs">导出</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="clear">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" quaternary>清空</n-button>
|
||||
</template>
|
||||
将清空内存中的日志缓冲(磁盘上的审计日志不受影响)。确定?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
|
||||
<div ref="boxRef" class="log-box">
|
||||
<div v-if="!filtered.length" class="empty">
|
||||
暂无日志。被控端启动后,其输出会实时出现在这里。
|
||||
</div>
|
||||
<div v-for="l in filtered" :key="l.seq" class="row">
|
||||
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<n-tag :type="streamType(l.stream)" size="tiny" :bordered="false" class="tag">
|
||||
{{ l.stream }}
|
||||
</n-tag>
|
||||
<span class="txt" :class="l.stream">{{ l.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.log-box {
|
||||
height: calc(100vh - 300px);
|
||||
min-height: 320px;
|
||||
overflow: auto;
|
||||
background: var(--na-sunken);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ts {
|
||||
opacity: 0.4;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tag {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.txt.stderr {
|
||||
color: var(--na-err);
|
||||
}
|
||||
.txt.system {
|
||||
color: var(--na-info);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.5;
|
||||
padding: 20px 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NTag,
|
||||
NEmpty,
|
||||
NText,
|
||||
NInputGroup,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NPopconfirm,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import { useNodeStore } from "../stores/node";
|
||||
|
||||
const conn = useConnStore();
|
||||
const nodeStore = useNodeStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const newName = ref("");
|
||||
const newUrl = ref("ws://127.0.0.1:8765");
|
||||
const newKey = ref("");
|
||||
|
||||
async function doConnect() {
|
||||
try {
|
||||
await conn.connect();
|
||||
msg.success("连接成功");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectTo(id: string) {
|
||||
const n = nodeStore.nodes.find((x) => x.id === id);
|
||||
if (!n) return;
|
||||
nodeStore.setCurrent(id);
|
||||
try {
|
||||
await conn.connect(n.url, n.key);
|
||||
msg.success(`已连接 ${n.name}`);
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
if (!newName.value || !newUrl.value) {
|
||||
msg.warning("请填写名称与地址");
|
||||
return;
|
||||
}
|
||||
nodeStore.add({ name: newName.value, url: newUrl.value, key: newKey.value });
|
||||
newName.value = "";
|
||||
newKey.value = "";
|
||||
msg.success("已保存设备");
|
||||
}
|
||||
|
||||
/** 能力按前缀分组,便于核对。 */
|
||||
const capGroups = computed(() => {
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const c of conn.capabilities) {
|
||||
const prefix = c.name.split(".")[0] ?? "other";
|
||||
(groups[prefix] ??= []).push(c.name);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<n-card title="连接被控端" size="small">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
|
||||
<n-input
|
||||
v-model:value="conn.key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="PSK 预共享密钥"
|
||||
style="flex: 2"
|
||||
/>
|
||||
<n-button type="primary" :loading="conn.connecting" @click="doConnect">连接</n-button>
|
||||
</n-input-group>
|
||||
<div v-if="conn.info" class="meta">
|
||||
<n-tag type="success" size="small" round>已连接</n-tag>
|
||||
<n-text depth="3">agent {{ conn.info.agent_version ?? "?" }}</n-text>
|
||||
<n-text depth="3">
|
||||
授权 {{ conn.info.authorized ? `${conn.info.authorized.length} 项` : "全部" }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="mono">
|
||||
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
|
||||
</n-text>
|
||||
</div>
|
||||
<n-empty :show-icon="false" v-else description="尚未连接" size="small" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="已保存设备" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ nodeStore.nodes.length }} 台</n-text>
|
||||
</template>
|
||||
<div v-if="nodeStore.nodes.length" class="nodes">
|
||||
<div
|
||||
v-for="n in nodeStore.nodes"
|
||||
:key="n.id"
|
||||
class="node"
|
||||
:class="{ active: nodeStore.currentId === n.id }"
|
||||
>
|
||||
<div class="node-main">
|
||||
<div class="node-name">{{ n.name }}</div>
|
||||
<n-text depth="3" class="mono" style="font-size: 12px">{{ n.url }}</n-text>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="nodeStore.remove(n.id)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary>删除</n-button>
|
||||
</template>
|
||||
确认删除该设备?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty :show-icon="false" v-else description="暂无保存的设备" size="small" />
|
||||
|
||||
<div class="add-row">
|
||||
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
|
||||
<n-input v-model:value="newUrl" placeholder="地址" size="small" style="flex: 2" />
|
||||
<n-input v-model:value="newKey" placeholder="PSK" size="small" style="flex: 1" />
|
||||
<n-button size="small" @click="addNode">保存</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card title="能力清单" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.capabilities.length }} 项</n-text>
|
||||
</template>
|
||||
<n-collapse v-if="conn.capabilities.length" :default-expanded-names="Object.keys(capGroups)">
|
||||
<n-collapse-item
|
||||
v-for="(caps, prefix) in capGroups"
|
||||
:key="prefix"
|
||||
:name="prefix"
|
||||
:title="`${prefix}.* (${caps.length})`"
|
||||
>
|
||||
<div class="cap-list">
|
||||
<code v-for="c in caps" :key="c">{{ c }}</code>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty :show-icon="false" v-else description="连接后展示" size="small" />
|
||||
</n-card>
|
||||
|
||||
<n-card title="日志" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" quaternary @click="conn.clearLogs()">清空</n-button>
|
||||
</template>
|
||||
<div class="logs">
|
||||
<div v-for="(l, i) in conn.logs.slice().reverse()" :key="i">{{ l }}</div>
|
||||
<div v-if="conn.logs.length === 0" class="logs-empty">暂无日志</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.grid > :nth-child(3),
|
||||
.grid > :nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--na-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.node.active {
|
||||
border-color: var(--na-accent);
|
||||
}
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cap-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.cap-list code {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--na-sunken);
|
||||
}
|
||||
.logs {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
opacity: 0.85;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.logs-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NProgress,
|
||||
NStatistic,
|
||||
NButton,
|
||||
NSpace,
|
||||
NEmpty,
|
||||
NText,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { DiskInfo, SystemInfo, SystemStatus } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const info = ref<SystemInfo | null>(null);
|
||||
const status = ref<SystemStatus | null>(null);
|
||||
const auto = ref(true);
|
||||
let timer: number | undefined;
|
||||
|
||||
/** 契约只给 total/free,已用量需自算。 */
|
||||
function usedOf(d: DiskInfo): number | undefined {
|
||||
if (d.total == null || d.free == null) return undefined;
|
||||
return Math.max(0, d.total - d.free);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!conn.connected) return;
|
||||
try {
|
||||
const [i, s] = await Promise.all([
|
||||
conn.ok<SystemInfo>("system.info"),
|
||||
conn.ok<SystemStatus>("system.status"),
|
||||
]);
|
||||
info.value = i;
|
||||
status.value = s;
|
||||
} catch (e) {
|
||||
msg.error(`刷新失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAuto(v: boolean) {
|
||||
auto.value = v;
|
||||
if (v) start(); else stop();
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
timer = window.setInterval(refresh, 3000);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) refresh();
|
||||
if (auto.value) start();
|
||||
});
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 14px">
|
||||
<n-space align="center">
|
||||
<n-text depth="3">自动刷新(3s)</n-text>
|
||||
<n-switch :value="auto" size="small" @update:value="toggleAuto" />
|
||||
</n-space>
|
||||
<n-button size="small" @click="refresh">手动刷新</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="主机">
|
||||
<n-statistic label="主机名" :value="info?.hostname ?? '-'" />
|
||||
<div class="kv"><span>系统</span><b>{{ info?.os }} {{ info?.os_version }}</b></div>
|
||||
<div class="kv"><span>架构</span><b>{{ info?.arch }}</b></div>
|
||||
<div class="kv"><span>CPU</span><b>{{ info?.cpu_model }}</b></div>
|
||||
<div class="kv"><span>核心</span><b>{{ info?.cpu_cores }}</b></div>
|
||||
<div class="kv"><span>管理员</span><b>{{ info?.is_admin ? "是" : "否" }}</b></div>
|
||||
<div class="kv"><span>开机</span><b>{{ formatDuration(info?.uptime_sec) }}</b></div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4 s:2">
|
||||
<n-card size="small" title="资源">
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">CPU 使用率</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.cpu_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.cpu_pct ?? 0) > 80 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">
|
||||
内存 {{ formatBytes(status?.memory_used) }} / {{ formatBytes(status?.memory_total) }}
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(status?.memory_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.memory_pct ?? 0) > 85 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
<n-statistic
|
||||
label="内存总量"
|
||||
:value="formatBytes(info?.memory_total ?? status?.memory_total)"
|
||||
/>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="磁盘">
|
||||
<div v-if="status?.disks?.length" class="disks">
|
||||
<div v-for="(d, i) in status.disks" :key="i" class="disk">
|
||||
<div class="gauge-label">
|
||||
{{ d.drive ?? "-" }}
|
||||
<span class="dim">
|
||||
{{ formatBytes(usedOf(d)) }} / {{ formatBytes(d.total) }} · 剩余
|
||||
{{ formatBytes(d.free) }}
|
||||
</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="Math.round(d.used_pct ?? 0)"
|
||||
:height="8"
|
||||
:color="(d.used_pct ?? 0) > 90 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty :show-icon="false" v-else description="无磁盘数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item span="4">
|
||||
<n-card size="small" title="网络适配器">
|
||||
<div v-if="status?.net?.length" class="net">
|
||||
<div v-for="(a, i) in status.net" :key="i" class="kv">
|
||||
<span>{{ a.adapter ?? "-" }}</span>
|
||||
<b>
|
||||
{{ a.ip || "-" }}
|
||||
<n-tag :type="a.up ? 'success' : 'default'" size="tiny" style="margin-left: 6px">
|
||||
{{ a.up ? "已连接" : "未连接" }}
|
||||
</n-tag>
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty :show-icon="false" v-else description="无网络数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.kv span {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.gauge {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.gauge-label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dim {
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.disks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NTabs,
|
||||
NTabPane,
|
||||
NDataTable,
|
||||
NButton,
|
||||
NSpace,
|
||||
NInput,
|
||||
NEmpty,
|
||||
NTag,
|
||||
NAlert,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { ProcessEntry, ServiceEntry } from "../types";
|
||||
import { formatBytes, formatDuration } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
|
||||
const procs = ref<ProcessEntry[]>([]);
|
||||
const services = ref<ServiceEntry[]>([]);
|
||||
const procFilter = ref("");
|
||||
const svcFilter = ref("");
|
||||
const loading = ref(false);
|
||||
const listError = ref("");
|
||||
|
||||
/** `system.service.list` 仅 Windows 被控端可用——事前门控。 */
|
||||
const servicesAvailable = computed(() => conn.platformReady);
|
||||
|
||||
const procCols: DataTableColumns<ProcessEntry> = [
|
||||
{ title: "PID", key: "pid", width: 90, sorter: (a, b) => (a.pid ?? 0) - (b.pid ?? 0) },
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "CPU",
|
||||
key: "cpu_pct",
|
||||
width: 100,
|
||||
sorter: (a, b) => (a.cpu_pct ?? 0) - (b.cpu_pct ?? 0),
|
||||
render: (r) => (r.cpu_pct != null ? `${r.cpu_pct.toFixed(1)}%` : "-"),
|
||||
},
|
||||
{
|
||||
title: "内存",
|
||||
key: "memory_bytes",
|
||||
width: 110,
|
||||
sorter: (a, b) => (a.memory_bytes ?? 0) - (b.memory_bytes ?? 0),
|
||||
render: (r) => (r.memory_bytes ? formatBytes(r.memory_bytes) : "-"),
|
||||
},
|
||||
{
|
||||
title: "已运行",
|
||||
key: "started_at",
|
||||
width: 120,
|
||||
render: (r) =>
|
||||
r.started_at ? formatDuration(Math.floor((Date.now() - r.started_at) / 1000)) : "-",
|
||||
},
|
||||
];
|
||||
|
||||
const svcCols: DataTableColumns<ServiceEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "显示名", key: "display_name", ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "状态",
|
||||
key: "state",
|
||||
width: 130,
|
||||
render: (r) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: /run/i.test(r.state ?? "") ? "success" : "default" },
|
||||
{ default: () => r.state ?? "-" },
|
||||
),
|
||||
},
|
||||
{ title: "启动类型", key: "start_type", width: 130 },
|
||||
];
|
||||
|
||||
function pick<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||
return (raw as Record<string, T[]>)[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadProcs() {
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.process.list", { limit: 200, sort_by: "cpu" });
|
||||
procs.value = pick<ProcessEntry>(raw, "processes");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
if (!servicesAvailable.value) return;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
try {
|
||||
const raw = await conn.ok("system.service.list", {
|
||||
limit: 200,
|
||||
filter: svcFilter.value ? { name_pattern: svcFilter.value } : undefined,
|
||||
});
|
||||
services.value = pick<ServiceEntry>(raw, "services");
|
||||
} catch (e) {
|
||||
listError.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProcs = ref<ProcessEntry[]>([]);
|
||||
function applyProcFilter() {
|
||||
const f = procFilter.value.trim().toLowerCase();
|
||||
filteredProcs.value = f
|
||||
? procs.value.filter((p) => p.name?.toLowerCase().includes(f))
|
||||
: procs.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) {
|
||||
loadProcs().then(applyProcFilter);
|
||||
loadServices();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<n-card v-else size="small">
|
||||
<n-alert v-if="listError" type="error" style="margin-bottom: 12px">
|
||||
{{ listError }}
|
||||
</n-alert>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="proc" tab="进程">
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input
|
||||
v-model:value="procFilter"
|
||||
placeholder="按名称筛选"
|
||||
size="small"
|
||||
style="width: 220px"
|
||||
@input="applyProcFilter"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" @click="loadProcs().then(applyProcFilter)">
|
||||
刷新
|
||||
</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filteredProcs.length }} / {{ procs.length }}</n-text>
|
||||
</n-space>
|
||||
<n-empty :show-icon="false" v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="procCols"
|
||||
:data="filteredProcs"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="svc" tab="服务">
|
||||
<n-alert v-if="!servicesAvailable" type="warning">
|
||||
`system.service.list` 仅在被控端为 <b>Windows</b> 时可用;当前被控端为
|
||||
<b>{{ conn.nodeOs ?? "未知" }}</b>。
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-input v-model:value="svcFilter" placeholder="筛选服务名" size="small" style="width: 220px" />
|
||||
<n-button size="small" :loading="loading" @click="loadServices">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ services.length }} 项</n-text>
|
||||
</n-space>
|
||||
<n-empty :show-icon="false" v-if="!services.length" description="暂无服务数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="svcCols"
|
||||
:data="services"
|
||||
:bordered="false"
|
||||
:max-height="520"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NInput,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NEmpty,
|
||||
NDataTable,
|
||||
NAlert,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AppEntry } from "../types";
|
||||
|
||||
const conn = useConnStore();
|
||||
const msg = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const apps = ref<AppEntry[]>([]);
|
||||
const filter = ref("");
|
||||
const pkg = ref("");
|
||||
const loading = ref(false);
|
||||
const installing = ref(false);
|
||||
const installResult = ref("");
|
||||
const error = ref("");
|
||||
|
||||
/** `app.list` / `app.install` 仅 Windows 被控端可用——事前门控。 */
|
||||
const available = computed(() => conn.platformReady);
|
||||
|
||||
const cols: DataTableColumns<AppEntry> = [
|
||||
{ title: "名称", key: "name", ellipsis: { tooltip: true } },
|
||||
{ title: "版本", key: "version", width: 150 },
|
||||
{ title: "发布者", key: "publisher", ellipsis: { tooltip: true } },
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
];
|
||||
|
||||
function pickApps(raw: unknown): AppEntry[] {
|
||||
if (Array.isArray(raw)) return raw as AppEntry[];
|
||||
if (raw && typeof raw === "object") {
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.apps)) return o.apps as AppEntry[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = filter.value.trim().toLowerCase();
|
||||
if (!f) return apps.value;
|
||||
return apps.value.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(f) ||
|
||||
a.publisher?.toLowerCase().includes(f) ||
|
||||
a.source?.toLowerCase().includes(f),
|
||||
);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!available.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
apps.value = pickApps(await conn.ok("app.list", {}, 120000));
|
||||
} catch (e) {
|
||||
error.value = String((e as Error).message ?? e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doInstall() {
|
||||
const p = pkg.value.trim();
|
||||
if (!p) return;
|
||||
dialog.warning({
|
||||
title: "确认安装",
|
||||
content: `将在被控端静默安装:${p}`,
|
||||
positiveText: "安装",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => install(p),
|
||||
});
|
||||
}
|
||||
|
||||
async function install(p: string) {
|
||||
installing.value = true;
|
||||
installResult.value = "";
|
||||
try {
|
||||
// winget 安装可能耗时数分钟,控制端 RPC 超时放大到 10 分钟。
|
||||
const r = await conn.ok<Record<string, unknown>>(
|
||||
"app.install",
|
||||
{ package: p, silent: true },
|
||||
600_000,
|
||||
);
|
||||
installResult.value = JSON.stringify(r, null, 2);
|
||||
msg.success("安装完成");
|
||||
load();
|
||||
} catch (e) {
|
||||
installResult.value = String(e);
|
||||
msg.error(`安装失败:${e}`);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-alert v-if="!available" type="warning" style="margin-bottom: 14px">
|
||||
软件能力(<code>app.list</code> / <code>app.install</code>)依赖被控端 <code>winget</code>,
|
||||
<b>仅在被控端为 Windows 时可用</b>;当前被控端为 <b>{{ conn.nodeOs ?? "未知" }}</b>,
|
||||
本页已禁用。
|
||||
</n-alert>
|
||||
<n-alert v-else type="info" style="margin-bottom: 14px">
|
||||
软件安装依赖被控端 <code>winget</code>。安装可能耗时数分钟,已把调用超时放大到 10 分钟。
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="error" type="error" style="margin-bottom: 14px">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-card size="small" title="静默安装(app.install)">
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="pkg"
|
||||
:disabled="!available"
|
||||
placeholder="winget 包名或 ID,例如 Git.Git / 7zip.7zip"
|
||||
style="width: 420px"
|
||||
/>
|
||||
<n-button type="primary" :loading="installing" :disabled="!available" @click="doInstall">
|
||||
安装
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="installResult" class="result">{{ installResult }}</pre>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" title="已装软件(app.list)" style="margin-top: 16px">
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-input
|
||||
v-model:value="filter"
|
||||
:disabled="!available"
|
||||
placeholder="筛选"
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" :disabled="!available" @click="load">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filtered.length }}</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty :show-icon="false"
|
||||
v-if="!apps.length && !loading"
|
||||
:description="available ? '暂无数据(点刷新)' : '当前被控端非 Windows,不支持'"
|
||||
size="small"
|
||||
/>
|
||||
<n-data-table v-else :columns="cols" :data="filtered" :bordered="false" size="small" :max-height="480" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.result {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--na-sunken);
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||