ソースを参照

完善 Windows 子进程清理并增加真实安装升级测试

Samien 1 ヶ月 前
親
コミット
2b314b7e51

+ 3 - 0
.github/workflows/windows-client.yml

@@ -26,6 +26,9 @@ jobs:
       - run: npm run desktop:test:win
         env:
           DESKTOP_TEST_EXECUTABLE: ${{ github.workspace }}\desktop-release\windows-x64\win-unpacked\Yaoyao.exe
+      - run: node --test desktop/windows-upgrade.test.mjs
+        env:
+          DESKTOP_TEST_UPGRADE: '1'
       - uses: actions/upload-artifact@v4
         with:
           name: yaoyao-windows-x64-unsigned

+ 3 - 3
desktop/environment-host.mjs

@@ -41,8 +41,8 @@ export class DesktopHostCore {
    if(!this.platform.computer)throw new Error('此电脑暂不支持本机控制')
    const full=c.scope==='full'
    const {response}=await dialog.showMessageBox({type:'question',title:full?'授权文件与命令':'授权本机控制',
-    message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人${full?'使用这台电脑 的文件并执行命令':'查看和操作这台电脑'}?`,
-    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台电脑 上执行 shell 命令。仅对信任的机器人开启;可随时在夭夭菜单中撤销。':'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',
+    message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人${full?'使用这台电脑的文件并执行命令':'查看和操作这台电脑'}?`,
+    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台电脑上执行 shell 命令。仅对信任的机器人开启;可随时在夭夭菜单中撤销。':'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',
     buttons:full?['取消','允许文件与命令']:['取消','允许并检查系统权限'],defaultId:0,cancelId:0})
    if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id)throw new Error(full?'文件与命令授权已取消':'本机控制授权已取消')
    if(full)this.approvedFull.add(c.owner);else this.approved.add(c.owner);this.save()
@@ -55,7 +55,7 @@ export class DesktopHostCore {
   if(c.operation==='file'||c.operation==='shell'){
    if(!this.platform.computer)throw new Error('此电脑暂不支持文件与命令')
    if(!this.approvedFull.has(c.owner))throw new Error('请在桌面端授权文件与命令访问')
-   if(c.operation==='shell'){const controller=new AbortController();const task={controller,promise:execHostShell(this.home,c.action,{signal:controller.signal})};this.shells.add(task);try{return await task.promise}finally{this.shells.delete(task)}}
+   if(c.operation==='shell'){const controller=new AbortController();const task={controller,promise:execHostShell(this.home,c.action,{signal:controller.signal,helper:this.native?.helper})};this.shells.add(task);try{return await task.promise}finally{this.shells.delete(task)}}
    const action=c.action??{}
    if(typeof action.op==='string'&&action.op.startsWith('transfer-')){
     const key=c.owner+':'+c.resource

+ 2 - 2
desktop/host-files.mjs

@@ -103,13 +103,13 @@ export async function receiveHostFile(root,input){
   }finally{await handle.close();await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error})}
 }
 
-export async function execHostShell(root,input,{signal}={}){
+export async function execHostShell(root,input,{signal,helper}={}){
   const command=String(input?.command??'')
   if(!command.trim())throw new Error('命令为空')
   const timeout=Math.min(Math.max(Number(input?.timeoutMs)||60000,1000),SHELL_TIMEOUT_MAX)
   let cwd
   try{cwd=resolve(String(root),String(input?.cwd??'').replace(process.platform==='win32'?/^~[\\/]/:/^~\//,'').replace(/^~$/,''))}catch{cwd=String(root)}
-  if(process.platform==='win32')return windowsShell(HOST_SHELL,command,{cwd,timeout,signal,limit:SHELL_OUTPUT_LIMIT})
+  if(process.platform==='win32')return windowsShell(HOST_SHELL,command,{cwd,timeout,signal,helper,limit:SHELL_OUTPUT_LIMIT})
   return new Promise(done=>{
     execFile(HOST_SHELL,['-c',command],{cwd,timeout,signal,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
       const clip=value=>Buffer.from(value??'','utf8').subarray(0,SHELL_OUTPUT_LIMIT).toString('utf8')

+ 2 - 2
desktop/host-files.test.mjs

@@ -1,6 +1,6 @@
 import {test} from 'node:test'
 import assert from 'node:assert/strict'
-import {mkdtemp,mkdir,writeFile,readFile,readdir,symlink,rm} from 'node:fs/promises'
+import {mkdtemp,mkdir,writeFile,readFile,readdir,symlink,rm,realpath} from 'node:fs/promises'
 import {createHash} from 'node:crypto'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
@@ -76,7 +76,7 @@ test('shell execution reports stdout, exit codes, timeouts and cwd stays in root
   await mkdir(join(root,'work'))
   const ok=await execHostShell(root,{command:process.platform==='win32'?'(Get-Location).Path':'pwd',cwd:'work'})
   assert.equal(ok.exitCode,0)
-  assert.ok(ok.stdout.toLowerCase().includes(join(root,'work').toLowerCase()),ok.stdout)
+  assert.equal((await realpath(ok.stdout.trim())).toLowerCase(),(await realpath(join(root,'work'))).toLowerCase())
   const failing=await execHostShell(root,{command:process.platform==='win32'?"[Console]::Error.WriteLine('boom'); exit 7":'echo boom >&2; exit 7'})
   assert.equal(failing.exitCode,7);assert.equal(failing.stderr.trim(),'boom')
   const timed=await execHostShell(root,{command:process.platform==='win32'?'Start-Sleep -Seconds 5':'sleep 5',timeoutMs:1000})

+ 3 - 3
desktop/host-manager.test.mjs

@@ -52,7 +52,7 @@ test('concurrent first logins share one persistent installation identity',async(
   assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),ids[0])
   await manager.forget()
   assert.equal(await new DesktopHostManager(manager.options).installIdentity(),ids[0])
-  assert.equal((await stat(join(home,'desktop-install-id'))).mode&0o077,0)
+  if(process.platform!=='win32')assert.equal((await stat(join(home,'desktop-install-id'))).mode&0o077,0)
  }finally{await rm(home,{recursive:true,force:true})}
 })
 
@@ -74,7 +74,7 @@ test('imports an encrypted config, exchanges with the paired server and stops on
   let body='';req.on('data',chunk=>{body+=chunk});req.on('end',()=>{
    exchanges.push(JSON.parse(body))
    res.setHeader('content-type','application/json')
-   res.end(JSON.stringify(exchanges.length===1?{commands:[{id:randomUUID(),deadline:Date.now()+30000,mode:'local',owner:'a'.repeat(64),resource:'local',profile:'persistent',operation:'open'}]}:{commands:[]}))
+   res.end(JSON.stringify({capabilities:{desktopPlatforms:['darwin','win32']},...(exchanges.length===1?{commands:[{id:randomUUID(),deadline:Date.now()+30000,mode:'local',owner:'a'.repeat(64),resource:'local',profile:'persistent',operation:'open'}]}:{commands:[]})}))
   })
  })
  await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
@@ -82,7 +82,7 @@ test('imports an encrypted config, exchanges with the paired server and stops on
  try{
   await manager.importFile(configPath)
   const mode=await stat(join(home,'desktop-host.enc'))
-  assert.equal(mode.mode&0o077,0)
+  if(process.platform!=='win32')assert.equal(mode.mode&0o077,0)
   await sleep(1500)
   assert.ok(cores.length>=1)
   assert.equal(cores[0].closed,false)

+ 1 - 1
desktop/preferences.test.mjs

@@ -11,7 +11,7 @@ test('background login remains opt-in and persists without changing system login
     assert.deepEqual(await preferences.load(),{backgroundAtLogin:false,startupChoice:'ask',remoteServer:''})
     await preferences.setBackgroundAtLogin(true)
     assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:''})
-    assert.equal((await stat(preferences.path)).mode&0o777,0o600)
+    if(process.platform!=='win32')assert.equal((await stat(preferences.path)).mode&0o777,0o600)
     await preferences.setBackgroundAtLogin(false)
     assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'ask',remoteServer:''})
   }finally{await rm(home,{recursive:true,force:true})}

+ 1 - 1
desktop/update.js

@@ -13,7 +13,7 @@ function render(state) {
   byId('subtitle').textContent = automatic ? '更新当前电脑的 App,下载完成后重启安装。' : '开发运行:从 GitHub 下载,校验后手动安装。'
   byId('check').disabled = busy || (automatic && ready) || state.retryable === false
   byId('download').disabled = busy || !state.available; byId('download').hidden = ready
-  byId('download').textContent = automatic ? '下载更新' : state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载 DMG'
+  byId('download').textContent = automatic ? '下载更新' : state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载安装包'
   byId('cancel').hidden = automatic ? !state.canCancel : !busy
   byId('open').hidden = !ready || automatic; byId('folder').hidden = !ready || automatic
   byId('install').hidden = !automatic || (!ready && state.phase !== 'installing')

+ 12 - 5
desktop/windows-input.mjs

@@ -18,20 +18,27 @@ export class WindowsInput {
   async call(value) {
     const controller = new AbortController()
     this.controllers.add(controller)
-    let pending
+    let completed, closed
+    const completion = new Promise(resolve => { completed = resolve })
+    this.pending.add(completion)
     try {
-      pending = new Promise((resolve, reject) => {
+      return await new Promise((resolve, reject) => {
         const child = this.execute(this.helper, [], { timeout: 10000, signal: controller.signal, windowsHide: true, maxBuffer: 65536 }, (error, stdout, stderr) => {
           if (error) { this.available = false; reject(new Error(String(stderr || '').trim() || 'Windows 电脑助手不可用,请检查桌面会话或重新安装客户端')); return }
           try { const result = JSON.parse(stdout); if (result.ok !== true) throw new Error(); resolve(result) }
           catch { this.available = false; reject(new Error('Windows 电脑助手响应无效')) }
         })
+        closed = new Promise(resolve => child.once('close', resolve))
         child.stdin.on('error', () => {})
         child.stdin.end(JSON.stringify(value))
       })
-      this.pending.add(pending)
-      return await pending
-    } finally { this.controllers.delete(controller); this.pending.delete(pending) }
+    } finally {
+      await closed
+      this.controllers.delete(controller)
+      // An aborted drag may have held the button when its process terminated.
+      if (controller.signal.aborted && value.action?.kind === 'drag') await this.call({ operation: 'release-drag' }).catch(() => {})
+      this.pending.delete(completion); completed()
+    }
   }
   async probe() {
     try { await this.call({ operation: 'probe' }); this.available = true }

+ 10 - 15
desktop/windows-shell.mjs

@@ -1,5 +1,5 @@
-import { spawn, execFile } from 'node:child_process'
-import { join } from 'node:path'
+import { spawn } from 'node:child_process'
+import { resolve as resolvePath } from 'node:path'
 
 export function powershellArguments(command) {
   const script = `[Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = New-Object System.Text.UTF8Encoding($false)\n$global:LASTEXITCODE = 0\ntry {\n& {\n${command}\n}\n$ok = $?\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\nif (-not $ok) { exit 1 }\n} catch { [Console]::Error.WriteLine($_.ToString()); exit 1 }`
@@ -8,9 +8,9 @@ export function powershellArguments(command) {
 
 /** Bound both output streams, and terminate the owned process tree before
  * resolving a timeout or authorization cancellation. Never spawn a shell. */
-export function windowsShell(shell, command, { cwd, timeout, signal, limit = 512 * 1024 }) {
+export function windowsShell(shell, command, { cwd, timeout, signal, helper = resolvePath(import.meta.dirname, '../.desktop-build/computer-helper-dev.exe'), limit = 512 * 1024 }) {
   return new Promise(resolve => {
-    let child, timer, stopping, timedOut = false, cancelled = false, finished = false
+    let child, timer, timedOut = false, cancelled = false, finished = false
     const output = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 }
     const capture = (key, bytes) => {
       const kept = bytes.subarray(0, Math.max(0, limit - sizes[key]))
@@ -19,27 +19,22 @@ export function windowsShell(shell, command, { cwd, timeout, signal, limit = 512
     const finish = async (code, error) => {
       if (finished) return
       finished = true; clearTimeout(timer); signal?.removeEventListener('abort', abort)
-      await stopping
       if (error) capture('stderr', Buffer.from(error.message))
       resolve({ command, exitCode: timedOut || cancelled || error ? 1 : code ?? 1, timedOut,
         ...(cancelled ? { cancelled: true } : {}),
         stdout: Buffer.concat(output.stdout).toString('utf8'), stderr: Buffer.concat(output.stderr).toString('utf8') })
     }
     const stop = () => {
-      if (stopping || finished || !child?.pid) return
-      stopping = new Promise(done => {
-        execFile(join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
-          ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 }, () => {
-            // The process may already have exited while taskkill was starting.
-            if (child.exitCode === null) child.kill()
-            done()
-          })
-      })
+      // The native helper owns a KILL_ON_JOB_CLOSE job. Terminating the helper
+      // closes that kernel handle and the entire owned PowerShell process tree.
+      if (!finished && child?.pid) child.kill()
     }
     const abort = () => { cancelled = true; stop() }
     if (signal?.aborted) { cancelled = true; void finish(1); return }
     try {
-      child = spawn(shell, powershellArguments(command), { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, TERM: 'dumb' } })
+      child = spawn(helper, ['--shell'], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, TERM: 'dumb' } })
+      child.stdin.on('error', () => {})
+      child.stdin.end(JSON.stringify({ encoded: powershellArguments(command).at(-1), cwd }))
       child.stdout.on('data', bytes => capture('stdout', bytes))
       child.stderr.on('data', bytes => capture('stderr', bytes))
       child.once('error', error => { void finish(1, error) })

+ 118 - 0
desktop/windows-upgrade.test.mjs

@@ -0,0 +1,118 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { mkdtemp, readFile, writeFile, mkdir, rm, readdir } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve, basename } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { _electron as electron, expect } from '@playwright/test'
+import { powershellArguments } from './windows-shell.mjs'
+const execute = promisify(execFile)
+
+test('installed NSIS client rejects a corrupt update, upgrades and preserves settings and cookies', {
+  skip: process.platform !== 'win32' || process.env.DESKTOP_TEST_UPGRADE !== '1', timeout: 600000,
+}, async () => {
+  const root = resolve(import.meta.dirname, '..'), output = join(root, 'desktop-release/windows-x64')
+  const evidence = join(root, 'test-results/windows-client'), sandbox = await mkdtemp(join(tmpdir(), 'yaoyao-upgrade-'))
+  const home = join(sandbox, '中文配置'), installed = join(sandbox, 'app'), feed = join(sandbox, 'feed')
+  const executablePath = join(installed, 'Yaoyao.exe'), cacheName = 'yaoyao-upgrade-' + randomUUID()
+  const originalManifest = await readFile(join(root, '.desktop-build/release.json'), 'utf8')
+  const originalPackage = await readFile(join(root, '.desktop-build/shell/package.json'), 'utf8')
+  const version = JSON.parse(originalPackage).version, nextVersion = version.replace(/\d+$/, value => String(Number(value) + 1))
+  let application, corrupt = true, requireCookie = false, restored = 0
+  const server = createServer(async (req, res) => {
+    try {
+      const path = new URL(req.url, 'http://localhost').pathname
+      if (path === '/api/app/bootstrap') {
+        const authenticated = !requireCookie || String(req.headers.cookie).includes('session=upgrade-fixture')
+        if (requireCookie && authenticated) restored++
+        res.setHeader('content-type', 'application/json')
+        res.end(JSON.stringify({ authenticated, csrfToken: 'fixture', user: { role: 'member' }, serverKind: 'yaoyao-web' })); return
+      }
+      if (path.startsWith('/feed/')) {
+        const name = decodeURIComponent(path.slice(6))
+        if (basename(name) !== name) { res.writeHead(404).end(); return }
+        const bytes = corrupt && name.endsWith('.exe') ? Buffer.from('damaged-installer') : await readFile(join(feed, name))
+        res.setHeader('content-length', bytes.length); res.end(bytes); return
+      }
+      res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>升级验收</title><h1>远程工作区</h1>')
+    } catch { res.writeHead(404).end() }
+  })
+  await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const origin = `http://127.0.0.1:${server.address().port}`
+  const shell = join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe')
+  const stopInstalled = async () => {
+    // Only stop this test's executable, never another Yaoyao installation.
+    const literal = executablePath.replaceAll("'", "''")
+    await execute(shell, powershellArguments(`Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq '${literal}' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`), { windowsHide: true }).catch(() => {})
+  }
+  try {
+    await mkdir(home, { recursive: true }); await mkdir(evidence, { recursive: true })
+    const { build, Platform, Arch } = await import('electron-builder')
+    try {
+      await writeFile(join(root, '.desktop-build/release.json'), JSON.stringify({ ...JSON.parse(originalManifest), webVersion: nextVersion }))
+      await writeFile(join(root, '.desktop-build/shell/package.json'), JSON.stringify({ ...JSON.parse(originalPackage), version: nextVersion }))
+      process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
+      await build({ projectDir: root, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
+        config: { directories: { output: feed }, forceCodeSigning: false } })
+    } finally {
+      await writeFile(join(root, '.desktop-build/release.json'), originalManifest)
+      await writeFile(join(root, '.desktop-build/shell/package.json'), originalPackage)
+    }
+    await execute(join(output, `Yaoyao-${version}-win-x64-setup.exe`), ['/S', `/D=${installed}`], { timeout: 120000 })
+    const preferences = { startupChoice: 'remote', remoteServer: origin, backgroundAtLogin: true }
+    await writeFile(join(home, 'desktop-preferences.json'), JSON.stringify(preferences))
+    // This installed test copy alone uses the isolated HTTP update source.
+    await writeFile(join(installed, 'resources/app-update.yml'), JSON.stringify({ provider: 'generic', url: origin + '/feed/', updaterCacheDirName: cacheName }))
+    const launch = () => electron.launch({ executablePath, env: { ...process.env,
+      HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1' } })
+    application = await launch(); let page = await application.firstWindow(); await page.waitForURL(origin + '/**')
+    assert.equal(await application.evaluate(({ app }) => app.getVersion()), version)
+    await application.evaluate(async ({ session, safeStorage }, { origin, home }) => {
+      await session.defaultSession.cookies.set({ url: origin, name: 'session', value: 'upgrade-fixture', expirationDate: Date.now() / 1000 + 3600 })
+      await session.defaultSession.cookies.flushStore()
+      const fs = process.getBuiltinModule('node:fs')
+      fs.writeFileSync(home + '/upgrade-token.enc', await safeStorage.encryptStringAsync('upgrade-device-identity'))
+    }, { origin, home })
+    requireCookie = true
+    const opened = application.waitForEvent('window'); await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    const updates = await opened
+    await expect(updates.locator('#download')).toBeEnabled({ timeout: 30000 })
+    await updates.locator('#download').click()
+    await expect(updates.locator('#error')).toBeVisible({ timeout: 120000 })
+    assert.equal(await updates.evaluate(async () => (await window.yaoyaoUpdate.state()).phase), 'failed')
+    corrupt = false
+    await updates.locator('#check').click(); await expect(updates.locator('#download')).toBeEnabled()
+    await updates.locator('#download').click(); await expect(updates.locator('#install')).toBeEnabled({ timeout: 120000 })
+    await updates.screenshot({ path: join(evidence, 'upgrade-ready.png') })
+    const closed = application.waitForEvent('close'); await updates.locator('#install').click().catch(error => { if (!updates.isClosed()) throw error })
+    await closed; application = undefined
+    await expect.poll(async () => JSON.parse(await readFile(join(installed, 'resources/runtime/release.json'), 'utf8')).webVersion,
+      { timeout: 120000 }).toBe(nextVersion)
+    await expect.poll(() => restored, { timeout: 60000 }).toBeGreaterThan(0)
+    await stopInstalled()
+    application = await launch(); page = await application.firstWindow(); await page.waitForURL(origin + '/**')
+    assert.equal(await application.evaluate(({ app }) => app.getVersion()), nextVersion)
+    assert.deepEqual(JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8')), preferences)
+    assert.equal(await application.evaluate(async ({ safeStorage }, home) => {
+      const fs = process.getBuiltinModule('node:fs'); return (await safeStorage.decryptStringAsync(fs.readFileSync(home + '/upgrade-token.enc'))).result
+    }, home), 'upgrade-device-identity')
+    await page.screenshot({ path: join(evidence, 'upgrade-restored.png') })
+    await application.close(); application = undefined
+    const uninstaller = (await readdir(installed)).find(name => /^Uninstall .*\.exe$/.test(name))
+    assert.ok(uninstaller)
+    await execute(join(installed, uninstaller), ['/S'], { timeout: 120000 })
+    await expect.poll(async () => (await readdir(installed).catch(() => [])).includes('Yaoyao.exe'), { timeout: 30000 }).toBe(false)
+    assert.deepEqual(JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8')), preferences)
+    await writeFile(join(evidence, 'upgrade.json'), JSON.stringify({ platform: 'win32', from: version, to: nextVersion,
+      installed: true, corruptDownloadRejected: true, nsisReplacement: true, automaticRestart: true,
+      cookieRestored: true, settingsPreserved: true, dpapiPreserved: true, uninstalled: true, dataRetained: true }, null, 2))
+  } finally {
+    await application?.close().catch(() => {}); await stopInstalled()
+    server.closeAllConnections(); await new Promise(done => server.close(done))
+    await rm(sandbox, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })
+    await rm(join(process.env.LOCALAPPDATA, cacheName), { recursive: true, force: true }).catch(() => {})
+  }
+})

+ 8 - 0
desktop/windows.test.mjs

@@ -88,6 +88,14 @@ test('native Windows PowerShell preserves Chinese, exit status and cancellation'
   const pending = execHostShell(root, { command: 'Start-Sleep -Seconds 60' }, { signal: controller.signal })
   setTimeout(() => controller.abort(), 500)
   assert.equal((await pending).cancelled, true)
+  const childCommand = "$p = Start-Process -FilePath $env:ComSpec -ArgumentList '/c ping -n 60 127.0.0.1 > nul' -WindowStyle Hidden -PassThru; [Console]::WriteLine($p.Id)"
+  for (const wait of [false, true]) {
+    const result = await execHostShell(root, { command: childCommand + (wait ? '; Start-Sleep -Seconds 60' : ''), timeoutMs: 3000 })
+    assert.equal(result.timedOut, wait)
+    const pid = Number(result.stdout.trim()); assert.ok(pid > 0, result.stderr)
+    const alive = await execHostShell(root, { command: `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { exit 1 }` })
+    assert.equal(alive.exitCode, 0, 'PowerShell 子进程在结束或超时后必须退出')
+  }
 })
 test('chunked file writes preserve contents and reject directory links outside the root', async t => {
   const root = await mkdtemp(join(tmpdir(), 'yaoyao-win-files-')), outside = await mkdtemp(join(tmpdir(), 'yaoyao-win-outside-'))

+ 39 - 3
desktop/windows/computer-helper.cpp

@@ -65,12 +65,42 @@ static DWORD integrity(HANDLE process) {
   auto label = reinterpret_cast<TOKEN_MANDATORY_LABEL*>(bytes.data());
   return *GetSidSubAuthority(label->Label.Sid, *GetSidSubAuthorityCount(label->Label.Sid) - 1);
 }
-static void checkForeground() {
-  DWORD pid = 0; GetWindowThreadProcessId(GetForegroundWindow(), &pid);
+static void checkWindow(HWND window) {
+  DWORD pid = 0; GetWindowThreadProcessId(window, &pid);
   if (!pid) return;
   Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
   require(process.value && integrity(process.value) <= integrity(GetCurrentProcess()), "无法操作管理员或受保护窗口,请在电脑上手动操作");
 }
+static void checkForeground() { checkWindow(GetForegroundWindow()); }
+// The non-inheritable job owns PowerShell and every normally spawned child.
+// Killing this helper, timeout, cancellation, or normal completion closes the
+// job and terminates descendants, even when PowerShell has already exited.
+static int runShell(const JsonObject& request) {
+  auto encoded = request.GetNamedString(L"encoded"), cwd = request.GetNamedString(L"cwd");
+  require(encoded.size() > 0 && encoded.size() < 30000, "PowerShell 命令超过长度限制");
+  for (wchar_t ch : encoded) require(iswalnum(ch) || ch == L'+' || ch == L'/' || ch == L'=', "PowerShell 编码无效");
+  wchar_t system[MAX_PATH]{};
+  require(GetSystemDirectoryW(system, MAX_PATH), "无法定位 Windows PowerShell");
+  auto shell = fs::path(system) / L"WindowsPowerShell" / L"v1.0" / L"powershell.exe";
+  std::wstring command = L"\"" + shell.wstring() + L"\" -NoLogo -NoProfile -NonInteractive -EncodedCommand " + std::wstring(encoded);
+  Handle job(CreateJobObjectW(nullptr, nullptr)); require(job.value != nullptr, "无法创建命令进程组");
+  JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
+  limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+  require(SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits)), "无法设置命令进程组");
+  STARTUPINFOW startup{}; startup.cb = sizeof(startup); startup.dwFlags = STARTF_USESTDHANDLES;
+  startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE); startup.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
+  for (HANDLE handle : {startup.hStdInput, startup.hStdOutput, startup.hStdError})
+    require(SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT), "无法连接命令输入输出");
+  PROCESS_INFORMATION created{};
+  require(CreateProcessW(shell.c_str(), command.data(), nullptr, nullptr, TRUE, CREATE_SUSPENDED | CREATE_NO_WINDOW,
+    nullptr, cwd.c_str(), &startup, &created), "无法启动 Windows PowerShell");
+  Handle process(created.hProcess), thread(created.hThread);
+  if (!AssignProcessToJobObject(job.value, process.value)) { TerminateProcess(process.value, 1); throw std::runtime_error("无法接管命令进程组"); }
+  require(ResumeThread(thread.value) != static_cast<DWORD>(-1), "无法启动命令线程");
+  require(WaitForSingleObject(process.value, INFINITE) == WAIT_OBJECT_0, "等待命令失败");
+  DWORD code = 1; require(GetExitCodeProcess(process.value, &code), "无法读取命令退出码");
+  return static_cast<int>(code);
+}
 static double number(const JsonObject& object, const wchar_t* key) {
   require(object.HasKey(key) && object.GetNamedValue(key).ValueType() == JsonValueType::Number, "电脑输入缺少数值参数");
   double value = object.GetNamedNumber(key); require(std::isfinite(value), "电脑输入数值无效"); return value;
@@ -114,6 +144,7 @@ static void input(const JsonObject& request) {
   auto kind = action.GetNamedString(L"kind");
   if (kind == L"click") {
     auto p = point(action, frame, L"x", L"y"); auto button = action.GetNamedString(L"button", L"left");
+    checkWindow(GetAncestor(WindowFromPoint(p), GA_ROOT));
     require(button == L"left" || button == L"right" || button == L"middle", "鼠标按键无效");
     DWORD down = button == L"right" ? MOUSEEVENTF_RIGHTDOWN : button == L"middle" ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_LEFTDOWN;
     DWORD up = button == L"right" ? MOUSEEVENTF_RIGHTUP : button == L"middle" ? MOUSEEVENTF_MIDDLEUP : MOUSEEVENTF_LEFTUP;
@@ -122,6 +153,8 @@ static void input(const JsonObject& request) {
     for (int i = 0; i < count; ++i) { send({mouse(down), mouse(up)}); Sleep(50); }
   } else if (kind == L"drag") {
     auto start = point(action, frame, L"fromX", L"fromY"), end = point(action, frame, L"toX", L"toY");
+    checkWindow(GetAncestor(WindowFromPoint(start), GA_ROOT));
+    checkWindow(GetAncestor(WindowFromPoint(end), GA_ROOT));
     send({move(start), mouse(MOUSEEVENTF_LEFTDOWN)});
     try { for (int i = 1; i <= 12; ++i) { send({move({start.x + (end.x - start.x) * i / 12, start.y + (end.y - start.y) * i / 12})}); Sleep(12); } }
     catch (...) { auto up = mouse(MOUSEEVENTF_LEFTUP); SendInput(1, &up, sizeof(INPUT)); throw; }
@@ -169,8 +202,11 @@ int main(int argc, char** argv) {
     std::string bytes; char chunk[4096];
     while (std::cin.read(chunk, sizeof(chunk)) || std::cin.gcount()) { bytes.append(chunk, static_cast<size_t>(std::cin.gcount())); require(bytes.size() <= 131072, "电脑输入过长"); }
     auto request = JsonObject::Parse(winrt::to_hstring(bytes));
+    if (argc == 2 && std::string(argv[1]) == "--shell") return runShell(request);
     checkDesktop();
-    if (request.GetNamedString(L"operation", L"input") != L"probe") input(request);
+    auto operation = request.GetNamedString(L"operation", L"input");
+    if (operation == L"release-drag") { auto up = mouse(MOUSEEVENTF_LEFTUP); SendInput(1, &up, sizeof(INPUT)); }
+    else if (operation != L"probe") input(request);
     std::cout << "{\"ok\":true}"; return 0;
   } catch (const winrt::hresult_error&) { std::cerr << "电脑输入格式无效或 Windows 组件不可用\n"; }
     catch (const std::exception& error) { std::cerr << error.what() << '\n'; }

+ 7 - 5
docs/desktop-hosts.md

@@ -1,6 +1,6 @@
 # 服务器与电脑
 
-一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 安装夭夭 App 后作为“电脑”出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。产品中只用两种名称:运行服务的一端叫“服务器”,连接服务器的 Mac 叫“电脑”。服务器自己机器上的 App 继续走本机回环通道,内部编号为 `local`。
+一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 或 Windows 电脑安装夭夭 App 后作为“电脑”出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。产品中只用两种名称:运行服务的一端叫“服务器”,连接服务器的设备叫“电脑”。服务器自己机器上的 App 继续走本机回环通道,内部编号为 `local`。
 
 ## 服务端执行与统一权限
 
@@ -16,6 +16,8 @@
 - **Hermes 原生工具**:原生终端和文件工具始终操作服务器,与消息来源无关;访问来源客户端应使用 `desktop_*`。全局服务器电脑开关控制 desktop 适配器,旧 `denyServerTools` 字段停用,不再展示未接入的封禁开关。
 - **接管界面**:`/api/app/agents/:id/computer` 系列接受 `backend=desktop|cloud|vm`,桌面可附带 `host`。界面显式选择目标。此处的 `host=local` 是历史 API 的服务器编号,不等于 Bot 工具中随消息变化的别名。
 
+Windows 客户端的构建、未签名安装包和验收记录见 [Windows 客户端](windows-client.md)。Windows 首次启动直接连接服务器,仅支持客户端模式;以下双模式选择与本机服务器说明仅适用于 Mac。
+
 ## 启用流程
 
 1. 在那台 Mac 上启动夭夭 App。**首次启动会询问这台 Mac 的使用方式:「连接服务器(客户端)」或「作为服务器运行」,默认选择客户端**。
@@ -30,10 +32,10 @@
 
 ## 文件与命令(全面操作)
 
-屏幕授权之外是独立的第二档授权「文件与命令」:电脑面板直接提供「授权文件与命令」按钮,无需先完成屏幕授权,Mac 端弹窗单独确认。授权后该账号下所有机器人获得四个工具:
+屏幕授权之外是独立的第二档授权「文件与命令」:电脑面板直接提供「授权文件与命令」按钮,无需先完成屏幕授权,桌面端弹窗单独确认。授权后该账号下所有机器人获得四个工具:
 
-- `desktop_file_list` / `desktop_file_read` / `desktop_file_write`:在这台 Mac 的**用户主目录**范围内列目录、读文件(≤12MB)和写文件(≤10MB);`..`、绝对路径越界和符号链接逃逸都会被拒绝,TCC 保护目录仍按系统权限处理。
-- `desktop_shell`:在主目录执行一条 zsh 命令(最长 120 秒,stdout/stderr 各截断 512KB,超时终止并如实报告)。
+- `desktop_file_list` / `desktop_file_read` / `desktop_file_write`:在这台电脑的**用户主目录**范围内列目录、读文件(≤12MB)和写文件(≤10MB);`..`、绝对路径越界和符号链接逃逸都会被拒绝,TCC 保护目录仍按系统权限处理。
+- `desktop_shell`:在主目录执行一条命令(Mac 使用 zsh,Windows 使用系统 Windows PowerShell)(最长 120 秒,stdout/stderr 各截断 512KB,超时终止并如实报告)。命令按当前系统用户权限执行,不受文件工具主目录边界限制。
 
 两档授权分别记录(`approved` 与 `approvedFull`),「撤销本机控制授权」同时清除;工具目录按账号下可用设备的授权汇总,且每次调用在服务端二次校验。
 
@@ -47,7 +49,7 @@
 
 ## 范围
 
-- 物理机控制仅支持 macOS(屏幕录制、辅助功能与 computer-helper 签名校验均为 macOS 专属)。
+- 物理机控制支持 macOS 和 Windows 10/11 x64。Mac 仍需屏幕录制、辅助功能授权;Windows 检查截图与原生输入助手,拒绝锁屏、安全桌面、断开的会话和高权限窗口,不自动提权。Windows 不托管本机服务器、Hermes、Runner 或虚拟机;服务器提供的功能仍可使用。
 - 本功能只提供真实桌面与独立浏览器环境;远程机器上的 Linux 虚拟桌面(无 Hermes 电脑节点)不在本范围。
 - 服务器侧 Bot 会话、模型与记忆全部留在服务器的 Hermes;卫星机不安装 Hermes。
 

+ 1 - 1
docs/windows-client.md

@@ -8,7 +8,7 @@ Windows 首版支持 Windows 10/11 x64,安装后连接已有夭夭服务器。
 2. 管理员登录后会注册这台电脑;普通账号可登录聊天。屏幕控制与文件、命令分别在本机确认授权,可从“电脑”菜单重新授权、导入配置或断开,也可从应用菜单撤销本机授权。
 3. Windows 电脑控制需要配套升级服务器。旧服务器仍可用于聊天;电脑菜单状态会提示升级要求。新版服务器继续支持现有 Mac 客户端。
 4. 截图与键鼠操作面向当前交互会话的主屏。锁屏、会话断开、UAC 安全桌面和高于客户端权限的窗口不能由普通权限客户端控制;在电脑上恢复会话或手动处理受保护操作。
-5. 文件工具限定在用户主目录,拒绝越界、目录联接逃逸、设备路径、UNC 和 NTFS 备用数据流。命令使用 Windows PowerShell,按当前 Windows 用户权限执行,**不受文件工具根目录约束**;命令最长 120 秒,两路输出分别截断至 512 KiB。
+5. 文件工具限定在用户主目录,拒绝越界、目录联接逃逸、设备路径、UNC 和 NTFS 备用数据流。命令使用 Windows PowerShell,按当前 Windows 用户权限执行,**不受文件工具根目录约束**;命令最长 120 秒,两路输出分别截断至 512 KiB。原生助手使用 Windows Job Object 管理 PowerShell 及其普通子进程,命令结束、超时或撤销时一起清理。
 6. 关闭窗口保留托盘连接;“退出夭夭”断开设备、结束应用自有操作。登录启动默认关闭,开启后可选择仅驻留托盘。更新和卸载默认保留用户数据。
 
 配置与设备令牌位于 `%USERPROFILE%\.yaoyao`(可由既有数据目录环境变量指定),Chromium 会话、浏览器和设备授权位于 Electron `userData`。令牌由当前 Windows 账号的 DPAPI 加密,密码不落盘。复制 Mac 加密文件不能迁移登录,应在 Windows 重新登录授权。