Explorar o código

feat(desktop-host): macOS App 支持导入桌面主机配置并连接远程服务

命令执行核心(授权弹窗/截图/输入/隔离浏览器)与传输解耦为
DesktopHostCore;新增 DesktopHostManager 以 keychain 加密保存配对
配置,出站长轮询 /api/desktop-host/v1 交换命令:成功 400ms、
网络错误退避至 5s、令牌失效即停止并提示重新导入。
菜单新增"桌面主机"(状态/导入/重连/忘记),退出时一并关闭,
"撤销本机控制授权"同时作用于本机与远程通道。
Samien hai 1 mes
pai
achega
8af11c6d31
Modificáronse 5 ficheiros con 272 adicións e 7 borrados
  1. 15 4
      desktop/environment-host.mjs
  2. 12 0
      desktop/host-config.mjs
  3. 111 0
      desktop/host-manager.mjs
  4. 113 0
      desktop/host-manager.test.mjs
  5. 21 3
      desktop/main.mjs

+ 15 - 4
desktop/environment-host.mjs

@@ -8,11 +8,16 @@ import {execFile} from 'node:child_process'
 import {BrowserEnvironment} from './browser-environment.mjs'
 
 function exchange(record,body){return new Promise((resolve,reject)=>{const url=new URL('/desktop/environment',record.url);if(url.protocol!=='http:'||url.hostname!=='127.0.0.1')return reject(new Error('桌面服务地址无效'));const bytes=Buffer.from(JSON.stringify(body)),req=request(url,{method:'POST',headers:{'x-yaoyao-desktop-token':record.token,'content-type':'application/json','content-length':bytes.length}},res=>{let size=0;const chunks=[];res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)});res.on('error',reject);res.on('end',()=>{try{if(res.statusCode!==200)throw new Error('桌面服务未连接');resolve(JSON.parse(Buffer.concat(chunks)))}catch(error){reject(error)}})});req.on('error',reject);req.setTimeout(5000,()=>req.destroy(new Error('桌面连接超时')));req.end(bytes)})}
-export class DesktopEnvironmentHost {
- constructor({manager,root,dataRoot}){this.manager=manager;this.root=root;this.dataRoot=dataRoot;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.permissionsFile=join(dataRoot,'computer-permissions.json');try{const list=JSON.parse(readFileSync(this.permissionsFile,'utf8'));if(Array.isArray(list))for(const key of list)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key)}catch{}this.frames=new Map()}
+
+/** Command execution shared by the loopback host and remote desktop hosts. */
+export class DesktopHostCore {
+ constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;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.permissionsFile=join(dataRoot,'computer-permissions.json');try{const list=JSON.parse(readFileSync(this.permissionsFile,'utf8'));if(Array.isArray(list))for(const key of list)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key)}catch{}this.frames=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]}}
  async revoke(){this.approved.clear();this.save();this.frames.clear();this.id=randomUUID()}
+ async recycle(){this.results=[];this.id=randomUUID();await this.browser.close()}
  save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify([...this.approved]),{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('此电脑暂不支持本机控制')
@@ -39,7 +44,13 @@ export class DesktopEnvironmentHost {
   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('显示器已变化,请刷新画面')
   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 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){this.results=[];this.id=randomUUID();await this.browser.close()}this.instance=record.instanceId;const sent=this.results.slice(),value=await exchange(record,{host:this.info(),results:sent});this.results.splice(0,sent.length);for(const c of value.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))}}}catch{}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
+ async close(){this.closed=true;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()});await this.core.handle(value.commands??[])}}catch{}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
  start(){this.closed=false;void this.cycle()}
- async close(){this.closed=true;clearTimeout(this.timer);this.id=randomUUID();this.results=[];await this.browser.close()}
+ async close(){this.closed=true;clearTimeout(this.timer);await this.core.close()}
 }

+ 12 - 0
desktop/host-config.mjs

@@ -0,0 +1,12 @@
+/** desktop-host.json downloaded once from the server's host settings panel. */
+export function parseDesktopHostConfiguration(value){
+ if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('桌面主机配置无效')
+ if(value.protocol!==1)throw new Error('桌面主机配置协议不兼容,请更新夭夭')
+ let web
+ try{web=new URL(value.serverURL)}catch{throw new Error('夭夭服务地址无效')}
+ if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('夭夭服务地址无效')
+ if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&value.allowInsecureLan!==true)throw new Error('远程主机需要 HTTPS,或使用注册时勾选可信局域网 HTTP 的配置')
+ if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(value.hostId)))throw new Error('主机编号无效,请从夭夭重新下载配置')
+ if(typeof value.token!=='string'||value.token.length<32||value.token.length>4096)throw new Error('主机凭据无效,请从夭夭重新下载配置')
+ return {protocol:1,serverURL:web.origin,hostId:value.hostId,token:value.token,allowInsecureLan:value.allowInsecureLan===true}
+}

+ 111 - 0
desktop/host-manager.mjs

@@ -0,0 +1,111 @@
+import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {request as httpRequest} from 'node:http'
+import {request as httpsRequest} from 'node:https'
+import {parseDesktopHostConfiguration} from './host-config.mjs'
+
+const FATAL = Symbol('desktop-host-fatal')
+
+function remoteExchange(config,body){return new Promise((resolve,reject)=>{
+ const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
+ if(!['http:','https:'].includes(url.protocol))return reject(new Error('夭夭服务地址无效'))
+ const bytes=Buffer.from(JSON.stringify(body)),send=url.protocol==='https'?httpsRequest:httpRequest
+ const req=send(url,{method:'POST',headers:{Authorization:`Bearer ${config.token}`,'x-desktop-host-protocol':'1','content-type':'application/json','content-length':bytes.length}},res=>{
+  let size=0;const chunks=[]
+  res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
+  res.on('error',reject)
+  res.on('end',()=>{try{
+   if(res.statusCode===401||res.statusCode===403){const error=new Error('桌面主机授权已失效,请在夭夭服务中重新注册并导入新配置');error[FATAL]=true;throw error}
+   if(res.statusCode===409){const error=new Error('桌面主机协议不兼容,请更新夭夭后重新导入');error[FATAL]=true;throw error}
+   if(res.statusCode!==200)throw new Error(`桌面主机服务未连接(HTTP ${res.statusCode})`)
+   resolve(JSON.parse(Buffer.concat(chunks).toString()))
+  }catch(error){reject(error)}})
+ })
+ req.on('error',reject)
+ req.setTimeout(10000,()=>req.destroy(new Error('桌面主机连接超时')))
+ req.end(bytes)
+})}
+
+/** Remote desktop host: imports an encrypted pairing config and exchanges
+ *  commands with the paired server. Native-only; no browser IPC. */
+export class DesktopHostManager {
+ constructor(options){
+  if(typeof options?.createCore!=='function')throw new Error('桌面主机需要命令执行核心')
+  this.options=options
+  this.path=join(options.home,'desktop-host.enc')
+  this.createCore=options.createCore
+  this.state='未配置桌面主机'
+  this.core=undefined
+  this.config=undefined
+  this.timer=undefined
+  this.backoff=400
+  this.chain=Promise.resolve()
+  this.generation=0
+ }
+ publish(message){this.state=message;this.options.onState?.(message)}
+ serial(work){const result=this.chain.catch(()=>{}).then(work);this.chain=result;return result}
+ async read(){
+  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('无法解锁桌面主机配置,请检查钥匙串或重新导入')}
+ }
+ importFile(path){
+  const generation=++this.generation
+  return this.serial(async()=>{
+   let config
+   try{config=parseDesktopHostConfiguration(JSON.parse(await readFile(path,'utf8')))}
+   catch(error){throw error instanceof Error?error:new Error('桌面主机配置无效,请从夭夭重新下载配置')}
+   // Encrypt before stopping a working host: failed Keychain access preserves it.
+   if(generation!==this.generation)return
+   const bytes=await this.options.encrypt(JSON.stringify(config))
+   if(generation!==this.generation)return
+   await this.stopLoop()
+   if(generation!==this.generation)return
+   await mkdir(this.options.home,{recursive:true,mode:0o700})
+   const temporary=`${this.path}.${randomUUID()}`
+   try{await writeFile(temporary,bytes,{flag:'wx',mode:0o600});await rename(temporary,this.path)}
+   finally{await rm(temporary,{force:true})}
+   if(generation!==this.generation)return
+   await this.startLoop(config)
+  })
+ }
+ start(){const generation=++this.generation;return this.serial(async()=>{const config=await this.read();if(generation!==this.generation)return;if(config)await this.startLoop(config)})}
+ async startLoop(config){
+  await this.stopLoop()
+  this.config=config
+  this.core=this.createCore({root:this.options.root,dataRoot:this.options.dataRoot})
+  this.backoff=400
+  this.publish(`桌面主机正在连接 ${new URL(config.serverURL).host}`)
+  this.timer=setTimeout(()=>void this.cycle(),400)
+  this.timer.unref?.()
+ }
+ async cycle(){
+  const config=this.config,core=this.core
+  if(!config||!core||this.core!==core)return
+  try{
+   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}`)
+   await core.handle(value.commands??[])
+  }catch(error){
+   if(this.config!==config||this.core!==core)return
+   if(error&&error[FATAL]){this.publish(error.message);await this.stopLoop();return}
+   this.backoff=Math.min(this.backoff*2,5000)
+   this.publish(`桌面主机连接中断,正在重试:${String(error.message).slice(0,120)}`)
+  }finally{
+   if(this.core===core){this.timer=setTimeout(()=>void this.cycle(),this.backoff);this.timer.unref?.()}
+  }
+ }
+ async stopLoop(){
+  clearTimeout(this.timer);this.timer=undefined
+  const core=this.core
+  this.core=undefined;this.config=undefined
+  if(core)await core.close().catch(()=>{})
+ }
+ stop(){this.generation++;return this.serial(()=>this.stopLoop())}
+ forget(){this.generation++;return this.serial(async()=>{await this.stopLoop();await rm(this.path,{force:true});this.publish('未配置桌面主机')})}
+ revoke(){return this.serial(async()=>{await this.core?.revoke()})}
+}

+ 113 - 0
desktop/host-manager.test.mjs

@@ -0,0 +1,113 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,readFile,writeFile,stat,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {createServer} from 'node:http'
+import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
+import {DesktopHostManager} from './host-manager.mjs'
+import {parseDesktopHostConfiguration} from './host-config.mjs'
+
+const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))
+
+test('desktop host configuration validation mirrors the runner rules',()=>{
+ const base={protocol:1,serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
+ assert.deepEqual(parseDesktopHostConfiguration(base),{...base,allowInsecureLan:false})
+ assert.deepEqual(parseDesktopHostConfiguration({...base,serverURL:'http://127.0.0.1:15300'}).serverURL,'http://127.0.0.1:15300')
+ assert.throws(()=>parseDesktopHostConfiguration({...base,protocol:2}),/协议不兼容/)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300'}),/HTTPS/)
+ assert.equal(parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300',allowInsecureLan:true}).allowInsecureLan,true)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'https://example.com/path'}),/服务地址/)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,hostId:'not-a-uuid'}),/主机编号/)
+ assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/主机凭据/)
+})
+
+async function fixture(config){
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-host-')),key=randomBytes(32)
+ const encrypt=value=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv);const bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
+ const decrypt=bytes=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
+ const cores=[],handled=[]
+ const manager=new DesktopHostManager({home,root:'/app',dataRoot:home,
+  encrypt,decrypt,
+  createCore:()=>{const core={id:randomUUID(),closed:false,results:[],exchanges:0,
+    info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[]}},
+    takeResults(){return core.results.splice(0,core.results.length)},
+    async handle(commands){handled.push(...commands??[])},
+    async revoke(){core.id=randomUUID()},
+    async close(){core.closed=true}}
+  cores.push(core);return core}})
+ const configPath=join(home,'desktop-host.json')
+ if(config)await writeFile(configPath,JSON.stringify(config))
+ return {manager,home,configPath,cores,handled}
+}
+
+test('imports an encrypted config, exchanges with the paired server and stops on revocation',async()=>{
+ const token=randomBytes(32).toString('base64url'),hostId=randomUUID()
+ const exchanges=[]
+ const server=createServer((req,res)=>{
+  if(!req.url.includes(`/api/desktop-host/v1/${hostId}/exchange`)){res.statusCode=404;res.end();return}
+  if(req.headers.authorization!==`Bearer ${token}`||req.headers['x-desktop-host-protocol']!=='1'){res.statusCode=403;res.end(JSON.stringify({error:'unauthorized'}));return}
+  let body='';req.on('data',chunk=>{body+=chunk});req.on('end',()=>{
+   exchanges.push(JSON.parse(body))
+   res.setHeader('content-type','application/json')
+   res.end(JSON.stringify(exchanges.length===1?{commands:[{id:randomUUID(),deadline:Date.now()+30000,mode:'local',owner:'a'.repeat(64),resource:'local',profile:'persistent',operation:'open'}]}:{commands:[]}))
+  })
+ })
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+ const {manager,home,configPath,cores,handled}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId,token})
+ try{
+  await manager.importFile(configPath)
+  const mode=await stat(join(home,'desktop-host.enc'))
+  assert.equal(mode.mode&0o077,0)
+  await sleep(1500)
+  assert.ok(cores.length>=1)
+  assert.equal(cores[0].closed,false)
+  assert.equal(exchanges.length>=2,true)
+  assert.equal(exchanges[0].host.name,'测试 Mac')
+  assert.equal(handled.length,1)
+  assert.equal(handled[0].operation,'open')
+  assert.match(manager.state,/已连接/)
+  await manager.forget()
+  assert.equal(cores[0].closed,true)
+  assert.equal(manager.state,'未配置桌面主机')
+  await assert.rejects(()=>readFile(join(home,'desktop-host.enc')),{code:'ENOENT'})
+ }finally{
+  await manager.stop().catch(()=>{})
+  server.close();server.closeAllConnections?.()
+ }
+})
+
+test('a revoked token stops the loop with a clear state instead of retrying',async()=>{
+ const token=randomBytes(32).toString('base64url')
+ let requests=0
+ const server=createServer((req,res)=>{requests++;res.statusCode=403;res.end(JSON.stringify({error:'revoked'}))})
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+ const {manager,configPath,cores}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId:randomUUID(),token})
+ try{
+  await manager.importFile(configPath)
+  await sleep(1200)
+  assert.match(manager.state,/授权已失效/)
+  assert.equal(cores[0].closed,true)
+  const settled=requests
+  await sleep(1200)
+  assert.equal(requests,settled)
+ }finally{
+  await manager.stop().catch(()=>{})
+  server.close();server.closeAllConnections?.()
+ }
+})
+
+test('network failures back off but keep retrying',async()=>{
+ const token=randomBytes(32).toString('base64url')
+ const {manager,home,configPath,cores}=await fixture({protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token})
+ try{
+  await manager.importFile(configPath)
+  await sleep(900)
+  assert.match(manager.state,/正在重试/)
+  assert.equal(cores[0].closed,false)
+  await manager.stop()
+  assert.equal(cores[0].closed,true)
+ }finally{
+  await rm(home,{recursive:true,force:true})
+ }
+})

+ 21 - 3
desktop/main.mjs

@@ -9,7 +9,8 @@ import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
 import { synchronizeLocalService, stopLocalService, migrateLocalData } from './service-sync.mjs'
 import { resolveDataHome } from './data-home.mjs'
-import { DesktopEnvironmentHost } from './environment-host.mjs'
+import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
+import { DesktopHostManager } from './host-manager.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { installComputerViewer } from './computer-viewer.mjs'
 
@@ -21,7 +22,7 @@ app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
-  let updateWindow, updater, environmentHost
+  let updateWindow, updater, environmentHost, hostManager
   const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => manager?.state.url,
     preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
   app.on('will-quit', closeComputerViewer)
@@ -181,6 +182,7 @@ else {
     setImmediate(() => { void (async()=>{
       try {
         await environmentHost?.close()
+        await hostManager?.stop()
         await runnerManager?.stop()
         if (stopBackground) await manager?.stopBackground()
         else await manager?.stop()
@@ -194,6 +196,7 @@ else {
         else {
           closing=false
           await manager?.reconnect().catch(error=>log(error.message))
+          await hostManager?.start().catch(error=>log(error.message))
           await runnerManager?.start().catch(error=>log(error.message))
           stateChanged(manager.state)
           timer=setInterval(()=>{void manager.check()},10000);timer.unref()
@@ -247,6 +250,16 @@ else {
       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}),
+      encrypt:value=>credentials.encrypt(value),
+      decrypt:bytes=>credentials.decrypt(bytes),
+      onState:message=>{log(`桌面主机: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
+    async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'桌面主机操作未完成',detail:error.message})}}
+    async function importDesktopHost(){
+      const selection=await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
+      if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
+    }
     async function importRunner(){
       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]))
@@ -271,13 +284,17 @@ else {
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' },
-        {label:'撤销本机控制授权',click:()=>environmentHost.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'},
         {id:'runner-import',label:'导入节点配置…',click:importRunner},
         {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
         {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
+      {label:'桌面主机',submenu:[{id:'desktop-host-status',label:'未配置桌面主机',enabled:false},{type:'separator'},
+        {id:'desktop-host-import',label:'导入主机配置…',click:importDesktopHost},
+        {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 })
@@ -291,6 +308,7 @@ else {
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
     await manager.start().catch(error => log(error.message))
     if(!closing)environmentHost.start()
+    if(!closing)hostManager.start().catch(error=>{hostManager.publish(error.message)})
     if(!closing&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }