Просмотр исходного кода

feat(desktop-host): App 启动时选择登录远程或进入本地

首次启动(及勾选"启动时询问使用方式"时)弹出选择:
登录远程=导入服务器下发的主机配置后,窗口直接打开远程夭夭界面,
本机不启动 Web 服务,同时作为桌面主机提供本机控制;
进入本地=维持原有本机服务行为。
选择可记住(startupChoice),菜单"桌面主机"内可随时双向切换,
导航锁定/页面权限/电脑查看器的信任源随模式切换。
原"仅作为桌面主机运行"勾选项被此机制取代。
Samien 1 месяц назад
Родитель
Сommit
53ee14e305
6 измененных файлов с 108 добавлено и 58 удалено
  1. 1 1
      README.md
  2. 1 1
      desktop/lifecycle.test.mjs
  3. 83 40
      desktop/main.mjs
  4. 3 3
      desktop/preferences.mjs
  5. 13 9
      desktop/preferences.test.mjs
  6. 7 4
      docs/desktop-hosts.md

+ 1 - 1
README.md

@@ -16,7 +16,7 @@
 
 Bot 模式的“工具”菜单提供“自动化”和“已连接应用”两个独立入口。“已连接应用”统一管理应用连接与 MCP;“自动化”使用独立的日历、任务列表和运行日志页面。所有模式均可从底部菜单打开独立的“关于”对话框。这些页面不属于设置中心。
 
-远程桌面主机:其他 Mac 安装夭夭 App 后可导入主机配置,出站连接本服务的 15300,把真实桌面提供给服务器上的 Bot 操作(多机共享 Bot 的形态)。注册入口在设置 → Hermes 连接 → 桌面主机,卫星机可勾选“仅作为桌面主机运行”,详见 [远程桌面主机](docs/desktop-hosts.md)。
+远程桌面主机:其他 Mac 上的夭夭 App 启动时可选择「登录远程」或「进入本地」;登录远程后窗口直接使用远程服务器(15300)的夭夭界面,本机不运行 Web 服务,且本机真实桌面可提供给服务器上的 Bot 操作(多机共享 Bot 的形态)。注册入口在服务器的设置 → Hermes 连接 → 桌面主机,详见 [远程桌面主机](docs/desktop-hosts.md)。
 
 ## 运行
 

+ 1 - 1
desktop/lifecycle.test.mjs

@@ -92,7 +92,7 @@ test('real desktop launches its bundled service, survives window close and recov
     assert.equal(updateStatus.installationMode,'desktop');assert.equal(updateStatus.supported,false)
     assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),false)
     await desktop.evaluate(async({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-background');await item.click(item)})
-    assert.deepEqual(await until(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')),value=>value.backgroundAtLogin===true,5000),{backgroundAtLogin:true,hostOnly:false})
+    assert.deepEqual(await until(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')),value=>value.backgroundAtLogin===true,5000),{backgroundAtLogin:true,startupChoice:'ask'})
     const external = new DesktopServiceManager({ home, port, fork: () => { throw new Error('must not fork a second service') } })
     assert.equal((await external.start()).external, true)
     await external.stop()

+ 83 - 40
desktop/main.mjs

@@ -23,7 +23,13 @@ if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
   let updateWindow, updater, environmentHost, hostManager
-  const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => manager?.state.url,
+  let remoteMode = false, remoteURL = ''
+  const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
+  const serviceOrigins = () => {
+    try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
+    catch { return [] }
+  }
+  const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
     preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
   app.on('will-quit', closeComputerViewer)
   const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
@@ -101,15 +107,9 @@ else {
 
   function show() {
     if (closing || quitting) return
-    if (preferences.value.hostOnly) {
-      if (window && !window.isDestroyed()) { window.show(); window.focus(); return }
-      void dialog.showMessageBox({ type: 'info', title: '夭夭', message: '仅桌面主机模式',
-        detail: '这台 Mac 正在作为远程桌面主机运行,本机 Web 服务未启动。如需使用本机服务,请打开菜单「桌面主机」,取消勾选「仅作为桌面主机运行」。' })
-      return
-    }
     if (!window || window.isDestroyed()) createWindow()
     window.show(); window.focus()
-    if (manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
+    if (!remoteMode && manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
   }
   function safeExternal(url) {
     try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
@@ -122,20 +122,21 @@ else {
         webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
     window.webContents.setWindowOpenHandler(({ url }) => {
       try {
-        if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url)
+        if (serviceOrigins().includes(new URL(url).origin)) void window.loadURL(url)
         else safeExternal(url)
       } catch { /* Invalid navigation is denied. */ }
       return { action: 'deny' }
     })
     const guardNavigation = (event, url) => {
-      if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
+      try { if (url === bootURL || serviceOrigins().includes(new URL(url).origin)) return }
+      catch { /* Invalid navigation is denied. */ }
       event.preventDefault(); safeExternal(url)
     }
     window.webContents.on('will-navigate', guardNavigation)
     window.webContents.on('will-redirect', guardNavigation)
     window.webContents.on('will-attach-webview', event => event.preventDefault())
     const trustedService = (contents, url) => {
-      try { return contents === window?.webContents && !!manager?.state.url && new URL(url).origin === manager.state.url }
+      try { return contents === window?.webContents && serviceOrigins().includes(new URL(url).origin) }
       catch { return false }
     }
     window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
@@ -150,14 +151,15 @@ else {
     })
     window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
     window.once('ready-to-show', () => { if (!startHidden) window.show() })
-    if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
+    if (remoteMode && remoteURL) void window.loadURL(remoteURL)
+    else if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
     else void window.loadFile(join(desktopRoot, 'boot.html'))
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
     // Quit owns the window from this point on. A detached connection is not a
     // stopped server and must never navigate the closing window to boot.html.
-    if (closing || quitting) return
+    if (closing || quitting || remoteMode) return
     const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
     if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
     const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
@@ -201,9 +203,11 @@ else {
         if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
         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))
+          if(remoteMode)await hostManager?.start().catch(error=>log(error.message))
+          else{
+            await manager?.reconnect().catch(error=>log(error.message))
+            await runnerManager?.start().catch(error=>log(error.message))
+          }
           stateChanged(manager.state)
           timer=setInterval(()=>{void manager.check()},10000);timer.unref()
           show()
@@ -262,29 +266,67 @@ else {
       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})}}
+    const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
     async function importDesktopHost(){
-      const selection=await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
+      const selection=window&&!window.isDestroyed()
+        ?await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
+        :await dialog.showOpenDialog({title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
       if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
     }
-    async function toggleHostOnly(item){
-      const enabled=item.checked
+    async function enterRemoteMode(){
+      let config=await hostManager.read().catch(error=>{log(error.message);return undefined})
+      if(!config){await importDesktopHost();config=await hostManager.read().catch(()=>undefined)}
+      if(!config)return false
+      remoteMode=true;remoteURL=config.serverURL
+      await preferences.setStartupChoice('remote').catch(error=>log(error.message))
+      await hostManager.start().catch(error=>{hostManager.publish(error.message)})
+      const status=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
+      if(status)status.label=`已登录远程 · ${new URL(remoteURL).host}`
+      tray?.setToolTip(`夭夭 · 远程 ${new URL(remoteURL).host}`)
+      syncModeMenu()
+      log(`远程模式:${remoteURL}`)
+      return true
+    }
+    function syncModeMenu(){
+      const menu=Menu.getApplicationMenu()
+      const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
+      if(useRemote)useRemote.enabled=!remoteMode
+      if(useLocal)useLocal.enabled=remoteMode
+      if(ask)ask.checked=preferences.value.startupChoice==='ask'
+    }
+    async function switchMode(mode){
+      if(closing||quitting)return
       try{
-        await preferences.setHostOnly(enabled)
-        if(enabled){
-          await environmentHost?.close().catch(error=>log(error.message))
-          await runnerManager?.stop().catch(error=>log(error.message))
-          await manager?.stop().catch(error=>log(error.message))
-          if(window&&!window.isDestroyed())window.hide()
-          await dialog.showMessageBox({type:'info',title:'夭夭',message:'已切换到仅桌面主机模式',detail:'本机 Web 服务已停止,这台 Mac 将只通过「桌面主机」连接远程夭夭服务器。需要本机服务时,请取消勾选。'})
+        if(mode==='remote'){
+          if(await enterRemoteMode()){
+            await environmentHost?.close().catch(error=>log(error.message))
+            await runnerManager?.stop().catch(error=>log(error.message))
+            await manager?.stop().catch(error=>log(error.message))
+            if(window&&!window.isDestroyed())void window.loadURL(remoteURL)
+            await appDialog({type:'info',message:'已登录远程服务器',detail:`窗口已切换到 ${remoteURL}。本机 Web 服务已停止;这台 Mac 的桌面可在远程服务器的电脑面板中授权使用。`})
+          }
         }else{
+          remoteMode=false;remoteURL=''
+          await hostManager?.stop().catch(error=>log(error.message))
+          await preferences.setStartupChoice('local').catch(error=>log(error.message))
+          if(window&&!window.isDestroyed())void window.loadFile(join(desktopRoot,'boot.html'))
           await manager?.start().catch(error=>log(error.message))
           if(!closing)environmentHost?.start()
-          await dialog.showMessageBox({type:'info',title:'夭夭',message:'已恢复本机 Web 服务',detail:'本机夭夭服务正在启动,稍后点按「显示窗口」即可使用。'})
+          await appDialog({type:'info',message:'已进入本地服务',detail:'本机夭夭服务正在启动,就绪后窗口会自动打开。'})
         }
-      }catch(error){
-        item.checked=preferences.value.hostOnly
-        await dialog.showMessageBox(window,{type:'error',message:'无法切换仅桌面主机模式',detail:error.message})
-      }
+        syncModeMenu()
+      }catch(error){await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})}
+    }
+    async function chooseStartupMode(){
+      if(fixtureHome)return 'local'
+      const stored=preferences.value.startupChoice
+      if(stored==='local'||stored==='remote')return stored
+      const choice=await dialog.showMessageBox({type:'question',title:'夭夭',message:'这台 Mac 如何使用夭夭?',
+        detail:'「登录远程」连接另一台电脑上的夭夭服务器:本机不运行 Web 服务,窗口直接打开远程夭夭,本机桌面也可供远程服务器的机器人操作(需要服务器下发的桌面主机配置文件)。「进入本地」使用这台 Mac 自己的夭夭服务。',
+        buttons:['登录远程…','进入本地'],defaultId:1,cancelId:1,checkboxLabel:'记住选择,之后启动不再询问',checkboxChecked:false,noLink:true})
+      const mode=choice.response===0?'remote':'local'
+      if(choice.checkboxChecked)await preferences.setStartupChoice(mode).catch(error=>log(error.message))
+      return mode
     }
     async function importRunner(){
       const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
@@ -293,7 +335,7 @@ else {
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
-        { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
+        { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { const url = serviceURL(); if (url) safeExternal(url) } },
         { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
         { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
@@ -318,11 +360,13 @@ else {
         {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-use-remote',label:'登录远程服务器…',enabled:!remoteMode,click:()=>void switchMode('remote')},
+        {id:'desktop-host-use-local',label:'进入本地服务',enabled:remoteMode,click:()=>void switchMode('local')},{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())},{type:'separator'},
-        {id:'desktop-host-only',label:'仅作为桌面主机运行',type:'checkbox',checked:preferences.value.hostOnly,
-          toolTip:'不启动本机 Web 服务,只连接远程夭夭服务器',click:toggleHostOnly}]},
+        {id:'desktop-host-ask',label:'启动时询问使用方式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
+          click:item=>{void preferences.setStartupChoice(item.checked?'ask':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
     ]))
     const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
@@ -331,16 +375,15 @@ else {
     tray.setToolTip('夭夭')
     tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
     tray.on('click', show)
-    if(!preferences.value.hostOnly)createWindow()
+    const startupRemote=(await chooseStartupMode())==='remote'&&await enterRemoteMode()
+    createWindow()
     powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
-    if(preferences.value.hostOnly){
-      log('仅桌面主机模式:不启动本机 Web 服务')
-    }else{
+    if(!startupRemote){
+      syncModeMenu()
       await manager.start().catch(error => log(error.message))
       if(!closing)environmentHost.start()
     }
-    if(!closing)hostManager.start().catch(error=>{hostManager.publish(error.message)})
-    if(!closing&&!preferences.value.hostOnly&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
+    if(!closing&&!startupRemote&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 3 - 3
desktop/preferences.mjs

@@ -3,13 +3,13 @@ import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {homedir} from 'node:os'
 export class DesktopPreferences {
-  constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false,hostOnly:false}}
-  async load(){try{let source;try{source=await readFile(this.path,'utf8')}catch(error){if(error.code!=='ENOENT'||this.home!==join(homedir(),'.yaoyao'))throw error;source=await readFile(join(homedir(),'.hermes-yaoyao','desktop-preferences.json'),'utf8')}const value=JSON.parse(source);this.value={backgroundAtLogin:value.backgroundAtLogin===true,hostOnly:value.hostOnly===true}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
+  constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false,startupChoice:'ask'}}
+  async load(){try{let source;try{source=await readFile(this.path,'utf8')}catch(error){if(error.code!=='ENOENT'||this.home!==join(homedir(),'.yaoyao'))throw error;source=await readFile(join(homedir(),'.hermes-yaoyao','desktop-preferences.json'),'utf8')}const value=JSON.parse(source);this.value={backgroundAtLogin:value.backgroundAtLogin===true,startupChoice:['ask','local','remote'].includes(value.startupChoice)?value.startupChoice:'ask'}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
   async write(value){
     const temporary=this.path+'.'+randomUUID()
     await mkdir(this.home,{recursive:true,mode:0o700})
     try{await writeFile(temporary,JSON.stringify(value),{flag:'wx',mode:0o600});await rename(temporary,this.path);this.value=value}finally{await rm(temporary,{force:true})}
   }
   async setBackgroundAtLogin(enabled){await this.write({...this.value,backgroundAtLogin:enabled===true})}
-  async setHostOnly(enabled){await this.write({...this.value,hostOnly:enabled===true})}
+  async setStartupChoice(choice){await this.write({...this.value,startupChoice:['ask','local','remote'].includes(choice)?choice:'ask'})}
 }

+ 13 - 9
desktop/preferences.test.mjs

@@ -8,24 +8,28 @@ test('background login remains opt-in and persists without changing system login
   const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-preferences-'))
   try{
     const preferences=new DesktopPreferences(home)
-    assert.deepEqual(await preferences.load(),{backgroundAtLogin:false,hostOnly:false})
+    assert.deepEqual(await preferences.load(),{backgroundAtLogin:false,startupChoice:'ask'})
     await preferences.setBackgroundAtLogin(true)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,hostOnly:false})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask'})
     assert.equal((await stat(preferences.path)).mode&0o777,0o600)
     await preferences.setBackgroundAtLogin(false)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,hostOnly:false})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'ask'})
   }finally{await rm(home,{recursive:true,force:true})}
 })
-test('host-only mode persists independently of background login',async()=>{
+test('startup choice persists independently of background login',async()=>{
   const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-preferences-'))
   try{
     const preferences=new DesktopPreferences(home)
     await preferences.load()
-    await preferences.setHostOnly(true)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,hostOnly:true})
+    await preferences.setStartupChoice('remote')
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'remote'})
     await preferences.setBackgroundAtLogin(true)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,hostOnly:true})
-    await preferences.setHostOnly(false)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,hostOnly:false})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'remote'})
+    await preferences.setStartupChoice('local')
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'local'})
+    await preferences.setStartupChoice('ask')
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask'})
+    await preferences.setStartupChoice('nonsense')
+    assert.equal((await new DesktopPreferences(home).load()).startupChoice,'ask')
   }finally{await rm(home,{recursive:true,force:true})}
 })

+ 7 - 4
docs/desktop-hosts.md

@@ -1,12 +1,15 @@
 # 远程桌面主机
 
-一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 安装夭夭 App 后作为"桌面主机"出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。服务器自己机器上的 App 继续走本机回环通道,等同一台编号为 `local` 的主机。
+一台服务器集中运行 Web(15300)与 Hermes(9119),其他 Mac 安装夭夭 App 后作为“桌面主机”出站连接服务器,向服务器上的 Bot 提供自己的真实桌面(截图、键鼠、独立浏览器)。服务器自己机器上的 App 继续走本机回环通道,等同一台编号为 `local` 的主机。
 
 ## 启用流程
 
 1. 管理员在 Web「设置 → Hermes 连接 → 桌面主机」注册主机(填写名称),一次性下载 `desktop-host.json`。凭据只在注册响应中出现,服务端只保存哈希;列表与停用接口不返回令牌。
-2. 在那台 Mac 上打开夭夭 App,菜单「桌面主机 → 导入主机配置…」选择下载的文件。配置经系统钥匙串加密保存在数据目录(`desktop-host.enc`,0600),不进入命令行或环境变量。
-3. 卫星机可在「桌面主机」菜单勾选「仅作为桌面主机运行」:不启动本机 Web 服务与本机回环通道,只维持远程连接。
+2. 在那台 Mac 上启动夭夭 App。**首次启动会询问这台 Mac 的使用方式:「登录远程…」或「进入本地」**。
+   - 选「登录远程」:App 请求选择服务器下发的 `desktop-host.json`(配置经系统钥匙串加密保存在数据目录,`desktop-host.enc`,0600,不进入命令行或环境变量)。连接后**窗口直接打开远程服务器的夭夭界面**(用远程账号登录使用),本机不启动 Web 服务;同时这台 Mac 的桌面可供远程服务器的机器人操作。
+   - 选「进入本地」:保持原有行为,使用本机 Web 服务。
+   - 勾选「记住选择,之后启动不再询问」会把选择写入偏好(`startupChoice`);也可在菜单「桌面主机 → 启动时询问使用方式」恢复每次询问。
+3. 使用中可随时通过菜单「桌面主机 → 登录远程服务器… / 进入本地服务」切换;切换会相应启停本机 Web 服务,并记住最后的选择。导入、重连、忘记配置仍在同一菜单。
 4. 管理员在机器人聊天右侧电脑面板选择「本机」或「仅浏览器」后,用「操作的电脑」选择器把机器人绑定到某台主机(`agent.desktopHost`);未绑定时自动解析:优先本机回环,其次唯一在线的远程主机,多台在线必须显式选择。
 5. 首次授权:管理员点「在桌面端授权」,那台 Mac 弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
 
@@ -27,5 +30,5 @@
 ## 验证
 
 - 服务端:`tests/server/desktopHosts.test.ts`(注册令牌一次性、准入与协议头、撤销围栏)、`tests/server/desktopEnvironments.test.ts`(多主机隔离、绑定解析、按机围栏、接管按机独立)。
-- App:`desktop/host-manager.test.mjs`(配置校验、加密导入、交换往返、403 停止、退避重试)、`desktop/preferences.test.mjs`(仅桌面主机偏好持久化)。
+- App:`desktop/host-manager.test.mjs`(配置校验、加密导入、交换往返、403 停止、退避重试)、`desktop/preferences.test.mjs`(启动选择偏好持久化)。启动选择对话框与远程窗口模式需按上述流程手工验收(自动化桌面测试通过 fixture 环境固定走本地路径)。
 - 浏览器:`tests/e2e/desktop-hosts.spec.ts`(注册/下载/停用;模拟远程主机连接后从电脑面板绑定机器人)。真实多机链路需按上述流程手工验收。