Ver Fonte

增加 Windows 完整客户端和原生构建验证

Samien há 1 mês atrás
pai
commit
a504ea8ff7
42 ficheiros alterados com 979 adições e 109 exclusões
  1. 46 0
      .github/workflows/windows-client.yml
  2. 11 7
      desktop/auto-update-manager.mjs
  3. 1 1
      desktop/auto-update-manager.test.mjs
  4. 1 1
      desktop/boot.html
  5. 11 0
      desktop/boot.js
  6. 15 2
      desktop/credentials.mjs
  7. 33 15
      desktop/environment-host.mjs
  8. 13 10
      desktop/host-files.mjs
  9. 7 7
      desktop/host-files.test.mjs
  10. 6 2
      desktop/host-manager.mjs
  11. 33 19
      desktop/main.mjs
  12. 4 4
      desktop/mode-switch.test.mjs
  13. 5 1
      desktop/onboarding.mjs
  14. 31 0
      desktop/platform.mjs
  15. 36 0
      desktop/platform.test.mjs
  16. 1 1
      desktop/remote-login.mjs
  17. 5 5
      desktop/update-manager.mjs
  18. 42 0
      desktop/windows-input.mjs
  19. 51 0
      desktop/windows-shell.mjs
  20. 67 0
      desktop/windows-smoke.test.mjs
  21. 103 0
      desktop/windows.test.mjs
  22. 14 0
      desktop/windows/CMakeLists.txt
  23. 178 0
      desktop/windows/computer-helper.cpp
  24. 48 0
      docs/windows-client.md
  25. 14 0
      electron-builder.yml
  26. 4 1
      package.json
  27. 1 1
      scripts/build-desktop.mjs
  28. 43 0
      scripts/build-windows-desktop.mjs
  29. 18 0
      scripts/build-windows-icon.cjs
  30. 2 2
      scripts/bundle-desktop-updater.mjs
  31. 4 0
      scripts/test-windows-desktop.mjs
  32. 56 0
      scripts/windows-desktop-release.mjs
  33. 5 5
      src/client/components/app/DesktopHostSettingsPanel.vue
  34. 8 7
      src/client/components/app/DesktopModePanel.vue
  35. 1 1
      src/client/components/app/HostToolsPanel.vue
  36. 1 1
      src/client/components/app/RunnerSettingsPanel.vue
  37. 4 4
      src/client/components/workspace/LocalVmChatPanel.vue
  38. 1 1
      src/client/env.d.ts
  39. 7 7
      src/server/desktopEnvironments.ts
  40. 4 3
      src/shared/fileTransferEndpoint.mjs
  41. 22 0
      src/shared/hostPaths.mjs
  42. 22 1
      tests/server/desktopEnvironments.test.ts

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

@@ -0,0 +1,46 @@
+name: Windows client
+on:
+  workflow_dispatch:
+  push:
+    branches: ['codex/windows-client*']
+  pull_request:
+    paths: ['desktop/**', 'scripts/**', 'src/**', 'tests/**', 'package*.json', 'electron-builder.yml', '.github/workflows/windows-client.yml']
+permissions:
+  contents: read
+jobs:
+  windows:
+    runs-on: windows-2022
+    timeout-minutes: 30
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '24'
+          cache: npm
+      - run: npm ci
+      - run: npm run typecheck
+      - run: npx vitest run tests/server/desktopEnvironments.test.ts tests/server/desktopHosts.test.ts
+      - run: npm run desktop:pack:win
+      - run: npm run desktop:test:win
+        env:
+          DESKTOP_TEST_EXECUTABLE: ${{ github.workspace }}\desktop-release\windows-x64\win-unpacked\Yaoyao.exe
+      - uses: actions/upload-artifact@v4
+        with:
+          name: yaoyao-windows-x64-unsigned
+          path: |
+            desktop-release/windows-x64/*.exe
+            desktop-release/windows-x64/*.blockmap
+            desktop-release/windows-x64/latest.yml
+            desktop-release/windows-x64/SHA256SUMS-win-x64.txt
+            desktop-release/windows-x64/BUILD-INFO.json
+            desktop-release/windows-x64/README.txt
+            docs/windows-client.md
+          if-no-files-found: error
+      - uses: actions/upload-artifact@v4
+        if: always()
+        with:
+          name: windows-client-test-evidence
+          path: test-results/windows-client
+          if-no-files-found: ignore

+ 11 - 7
desktop/auto-update-manager.mjs

@@ -18,12 +18,13 @@ function errorMessage(error) {
  * The patched MacUpdater resolves downloadUpdate only after native staging.
  * Native staging cannot be canceled or retried safely in the same process. */
 export class DesktopAutoUpdateManager {
-  constructor({ driver, version, arch = process.arch, prepareInstall = async () => {}, recoverInstall = async () => {}, stagingTimeout = 300000, restartTimeout = 120000 }) {
-    Object.assign(this, { driver, prepareInstall, recoverInstall, stagingTimeout, restartTimeout })
+  constructor({ driver, version, arch = process.arch, platform = process.platform, prepareInstall = async () => {}, recoverInstall = async () => {}, stagingTimeout = 300000, restartTimeout = 120000 }) {
+    Object.assign(this, { driver, platform, prepareInstall, recoverInstall, stagingTimeout, restartTimeout })
     this.state = { phase: 'idle', installMode: 'restart', currentVersion: version, arch, available: false,
       received: 0, total: 0, message: '点击检查 App 更新', releasePageUrl, retryable: true }
     driver.autoDownload = false
-    driver.autoInstallOnAppQuit = true
+    // NSIS installation must follow our explicit connection/process cleanup.
+    driver.autoInstallOnAppQuit = platform !== 'win32'
     driver.autoRunAppAfterInstall = true
     driver.allowPrerelease = false
     driver.allowDowngrade = false
@@ -37,9 +38,11 @@ export class DesktopAutoUpdateManager {
     driver.on('update-downloaded', () => {
       if (this.operation !== 'downloading' || this.recoveryRequired || this.cancelled) return
       this.nativeStarted = true
-      this.set({ phase: 'preparing', message: '下载完成,正在验证签名并准备安装…' })
-      this.stagingTimer = setTimeout(() => this.failure(new Error('准备更新超时')), this.stagingTimeout)
-      this.stagingTimer.unref?.()
+      this.set({ phase: 'preparing', message: platform === 'win32' ? '下载完成,正在核对安装包…' : '下载完成,正在验证签名并准备安装…' })
+      if (platform !== 'win32') {
+        this.stagingTimer = setTimeout(() => this.failure(new Error('准备更新超时')), this.stagingTimeout)
+        this.stagingTimer.unref?.()
+      }
     })
   }
   set(patch) { this.state = { ...this.state, ...patch } }
@@ -109,7 +112,8 @@ export class DesktopAutoUpdateManager {
     if (this.recoveryRequired || this.cancelled) return
     this.failed = true
     clearTimeout(this.stagingTimer); clearTimeout(this.restartTimer)
-    this.recoveryRequired = Boolean(this.nativeStarted)
+    this.recoveryRequired = Boolean(this.nativeStarted && (this.platform !== 'win32' || this.installAttempted))
+    if (this.platform === 'win32' && !this.recoveryRequired) this.nativeStarted = false
     const suffix = this.recoveryRequired ? ' 请退出并重新打开 App 后再尝试。' : ''
     const silent = this.manual === false && !this.nativeStarted
     this.set({ phase: silent ? 'idle' : 'failed',

+ 1 - 1
desktop/auto-update-manager.test.mjs

@@ -9,7 +9,7 @@ function fixture(t, options = {}) {
   driver.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '0.4.63', releaseNotes: '<b>纯文本说明</b>' }, cancellationToken: { cancel() { calls.push('cancel') } } })
   driver.downloadUpdate = async () => { driver.emit('update-downloaded'); return ['update.zip'] }
   driver.quitAndInstall = () => calls.push('install')
-  const manager = new DesktopAutoUpdateManager({ driver, version: '0.4.62', ...options })
+  const manager = new DesktopAutoUpdateManager({ driver, platform: 'darwin', version: '0.4.62', ...options })
   t.after(() => { manager.stopChecking(); clearTimeout(manager.stagingTimer); clearTimeout(manager.restartTimer) })
   return { manager, driver, calls }
 }

+ 1 - 1
desktop/boot.html

@@ -18,7 +18,7 @@
     <li id="step-prepare"><span class="step-dot"><b>2</b><svg><use href="boot-icons.svg#check"/></svg></span>准备服务</li>
     <li id="step-account"><span class="step-dot"><b>3</b><svg><use href="boot-icons.svg#check"/></svg></span><span id="account-step-title">登录账号</span></li>
   </ol>
-  <section class="section choice-section" aria-labelledby="choice-title">
+  <section id="choice-section" class="section choice-section" aria-labelledby="choice-title">
     <h2 id="choice-title"><span>01</span>选择服务器</h2>
     <fieldset id="mode-options" class="mode-options"><legend class="sr-only">服务器运行方式</legend>
       <label class="mode-option"><input type="radio" name="mode" value="local"><svg class="mode-icon"><use href="boot-icons.svg#monitor"/></svg><span><strong>本机运行</strong><small>在这台电脑准备并运行服务</small></span></label>

+ 11 - 0
desktop/boot.js

@@ -16,6 +16,17 @@ function display(next) {
     initialized = true
   }
   const busy = pending || state.busy
+  const clientOnly = state.supportedModes && !state.supportedModes.includes('server')
+  $('choice-section').hidden = Boolean(clientOnly)
+  $('step-choice').hidden = Boolean(clientOnly)
+  if (clientOnly) {
+    $('prepare-title').firstElementChild.textContent = '01'
+    $('account-title').firstElementChild.textContent = '02'
+    $('step-prepare').querySelector('b').textContent = '1'
+    $('step-account').querySelector('b').textContent = '2'
+    $('help').firstElementChild.hidden = true
+    $('help').lastElementChild.textContent = '更换服务器不会迁移原服务器上的数据。连接失败时请检查服务器地址和网络。'
+  }
   const local = state.mode === 'local'
   const ready = ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
   const setup = ready && state.setupRequired

+ 15 - 2
desktop/credentials.mjs

@@ -4,8 +4,9 @@ import {readFileSync} from 'node:fs'
 import {join} from 'node:path'
 import {homedir} from 'node:os'
 const prefix=Buffer.from('YAOYAO-RUNNER-KEYCHAIN-1\n')
+const windowsPrefix=Buffer.from('YAOYAO-WINDOWS-DPAPI-1\n')
 export class DesktopCredentials {
-  constructor({home,helper,legacyDecrypt}){this.home=home;this.helper=helper;this.legacyDecrypt=legacyDecrypt}
+  constructor({home,helper,legacyDecrypt,platform=process.platform,safeStorage}){Object.assign(this,{home,helper,legacyDecrypt,platform,safeStorage})}
   get account(){
     let identity=this.home
     try {
@@ -25,8 +26,20 @@ export class DesktopCredentials {
       child.stdin.on('error',()=>{});child.stdin.end(input)
     })
   }
-  async encrypt(value){return Buffer.concat([prefix,await this.invoke('encrypt',Buffer.from(value))])}
+  async encrypt(value){
+    if(this.platform==='win32'){
+      if(Buffer.byteLength(value)>1048576)throw new Error('电脑配置超过加密大小上限')
+      if(!await this.safeStorage?.isAsyncEncryptionAvailable())throw new Error('系统加密暂不可用,请稍后重试')
+      return Buffer.concat([windowsPrefix,await this.safeStorage.encryptStringAsync(value)])
+    }
+    return Buffer.concat([prefix,await this.invoke('encrypt',Buffer.from(value))])
+  }
   async decrypt(value){
+    if(this.platform==='win32'){
+      if(!value.subarray(0,windowsPrefix.length).equals(windowsPrefix))throw new Error('此电脑配置不属于 Windows,请重新登录授权')
+      try{return (await this.safeStorage.decryptStringAsync(value.subarray(windowsPrefix.length))).result}
+      catch{throw new Error('无法解锁电脑配置,请使用原 Windows 账号或重新登录授权')}
+    }
     if(value.subarray(0,prefix.length).equals(prefix))return (await this.invoke('decrypt',value.subarray(prefix.length))).toString('utf8')
     // Existing safeStorage files remain readable after the user grants normal OS access.
     let timer

+ 33 - 15
desktop/environment-host.mjs

@@ -1,5 +1,5 @@
-import {app,desktopCapturer,dialog,screen,shell,systemPreferences} from 'electron'
-import {hostname} from 'node:os'
+import {app,desktopCapturer,dialog,screen,shell,systemPreferences,powerMonitor} from 'electron'
+import {hostname,homedir} from 'node:os'
 import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
@@ -9,38 +9,53 @@ import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell}
 import {exchangeDesktopEnvironment as exchange} from './environment-exchange.mjs'
 import {HostEnvironmentReporter} from './host-environment.mjs'
 import {FileTransferFiles} from './file-transfer.mjs'
+import {desktopPlatform} from './platform.mjs'
+import {WindowsInput,windowsFrame,sameWindowsDisplay} from './windows-input.mjs'
 
 /** Command execution shared by the loopback server and remote computers. */
 export class DesktopHostCore {
- constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=process.env.HOME||'/';this.environmentReporter=new HostEnvironmentReporter(this.home);this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map()}
+ constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=homedir();this.environmentReporter=new HostEnvironmentReporter(this.home);this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map();this.shells=new Set();this.platform=desktopPlatform();this.native=this.platform.windows?new WindowsInput(root,app.isPackaged):undefined;this.windowsScreen=false;this.locked=false;this.lastProbe=0;this.onLock=()=>{this.locked=true;this.windowsScreen=false;this.native?.cancel();this.frames.clear()};this.onUnlock=()=>{this.locked=false;this.lastProbe=0};if(this.platform.windows){powerMonitor.on('lock-screen',this.onLock);powerMonitor.on('unlock-screen',this.onUnlock)}}
  transfers=new Map()
- info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull],...this.environmentReporter.fields()}}
+ info(){return {id:this.id,name:hostname(),platform:process.platform,screen:this.platform.windows?this.windowsScreen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:this.platform.windows?this.native.available:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull],...this.environmentReporter.fields()}}
+ async prepareInfo(force=false){
+  if(!this.native||this.closed||this.locked||!this.approved.size)return
+  if(!force&&Date.now()-this.lastProbe<2000)return
+  this.lastProbe=Date.now();const epoch=this.id
+  const available=await this.native.probe()
+  try{const d=screen.getPrimaryDisplay();const sources=available?await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1,height:1}}):[]
+   if(epoch!==this.id||this.closed||this.locked){this.native.cancel();return}
+   this.windowsScreen=sources.some(s=>s.display_id===String(d.id)&&!s.thumbnail.isEmpty())
+  }catch{this.windowsScreen=false}
+  if(!this.windowsScreen||!this.native.available)this.frames.clear()
+ }
+ async cancelOperations(){this.windowsScreen=false;this.lastProbe=0;for(const task of this.shells)task.controller.abort();await Promise.allSettled([this.native?.cancel(),...[...this.shells].map(task=>task.promise)])}
  acceptCapabilities(capabilities){this.environmentReporter.accept(capabilities)}
  async clearTransfers(){const transfers=[...this.transfers.values()];this.transfers.clear();await Promise.allSettled(transfers.map(transfer=>transfer.close()))}
- async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID();await this.clearTransfers()}
- async recycle(){this.environmentReporter.reset();this.results=[];this.id=randomUUID();await this.clearTransfers();await this.browser.close()}
+ async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID();await this.cancelOperations();await this.clearTransfers()}
+ async recycle(){this.environmentReporter.reset();this.results=[];this.id=randomUUID();await this.cancelOperations();await this.clearTransfers();await this.browser.close()}
  save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify({screen:[...this.approved],full:[...this.approvedFull]}),{mode:0o600})}
  takeResults(){const sent=this.results.slice();this.results.splice(0,sent.length);return sent}
  async handle(commands){for(const c of commands??[]){if(this.running.has(c.id))continue;this.running.add(c.id);const epoch=this.id;void this.execute(c).then(value=>{if(epoch===this.id)this.results.push({id:c.id,value})},error=>{if(epoch===this.id)this.results.push({id:c.id,error:String(error.message).slice(0,500)})}).finally(()=>this.running.delete(c.id))}}
  async execute(c){const epoch=this.id;if(Date.now()>c.deadline||this.closed)throw new Error('桌面请求已过期');if(!/^[a-f0-9]{64}$/.test(c.owner))throw new Error('桌面请求身份无效')
   if(c.operation==='authorize'){
-   if(process.platform!=='darwin')throw new Error('此电脑暂不支持本机控制')
+   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?'使用这台 Mac 的文件并执行命令':'查看和操作这台 Mac'}?`,
-    detail:full?'机器人可以列出、读取和写入你用户主目录内的文件,并在这台 Mac 上执行 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()
    if(full)return {ok:true}
+   if(this.native){await this.prepareInfo(true);return {ok:true}}
    if(!systemPreferences.isTrustedAccessibilityClient(false))systemPreferences.isTrustedAccessibilityClient(true)
    if(systemPreferences.getMediaAccessStatus('screen')!=='granted'){await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1,height:1}}).catch(()=>{});if(systemPreferences.getMediaAccessStatus('screen')!=='granted')await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture')}
    return {ok:true}
   }
   if(c.operation==='file'||c.operation==='shell'){
-   if(process.platform!=='darwin')throw new Error('此电脑暂不支持文件与命令')
+   if(!this.platform.computer)throw new Error('此电脑暂不支持文件与命令')
    if(!this.approvedFull.has(c.owner))throw new Error('请在桌面端授权文件与命令访问')
-   if(c.operation==='shell')return execHostShell(this.home,c.action)
+   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)}}
    const action=c.action??{}
    if(typeof action.op==='string'&&action.op.startsWith('transfer-')){
     const key=c.owner+':'+c.resource
@@ -63,20 +78,23 @@ export class DesktopHostCore {
    if(c.operation==='view')return this.browser.view(c.resource)
    return this.browser.input(c.resource,c.action,c.frame)
   }
-  const info=this.info();if(!info.approved.includes(c.owner)||!info.screen||!info.accessibility)throw new Error('请在桌面端授权,并在系统设置开启屏幕录制和辅助功能')
+  await this.prepareInfo(true)
+  if(this.closed||epoch!==this.id||Date.now()>c.deadline)throw new Error('电脑授权已改变,请重新操作')
+  const info=this.info();if(!info.approved.includes(c.owner)||!info.screen||!info.accessibility)throw new Error(this.platform.windows?'请授权本机控制,并在 Windows 上解锁电脑、恢复桌面会话':'请在桌面端授权,并在系统设置开启屏幕录制和辅助功能')
   if(c.operation==='open')return {ok:true}
-  if(c.operation==='view'){const d=screen.getPrimaryDisplay(),sources=await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1600,height:1200}}),source=sources.find(s=>s.display_id===String(d.id));if(!source||source.thumbnail.isEmpty())throw new Error('系统尚未允许读取屏幕');const dimensions=source.thumbnail.getSize();this.frames.set(c.resource,{display:d.id,...dimensions,bounds:d.bounds});return {data:source.thumbnail.toPNG().toString('base64'),...dimensions}}
+  if(c.operation==='view'){const d=screen.getPrimaryDisplay(),sources=await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1600,height:1200}}),source=sources.find(s=>s.display_id===String(d.id));if(!source||source.thumbnail.isEmpty())throw new Error('系统尚未允许读取屏幕');if(this.closed||epoch!==this.id||this.locked||Date.now()>c.deadline)throw new Error('电脑授权或会话已改变');const dimensions=source.thumbnail.getSize();this.frames.set(c.resource,this.native?windowsFrame(d,dimensions,screen.dipToScreenRect(null,d.bounds)):{display:d.id,...dimensions,bounds:d.bounds});return {data:source.thumbnail.toPNG().toString('base64'),...dimensions}}
   if(c.operation!=='input')throw new Error('本机操作无效')
   const f=this.frames.get(c.resource),d=screen.getPrimaryDisplay();if(!f||f.display!==d.id||JSON.stringify(f.bounds)!==JSON.stringify(d.bounds)||f.width!==c.frame?.width||f.height!==c.frame?.height)throw new Error('显示器已变化,请刷新画面')
+  if(this.native){if(!sameWindowsDisplay(f,d,screen.dipToScreenRect(null,d.bounds)))throw new Error('显示器已变化,请刷新画面');return this.native.call({action:c.action,frame:f})}
   return new Promise((resolve,reject)=>{const child=execFile(join(this.root,app.isPackaged?'computer-helper':'computer-helper-dev'),[],{timeout:10000,maxBuffer:65536},(error,stdout,stderr)=>{if(error)reject(new Error(stderr.trim()||'系统未接受电脑输入'));else{try{resolve(JSON.parse(stdout))}catch{reject(new Error('电脑助手响应无效'))}}});child.stdin.end(JSON.stringify({action:c.action,frame:f}))})
  }
- async close(){this.closed=true;await this.recycle()}
+ async close(){this.closed=true;powerMonitor.removeListener('lock-screen',this.onLock);powerMonitor.removeListener('unlock-screen',this.onUnlock);await this.recycle()}
 }
 
 export class DesktopEnvironmentHost {
  constructor({manager,root,dataRoot}){this.manager=manager;this.core=new DesktopHostCore({root,dataRoot});this.closed=false;this.timer=undefined;this.instance=undefined}
  revoke(){return this.core.revoke()}
- async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId)await this.core.recycle();this.instance=record.instanceId;const value=await exchange(record,{host:this.core.info(),results:this.core.takeResults()});this.core.acceptCapabilities(value.capabilities);await this.core.handle(value.commands??[])}}catch{this.core.acceptCapabilities(undefined)}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
+ async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId)await this.core.recycle();this.instance=record.instanceId;await this.core.prepareInfo();const value=await exchange(record,{host:this.core.info(),results:this.core.takeResults()});this.core.acceptCapabilities(value.capabilities);await this.core.handle(value.commands??[])}}catch{this.core.acceptCapabilities(undefined)}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
  start(){this.closed=false;this.core.closed=false;void this.cycle()}
  async close(){this.closed=true;clearTimeout(this.timer);await this.core.close()}
 }

+ 13 - 10
desktop/host-files.mjs

@@ -1,10 +1,12 @@
 import {readdir,readFile,writeFile,mkdir,stat,realpath,open,link,rename,unlink} from 'node:fs/promises'
 import {createHash,randomUUID} from 'node:crypto'
-import {join,resolve,sep} from 'node:path'
+import {join,resolve} from 'node:path'
 import {homedir} from 'node:os'
 import {execFile} from 'node:child_process'
+import {hostPathInput,pathInside} from '../src/shared/hostPaths.mjs'
+import {windowsShell} from './windows-shell.mjs'
 
-export const HOST_SHELL='/bin/zsh'
+export const HOST_SHELL=process.platform==='win32'?join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe'):'/bin/zsh'
 export const FILE_READ_LIMIT=12*1024*1024
 export const FILE_WRITE_LIMIT=10*1024*1024
 export const FILE_LIST_LIMIT=500
@@ -15,9 +17,9 @@ export const SHELL_TIMEOUT_MAX=120000
  *  through .., absolute outsiders and symlinks pointing out of the root. */
 export async function resolveWithin(root,input,kind='target'){
   const base=await realpath(root)
-  const raw=String(input??'').replace(/^~\//,'').replace(/^~$/,'')
-  if(/\u0000/.test(raw))throw new Error('路径无效')
+  const raw=hostPathInput(input)
   const target=resolve(base,raw)
+  if(!pathInside(base,target))throw new Error('路径超出允许范围')
   let real=target
   try{real=await realpath(target)}catch(error){
     if(kind==='existing')throw new Error('文件或目录不存在')
@@ -26,10 +28,10 @@ export async function resolveWithin(root,input,kind='target'){
       for(;;){
         try{
           const parent=await realpath(directory)
-          if(parent!==base&&!parent.startsWith(base+sep))throw new Error('路径超出允许范围')
+          if(!pathInside(base,parent))throw new Error('路径超出允许范围')
           break
         }catch(failure){
-          if(failure.message==='路径超出允许范围')throw failure
+          if(failure.code!=='ENOENT')throw failure
           const next=join(directory,'..')
           if(directory===next)throw error
           directory=next
@@ -38,7 +40,7 @@ export async function resolveWithin(root,input,kind='target'){
       return {base,target}
     }
   }
-  if(real!==base&&!real.startsWith(base+sep))throw new Error('路径超出允许范围')
+  if(!pathInside(base,real))throw new Error('路径超出允许范围')
   return {base,target}
 }
 
@@ -101,14 +103,15 @@ 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){
+export async function execHostShell(root,input,{signal}={}){
   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(/^~\//,'').replace(/^~$/,''))}catch{cwd=String(root)}
+  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})
   return new Promise(done=>{
-    execFile(HOST_SHELL,['-c',command],{cwd,timeout,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
+    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')
       done({command,exitCode:error?typeof error.code==='number'?error.code:1:0,timedOut:error?.killed===true&&error?.signal==='SIGKILL',stdout:clip(stdout),stderr:clip(stderr)})
     })

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

@@ -30,7 +30,7 @@ test('path escapes are refused for every operation',async()=>{
  const outside=await mkdtemp(join(tmpdir(),'yaoyao-host-files-out-'))
  try{
   await writeFile(join(outside,'secret.txt'),'secret','utf8')
-  await symlink(outside,join(root,'escape'))
+  await symlink(outside,join(root,'escape'),process.platform==='win32'?'junction':'dir')
   await writeFile(join(root,'keep.txt'),'keep','utf8')
   await assert.rejects(()=>resolveWithin(root,'../x'),/超出允许范围/)
   await assert.rejects(()=>resolveWithin(root,'/etc/passwd'),/超出允许范围/)
@@ -64,7 +64,7 @@ test('receives verified binary and empty files atomically and preserves existing
   const empty={...input,path:'Desktop/empty',data:'',sha256:createHash('sha256').update('').digest('hex')}
   assert.equal((await receiveHostFile(target,empty)).size,0)
   assert.equal((await readFile(join(target,'Desktop','empty'))).length,0)
-  await symlink(source,join(target,'escape'))
+  await symlink(source,join(target,'escape'),process.platform==='win32'?'junction':'dir')
   await assert.rejects(()=>receiveHostFile(target,{...input,path:'escape/evil.bin'}),/超出允许范围/)
   assert.deepEqual((await readdir(join(target,'Desktop'))).sort(),['a.bin','empty'])
  }finally{await rm(source,{recursive:true,force:true});await rm(target,{recursive:true,force:true})}
@@ -74,14 +74,14 @@ test('shell execution reports stdout, exit codes, timeouts and cwd stays in root
  const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
  try{
   await mkdir(join(root,'work'))
-  const ok=await execHostShell(root,{command:'pwd',cwd:'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.includes(`${root}/work`),ok.stdout)
-  const failing=await execHostShell(root,{command:'echo boom >&2; exit 7'})
+  assert.ok(ok.stdout.toLowerCase().includes(join(root,'work').toLowerCase()),ok.stdout)
+  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:'sleep 5',timeoutMs:1000})
+  const timed=await execHostShell(root,{command:process.platform==='win32'?'Start-Sleep -Seconds 5':'sleep 5',timeoutMs:1000})
   assert.equal(timed.timedOut,true);assert.notEqual(timed.exitCode,0)
-  const escaped=await execHostShell(root,{command:'cd / && pwd'})
+  const escaped=await execHostShell(root,{command:process.platform==='win32'?'Set-Location $env:SystemRoot; (Get-Location).Path':'cd / && pwd'})
   assert.equal(escaped.exitCode,0)
   await assert.rejects(()=>execHostShell(root,{command:'  '}),/命令为空/)
  }finally{await rm(root,{recursive:true,force:true})}

+ 6 - 2
desktop/host-manager.mjs

@@ -67,7 +67,7 @@ export class DesktopHostManager {
   let encrypted
   try{encrypted=await readFile(this.path)}catch(error){if(error.code==='ENOENT')return;throw error}
   try{return parseDesktopHostConfiguration(JSON.parse(await this.options.decrypt(encrypted)))}
-  catch{throw new Error('无法解锁电脑配置,请检查钥匙串或重新导入')}
+  catch{throw new Error('无法解锁电脑配置,请使用原系统账号或重新登录授权')}
  }
  importFile(path){
   const generation=++this.generation
@@ -114,10 +114,14 @@ export class DesktopHostManager {
   const config=this.config,core=this.core
   if(!config||!core||this.core!==core)return
   try{
+   await core.prepareInfo?.()
+   if(this.config!==config||this.core!==core)return
    const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()})
    this.backoff=400
    if(this.config!==config||this.core!==core)return
-   this.publish(`电脑已连接 ${new URL(config.serverURL).host}`)
+   this.publish(process.platform==='win32'&&!value.capabilities?.desktopPlatforms?.includes('win32')
+    ? '已连接;服务器尚不支持 Windows 电脑操作,请升级服务器(聊天可继续使用)'
+    : `电脑已连接 ${new URL(config.serverURL).host}`)
    core.acceptCapabilities?.(value.capabilities)
    await core.handle(value.commands??[])
   }catch(error){

+ 33 - 19
desktop/main.mjs

@@ -17,12 +17,16 @@ import { DesktopUpdateManager } from './update-manager.mjs'
 import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
 import { createRequire } from 'node:module'
 import { installComputerViewer } from './computer-viewer.mjs'
+import { desktopPlatform, desktopMenu, loginItemOptions } from './platform.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
+const platform = desktopPlatform()
+const loginOptions = loginItemOptions()
 // Fixture identity also isolates Chromium cookies, window state and the app lock.
 if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
 app.setName('夭夭')
+if (platform.windows) app.setAppUserModelId('cn.samien.yaoyao.desktop')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
@@ -61,6 +65,7 @@ else {
   })
   ipcMain.handle('desktop:retry', async event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
+    if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
     manager.restarts = []
     return onboarding.prepare()
   })
@@ -162,7 +167,8 @@ else {
       if (closeComputerViewer.owns(contents)) { callback(['fullscreen', 'clipboard-sanitized-write'].includes(permission)); return }
       if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
       if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
-        void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
+        if (platform.windows) callback(systemPreferences.getMediaAccessStatus('microphone') !== 'denied')
+        else void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
       } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
     })
     window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
@@ -238,7 +244,7 @@ else {
             await manager?.reconnect().catch(error=>log(error.message))
             await runnerManager?.start().catch(error=>log(error.message))
           }
-          stateChanged(manager.state)
+          if (manager) stateChanged(manager.state)
           timer=setInterval(()=>{if(serverModeActive)void manager.check()},10000);timer.unref()
           updater?.startChecking?.()
           show()
@@ -278,14 +284,16 @@ else {
   })
   app.whenReady().then(async () => {
     await preferences.load()
-    startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
+    startHidden = preferences.value.backgroundAtLogin && (platform.windows
+      ? process.argv.includes('--yaoyao-login') : app.getLoginItemSettings().wasOpenedAtLogin)
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
     const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
     const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
     if ((app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE === '1')) {
-      const { autoUpdater } = createRequire(import.meta.url)(join(root, 'electron-updater.cjs'))
+      const updateModule = createRequire(import.meta.url)(join(root, 'electron-updater.cjs'))
+      const autoUpdater = platform.windows ? new updateModule.NsisUpdater() : updateModule.autoUpdater
       autoUpdater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, (...values) => log(`[更新:${level}] ${values.join(' ')}`)]))
-      updater = new DesktopAutoUpdateManager({ driver: autoUpdater, version: packageVersion,
+      updater = new DesktopAutoUpdateManager({ driver: autoUpdater, version: packageVersion, platform: platform.platform,
         prepareInstall: prepareUpdateRestart, recoverInstall: recoverUpdateRestart })
       updater.startChecking()
     } else {
@@ -294,6 +302,8 @@ else {
         fetchImpl: (...args) => net.fetch(...args) })
     }
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
+    const credentials=new DesktopCredentials({home,platform:platform.platform,safeStorage,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
+    if (platform.server) {
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
       stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
@@ -316,13 +326,13 @@ else {
     })
     environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
     const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
-    const credentials=new DesktopCredentials({home,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
     runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
       encrypt:value=>credentials.encrypt(value),
       decrypt:bytes=>credentials.decrypt(bytes),
       onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
       fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{HOME:homedir(),PATH:[join(homedir(),'.local/bin'),join(homedir(),'.orbstack/bin'),'/opt/homebrew/bin','/usr/local/bin',process.env.PATH||'/usr/bin:/bin'].join(':'),NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
     })
+    }
     async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
     hostManager=new DesktopHostManager({home,root,dataRoot:app.getPath('userData'),
       createCore:({root,dataRoot})=>new DesktopHostCore({root,dataRoot}),
@@ -361,7 +371,8 @@ else {
     }
     ipcMain.handle('desktop:mode-state',event=>{
       requireModePage(event)
-      return {mode:remoteMode?'client':'server',serverURL:serviceURL()||'',switching:switchingMode}
+      return {mode:remoteMode||!platform.server?'client':'server',serverURL:serviceURL()||'',switching:switchingMode,
+        platform:platform.platform,supportedModes:platform.supportedModes}
     })
     ipcMain.handle('desktop:mode-switch',async(event,mode)=>{
       requireModePage(event)
@@ -399,9 +410,11 @@ else {
       if (enrolled?.serverURL) await preferences.setRemoteServer(enrolled.serverURL)
     }
     onboarding = new DesktopOnboarding({
+      platform: platform.platform, supportedModes: platform.supportedModes,
       remoteServer: () => preferences.value.remoteServer,
       inspect: server => inspectServer(server, (...args) => net.fetch(...args)),
       prepareLocal: async ({ force }) => {
+        if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
         serverModeActive = true; remoteMode = false; remoteURL = ''
         await hostManager.stop()
         manager.restarts = []
@@ -432,9 +445,9 @@ else {
         if (mode === 'remote') {
           // Detach only; an independently installed local service keeps running.
           serverModeActive = false
-          await environmentHost.close()
-          await runnerManager.stop()
-          await manager.stop()
+          await environmentHost?.close()
+          await runnerManager?.stop()
+          await manager?.stop()
           remoteMode = true; remoteURL = serverURL
           await preferences.setRemoteServer(serverURL)
           const enrolled = await hostManager.read().catch(error => { log(error.message); return undefined })
@@ -481,6 +494,7 @@ else {
       if(ask)ask.checked=preferences.value.startupChoice==='ask'
     }
     async function switchMode(mode) {
+      if (mode === 'local' && !platform.server) return { ok: false, error: '此客户端不支持在本机运行服务器' }
       if (closing || quitting) return { ok: false, error: 'App 正在退出' }
       if (switchingMode || onboarding.busy) return { ok: false, error: '当前步骤尚未完成,请稍候' }
       switchingMode = true; syncModeMenu()
@@ -495,7 +509,7 @@ else {
       const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
       if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
     }
-    Menu.setApplicationMenu(Menu.buildFromTemplate([
+    Menu.setApplicationMenu(Menu.buildFromTemplate(desktopMenu([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { const url = serviceURL(); if (url) safeExternal(url) } },
@@ -506,8 +520,8 @@ else {
           click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
         { id:'desktop-sync-force', label:'使用当前 App 覆盖同版本 Web', enabled:false,
           click:async()=>{if(closing||quitting||!manager.canForceSynchronization)return;try{await manager.retrySynchronization({force:true})}catch(error){log(error.message)}} },
-        { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin,
-          click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
+        { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings(loginOptions).openAtLogin,
+          click:async item=>{try{app.setLoginItemSettings({...loginOptions,openAtLogin:item.checked});item.checked=app.getLoginItemSettings(loginOptions).openAtLogin}catch(error){item.checked=app.getLoginItemSettings(loginOptions).openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
         { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
           click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} },
         { id:'desktop-update-check', label:'检查 App 更新…', click:showUpdates },
@@ -515,7 +529,7 @@ else {
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' },
-        {label:'撤销本机控制授权',click:()=>{void environmentHost.revoke();void hostManager?.revoke()}},
+        {label:'撤销本机控制授权',click:()=>{void environmentHost?.revoke();void hostManager?.revoke()}},
         { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
         { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
       {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
@@ -534,19 +548,19 @@ else {
         {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
         {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
-    ]))
+    ])))
     const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
-    icon.setTemplateImage(true)
+    if (!platform.windows) icon.setTemplateImage(true)
     tray = new Tray(icon)
     tray.setToolTip('夭夭')
-    tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
+    tray.setContextMenu(Menu.buildFromTemplate(desktopMenu([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }])))
     tray.on('click', show)
     // Restore a saved session behind neutral loading feedback. The guide only
     // becomes visible if a connection or account actually needs attention.
-    const startupMode = preferences.value.startupChoice === 'ask'
+    const startupMode = !platform.server ? 'remote' : preferences.value.startupChoice === 'ask'
       ? (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_MODE !== 'ask' ? 'local' : null)
       : preferences.value.startupChoice
-    const restoreSession = Boolean(startupMode && (startupMode === 'local' || preferences.value.remoteServer))
+    const restoreSession = Boolean(startupMode && (startupMode === 'local' || preferences.value.remoteServer)) && (platform.server || preferences.value.startupChoice !== 'ask')
     onboarding.open({ mode: startupMode, serverURL: preferences.value.remoteServer,
       remember: preferences.value.startupChoice !== 'ask', restoring: restoreSession })
     // Finish the initial navigation before a remembered session enters its server.

+ 4 - 4
desktop/mode-switch.test.mjs

@@ -28,12 +28,12 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
   try{
     app=await electron.launch({args:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(local.address().port),HERMES_YAOYAO_DESKTOP_TEST_SYNC:'0'}})
     const page=await app.firstWindow();releaseIdentity();await page.waitForURL(origin+'/**')
-    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'server',serverURL:origin,switching:false})
+    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
     assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.authorizeComputer('csrf-test')),{registered:false})
     assert.equal(await page.evaluate(()=>window.yaoyaoDesktop.switchMode('invalid').then(()=>false,()=>true)),true)
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
     await page.waitForURL(remoteURL+'/**')
-    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'client',serverURL:remoteURL,switching:false})
+    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'client',serverURL:remoteURL,switching:false,platform:'darwin',supportedModes:['client','server']})
     await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
     // Unchecking "ask at startup" must retain the active client role.
     await app.evaluate(({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-host-ask');item.click(item)})
@@ -50,7 +50,7 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     assert.deepEqual(denied,[true,true,true,true])
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('server')})
     await page.waitForURL(origin+'/**')
-    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false})
+    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
     assert.equal(JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice,'local')
     assert.equal(local.listening,true,'switching must leave an independent service running')
     assert.equal(remote.listening,true)
@@ -71,7 +71,7 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     await page.locator('#prepare-local').click()
     await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
-    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false})
+    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
   }finally{
     releaseIdentity();await app?.close().catch(()=>{})
     await Promise.all([local,remote].map(server=>new Promise(done=>{server.close(done);server.closeAllConnections()})))

+ 5 - 1
desktop/onboarding.mjs

@@ -8,9 +8,12 @@ export class DesktopOnboarding {
     this.open()
   }
   get busy() { return ['preparing', 'authenticating', 'entering'].includes(this.state.phase) }
-  snapshot() { return { ...this.state, history: [...this.state.history], busy: this.busy } }
+  snapshot() { return { ...this.state, history: [...this.state.history], busy: this.busy,
+    platform: this.options.platform ?? process.platform, supportedModes: this.options.supportedModes ?? ['client', 'server'] } }
   open({ mode = null, serverURL = '', remember = false, forceLogin = false, restoring = false } = {}) {
     if (this.state && this.busy) throw new Error('当前步骤尚未完成,请稍候')
+    if (mode === 'local' && !this.snapshotModes().includes('server')) throw new Error('此客户端不支持在本机运行服务器')
+    if (!mode && !this.snapshotModes().includes('server')) mode = 'remote'
     this.state = { active: true, mode, serverURL, remember, forceLogin, restoring, phase: 'idle', stage: 'checking',
       setupRequired: false, registrationAvailable: false, registrationNotice: '', authenticated: false, username: '', message: '', error: '',
       canForceSync: false, history: [] }
@@ -20,6 +23,7 @@ export class DesktopOnboarding {
     if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
     return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
   }
+  snapshotModes() { return this.options.supportedModes ?? ['client', 'server'] }
   serviceChanged(service) {
     if (!this.state.active || this.state.mode !== 'local') return
     this.state.stage = service.stage || this.state.stage

+ 31 - 0
desktop/platform.mjs

@@ -0,0 +1,31 @@
+/** Platform policy is shared by startup, native menus and the renderer bridge. */
+export function desktopPlatform(platform = process.platform) {
+  return Object.freeze({ platform, server: platform === 'darwin',
+    supportedModes: platform === 'darwin' ? ['client', 'server'] : ['client'],
+    computer: ['darwin', 'win32'].includes(platform), windows: platform === 'win32' })
+}
+
+export function loginItemOptions(platform = process.platform, executable = process.execPath) {
+  return platform === 'win32' ? { path: executable, args: ['--yaoyao-login'] } : {}
+}
+
+export function desktopMenu(template, platform = process.platform) {
+  if (platform === 'darwin') return template
+  const excluded = new Set(['desktop-local-vm', 'desktop-sync-notice', 'desktop-sync-retry',
+    'desktop-sync-force', 'desktop-stop-and-quit', 'desktop-host-use-local', 'desktop-host-ask'])
+  const result = []
+  for (const item of template) {
+    if (excluded.has(item.id) || item.label === '执行节点' || ['hide', 'hideOthers', 'unhide'].includes(item.role)) continue
+    const next = { ...item }
+    if (next.submenu) next.submenu = desktopMenu(next.submenu, platform)
+    if (next.id === 'desktop-login') next.label = '登录 Windows 时启动'
+    if (next.id === 'desktop-background') next.label = '登录启动时仅驻留托盘'
+    if (next.id === 'desktop-service-status') next.label = '尚未连接服务器'
+    if (next.label === '退出夭夭(后台继续运行)') next.label = '退出夭夭'
+    if (next.label === '停止后台服务并退出') continue
+    if (next.type === 'separator' && (!result.length || result.at(-1).type === 'separator')) continue
+    result.push(next)
+  }
+  if (result.at(-1)?.type === 'separator') result.pop()
+  return result
+}

+ 36 - 0
desktop/platform.test.mjs

@@ -0,0 +1,36 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { desktopPlatform, desktopMenu, loginItemOptions } from './platform.mjs'
+import { DesktopOnboarding } from './onboarding.mjs'
+import { hostPathInput, pathInside } from '../src/shared/hostPaths.mjs'
+
+test('Windows can only connect to a server, including direct onboarding calls', () => {
+  const platform = desktopPlatform('win32')
+  assert.deepEqual(platform.supportedModes, ['client'])
+  const onboarding = new DesktopOnboarding({ ...platform, remoteServer: () => '' })
+  assert.equal(onboarding.snapshot().mode, 'remote')
+  assert.equal(onboarding.snapshot().platform, 'win32')
+  assert.throws(() => onboarding.select('local'), /不支持/)
+  assert.throws(() => onboarding.open({ mode: 'local' }), /不支持/)
+  assert.equal(onboarding.select('remote').mode, 'remote')
+  assert.deepEqual(desktopPlatform('darwin').supportedModes, ['client', 'server'])
+})
+test('Windows menus retain useful actions and omit all local service actions', () => {
+  const template = [{ label: '夭夭', submenu: [{ role: 'hide' }, { id: 'desktop-local-vm' }, { id: 'desktop-login' }, { id: 'desktop-quit', label: '退出夭夭(后台继续运行)' }] },
+    { label: '执行节点', submenu: [{ id: 'runner-import' }] }, { label: '运行模式', submenu: [{ id: 'desktop-host-use-local' }, { label: '更换服务器…' }] }]
+  const windows = desktopMenu(template, 'win32')
+  assert.equal(windows.length, 2)
+  assert.deepEqual(windows[0].submenu.map(item => item.label), ['登录 Windows 时启动', '退出夭夭'])
+  assert.deepEqual(windows[1].submenu, [{ label: '更换服务器…' }])
+  assert.equal(desktopMenu(template, 'darwin'), template)
+  assert.deepEqual(loginItemOptions('win32', 'C:\\Apps\\Yaoyao.exe'), { path: 'C:\\Apps\\Yaoyao.exe', args: ['--yaoyao-login'] })
+})
+test('Windows file boundaries handle drive casing, separators, traversal and device paths', () => {
+  const root = 'C:\\Users\\张三'
+  for (const path of ['c:\\users\\张三\\Documents\\a.txt', 'C:/Users/张三/Desktop/a.txt', root]) assert.equal(pathInside(root, path, 'win32'), true, path)
+  for (const path of ['C:\\Users\\张三2\\a.txt', 'D:\\Users\\张三\\a.txt', 'C:\\Users\\张三\\..\\secret']) assert.equal(pathInside(root, path, 'win32'), false, path)
+  assert.equal(hostPathInput('~\\Documents\\中文.txt', 'win32'), 'Documents\\中文.txt')
+  for (const path of ['C:secret', '\\\\server\\share', '\\\\?\\C:\\secret', 'a.txt:secret', 'CON', 'x\\NUL.txt', 'x.\\secret', 'a\0b']) assert.throws(() => hostPathInput(path, 'win32'), /路径无效/, path)
+  assert.equal(hostPathInput('notes:today', 'darwin'), 'notes:today')
+  assert.equal(pathInside('/home/a', '/home/ab', 'linux'), false)
+})

+ 1 - 1
desktop/remote-login.mjs

@@ -92,7 +92,7 @@ export async function enrollDesktopHost(session,{name,installId,previousHostId},
     headers:{accept:'application/json','content-type':'application/json',cookie,'x-csrf-token':session.csrfToken,origin:session.origin},
     body:JSON.stringify({name:String(name).slice(0,100),...(installId?{installId}:{}),...(previousHostId?{previousHostId}:{})}),
   })
-  if(response.status===403)throw new Error('需要管理员账号,才能把这台 Mac 注册为可远程控制的电脑')
+  if(response.status===403)throw new Error('需要管理员账号,才能把这台电脑注册为可远程控制的电脑')
   if(!response.ok)throw new Error(`注册电脑失败(HTTP ${response.status})`)
   const result=await response.json()
   if(typeof result?.token!=='string'||!result?.host?.id)throw new Error('注册电脑响应无效')

+ 5 - 5
desktop/update-manager.mjs

@@ -35,7 +35,7 @@ function assertDigest(digest, actual) {
 
 export class DesktopUpdateManager {
   constructor({ version, arch = process.arch, platform = process.platform, cacheRoot, inspect, compare, source, fetchImpl = fetch,
-    verifyImage = (path, signal) => promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
+    verifyImage = (path, signal) => platform === 'win32' ? Promise.resolve() : promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
     Object.assign(this, { version, arch, platform, cacheRoot, inspect, compare, source, fetchImpl, verifyImage })
     this.state = { phase: 'idle', installMode: 'manual', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
   }
@@ -53,10 +53,10 @@ export class DesktopUpdateManager {
       controller.signal.throwIfAborted()
       this.release = release
       const version = release.manifest.webVersion
-      this.asset = release.assets.find(asset => asset.name === `Yaoyao-${version}-${this.arch}.dmg`)
+      this.asset = release.assets.find(asset => asset.name === (this.platform === 'win32' ? `Yaoyao-${version}-win-${this.arch}-setup.exe` : `Yaoyao-${version}-${this.arch}.dmg`))
       this.checksum = release.assets.find(asset => asset.name === 'SHA256SUMS.txt')
       const newer = this.compare(version, this.version) > 0
-      const supported = this.platform === 'darwin' && ['arm64', 'x64'].includes(this.arch)
+      const supported = (this.platform === 'darwin' && ['arm64', 'x64'].includes(this.arch) || this.platform === 'win32' && this.arch === 'x64')
         && this.asset?.size > 0 && this.asset.size <= 2 * 1024 ** 3 && this.checksum?.size <= 65536
       this.state = { ...this.state, phase: 'checked', latestVersion: version, notes: release.notes, releasePageUrl: release.releasePageUrl,
         available: Boolean(newer && supported), total: supported ? this.asset.size : 0,
@@ -111,7 +111,7 @@ export class DesktopUpdateManager {
           }
           if (received !== this.asset.size || hash.digest('hex') !== expected) throw new Error('安装包大小或 SHA-256 校验失败')
         } finally { await file.close() }
-        this.state = { ...this.state, phase: 'verifying', message: '正在验证 DMG 完整性…' }
+        this.state = { ...this.state, phase: 'verifying', message: '正在验证安装包完整性…' }
         await this.verifyImage(partial, signal); signal.throwIfAborted()
         await rename(partial, path)
       } else {
@@ -119,7 +119,7 @@ export class DesktopUpdateManager {
         await this.verifyImage(path, signal); signal.throwIfAborted()
       }
       this.file = { path, hash: expected, size: this.asset.size }
-      this.state = { ...this.state, phase: 'ready', received: this.asset.size, message: '校验通过。打开安装包后手动拖入“应用程序”完成安装。' }
+      this.state = { ...this.state, phase: 'ready', received: this.asset.size, message: this.platform === 'win32' ? '校验通过。打开 EXE 安装包完成安装。' : '校验通过。打开安装包后手动拖入“应用程序”完成安装。' }
     } catch (error) { this.failure(error, controller.signal) }
     finally { await rm(partial, { force: true }).catch(() => {}); this.controller = undefined }
     return this.snapshot()

+ 42 - 0
desktop/windows-input.mjs

@@ -0,0 +1,42 @@
+import { execFile } from 'node:child_process'
+import { join } from 'node:path'
+
+export function windowsFrame(display, dimensions, physicalBounds) {
+  return { display: display.id, ...dimensions, bounds: display.bounds, scaleFactor: display.scaleFactor, physicalBounds }
+}
+export function sameWindowsDisplay(frame, display, physicalBounds) {
+  return frame?.display === display.id && frame.scaleFactor === display.scaleFactor
+    && JSON.stringify(frame.bounds) === JSON.stringify(display.bounds)
+    && JSON.stringify(frame.physicalBounds) === JSON.stringify(physicalBounds)
+}
+
+export class WindowsInput {
+  constructor(root, packaged, execute = execFile) {
+    this.helper = join(root, packaged ? 'computer-helper.exe' : 'computer-helper-dev.exe')
+    this.execute = execute; this.controllers = new Set(); this.pending = new Set(); this.available = false
+  }
+  async call(value) {
+    const controller = new AbortController()
+    this.controllers.add(controller)
+    let pending
+    try {
+      pending = 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 电脑助手响应无效')) }
+        })
+        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) }
+  }
+  async probe() {
+    try { await this.call({ operation: 'probe' }); this.available = true }
+    catch { this.available = false }
+    return this.available
+  }
+  cancel() { for (const controller of this.controllers) controller.abort(); this.available = false; return Promise.allSettled([...this.pending]) }
+}

+ 51 - 0
desktop/windows-shell.mjs

@@ -0,0 +1,51 @@
+import { spawn, execFile } from 'node:child_process'
+import { join } 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 }`
+  return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]
+}
+
+/** 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 }) {
+  return new Promise(resolve => {
+    let child, timer, stopping, 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]))
+      output[key].push(kept); sizes[key] += kept.length
+    }
+    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()
+          })
+      })
+    }
+    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.stdout.on('data', bytes => capture('stdout', bytes))
+      child.stderr.on('data', bytes => capture('stderr', bytes))
+      child.once('error', error => { void finish(1, error) })
+      child.once('close', code => { void finish(code) })
+      signal?.addEventListener('abort', abort, { once: true })
+      timer = setTimeout(() => { timedOut = true; stop() }, timeout)
+    } catch (error) { void finish(1, error) }
+  })
+}

+ 67 - 0
desktop/windows-smoke.test.mjs

@@ -0,0 +1,67 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { mkdtemp, readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { _electron as electron, expect } from '@playwright/test'
+
+test('packaged Windows client logs in, encrypts pairing, restores its session and never starts a server', { skip: process.platform !== 'win32', timeout: 90000 }, async () => {
+  const home = await mkdtemp(join(tmpdir(), '夭夭-windows-')), hostId = randomUUID(), token = 'windows-fixture-token-not-for-production'
+  let exchanges = 0, app
+  const server = createServer((req, res) => {
+    const json = (body, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)) }
+    if (req.url === '/api/app/bootstrap') {
+      res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
+      return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+    }
+    if (req.url === '/api/app/login') {
+      req.resume(); res.setHeader('set-cookie', 'session=fixture; Path=/; HttpOnly; Max-Age=3600')
+      return json({ user: { id: 'fixture', username: 'admin', role: 'admin' }, csrfToken: 'fixture' })
+    }
+    if (req.url === '/api/app/admin/desktop-hosts') { req.resume(); return json({ host: { id: hostId }, token }) }
+    if (req.url === `/api/desktop-host/v1/${hostId}/exchange`) {
+      let raw = ''; req.on('data', bytes => { raw += bytes }); req.on('end', () => {
+        const body = JSON.parse(raw)
+        assert.equal(body.host.platform, 'win32'); assert.equal(req.headers.authorization, `Bearer ${token}`)
+        exchanges++; json({ commands: [], capabilities: { environmentMetadata: 1, desktopPlatforms: ['darwin', 'win32'] } })
+      }); return
+    }
+    res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>Windows 登录验证</title><h1>远程工作区</h1>')
+  })
+  await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const url = `http://127.0.0.1:${server.address().port}`
+  const launch = () => electron.launch({ executablePath: process.env.DESKTOP_TEST_EXECUTABLE || resolve('desktop-release/windows-x64/win-unpacked/Yaoyao.exe'),
+    args: [], env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home }, timeout: 30000 })
+  const evidence = resolve('test-results/windows-client')
+  try {
+    app = await launch(); let page = await app.firstWindow()
+    await expect(page.locator('#connection-form')).toBeVisible()
+    await expect(page.locator('#choice-section')).toBeHidden()
+    assert.deepEqual((await page.evaluate(() => window.yaoyaoDesktop.status())).supportedModes, ['client'])
+    assert.equal(await page.evaluate(async () => { try { await window.yaoyaoDesktop.selectServer('local'); return false } catch { return true } }), true)
+    await page.locator('#server').fill(url); await page.locator('#detect').click()
+    await expect(page.locator('#login-form')).toBeVisible()
+    await page.locator('#username').fill('admin'); await page.locator('#password').fill('fixture-password'); await page.locator('#remember').check(); await page.locator('#submit').click()
+    await page.waitForURL(url + '/**')
+    const state = await page.evaluate(() => window.yaoyaoDesktop.modeState())
+    assert.equal(state.mode, 'client'); assert.deepEqual(state.supportedModes, ['client'])
+    assert.equal((await page.evaluate(() => window.yaoyaoDesktop.switchMode('server'))).ok, false)
+    await expect.poll(() => exchanges).toBeGreaterThan(0)
+    const bytes = await readFile(join(home, 'desktop-host.enc'))
+    assert.equal(bytes.includes(token), false); assert.equal(bytes.includes('fixture-password'), false)
+    assert.equal(bytes.subarray(0, Buffer.byteLength('YAOYAO-WINDOWS-DPAPI-1\n')).toString(), 'YAOYAO-WINDOWS-DPAPI-1\n')
+    assert.equal((await readdir(home)).includes('service-instance.json'), false)
+    const menu = await app.evaluate(({ Menu }) => ['desktop-host-use-local', 'desktop-stop-and-quit', 'runner-import'].map(id => Boolean(Menu.getApplicationMenu().getMenuItemById(id))))
+    assert.deepEqual(menu, [false, false, false])
+    await mkdir(evidence, { recursive: true }); await page.screenshot({ path: join(evidence, 'remote-login.png') })
+    await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
+    assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
+    await app.close(); app = undefined
+    const before = exchanges; app = await launch(); page = await app.firstWindow()
+    await page.waitForURL(url + '/**'); await expect.poll(() => exchanges).toBeGreaterThan(before)
+    await writeFile(join(evidence, 'smoke.json'), JSON.stringify({ platform: 'win32', login: true, encryptedPairing: true, sessionRestored: true,
+      clientOnly: true, nativeDesktopControl: 'requires interactive Windows 10/11 acceptance', upgradeReplacement: 'requires two-version installed-app acceptance' }, null, 2))
+  } finally { await app?.close().catch(() => {}); await new Promise(done => server.close(done)); await rm(home, { recursive: true, force: true }) }
+})

+ 103 - 0
desktop/windows.test.mjs

@@ -0,0 +1,103 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, writeFile, readFile, rm, symlink } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { randomUUID, createHash } from 'node:crypto'
+import { EventEmitter } from 'node:events'
+import { DesktopCredentials } from './credentials.mjs'
+import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
+import { powershellArguments } from './windows-shell.mjs'
+import { execHostShell } from './host-files.mjs'
+import { windowsFrame, sameWindowsDisplay } from './windows-input.mjs'
+import { FileTransferFiles } from './file-transfer.mjs'
+import { windowsSigningOptions, verifyWindowsArtifacts } from '../scripts/windows-desktop-release.mjs'
+
+test('Windows credentials use the asynchronous OS provider and reject foreign/corrupt ciphertext', async () => {
+  const raw = 'fixture-token', cipher = Buffer.from('opaque-encrypted-value')
+  const store = { isAsyncEncryptionAvailable: async () => true, encryptStringAsync: async value => { assert.equal(value, raw); return cipher },
+    decryptStringAsync: async value => { assert.deepEqual(value, cipher); return { result: raw } } }
+  const credentials = new DesktopCredentials({ home: 'C:\\fixture', platform: 'win32', safeStorage: store })
+  const encrypted = await credentials.encrypt(raw)
+  assert.equal(encrypted.includes(raw), false)
+  assert.equal(await credentials.decrypt(encrypted), raw)
+  await assert.rejects(credentials.decrypt(Buffer.from('YAOYAO-RUNNER-KEYCHAIN-1\ninvalid')), /不属于 Windows/)
+  store.isAsyncEncryptionAvailable = async () => false
+  await assert.rejects(credentials.encrypt(raw), /加密暂不可用/)
+  store.decryptStringAsync = async () => { throw new Error('DPAPI failure') }
+  await assert.rejects(credentials.decrypt(encrypted), /重新登录/)
+})
+test('PowerShell transports quoted Unicode as UTF-16 without shell interpolation', () => {
+  const command = 'Write-Output "你好 $env:USERNAME"; exit 7', args = powershellArguments(command)
+  assert.equal(args[3], '-EncodedCommand')
+  const script = Buffer.from(args[4], 'base64').toString('utf16le')
+  assert.ok(script.includes(command)); assert.match(script, /UTF8Encoding/); assert.match(script, /LASTEXITCODE/)
+})
+test('Windows input invalidates screenshots after DPI, geometry or primary display changes', () => {
+  const d = { id: 1, scaleFactor: 1.5, bounds: { x: 0, y: 0, width: 1280, height: 720 } }
+  const physical = { x: 0, y: 0, width: 1920, height: 1080 }
+  const frame = windowsFrame(d, { width: 1600, height: 900 }, physical)
+  assert.equal(sameWindowsDisplay(frame, d, physical), true)
+  assert.equal(sameWindowsDisplay(frame, { ...d, scaleFactor: 2 }, physical), false)
+  assert.equal(sameWindowsDisplay(frame, { ...d, id: 2 }, physical), false)
+  assert.equal(sameWindowsDisplay(frame, d, { ...physical, width: 2560 }), false)
+})
+test('NSIS installation is explicit and runs exactly once after cleanup', async t => {
+  const driver = new EventEmitter(), events = []
+  driver.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '1.1.0' } })
+  driver.downloadUpdate = async () => { driver.emit('update-downloaded'); return ['setup.exe'] }
+  driver.quitAndInstall = () => events.push('install')
+  const manager = new DesktopAutoUpdateManager({ driver, platform: 'win32', version: '1.0.0', prepareInstall: async () => events.push('cleanup') })
+  t.after(() => { manager.stopChecking(); clearTimeout(manager.restartTimer) })
+  assert.equal(driver.autoInstallOnAppQuit, false)
+  await manager.check(); await manager.download()
+  assert.equal(manager.snapshot().phase, 'ready'); assert.equal(manager.stagingTimer, undefined)
+  assert.deepEqual(events, [])
+  await manager.install(); await manager.install()
+  assert.deepEqual(events, ['cleanup', 'install'])
+})
+test('NSIS errors before install can retry, while dispatched installs stay fenced', async t => {
+  const driver = new EventEmitter(), manager = new DesktopAutoUpdateManager({ driver, platform: 'win32', version: '1.0.0' })
+  t.after(() => manager.stopChecking())
+  manager.nativeStarted = true; manager.failure(new Error('checksum'))
+  assert.equal(manager.snapshot().retryable, true); assert.equal(manager.nativeStarted, false)
+  manager.nativeStarted = true; manager.installAttempted = true; manager.failure(new Error('installer failed'))
+  assert.equal(manager.snapshot().retryable, false)
+})
+test('signed Windows builds fail closed without a certificate and publisher', () => {
+  assert.equal(windowsSigningOptions({}).forceCodeSigning, false)
+  assert.throws(() => windowsSigningOptions({ YAOYAO_WINDOWS_SIGNED: '1' }), /CSC_LINK/)
+  const result = windowsSigningOptions({ YAOYAO_WINDOWS_SIGNED: '1', CSC_LINK: 'fixture.pfx', YAOYAO_WINDOWS_PUBLISHER: 'Fixture publisher' })
+  assert.equal(result.forceCodeSigning, true); assert.equal(result.win.verifyUpdateCodeSignature, true)
+})
+test('Windows artifact verification detects corruption before delivery', async t => {
+  const dir = await mkdtemp(join(tmpdir(), 'yaoyao-win-release-')); t.after(() => rm(dir, { recursive: true, force: true }))
+  const name = 'Yaoyao-1.0.0-win-x64-setup.exe', bytes = Buffer.from('MZ-test-installer')
+  await writeFile(join(dir, name), bytes); await writeFile(join(dir, name + '.blockmap'), 'map')
+  await writeFile(join(dir, 'latest.yml'), JSON.stringify({ version: '1.0.0', files: [{ url: name, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] }))
+  await verifyWindowsArtifacts(dir, '1.0.0')
+  assert.match(await readFile(join(dir, 'SHA256SUMS-win-x64.txt'), 'utf8'), /setup\.exe/)
+  await writeFile(join(dir, name), 'corrupt')
+  await assert.rejects(verifyWindowsArtifacts(dir, '1.0.0'), /不匹配/)
+})
+test('native Windows PowerShell preserves Chinese, exit status and cancellation', { skip: process.platform !== 'win32' }, async t => {
+  const root = await mkdtemp(join(tmpdir(), '夭夭-shell-')); t.after(() => rm(root, { recursive: true, force: true }))
+  const result = await execHostShell(root, { command: "[Console]::WriteLine('你好,Windows'); exit 7" })
+  assert.equal(result.stdout.trim(), '你好,Windows'); assert.equal(result.exitCode, 7)
+  const controller = new AbortController()
+  const pending = execHostShell(root, { command: 'Start-Sleep -Seconds 60' }, { signal: controller.signal })
+  setTimeout(() => controller.abort(), 500)
+  assert.equal((await pending).cancelled, true)
+})
+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-'))
+  const files = new FileTransferFiles(root)
+  t.after(async () => { await files.close(); await rm(root, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }) })
+  const bytes = Buffer.from('中文附件'), transferId = randomUUID(), path = '中文 文件.txt'
+  await files.call({ op: 'transfer-write-open', transferId, path, maxBytes: 1048576, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), overwrite: false })
+  await files.call({ op: 'transfer-append', transferId, offset: 0, data: bytes.toString('base64') })
+  await files.call({ op: 'transfer-finish', transferId })
+  assert.equal(await readFile(join(root, path), 'utf8'), '中文附件')
+  await symlink(outside, join(root, 'escape'), process.platform === 'win32' ? 'junction' : 'dir')
+  await assert.rejects(files.path('escape/secret', true), /超出允许范围/)
+})

+ 14 - 0
desktop/windows/CMakeLists.txt

@@ -0,0 +1,14 @@
+cmake_minimum_required(VERSION 3.21)
+project(YaoyaoComputerHelper LANGUAGES CXX)
+if(NOT WIN32 OR NOT MSVC)
+  message(FATAL_ERROR "Build this helper on Windows with MSVC and the Windows 10 SDK")
+endif()
+foreach(target computer-helper computer-helper-dev)
+  add_executable(${target} computer-helper.cpp)
+  target_compile_features(${target} PRIVATE cxx_std_17)
+  target_compile_definitions(${target} PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
+  target_compile_options(${target} PRIVATE /W4 /utf-8 /EHsc)
+  set_property(TARGET ${target} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
+  target_link_libraries(${target} PRIVATE windowsapp user32 advapi32 wtsapi32)
+endforeach()
+target_compile_definitions(computer-helper-dev PRIVATE DEVELOPMENT_HELPER)

+ 178 - 0
desktop/windows/computer-helper.cpp

@@ -0,0 +1,178 @@
+#include <windows.h>
+#include <tlhelp32.h>
+#include <wtsapi32.h>
+#include <winrt/base.h>
+#include <winrt/Windows.Foundation.Collections.h>
+#include <winrt/Windows.Data.Json.h>
+#include <algorithm>
+#include <cmath>
+#include <filesystem>
+#include <iostream>
+#include <map>
+#include <stdexcept>
+#include <string>
+#include <vector>
+
+using winrt::Windows::Data::Json::JsonObject;
+using winrt::Windows::Data::Json::JsonValueType;
+namespace fs = std::filesystem;
+static void require(bool ok, const char* message) { if (!ok) throw std::runtime_error(message); }
+struct Handle {
+  HANDLE value = nullptr;
+  explicit Handle(HANDLE h) : value(h) {}
+  ~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
+  Handle(const Handle&) = delete;
+};
+static std::wstring processPath(HANDLE process) {
+  std::wstring value(32768, L'\0'); DWORD size = static_cast<DWORD>(value.size());
+  require(QueryFullProcessImageNameW(process, 0, value.data(), &size), "无法核对 App 身份");
+  value.resize(size); return fs::weakly_canonical(value).wstring();
+}
+static void checkParent() {
+#ifndef DEVELOPMENT_HELPER
+  Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0));
+  require(snapshot.value != INVALID_HANDLE_VALUE, "无法核对 App 身份");
+  PROCESSENTRY32W entry{}; entry.dwSize = sizeof(entry); DWORD parent = 0;
+  if (Process32FirstW(snapshot.value, &entry)) do {
+    if (entry.th32ProcessID == GetCurrentProcessId()) { parent = entry.th32ParentProcessID; break; }
+  } while (Process32NextW(snapshot.value, &entry));
+  Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, parent));
+  require(process.value != nullptr, "电脑助手只允许所属 App 调用");
+  auto own = fs::path(processPath(GetCurrentProcess()));
+  auto expected = own.parent_path().parent_path().parent_path() / L"Yaoyao.exe";
+  require(_wcsicmp(processPath(process.value).c_str(), expected.c_str()) == 0, "电脑助手只允许所属 App 调用");
+#endif
+}
+static void checkDesktop() {
+  DWORD session = 0;
+  require(ProcessIdToSessionId(GetCurrentProcessId(), &session), "无法读取 Windows 会话");
+  LPWSTR info = nullptr; DWORD size = 0;
+  require(WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, WTSConnectState, &info, &size), "无法读取 Windows 会话");
+  bool active = size >= sizeof(WTS_CONNECTSTATE_CLASS) && *reinterpret_cast<WTS_CONNECTSTATE_CLASS*>(info) == WTSActive;
+  WTSFreeMemory(info); require(active, "Windows 会话已断开,请在电脑上重新登录");
+  HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
+  require(desktop != nullptr, "Windows 已锁屏或处于安全桌面,请在电脑上恢复后重试");
+  wchar_t name[256]{}; DWORD needed = 0;
+  bool regular = GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &needed) && _wcsicmp(name, L"Default") == 0;
+  CloseDesktop(desktop);
+  require(regular, "无法控制 Windows 锁屏或 UAC 安全桌面");
+}
+static DWORD integrity(HANDLE process) {
+  HANDLE raw = nullptr; require(OpenProcessToken(process, TOKEN_QUERY, &raw), "无法核对目标窗口权限"); Handle token(raw);
+  DWORD size = 0; GetTokenInformation(token.value, TokenIntegrityLevel, nullptr, 0, &size);
+  std::vector<BYTE> bytes(size);
+  require(size && GetTokenInformation(token.value, TokenIntegrityLevel, bytes.data(), size, &size), "无法核对目标窗口权限");
+  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);
+  if (!pid) return;
+  Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
+  require(process.value && integrity(process.value) <= integrity(GetCurrentProcess()), "无法操作管理员或受保护窗口,请在电脑上手动操作");
+}
+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;
+}
+static int integer(const JsonObject& object, const wchar_t* key, int fallback, int low, int high) {
+  double value = object.HasKey(key) ? number(object, key) : fallback;
+  require(value >= low && value <= high && std::floor(value) == value, "电脑输入参数超出范围"); return static_cast<int>(value);
+}
+static void send(std::vector<INPUT> events) {
+  checkDesktop(); checkForeground();
+  require(SendInput(static_cast<UINT>(events.size()), events.data(), sizeof(INPUT)) == events.size(), "Windows 未接受输入,请检查目标窗口权限");
+}
+static INPUT key(WORD code, bool up, bool unicode = false) {
+  INPUT input{}; input.type = INPUT_KEYBOARD;
+  input.ki.wVk = unicode ? 0 : code; input.ki.wScan = unicode ? code : 0;
+  input.ki.dwFlags = (unicode ? KEYEVENTF_UNICODE : 0) | (up ? KEYEVENTF_KEYUP : 0);
+  if (!unicode && (code == VK_DELETE || code == VK_HOME || code == VK_END || code == VK_PRIOR || code == VK_NEXT || (code >= VK_LEFT && code <= VK_DOWN))) input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
+  return input;
+}
+static INPUT mouse(DWORD flags, LONG x = 0, LONG y = 0, DWORD data = 0) {
+  INPUT input{}; input.type = INPUT_MOUSE; input.mi.dx = x; input.mi.dy = y; input.mi.dwFlags = flags; input.mi.mouseData = data; return input;
+}
+static POINT point(const JsonObject& action, const JsonObject& frame, const wchar_t* xKey, const wchar_t* yKey) {
+  double width = number(frame, L"width"), height = number(frame, L"height");
+  double x = number(action, xKey), y = number(action, yKey);
+  require(width > 0 && height > 0 && x >= 0 && y >= 0 && x < width && y < height, "输入超出当前画面");
+  auto bounds = frame.GetNamedObject(L"physicalBounds");
+  double bx = number(bounds, L"x"), by = number(bounds, L"y"), bw = number(bounds, L"width"), bh = number(bounds, L"height");
+  require(bx == 0 && by == 0 && bw == GetSystemMetrics(SM_CXSCREEN) && bh == GetSystemMetrics(SM_CYSCREEN), "显示器已变化,请刷新画面");
+  return {static_cast<LONG>(bx + x * bw / width), static_cast<LONG>(by + y * bh / height)};
+}
+static INPUT move(POINT p) {
+  LONG width = GetSystemMetrics(SM_CXVIRTUALSCREEN), height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+  require(width > 1 && height > 1, "显示器不可用");
+  LONG x = static_cast<LONG>(std::llround((p.x - GetSystemMetrics(SM_XVIRTUALSCREEN)) * 65535.0 / (width - 1)));
+  LONG y = static_cast<LONG>(std::llround((p.y - GetSystemMetrics(SM_YVIRTUALSCREEN)) * 65535.0 / (height - 1)));
+  return mouse(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, x, y);
+}
+static void input(const JsonObject& request) {
+  auto action = request.GetNamedObject(L"action"), frame = request.GetNamedObject(L"frame");
+  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");
+    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;
+    int count = integer(action, L"count", 1, 1, 2);
+    send({move(p)}); checkForeground();
+    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");
+    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; }
+    send({mouse(MOUSEEVENTF_LEFTUP)});
+  } else if (kind == L"text") {
+    auto text = action.GetNamedString(L"text"); require(text.size() <= 16000, "输入文本过长");
+    std::vector<INPUT> events;
+    for (wchar_t character : text) { events.push_back(key(character, false, true)); events.push_back(key(character, true, true)); }
+    if (!events.empty()) send(events);
+  } else if (kind == L"key") {
+    const std::map<std::wstring, WORD> keys = {{L"Return", VK_RETURN}, {L"Enter", VK_RETURN}, {L"Tab", VK_TAB}, {L"Space", VK_SPACE}, {L"space", VK_SPACE},
+      {L"BackSpace", VK_BACK}, {L"Backspace", VK_BACK}, {L"Escape", VK_ESCAPE}, {L"Delete", VK_DELETE}, {L"Home", VK_HOME}, {L"End", VK_END},
+      {L"PageUp", VK_PRIOR}, {L"PageDown", VK_NEXT}, {L"Left", VK_LEFT}, {L"Right", VK_RIGHT}, {L"Up", VK_UP}, {L"Down", VK_DOWN}};
+    std::wstring name(action.GetNamedString(L"key")); WORD code = 0;
+    auto found = keys.find(name);
+    if (found != keys.end()) code = found->second;
+    else if (name.size() == 1 && ((name[0] >= L'a' && name[0] <= L'z') || (name[0] >= L'A' && name[0] <= L'Z') || (name[0] >= L'0' && name[0] <= L'9'))) code = static_cast<WORD>(towupper(name[0]));
+    require(code != 0, "暂不支持这个按键");
+    std::vector<WORD> modifiers; std::vector<INPUT> events;
+    if (action.HasKey(L"modifiers")) for (const auto& value : action.GetNamedArray(L"modifiers")) {
+      auto modifier = value.GetString(); WORD m = modifier == L"ctrl" ? VK_CONTROL : modifier == L"alt" ? VK_MENU : modifier == L"shift" ? VK_SHIFT : modifier == L"super" ? VK_LWIN : 0;
+      require(m != 0, "按键修饰符无效"); if (std::find(modifiers.begin(), modifiers.end(), m) == modifiers.end()) modifiers.push_back(m);
+    }
+    for (WORD m : modifiers) events.push_back(key(m, false));
+    events.push_back(key(code, false)); events.push_back(key(code, true));
+    for (auto m = modifiers.rbegin(); m != modifiers.rend(); ++m) events.push_back(key(*m, true));
+    send(events);
+  } else if (kind == L"scroll") {
+    auto direction = action.GetNamedString(L"direction"); int amount = integer(action, L"amount", 3, 1, 50) * WHEEL_DELTA;
+    require(direction == L"up" || direction == L"down" || direction == L"left" || direction == L"right", "滚动方向无效");
+    if (direction == L"down" || direction == L"left") amount = -amount;
+    send({mouse(direction == L"up" || direction == L"down" ? MOUSEEVENTF_WHEEL : MOUSEEVENTF_HWHEEL, 0, 0, static_cast<DWORD>(amount))});
+  } else throw std::runtime_error("电脑操作无效");
+}
+int main(int argc, char** argv) {
+  try {
+    winrt::init_apartment();
+    SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
+    if (argc == 2 && std::string(argv[1]) == "--self-test") {
+      auto value = JsonObject::Parse(L"{\"count\":2,\"text\":\"中文\"}");
+      require(integer(value, L"count", 1, 1, 2) == 2 && value.GetNamedString(L"text") == L"中文", "JSON 自检失败");
+      std::cout << "{\"ok\":true}"; return 0;
+    }
+    checkParent();
+    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));
+    checkDesktop();
+    if (request.GetNamedString(L"operation", L"input") != 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'; }
+  return 1;
+}

+ 48 - 0
docs/windows-client.md

@@ -0,0 +1,48 @@
+# Windows 客户端
+
+Windows 首版支持 Windows 10/11 x64,安装后连接已有夭夭服务器。聊天、团队、文件库和服务器上的电脑环境沿用服务器功能;当前 Windows 客户端不托管本机 Web、Hermes、Runner 或虚拟机。用户无需安装 Node、Python 或开发工具。
+
+## 使用
+
+1. 安装 `Yaoyao-<版本>-win-x64-setup.exe`,填写服务器地址并登录。测试包未签名,Windows 可能显示未知发布者。安装在当前用户目录,不申请管理员权限。
+2. 管理员登录后会注册这台电脑;普通账号可登录聊天。屏幕控制与文件、命令分别在本机确认授权,可从“电脑”菜单重新授权、导入配置或断开,也可从应用菜单撤销本机授权。
+3. Windows 电脑控制需要配套升级服务器。旧服务器仍可用于聊天;电脑菜单状态会提示升级要求。新版服务器继续支持现有 Mac 客户端。
+4. 截图与键鼠操作面向当前交互会话的主屏。锁屏、会话断开、UAC 安全桌面和高于客户端权限的窗口不能由普通权限客户端控制;在电脑上恢复会话或手动处理受保护操作。
+5. 文件工具限定在用户主目录,拒绝越界、目录联接逃逸、设备路径、UNC 和 NTFS 备用数据流。命令使用 Windows PowerShell,按当前 Windows 用户权限执行,**不受文件工具根目录约束**;命令最长 120 秒,两路输出分别截断至 512 KiB。
+6. 关闭窗口保留托盘连接;“退出夭夭”断开设备、结束应用自有操作。登录启动默认关闭,开启后可选择仅驻留托盘。更新和卸载默认保留用户数据。
+
+配置与设备令牌位于 `%USERPROFILE%\.yaoyao`(可由既有数据目录环境变量指定),Chromium 会话、浏览器和设备授权位于 Electron `userData`。令牌由当前 Windows 账号的 DPAPI 加密,密码不落盘。复制 Mac 加密文件不能迁移登录,应在 Windows 重新登录授权。
+
+## 构建与安装包
+
+日常可以在 Mac 修改共享代码;原生助手和 EXE 安装包在 Windows x64 上构建。需要 Node 24、CMake、Visual Studio 2022 C++ 工具链和 Windows SDK;GitHub Actions `windows-2022` 提供该构建环境。
+
+```powershell
+npm ci
+npm run desktop:pack:win
+npm run desktop:test:win
+```
+
+GitHub Actions 工作流 `Windows client` 支持手动触发、相关 PR 和 `codex/windows-client*` 分支。CI 只上传 artifacts,不发布正式 Release。下载 `yaoyao-windows-x64-unsigned`,其中包含 EXE、blockmap、`latest.yml`、`SHA256SUMS-win-x64.txt` 和构建身份。生成目录为 `desktop-release/windows-x64`。
+
+默认构建为未签名测试包。正式签名构建设置 `YAOYAO_WINDOWS_SIGNED=1`、`CSC_LINK`、适用的 `CSC_KEY_PASSWORD` 和证书对应的 `YAOYAO_WINDOWS_PUBLISHER`;凭据仅通过 CI secret 或本机环境注入。正式构建强制签名和更新签名校验。
+
+Windows 更新使用 NSIS,只有用户点击“重启更新”才安装,先清理设备连接和自有操作。Windows 使用 `latest.yml`,Mac 继续使用 `latest-mac.yml`。更新附件必须来自同一次构建,并在清单引用的安装包和差分文件齐备后发布;测试包不写入正式更新源。
+
+## 验证记录与人工验收
+
+自动测试覆盖平台模式、路径边界、凭据提供器、更新状态、设备权限与来源路由。Windows CI 另外编译并自检原生助手,测试真实 PowerShell,以及打包应用的登录、DPAPI 配置保存、会话恢复、菜单与托盘行为。测试证据位于 `test-results/windows-client`。
+
+以下项目需要真实 Windows 10 和 Windows 11 交互环境,不以 CI 编译或模拟响应代替:
+
+| 项目 | 验收方法 |
+| --- | --- |
+| 安装与卸载 | 干净用户、中文用户名、无开发运行时;核对快捷方式、单实例、数据保留 |
+| 登录与网络 | 管理员及普通账号、会话过期、更换服务器、断网恢复、旧服务器升级提示 |
+| 主屏控制 | 100%、150%、200% 缩放;单击、双击、拖拽、滚动、Ctrl/Alt/Win 组合键及中文/emoji;切主屏后旧截图被拒绝 |
+| 授权与会话 | 分别拒绝/授予两档权限、撤销在途操作、人工接管、锁屏、RDP 断开、UAC 和管理员窗口 |
+| 浏览器与文件 | 独立浏览器与文件双向传输,中文路径、跨盘越界、junction 逃逸、空文件、覆盖保护、校验失败 |
+| 生命周期 | 关闭驻留、退出清理、登录启动、仅托盘启动;不启动本机服务 |
+| 实际升级 | 隔离更新源提供两个递增版本;已安装旧版下载并重启替换新版;版本、设置、登录和设备身份保留;断网、损坏包和安装失败可恢复 |
+
+首版的真实桌面控制与两个已安装版本之间的升级,必须在记录 Windows 版本、构建提交及测试结果后才可标记通过。未执行的项目标记为“未验证”。

+ 14 - 0
electron-builder.yml

@@ -31,10 +31,24 @@ extraResources:
       - electron-updater.cjs
       - keychain-helper
       - computer-helper
+      - computer-helper.exe
       - ui/**/*
       - web-service/**/*
 asar: true
 npmRebuild: false
+win:
+  executableName: Yaoyao
+  icon: .desktop-build/branding/icon.ico
+  requestedExecutionLevel: asInvoker
+  artifactName: Yaoyao-${version}-win-${arch}-setup.${ext}
+  target:
+    - target: nsis
+      arch: [x64]
+nsis:
+  oneClick: true
+  perMachine: false
+  allowElevation: false
+  deleteAppDataOnUninstall: false
 mac:
   # Keep the independently signed Node runtime byte-identical to its sealed manifest.
   signIgnore:

+ 4 - 1
package.json

@@ -35,7 +35,10 @@
     "desktop:test": "node --test desktop/*.test.mjs",
     "runner": "node dist-server/runner/index.js",
     "runner:dev": "tsx src/runner/index.ts",
-    "runner:build": "node scripts/build-runner.mjs"
+    "runner:build": "node scripts/build-runner.mjs",
+    "desktop:build:win": "npm run build && node scripts/build-windows-desktop.mjs",
+    "desktop:pack:win": "npm run desktop:build:win && node scripts/windows-desktop-release.mjs",
+    "desktop:test:win": "node scripts/test-windows-desktop.mjs"
   },
   "dependencies": {
     "@koa/bodyparser": "^5.0.0",

+ 1 - 1
scripts/build-desktop.mjs

@@ -34,7 +34,7 @@ await mkdir(shell)
 await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
 for (const file of await readdir(resolve(root, 'desktop'))) {
   if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
-  if(file==='file-transfer.mjs'){await build({entryPoints:[resolve(root,'desktop',file)],outfile:resolve(shell,file),bundle:true,platform:'node',format:'esm',target:'node24'});continue}
+  if(['file-transfer.mjs','host-files.mjs'].includes(file)){await build({entryPoints:[resolve(root,'desktop',file)],outfile:resolve(shell,file),bundle:true,platform:'node',format:'esm',target:'node24'});continue}
   if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }

+ 43 - 0
scripts/build-windows-desktop.mjs

@@ -0,0 +1,43 @@
+import { build } from 'esbuild'
+import { execFileSync } from 'node:child_process'
+import { cp, mkdir, rm, readdir, readFile, writeFile } from 'node:fs/promises'
+import { resolve, join } from 'node:path'
+import { createRequire } from 'node:module'
+import { bundleDesktopUpdater } from './bundle-desktop-updater.mjs'
+
+if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('Windows x64 原生助手请通过 Windows CI 或 Windows x64 电脑构建')
+const root = resolve(import.meta.dirname, '..'), out = join(root, '.desktop-build'), shell = join(out, 'shell')
+await rm(out, { recursive: true, force: true })
+await mkdir(shell, { recursive: true })
+await mkdir(join(out, 'branding'))
+const native = join(out, 'native')
+execFileSync('cmake', ['-S', join(root, 'desktop/windows'), '-B', native, '-A', 'x64'], { stdio: 'inherit' })
+execFileSync('cmake', ['--build', native, '--config', 'Release'], { stdio: 'inherit' })
+for (const name of ['computer-helper.exe', 'computer-helper-dev.exe']) {
+  await cp(join(native, 'Release', name), join(out, name))
+  execFileSync(join(out, name), ['--self-test'], { stdio: 'inherit' })
+}
+const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE
+execFileSync(createRequire(import.meta.url)('electron'), [join(root, 'scripts/build-windows-icon.cjs'),
+  join(root, 'public/icons/icon-512.png'), join(out, 'branding/icon.ico')], { stdio: 'inherit', env })
+await bundleDesktopUpdater(join(out, 'electron-updater.cjs'), 'win32')
+await build({ entryPoints: [join(root, 'src/server/githubReleases.ts')], outfile: join(out, 'github-release.mjs'),
+  bundle: true, platform: 'node', format: 'esm', target: 'node24' })
+await cp(join(root, 'bin/lib/data-home.mjs'), join(shell, 'data-home.mjs'))
+for (const file of await readdir(join(root, 'desktop'))) {
+  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
+  if (['file-transfer.mjs', 'host-files.mjs'].includes(file)) {
+    await build({ entryPoints: [join(root, 'desktop', file)], outfile: join(shell, file), bundle: true, platform: 'node', format: 'esm', target: 'node24' })
+  } else {
+    if (/\.(js|mjs|cjs)$/.test(file)) execFileSync(process.execPath, ['--check', join(root, 'desktop', file)])
+    await cp(join(root, 'desktop', file), join(shell, file))
+  }
+}
+await cp(join(root, 'public/icons/icon-512.png'), join(shell, 'icon.png'))
+await cp(join(root, 'public/icons/icon-512.png'), join(root, 'desktop/icon.png'))
+for (const file of ['release.json', 'build-info.json']) await cp(join(root, file), join(out, file))
+for (const file of ['LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'licenses']) await cp(join(root, file), join(shell, file), { recursive: true })
+const { webVersion: version } = JSON.parse(await readFile(join(root, 'release.json'), 'utf8'))
+await writeFile(join(shell, 'package.json'), JSON.stringify({ name: 'yaoyao-desktop', version, productName: '夭夭',
+  description: '夭夭 AI:Windows 客户端', author: 'YaoYao contributors', license: 'Apache-2.0', main: 'main.mjs', type: 'module' }, null, 2))
+console.log('Windows 客户端与原生助手已构建,不包含本机服务器运行时')

+ 18 - 0
scripts/build-windows-icon.cjs

@@ -0,0 +1,18 @@
+const { app, nativeImage } = require('electron')
+const { readFileSync, writeFileSync } = require('node:fs')
+app.whenReady().then(() => {
+  const source = nativeImage.createFromBuffer(readFileSync(process.argv[2]))
+  if (source.isEmpty()) throw new Error('应用图标无效')
+  const sizes = [16, 32, 48, 256], images = sizes.map(size => source.resize({ width: size, height: size }).toPNG())
+  const header = Buffer.alloc(6 + 16 * sizes.length)
+  header.writeUInt16LE(1, 2); header.writeUInt16LE(sizes.length, 4)
+  let offset = header.length
+  images.forEach((bytes, index) => {
+    const at = 6 + index * 16
+    header[at] = header[at + 1] = sizes[index] % 256
+    header.writeUInt16LE(1, at + 4); header.writeUInt16LE(32, at + 6)
+    header.writeUInt32LE(bytes.length, at + 8); header.writeUInt32LE(offset, at + 12)
+    offset += bytes.length
+  })
+  writeFileSync(process.argv[3], Buffer.concat([header, ...images])); app.quit()
+}).catch(error => { console.error(error.message); app.exit(1) })

+ 2 - 2
scripts/bundle-desktop-updater.mjs

@@ -6,11 +6,11 @@ import { pathToFileURL } from 'node:url'
 import { patchMacUpdater } from './patch-mac-updater.mjs'
 
 const require = createRequire(import.meta.url)
-export async function bundleDesktopUpdater(outfile) {
+export async function bundleDesktopUpdater(outfile, platform = process.platform) {
   await build({ entryPoints: [require.resolve('electron-updater')], outfile,
     bundle: true, platform: 'node', target: 'node24', format: 'cjs', external: ['electron'], legalComments: 'eof' })
   // Pin electron-updater and fail the build if its native staging code changes.
-  await writeFile(outfile, patchMacUpdater(await readFile(outfile, 'utf8')))
+  if (platform === 'darwin') await writeFile(outfile, patchMacUpdater(await readFile(outfile, 'utf8')))
 }
 if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href)
   await bundleDesktopUpdater(resolve(import.meta.dirname, '../.desktop-build/electron-updater.cjs'))

+ 4 - 0
scripts/test-windows-desktop.mjs

@@ -0,0 +1,4 @@
+import { execFileSync } from 'node:child_process'
+const files = ['platform', 'windows', 'windows-smoke', 'onboarding', 'onboarding-renderer', 'host-files', 'host-manager', 'host-environment',
+  'preferences', 'remote-login', 'auto-update-manager', 'update-manager']
+execFileSync(process.execPath, ['--test', ...files.map(name => `desktop/${name}.test.mjs`)], { stdio: 'inherit' })

+ 56 - 0
scripts/windows-desktop-release.mjs

@@ -0,0 +1,56 @@
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { readFile, writeFile, stat } from 'node:fs/promises'
+import { basename, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { load } from 'js-yaml'
+
+async function hash(path, algorithm, encoding) {
+  const result = createHash(algorithm)
+  for await (const bytes of createReadStream(path)) result.update(bytes)
+  return result.digest(encoding)
+}
+export function windowsSigningOptions(env = process.env) {
+  if (env.YAOYAO_WINDOWS_SIGNED !== '1') return { forceCodeSigning: false, win: { signAndEditExecutable: true } }
+  if (!env.CSC_LINK || !env.YAOYAO_WINDOWS_PUBLISHER?.trim()) throw new Error('正式 Windows 包需要 CSC_LINK、CSC_KEY_PASSWORD(如适用)和 YAOYAO_WINDOWS_PUBLISHER')
+  return { forceCodeSigning: true, win: { publisherName: env.YAOYAO_WINDOWS_PUBLISHER.trim(), verifyUpdateCodeSignature: true } }
+}
+export async function verifyWindowsArtifacts(directory, version) {
+  const name = `Yaoyao-${version}-win-x64-setup.exe`
+  const metadata = load(await readFile(join(directory, 'latest.yml'), 'utf8'))
+  if (metadata?.version !== version || metadata.files?.length !== 1 || metadata.files[0].url !== name) throw new Error('Windows 更新清单与安装包不匹配')
+  for (const file of metadata.files) {
+    if (basename(file.url) !== file.url) throw new Error('安装包路径无效')
+    const path = join(directory, file.url), info = await stat(path)
+    if (!info.isFile() || info.size === 0 || info.size !== file.size || file.sha512 !== await hash(path, 'sha512', 'base64')) throw new Error('Windows 安装包大小或 SHA-512 不匹配')
+    const exe = await readFile(path)
+    if (exe.toString('ascii', 0, 2) !== 'MZ') throw new Error('Windows 安装包不是 PE 文件')
+  }
+  if (!(await stat(join(directory, `${name}.blockmap`))).size) throw new Error('Windows 差分更新文件为空')
+  const names = [name, `${name}.blockmap`, 'latest.yml']
+  await writeFile(join(directory, 'SHA256SUMS-win-x64.txt'), (await Promise.all(names.map(async name => `${await hash(join(directory, name), 'sha256', 'hex')}  ${name}`))).join('\n') + '\n')
+  return names
+}
+export async function packageWindowsDesktop() {
+  if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('请在 Windows x64 CI 或 Windows 电脑打包')
+  const root = resolve(import.meta.dirname, '..'), directory = join(root, 'desktop-release', 'windows-x64')
+  const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
+  const { build, Platform, Arch } = await import('electron-builder')
+  const signing = windowsSigningOptions()
+  if (process.env.YAOYAO_WINDOWS_SIGNED !== '1') process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
+  await build({ projectDir: root, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
+    config: { ...signing, directories: { output: directory }, electronUpdaterCompatibility: '>=2.16' } })
+  await verifyWindowsArtifacts(directory, version)
+  const feed = load(await readFile(join(directory, 'win-unpacked/resources/app-update.yml'), 'utf8'))
+  if (feed.provider !== 'github' || feed.owner !== 'Lsamien' || feed.repo !== 'yaoyao') throw new Error('Windows 更新源配置无效')
+  const info = JSON.parse(await readFile(join(root, 'build-info.json'), 'utf8'))
+  await writeFile(join(directory, 'BUILD-INFO.json'), JSON.stringify({ version, platform: 'win32', arch: 'x64',
+    signing: process.env.YAOYAO_WINDOWS_SIGNED === '1' ? 'signed' : 'unsigned-test', ...info }, null, 2) + '\n')
+  await writeFile(join(directory, 'README.txt'), '夭夭 Windows x64 客户端\n' +
+    (process.env.YAOYAO_WINDOWS_SIGNED === '1' ? '正式签名构建。\n' : '未签名测试包,Windows 可能提示未知发布者。\n') +
+    '支持 Windows 10/11 x64;连接已有夭夭服务器。本机无需 Node、Python、Hermes。\n' +
+    'Windows 电脑操作需要服务器支持;更新清单随本次构建交付,尚未上传正式更新源。\n' +
+    '完整验收步骤见 docs/windows-client.md。CI 构建不代表真实桌面控制已完成验收。\n')
+  console.log(`Windows 安装包及更新清单已校验:${directory}(尚未发布)`)
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await packageWindowsDesktop()

+ 5 - 5
src/client/components/app/DesktopHostSettingsPanel.vue

@@ -27,7 +27,7 @@ async function register() {
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程电脑请使用 HTTPS,或勾选可信局域网 HTTP')
     const result = await apiRequest<{host:DesktopHostSummary;token:string}>('/api/app/admin/desktop-hosts', {method:'POST',body:{name:name.value.trim()}})
     configuration.value = {protocol:1,serverURL:web.origin,hostId:result.host.id,token:result.token,...(insecure.value?{allowInsecureLan:true}:{})}
-    notice.value = '电脑已注册,请下载配置并在那台 Mac 的夭夭 App 中导入。'
+    notice.value = '电脑已注册,请下载配置并在那台电脑 的夭夭 App 中导入。'
     await refresh()
   })
 }
@@ -49,11 +49,11 @@ onMounted(()=>{void action(refresh)})
 
 <template>
   <section class="desktop-host-panel" aria-label="电脑设置">
-    <p>在其他 Mac 上安装夭夭 App 并导入电脑配置后,那台电脑会主动连接服务器,机器人即可操作它的真实桌面。每台电脑单独授权,配置凭据仅注册时下发一次。</p>
+    <p>在其他电脑 上安装夭夭 App 并导入电脑配置后,那台电脑会主动连接服务器,机器人即可操作它的真实桌面。每台电脑单独授权,配置凭据仅注册时下发一次。</p>
     <div class="host-toolbar"><strong>已注册电脑</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="hosts.length" class="host-list">
       <li v-for="host in hosts" :key="host.id">
-        <div><strong>{{ host.name }}</strong><span>{{ !host.enabled ? '已停用' : host.online ? `已连接 · ${host.hostName || host.platform || 'Mac'}` : '待连接' }}</span></div>
+        <div><strong>{{ host.name }}</strong><span>{{ !host.enabled ? '已停用' : host.online ? `已连接 · ${host.hostName || host.platform || '电脑'}` : '待连接' }}</span></div>
         <button v-if="host.enabled" type="button" :disabled="busy" :aria-label="`停用 ${host.name}`" @click="disable(host)">停用</button>
       </li>
     </ul>
@@ -62,13 +62,13 @@ onMounted(()=>{void action(refresh)})
       <label>电脑名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="书房 iMac" :disabled="busy"></label>
       <small>用于在电脑面板中区分各台机器。</small>
       <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"></label>
-      <small>必须能从那台 Mac 访问。</small>
+      <small>必须能从那台电脑 访问。</small>
       <label class="host-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <button class="solid-button" :disabled="busy || !name.trim()">注册电脑</button>
     </form>
     <div v-else class="host-setup">
       <button class="solid-button" type="button" @click="download">下载电脑配置</button>
-      <p>配置包含这台电脑的连接凭据,仅本次可下载。在那台 Mac 上打开夭夭 App,通过菜单「电脑 → 导入电脑配置…」选择下载的 desktop-host.json。</p>
+      <p>配置包含这台电脑的连接凭据,仅本次可下载。在那台电脑 上打开夭夭 App,通过菜单「电脑 → 导入电脑配置…」选择下载的 desktop-host.json。</p>
       <small>配置丢失后可停用电脑并重新注册;停用立即断开那台电脑。</small>
     </div>
     <p v-if="error" class="host-error" role="alert">{{ error }}</p>

+ 8 - 7
src/client/components/app/DesktopModePanel.vue

@@ -1,18 +1,19 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 
 type Mode = 'client' | 'server'
 const desktop = window.yaoyaoDesktop
-const state = ref<{ mode: Mode; serverURL: string; switching: boolean }>()
+const state = ref<{ mode: Mode; serverURL: string; switching: boolean; supportedModes?: Mode[] }>()
 const selected = ref<Mode>('client')
 const busy = ref(false)
 const error = ref('')
 const notice = ref('')
-const options = [
+const options = computed(() => ([
   { mode: 'client', title: '客户端模式', description: '连接另一台电脑上的夭夭服务器。本机无需安装 Hermes。', icon: 'link' },
   { mode: 'server', title: '服务器模式', description: '在这台电脑上运行夭夭服务,使用本机 Hermes,并供其他客户端连接。', icon: 'monitor' },
-] as const
+] as const).filter(option => !state.value?.supportedModes || state.value.supportedModes.includes(option.mode)))
+const canSwitch = computed(() => options.value.length > 1)
 
 async function load() {
   busy.value = true; error.value = ''
@@ -49,7 +50,7 @@ onMounted(load)
       <span v-if="state.serverURL">{{ state.serverURL }}</span>
     </div>
     <p v-else-if="busy" role="status">正在读取运行模式…</p>
-    <fieldset :disabled="busy || !state || state.switching">
+    <fieldset v-if="canSwitch" :disabled="busy || !state || state.switching">
       <legend>这台电脑如何使用夭夭?</legend>
       <label v-for="option in options" :key="option.mode" class="mode-option" :class="{ selected: selected === option.mode }">
         <input v-model="selected" type="radio" name="desktop-running-mode" :value="option.mode" />
@@ -57,12 +58,12 @@ onMounted(load)
         <span><strong>{{ option.title }}</strong><small>{{ option.description }}</small></span>
       </label>
     </fieldset>
-    <p class="mode-note">切换后会打开对应服务的页面,并记住选择。数据保留在原服务器;连接另一台服务器时,可能需要登录。</p>
+    <p class="mode-note">{{ canSwitch ? '切换后会打开对应服务的页面,并记住选择。' : '此客户端连接已有服务器。' }}数据保留在原服务器;连接另一台服务器时,可能需要登录。</p>
     <p v-if="error" class="mode-error" role="alert">{{ error }}</p>
     <p v-if="notice" role="status">{{ notice }}</p>
     <div class="mode-actions">
       <button v-if="!state" type="button" :disabled="busy" @click="load">重新读取</button>
-      <button v-else class="mode-primary" type="button" :disabled="busy || state.switching || selected === state.mode" @click="switchMode">
+      <button v-else-if="canSwitch" class="mode-primary" type="button" :disabled="busy || state.switching || selected === state.mode" @click="switchMode">
         {{ busy ? '正在切换…' : selected === state.mode ? '正在使用此模式' : selected === 'client' ? '切换为客户端' : '切换为服务器' }}
       </button>
       <button v-if="desktop?.openRemoteLogin" type="button" :disabled="busy || state?.switching" @click="changeServer">更换服务器…</button>

+ 1 - 1
src/client/components/app/HostToolsPanel.vue

@@ -105,7 +105,7 @@ onMounted(() => { void load(); void loadNames() })
   <section class="host-tools" aria-label="电脑与服务端工具">
     <p>这些开关对所有机器人生效,不再按单个机器人勾选。所有 Bot 共用电脑权限和审批策略。设备是否在线、是否获得系统授权会单独显示。</p>
     <form @submit.prevent="save" @change="changed">
-      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>电脑</strong><small>连接服务器的 Mac。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
+      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>电脑</strong><small>连接服务器的 Mac 或 Windows 电脑。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
       <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。文件走文件工具,命令走 shell;只有看窗口或点按时才截图操作。</small></span></label>
       <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
       <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>

+ 1 - 1
src/client/components/app/RunnerSettingsPanel.vue

@@ -57,7 +57,7 @@ onMounted(()=>{void action(refresh)})
 
 <template>
   <section class="runner-panel" aria-label="执行节点设置">
-    <p>执行节点只给虚拟环境用。聊天和 Mac 桌面不经过这里。节点要和 Hermes 装在同一台电脑上。</p>
+    <p>执行节点只给虚拟环境用。聊天和真实桌面不经过这里。节点要和 Hermes 装在同一台电脑上。</p>
     <p v-if="bundleAvailable"><a href="/api/app/admin/runners/bundle" download>下载配套执行节点程序</a>(包含桌面资源,需要 Node.js 24 或更高版本)。</p>
     <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="runners.length" class="runner-list">

+ 4 - 4
src/client/components/workspace/LocalVmChatPanel.vue

@@ -146,14 +146,14 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
     <div class="computer-options">
     <template v-if="showDesktop">
-      <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
+      <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac 或 Windows 电脑。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
       <div v-for="host in desktopHosts" :key="host.id" class="host-permission" :class="{offline:!host.online}">
        <div class="host-permission-head"><strong>{{host.id==='local'?('服务器 · '+(host.name||'服务器')):('电脑 · '+(host.name||'未命名'))}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
-       <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 屏幕控制授权</span><span>{{host.local.screen?'✓':'○'}} 屏幕录制</span><span>{{host.local.accessibility?'✓':'○'}} 辅助功能</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>
+       <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 屏幕控制授权</span><span>{{host.local.screen?'✓':'○'}} {{host.platform==='win32'?'屏幕可用':'屏幕录制'}}</span><span>{{host.local.accessibility?'✓':'○'}} {{host.platform==='win32'?'键鼠可用':'辅助功能'}}</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>
        <button v-if="host.online&&!host.local.ready" class="primary" :disabled="busy||!isAdmin" @click="authorizeHost(host.id)">在桌面端授权</button>
-       <p v-if="host.online&&!host.local.ready" class="hint">在 {{host.id==='local'?'这台':host.name}} Mac 上确认授权,并按系统提示开启权限;系统可能要求重新打开 App。</p>
+       <p v-if="host.online&&!host.local.ready" class="hint">{{host.platform==='win32'?'在 Windows 客户端确认授权,并解锁电脑、保持桌面会话连接。':'在目标 Mac 上确认授权,并按系统提示开启屏幕录制和辅助功能;可能需要重新打开 App。'}}</p>
        <button v-if="host.online&&!host.local.fullAuthorized" :disabled="busy||!isAdmin" @click="authorizeHost(host.id,'full')">授权文件与命令</button>
-       <p v-if="host.online&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台 Mac 的用户主目录内读写文件并执行命令;每台电脑单独授权,可随时撤销。</p>
+       <p v-if="host.online&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台电脑 的用户主目录内读写文件,并以当前用户权限执行命令;每台电脑单独授权,可随时撤销。</p>
       </div>
       <button v-if="serverHost" class="primary" :disabled="busy||!serverHost.online||!serverHost.local.ready" @click="openDesktop('desktop','local')">接管服务器</button>
     </template>

+ 1 - 1
src/client/env.d.ts

@@ -13,7 +13,7 @@ interface Window {
   yaoyaoDesktop?: {
     openLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>
-    modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean }>
+    modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean; platform?: string; supportedModes?: ('client' | 'server')[] }>
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
     openRemoteLogin?(): Promise<void>
     authorizeComputer?(csrfToken: string): Promise<{ registered: boolean; hostId?: string }>

+ 7 - 7
src/server/desktopEnvironments.ts

@@ -88,9 +88,9 @@ export class DesktopEnvironments {
  get online(){const s=this.sessions.get('local');return !!s&&this.hostOnline(s)}
  get idleForUpdate(){for(const s of this.sessions.values()){for(const key of s.manual.keys())this.current(s,key);if(s.pending.size||s.queue.size||s.manual.size)return false}return true}
  private ownerKey(owner:string){return digest((this.nodes.localNodeID??this.nodes.local.url.toString())+':'+owner)}
- private ready(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&s.info!.screen&&s.info!.accessibility&&s.info!.approved.includes(this.ownerKey(owner))}
+ private ready(s:HostState,owner:string){return this.hostOnline(s)&&['darwin','win32'].includes(s.info!.platform)&&s.info!.screen&&s.info!.accessibility&&s.info!.approved.includes(this.ownerKey(owner))}
  /** File and shell grant: a separate approval tier held by the desktop app. */
- private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&(s.info!.full??[]).includes(this.ownerKey(owner))}
+ private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&['darwin','win32'].includes(s.info!.platform)&&(s.info!.full??[]).includes(this.ownerKey(owner))}
  fileTools(owner:string,agent:WorkspaceAgent){return this.envTools(owner,agent).file}
  private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
  /** Default session: agent desktopHost binding, else server local.
@@ -162,7 +162,7 @@ export class DesktopEnvironments {
   const hosts:BotDeviceSnapshot[]=states.filter(s=>!revoked.has(s.key)).map((s):BotDeviceSnapshot=>{
    const info=s.info,online=this.hostOnline(s),record=records.find(record=>record.id===s.key),open=(s.key==='local'?global.server:global.script)&&record?.enabled!==false
    const manual=this.current(s,'local'),waiting=manual?'human_control':s.paused.has('local')?'paused':'ready'
-   const base:DeviceCapabilityStatus|undefined=agent&&(agent.archived||agent.remoteAgentId)?'agent_unavailable':!open?'disabled':!online?'offline':info?.platform!=='darwin'?'unsupported':undefined
+   const base:DeviceCapabilityStatus|undefined=agent&&(agent.archived||agent.remoteAgentId)?'agent_unavailable':!open?'disabled':!online?'offline':!['darwin','win32'].includes(info?.platform??'')?'unsupported':undefined
    const capability=(reason?:DeviceCapabilityStatus):DeviceCapability=>({enabled:!reason,status:reason??waiting})
    const screen=capability(base??(!info?.approved.includes(this.ownerKey(owner))?'not_authorized':!info.screen||!info.accessibility?'system_permission_required':undefined))
    const files=capability(base??(!(info?.full??[]).includes(this.ownerKey(owner))?'not_authorized':undefined))
@@ -178,11 +178,11 @@ export class DesktopEnvironments {
  deviceContextLine(owner:string,deviceHost?:string){return deviceSourceText(this.snapshot(owner,undefined,deviceHost))}
  private agent(owner:string,id:string){const a=this.store.require<WorkspaceAgent>(owner,'agent',id);this.nodes.requireSource(owner,a);if(a.archived||a.remoteAgentId)throw new HttpError(409,'此机器人无法使用桌面环境','computer_unavailable');return a}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
- hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:this.label(s),platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
- state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:this.label(s!),platform:info.platform}:null,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
+ hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:this.label(s),platform:info?.platform??'',online,local:{supported:['darwin','win32'].includes(info?.platform??''),authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
+ state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:this.label(s!),platform:info.platform}:null,local:{supported:['darwin','win32'].includes(info?.platform??''),authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
  /** Every Bot follows the same global desktop policy. */
  private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){return this.selected(owner,agent)===mode}
- private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number,files=false){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||!this.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');this.requireHost(s);if(files&&!this.readyFull(s,owner))throw new HttpError(403,'请在桌面端授权文件与命令访问。','desktop_full_required');if(mode==='local'&&!files&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
+ private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number,files=false){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||!this.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');this.requireHost(s);if(files&&!this.readyFull(s,owner))throw new HttpError(403,'请在桌面端授权文件与命令访问。','desktop_full_required');if(mode==='local'&&!files&&!this.ready(s,owner))throw new HttpError(403,s.info!.platform==='win32'?'请在 Windows 客户端授权本机控制,并解锁电脑、恢复桌面会话。':'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
  private invalidate(s:HostState){for(const p of s.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))s.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}s.pending.clear();s.frames.clear();for(const [key,m] of s.manual){s.paused.add(key);this.store.remove('_system','computer-control',m.id)}s.manual.clear()}
  close(){this.closed=true;for(const s of this.sessions.values())this.invalidate(s)}
  async bridge(ctx:Koa.Context){
@@ -210,7 +210,7 @@ export class DesktopEnvironments {
   for(const result of value.results){const p=s.pending.get(result.id);if(!p||p.epoch!==value.host.id)continue;s.pending.delete(p.id);clearTimeout(p.timer);try{p.check();if(result.error)throw new HttpError(409,result.error,'desktop_command_failed');p.resolve(result.value)}catch(error){p.reject(error as Error)}}
   const commands:Record<string,unknown>[]=[]
   for(const p of s.pending.values()){if(p.sent)continue;try{p.check();p.sent=true;commands.push({id:p.id,deadline:p.deadline,...p.command})}catch(error){s.pending.delete(p.id);clearTimeout(p.timer);p.reject(error as Error)}if(commands.length>=8)break}
-  return {commands,capabilities:{environmentMetadata:1}}
+  return {commands,capabilities:{environmentMetadata:1,desktopPlatforms:['darwin','win32']}}
  }
  private command(s:HostState,command:Record<string,unknown>,check:()=>void,timeout=30000):Promise<any>{
   check();if(!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')

+ 4 - 3
src/shared/fileTransferEndpoint.mjs

@@ -1,6 +1,7 @@
 import { open, realpath, mkdir, mkdtemp, unlink, rename, link, rm } from 'node:fs/promises'
 import { constants } from 'node:fs'
-import { resolve, dirname, join, sep } from 'node:path'
+import { resolve, dirname, join } from 'node:path'
+import { hostPathInput, pathInside } from './hostPaths.mjs'
 import { tmpdir } from 'node:os'
 import { createHash } from 'node:crypto'
 
@@ -16,8 +17,8 @@ export class FileTransferFiles {
   }
   async path(input, writing = false) {
     require(typeof input === 'string' && input.length > 0 && input.length <= 4096 && !input.includes('\0'), '文件路径无效')
-    const root = await realpath(this.root), raw = resolve(root, input.replace(/^~\//, '').replace(/^~$/, ''))
-    const inside = value => !this.confined || value === root || value.startsWith(root + sep)
+    const root = await realpath(this.root), raw = resolve(root, hostPathInput(input))
+    const inside = value => !this.confined || pathInside(root, value)
     require(inside(raw), '路径超出允许范围')
     if (!writing) { const file = await realpath(raw); require(inside(file), '路径超出允许范围'); return file }
     let parent = dirname(raw)

+ 22 - 0
src/shared/hostPaths.mjs

@@ -0,0 +1,22 @@
+import path from 'node:path'
+
+export function hostPathInput(input, platform = process.platform) {
+  const value = String(input ?? '')
+  if (value.includes('\0')) throw new Error('路径无效')
+  if (platform === 'win32') {
+    // File tools never accept device namespaces, UNC shares, drive-relative
+    // paths, alternate data streams, or Win32 names with ambiguous semantics.
+    if (/^[\\/]{2}|^[A-Za-z]:(?![\\/])/.test(value)) throw new Error('路径无效')
+    const rest = value.replace(/^[A-Za-z]:/, '')
+    if (rest.includes(':') || rest.split(/[\\/]/).some(part =>
+      (part !== '.' && part !== '..' && /[ .]$/.test(part)) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('路径无效')
+    return value.replace(/^~[\\/]/, '').replace(/^~$/, '')
+  }
+  return value.replace(/^~\//, '').replace(/^~$/, '')
+}
+
+export function pathInside(root, target, platform = process.platform) {
+  const api = platform === 'win32' ? path.win32 : path.posix
+  const relative = api.relative(root, target)
+  return relative === '' || (!api.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + api.sep))
+}

+ 22 - 1
tests/server/desktopEnvironments.test.ts

@@ -36,6 +36,27 @@ it('uses global desktop capabilities for legacy, modern and temporary Bots',asyn
 
 function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});const p=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
 const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
+it('admits Windows computers with independent screen and file grants and preserves source routing',async()=>{
+ const seen:any[]=[]
+ const win={...host,platform:'win32',name:'Windows 工作电脑',full:[ownerKey('owner')],screen:false,accessibility:false}
+ driveRemote(HOST_A,win,c=>{seen.push(c);return {entries:[],path:c.action?.path}})
+ const status=service.hostStates('owner').find(h=>h.id===HOST_A)!
+ expect(status.local).toMatchObject({supported:true,ready:false,fullAuthorized:true})
+ const listing=await service.call('owner',agent.id,'desktop_file_list',{path:'C:\\Users\\测试\\Documents'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
+ expect(listing.entries).toEqual([])
+ expect(seen[0].action.path).toBe('C:\\Users\\测试\\Documents')
+ win.screen=true;win.accessibility=true
+ service.remoteExchange(HOST_A,{host:win,results:[]})
+ expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local.ready).toBe(true)
+ win.full=[];win.approved=[]
+ service.remoteExchange(HOST_A,{host:win,results:[]})
+ expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local).toMatchObject({ready:false,fullAuthorized:false})
+ await expect(service.call('owner',agent.id,'desktop_file_list',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)).rejects.toMatchObject({code:'desktop_full_required'})
+})
+it('unrecognized desktop platforms remain unsupported',()=>{
+ service.remoteExchange(HOST_A,{host:{...host,platform:'unknown',full:[ownerKey('owner')]},results:[]})
+ expect(service.hostStates('owner').find(h=>h.id===HOST_A)!.local).toMatchObject({supported:false,ready:false})
+})
 function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});const p=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
 it('defaults bot work to the customer machine and uses that binding while the server desktop is also online',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
@@ -379,7 +400,7 @@ it('lists stable tool targets and separate per-host capabilities without claimin
 const environmentMetadata={version:1 as const,osRelease:'25.0.0',arch:'arm64',shell:'/bin/zsh',homeDirectory:'/Users/fixture',defaultCwd:'/Users/fixture',fileRoots:['/Users/fixture'],shellScope:'user' as const,timezone:'Asia/Shanghai'}
 it('snapshots negotiated host facts per owner without inventing paths for old clients',()=>{
  const result=service.exchange({host:{...host,full:[ownerKey('owner')],fileTransferVersion:1,environment:environmentMetadata},results:[]})
- expect(result.capabilities).toEqual({environmentMetadata:1})
+ expect(result.capabilities).toEqual({environmentMetadata:1,desktopPlatforms:['darwin','win32']})
  service.remoteExchange(HOST_A,{host:{...host,id:randomUUID(),name:'旧客户端'},results:[]})
  const snapshot=service.snapshot('owner',agent,'local'),server=snapshot.hosts.find(host=>host.id==='local')!
  expect(server.metadata).toEqual(environmentMetadata)