Procházet zdrojové kódy

feat(desktop-host): 远程登录改为 IP + 用户名密码,免配置文件

启动选择"登录远程"后弹出原生登录窗口:填服务器地址与账号密码,
走与浏览器一致的 CSRF/Origin 登录往返,成功后窗口直接进入已登录
的远程夭夭;管理员账号自动把本机注册为桌面主机(令牌钥匙串加密
保存),不再需要导入 desktop-host.json。会话 Cookie 存于 App 会话
存储、密码不落盘;过期后自动重弹登录窗并预填地址。非管理员仅
登录界面并提示。手动导入配置仍保留为备选。
Samien před 1 měsícem
rodič
revize
caa26b6b02

+ 1 - 1
README.md

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

+ 12 - 1
desktop/host-manager.mjs

@@ -57,6 +57,18 @@ export class DesktopHostManager {
    let config
    try{config=parseDesktopHostConfiguration(JSON.parse(await readFile(path,'utf8')))}
    catch(error){throw error instanceof Error?error:new Error('桌面主机配置无效,请从夭夭重新下载配置')}
+   await this.store(config,generation)
+  })
+ }
+ /** Adopt a config obtained in-process (e.g. after a native remote login). */
+ adopt(value){
+  const generation=++this.generation
+  return this.serial(async()=>{
+   const config=parseDesktopHostConfiguration(value)
+   await this.store(config,generation)
+  })
+ }
+ async store(config,generation){
    // 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))
@@ -69,7 +81,6 @@ export class DesktopHostManager {
    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){

+ 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,startupChoice:'ask'})
+    assert.deepEqual(await until(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')),value=>value.backgroundAtLogin===true,5000),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:''})
     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()

+ 75 - 17
desktop/main.mjs

@@ -1,7 +1,7 @@
-import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net } from 'electron'
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net, session as electronSession } from 'electron'
 import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
 import { join, dirname } from 'node:path'
-import { homedir } from 'node:os'
+import { homedir, hostname } from 'node:os'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { DesktopServiceManager } from './service-manager.mjs'
 import { DesktopRunnerManager } from './runner-manager.mjs'
@@ -11,6 +11,7 @@ import { synchronizeLocalService, stopLocalService, migrateLocalData } from './s
 import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.mjs'
+import { remoteSession, enrollDesktopHost, normalizeServerURL } from './remote-login.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { installComputerViewer } from './computer-viewer.mjs'
 
@@ -22,7 +23,7 @@ app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
-  let updateWindow, updater, environmentHost, hostManager
+  let updateWindow, updater, environmentHost, hostManager, loginWindow
   let remoteMode = false, remoteURL = ''
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   const serviceOrigins = () => {
@@ -183,7 +184,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     closing = true; clearInterval(timer)
-    updater?.cancel(); updateWindow?.close()
+    updater?.cancel(); updateWindow?.close(); loginWindow?.close()
     window?.hide()
     // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
     // Both cleanup and the final quit must run after that callback unwinds.
@@ -273,18 +274,38 @@ else {
         :await dialog.showOpenDialog({title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
       if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
     }
+    function openRemoteLogin(){
+      if(closing||quitting)return
+      if(loginWindow&&!loginWindow.isDestroyed()){loginWindow.show();loginWindow.focus();return}
+      loginWindow=new BrowserWindow({width:420,height:580,minWidth:360,minHeight:440,show:false,title:'登录远程',backgroundColor:'#f7f7f7',
+        webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true,webSecurity:true,preload:join(desktopRoot,'remote-login-preload.cjs')}})
+      loginWindow.once('ready-to-show',()=>{if(loginWindow)loginWindow.show()})
+      loginWindow.on('closed',()=>{loginWindow=undefined})
+      const stored=preferences.value.remoteServer
+      void loginWindow.loadFile(join(desktopRoot,'remote-login.html'),stored?{query:{server:stored}}:undefined)
+    }
     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
+      const enrolled=await hostManager.read().catch(error=>{log(error.message);return undefined})
+      let server=''
+      const candidate=preferences.value.remoteServer||enrolled?.serverURL
+      if(candidate){try{server=normalizeServerURL(candidate)}catch(error){log(error.message)}}
+      if(!server){openRemoteLogin();return false}
+      remoteMode=true;remoteURL=server
+      await preferences.setRemoteServer(server).catch(error=>log(error.message))
       await preferences.setStartupChoice('remote').catch(error=>log(error.message))
-      await hostManager.start().catch(error=>{hostManager.publish(error.message)})
+      if(enrolled)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}`)
+      if(status)status.label=`已登录远程 · ${new URL(server).host}`
+      tray?.setToolTip(`夭夭 · 远程 ${new URL(server).host}`)
       syncModeMenu()
-      log(`远程模式:${remoteURL}`)
+      log(`远程模式:${server}`)
+      if(!fixtureHome){
+        try{
+          const probe=await net.fetch(new URL('/api/app/bootstrap',server),{headers:{accept:'application/json'},redirect:'error'})
+          const info=await probe.json().catch(()=>undefined)
+          if(info?.authenticated!==true)openRemoteLogin()
+        }catch{openRemoteLogin()}
+      }
       return true
     }
     function syncModeMenu(){
@@ -317,12 +338,43 @@ else {
         syncModeMenu()
       }catch(error){await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})}
     }
+    ipcMain.handle('desktop-remote-login:submit',async(_event,value)=>{
+      if(closing||quitting)return {ok:false,error:'App 正在退出'}
+      try{
+        const server=normalizeServerURL(value?.serverURL)
+        const auth=await remoteSession(server,{username:value?.username,password:value?.password})
+        let registered=false
+        if(auth.user?.role==='admin'){
+          const registration=await enrollDesktopHost(auth,{name:hostname()})
+          await hostManager.adopt({protocol:1,serverURL:server,hostId:registration.host.id,token:registration.token,...(server.startsWith('http://')?{allowInsecureLan:true}:{})})
+          registered=true
+        }
+        try{
+          for(const [name,cookie] of auth.cookies)
+            await electronSession.defaultSession.cookies.set({url:server,name,value:cookie,httpOnly:true,secure:server.startsWith('https://')})
+        }catch(error){log(`远程会话 Cookie 写入失败:${error.message}`)}
+        await enterRemoteMode()
+        if(!window||window.isDestroyed())createWindow()
+        else void window.loadURL(server)
+        loginWindow?.close()
+        if(!registered)await appDialog({type:'info',message:'已登录远程服务器',detail:'当前账号不是管理员:窗口将使用远程夭夭。要把这台 Mac 注册为可远程控制的桌面主机,请使用管理员账号重新登录。'})
+        return {ok:true,hostRegistered:registered,user:{username:auth.user.username,role:auth.user.role}}
+      }catch(error){return {ok:false,error:error instanceof Error?error.message:'登录失败'}}
+    })
+    ipcMain.handle('desktop-remote-login:use-local',async()=>{
+      loginWindow?.close()
+      if(!remoteMode){
+        if(!window||window.isDestroyed())createWindow()
+        await switchMode('local')
+      }
+      return {ok:true}
+    })
     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 自己的夭夭服务。',
+        detail:'「登录远程」用夭夭服务器的地址和账号密码登录另一台电脑上的夭夭:本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号还会把这台 Mac 注册为可远程控制的桌面主机。「进入本地」使用这台 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))
@@ -375,15 +427,21 @@ 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)
-    const startupRemote=(await chooseStartupMode())==='remote'&&await enterRemoteMode()
-    createWindow()
+    let startupRemote=false,startupLogin=false
+    if((await chooseStartupMode())==='remote'){
+      const enrolled=await hostManager.read().catch(()=>undefined)
+      if(preferences.value.remoteServer||enrolled)startupRemote=await enterRemoteMode()
+      else startupLogin=true
+    }
+    if(!startupLogin)createWindow()
     powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
-    if(!startupRemote){
+    if(startupLogin)openRemoteLogin()
+    else if(!startupRemote){
       syncModeMenu()
       await manager.start().catch(error => log(error.message))
       if(!closing)environmentHost.start()
     }
-    if(!closing&&!startupRemote&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
+    if(!startupLogin&&!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 - 2
desktop/preferences.mjs

@@ -3,8 +3,8 @@ 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,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}
+  constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false,startupChoice:'ask',remoteServer:''}}
+  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',remoteServer:typeof value.remoteServer==='string'?value.remoteServer.slice(0,200):''}}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})
@@ -12,4 +12,5 @@ export class DesktopPreferences {
   }
   async setBackgroundAtLogin(enabled){await this.write({...this.value,backgroundAtLogin:enabled===true})}
   async setStartupChoice(choice){await this.write({...this.value,startupChoice:['ask','local','remote'].includes(choice)?choice:'ask'})}
+  async setRemoteServer(url){await this.write({...this.value,remoteServer:typeof url==='string'?url.slice(0,200):''})}
 }

+ 11 - 7
desktop/preferences.test.mjs

@@ -8,12 +8,12 @@ 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,startupChoice:'ask'})
+    assert.deepEqual(await preferences.load(),{backgroundAtLogin:false,startupChoice:'ask',remoteServer:''})
     await preferences.setBackgroundAtLogin(true)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:''})
     assert.equal((await stat(preferences.path)).mode&0o777,0o600)
     await preferences.setBackgroundAtLogin(false)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'ask'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'ask',remoteServer:''})
   }finally{await rm(home,{recursive:true,force:true})}
 })
 test('startup choice persists independently of background login',async()=>{
@@ -22,14 +22,18 @@ test('startup choice persists independently of background login',async()=>{
     const preferences=new DesktopPreferences(home)
     await preferences.load()
     await preferences.setStartupChoice('remote')
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'remote'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false,startupChoice:'remote',remoteServer:''})
     await preferences.setBackgroundAtLogin(true)
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'remote'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'remote',remoteServer:''})
     await preferences.setStartupChoice('local')
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'local'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'local',remoteServer:''})
     await preferences.setStartupChoice('ask')
-    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask'})
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:''})
     await preferences.setStartupChoice('nonsense')
     assert.equal((await new DesktopPreferences(home).load()).startupChoice,'ask')
+    await preferences.setRemoteServer('http://192.168.1.10:15300')
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:'http://192.168.1.10:15300'})
+    await preferences.setRemoteServer(undefined)
+    assert.equal((await new DesktopPreferences(home).load()).remoteServer,'')
   }finally{await rm(home,{recursive:true,force:true})}
 })

+ 5 - 0
desktop/remote-login-preload.cjs

@@ -0,0 +1,5 @@
+const { contextBridge, ipcRenderer } = require('electron')
+contextBridge.exposeInMainWorld('yaoyaoRemoteLogin', Object.freeze({
+  submit: value => ipcRenderer.invoke('desktop-remote-login:submit', value),
+  useLocal: () => ipcRenderer.invoke('desktop-remote-login:use-local'),
+}))

+ 21 - 0
desktop/remote-login.css

@@ -0,0 +1,21 @@
+:root { color-scheme: light dark; }
+* { box-sizing: border-box; }
+body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 28px;
+  font: 14px/1.6 -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
+  background: #f7f7f7; color: #1d2129; }
+@media (prefers-color-scheme: dark) { body { background: #14181e; color: #dce1e7; } }
+main { width: min(360px, 100%); display: grid; gap: 22px; }
+header { display: grid; justify-items: center; text-align: center; gap: 10px; }
+h1 { margin: 0; font-size: 19px; }
+header p { margin: 0; font-size: 12.5px; opacity: .75; }
+form { display: grid; gap: 14px; padding: 20px; border: 1px solid rgba(128,128,138,.35); border-radius: 14px; background: rgba(127,127,127,.06); }
+label { display: grid; gap: 6px; font-size: 13px; }
+input { font: inherit; padding: 9px 11px; border: 1px solid rgba(128,128,138,.5); border-radius: 8px; background: transparent; color: inherit; min-width: 0; }
+input:focus-visible, button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
+button { font: inherit; min-height: 38px; padding: 8px 14px; border: 1px solid rgba(128,128,138,.5); border-radius: 8px; background: transparent; color: inherit; cursor: pointer; }
+button.primary { background: #2f6fed; border-color: #2f6fed; color: #fff; }
+button.primary:disabled { opacity: .55; cursor: default; }
+footer { display: grid; justify-items: center; }
+footer button { border: 0; font-size: 12.5px; opacity: .8; }
+#error { margin: 0; font-size: 12.5px; color: #d5443c; }
+#status { margin: 0; font-size: 12.5px; opacity: .75; }

+ 17 - 0
desktop/remote-login.html

@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'">
+<title>登录远程 · 夭夭</title><link rel="stylesheet" href="remote-login.css"></head>
+<body><main>
+  <header><img src="icon.png" alt="" width="56" height="56"><h1>登录远程夭夭</h1>
+    <p>连接另一台电脑上的夭夭服务器。登录后本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号同时会把这台 Mac 注册为可远程控制的桌面主机。</p></header>
+  <form id="login-form" aria-label="远程登录">
+    <label>服务器地址<input id="server" name="server" required autocomplete="off" spellcheck="false" placeholder="192.168.1.10:15300" inputmode="url"></label>
+    <label>用户名<input id="username" name="username" required autocomplete="username" autocapitalize="none" spellcheck="false"></label>
+    <label>密码<input id="password" name="password" type="password" required autocomplete="current-password"></label>
+    <button type="submit" id="submit" class="primary">登录</button>
+    <p id="error" role="alert" hidden></p>
+    <p id="status" role="status" aria-live="polite" hidden></p>
+  </form>
+  <footer><button type="button" id="use-local">改为进入本地服务</button></footer>
+</main><script src="remote-login.js"></script></body></html>

+ 23 - 0
desktop/remote-login.js

@@ -0,0 +1,23 @@
+const byId = id => document.getElementById(id)
+const form = byId('login-form')
+const preset = new URLSearchParams(location.search).get('server')
+if (preset) byId('server').value = preset
+form.addEventListener('submit', async event => {
+  event.preventDefault()
+  const error = byId('error'), status = byId('status'), submit = byId('submit')
+  error.hidden = true; status.hidden = false; status.textContent = '正在登录…'; submit.disabled = true
+  try {
+    const result = await window.yaoyaoRemoteLogin.submit({
+      serverURL: byId('server').value,
+      username: byId('username').value,
+      password: byId('password').value,
+    })
+    if (!result.ok) throw new Error(result.error || '登录失败')
+    status.textContent = result.hostRegistered ? '登录成功,已注册本机为远程桌面主机…' : '登录成功…'
+  } catch (cause) {
+    status.hidden = true; error.hidden = false
+    error.textContent = cause instanceof Error ? cause.message : '登录失败'
+    submit.disabled = false
+  }
+})
+byId('use-local').addEventListener('click', () => { void window.yaoyaoRemoteLogin.useLocal() })

+ 62 - 0
desktop/remote-login.mjs

@@ -0,0 +1,62 @@
+/** Native remote login: username/password against a yaoyao Web server.
+ *  Mirrors the browser's CSRF/Origin dance so no server changes are needed,
+ *  and can enroll this Mac as a desktop host with the logged-in session. */
+
+export function normalizeServerURL(input){
+  const raw=String(input??'').trim()
+  if(!raw)throw new Error('请填写夭夭服务器地址')
+  const withScheme=/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)?raw:`http://${raw}`
+  let url
+  try{url=new URL(withScheme)}catch{throw new Error('服务器地址无效')}
+  if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/'))throw new Error('服务器地址无效')
+  return url.origin
+}
+
+/** Minimal cookie jar across the bootstrap → login dance. */
+class CookieJar{
+  constructor(){this.cookies=new Map()}
+  absorb(response){
+    for(const line of response.headers.getSetCookie?.()??[]){
+      const pair=line.split(';')[0],eq=pair.indexOf('=')
+      if(eq>0)this.cookies.set(pair.slice(0,eq).trim(),pair.slice(eq+1).trim())
+    }
+  }
+  header(){return [...this.cookies.entries()].map(([name,value])=>`${name}=${value}`).join('; ')}
+}
+
+export async function remoteSession(serverURL,{username,password},fetchImpl=globalThis.fetch){
+  const base=normalizeServerURL(serverURL)
+  const user=String(username??'').trim(),pass=String(password??'')
+  if(!user||!pass)throw new Error('请填写用户名和密码')
+  const jar=new CookieJar()
+  const bootstrap=await fetchImpl(new URL('/api/app/bootstrap',base),{headers:{accept:'application/json'},redirect:'error'})
+  jar.absorb(bootstrap)
+  if(!bootstrap.ok)throw new Error(`无法连接服务器(HTTP ${bootstrap.status})`)
+  const info=await bootstrap.json().catch(()=>{throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')})
+  if(typeof info?.csrfToken!=='string')throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')
+  const response=await fetchImpl(new URL('/api/app/login',base),{
+    method:'POST',redirect:'error',
+    headers:{accept:'application/json','content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
+    body:JSON.stringify({username:user,password:pass}),
+  })
+  jar.absorb(response)
+  if(response.status===401||response.status===403)throw new Error('用户名或密码不正确')
+  if(!response.ok)throw new Error(`登录失败(HTTP ${response.status})`)
+  const result=await response.json().catch(()=>{throw new Error('登录响应无效')})
+  if(!result?.user?.id)throw new Error('登录响应无效')
+  return {origin:base,user:result.user,csrfToken:String(result.csrfToken??info.csrfToken),cookies:[...jar.cookies.entries()]}
+}
+
+export async function enrollDesktopHost(session,{name},fetchImpl=globalThis.fetch){
+  const cookie=session.cookies.map(([key,value])=>`${key}=${value}`).join('; ')
+  const response=await fetchImpl(new URL('/api/app/admin/desktop-hosts',session.origin),{
+    method:'POST',redirect:'error',
+    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)}),
+  })
+  if(response.status===403)throw new Error('需要管理员账号,才能把这台 Mac 注册为可远程控制的桌面主机')
+  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('注册桌面主机响应无效')
+  return result
+}

+ 76 - 0
desktop/remote-login.test.mjs

@@ -0,0 +1,76 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {createServer} from 'node:http'
+import {randomUUID,randomBytes} from 'node:crypto'
+import {remoteSession,enrollDesktopHost,normalizeServerURL} from './remote-login.mjs'
+
+test('server addresses are normalized like browser URLs',()=>{
+  assert.equal(normalizeServerURL('192.168.1.10:15300'),'http://192.168.1.10:15300')
+  assert.equal(normalizeServerURL('http://192.168.1.10:15300/'),'http://192.168.1.10:15300')
+  assert.equal(normalizeServerURL('https://yaoyao.example.com'),'https://yaoyao.example.com')
+  assert.throws(()=>normalizeServerURL(''),/服务器地址/)
+  assert.throws(()=>normalizeServerURL('https://user:pass@example.com'),/无效/)
+  assert.throws(()=>normalizeServerURL('http://example.com/path'),/无效/)
+  assert.throws(()=>normalizeServerURL('ftp://example.com'),/无效/)
+})
+
+async function fakeServer(){
+  const csrf=randomUUID(),session=randomBytes(24).toString('base64url'),token=randomBytes(32).toString('base64url'),hostId=randomUUID()
+  const requests=[]
+  const server=createServer((req,res)=>{
+    const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{
+      const body=Buffer.concat(chunks).toString()
+      requests.push({method:req.method,url:req.url,origin:req.headers.origin,csrf:req.headers['x-csrf-token'],cookie:req.headers.cookie??'',body})
+      const json=payload=>{res.setHeader('content-type','application/json');res.end(JSON.stringify(payload))}
+      if(req.url==='/api/app/bootstrap'&&(req.method==='GET'||req.method==='HEAD')){res.setHeader('set-cookie','csrf=test-csrf; Path=/; HttpOnly');return json({authRequired:true,csrfToken:csrf})}
+      if(req.url==='/api/app/login'&&req.method==='POST'){
+        const credentials=JSON.parse(body||'{}')
+        if(credentials.username!=='admin'||credentials.password!=='pass'){res.statusCode=401;return json({error:'invalid'})}
+        if(req.headers['x-csrf-token']!==csrf||!String(req.headers.cookie).includes('csrf=')){res.statusCode=403;return json({error:'csrf'})}
+        res.setHeader('set-cookie',[`session=${session}; Path=/; HttpOnly`,`csrf=${csrf}.sig; Path=/; HttpOnly`])
+        return json({user:{id:randomUUID(),username:'admin',role:'admin'},csrfToken:csrf+'-rotated'})
+      }
+      if(req.url==='/api/app/admin/desktop-hosts'&&req.method==='POST'){
+        if(req.headers['x-csrf-token']!==csrf+'-rotated'||!String(req.headers.cookie).includes(`session=${session}`)){res.statusCode=403;return json({error:'session'})}
+        return json({host:{id:hostId,name:JSON.parse(body||'{}').name,enabled:true},token})
+      }
+      res.statusCode=404;json({error:'not found'})
+    })
+  })
+  await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+  return {server,url:`http://127.0.0.1:${server.address().port}`,requests,csrf,session,token,hostId}
+}
+
+test('logs in with username and password and enrolls this Mac as a desktop host',async()=>{
+  const fake=await fakeServer()
+  try{
+    const auth=await remoteSession(fake.url,{username:'admin',password:'pass'})
+    assert.equal(auth.user.role,'admin')
+    assert.equal(auth.origin,fake.url)
+    assert.ok(auth.cookies.some(([name])=>name==='session'))
+    const enrollment=await enrollDesktopHost(auth,{name:'书房 iMac'})
+    assert.equal(enrollment.host.id,fake.hostId)
+    assert.equal(enrollment.token,fake.token)
+    const [bootstrap,login,enroll]=fake.requests
+    assert.equal(bootstrap.url,'/api/app/bootstrap')
+    assert.equal(login.origin,fake.url)
+    assert.equal(login.csrf,fake.csrf)
+    assert.equal(enroll.csrf,fake.csrf+'-rotated')
+    assert.ok(enroll.cookie.includes(`session=${fake.session}`))
+    assert.equal(JSON.parse(enroll.body).name,'书房 iMac')
+  }finally{
+    fake.server.close();fake.server.closeAllConnections?.()
+  }
+})
+
+test('wrong credentials and non-admin accounts surface clear errors',async()=>{
+  const fake=await fakeServer()
+  try{
+    await assert.rejects(()=>remoteSession(fake.url,{username:'admin',password:'wrong'}),/用户名或密码不正确/)
+    await assert.rejects(()=>remoteSession(fake.url,{username:'',password:'pass'}),/用户名和密码/)
+    const auth={origin:fake.url,csrfToken:'stale',cookies:[['session','x']]}
+    await assert.rejects(()=>enrollDesktopHost(auth,{name:'x'}),/管理员账号|注册桌面主机失败/)
+  }finally{
+    fake.server.close();fake.server.closeAllConnections?.()
+  }
+})

+ 6 - 6
docs/desktop-hosts.md

@@ -4,12 +4,12 @@
 
 ## 启用流程
 
-1. 管理员在 Web「设置 → Hermes 连接 → 桌面主机」注册主机(填写名称),一次性下载 `desktop-host.json`。凭据只在注册响应中出现,服务端只保存哈希;列表与停用接口不返回令牌。
-2. 在那台 Mac 上启动夭夭 App。**首次启动会询问这台 Mac 的使用方式:「登录远程…」或「进入本地」**。
-   - 选「登录远程」:App 请求选择服务器下发的 `desktop-host.json`(配置经系统钥匙串加密保存在数据目录,`desktop-host.enc`,0600,不进入命令行或环境变量)。连接后**窗口直接打开远程服务器的夭夭界面**(用远程账号登录使用),本机不启动 Web 服务;同时这台 Mac 的桌面可供远程服务器的机器人操作。
+1. 在那台 Mac 上启动夭夭 App。**首次启动会询问这台 Mac 的使用方式:「登录远程…」或「进入本地」**。
+   - 选「登录远程」:弹出登录窗口,填写**夭夭服务器地址(如 `192.168.1.10:15300`)、用户名和密码**。登录成功后窗口直接使用远程服务器的夭夭界面(本机不运行 Web 服务);用**管理员账号**登录时,App 会自动把这台 Mac 注册为远程桌面主机(令牌经系统钥匙串加密保存在数据目录 `desktop-host.enc`,0600),无需任何配置文件。非管理员账号只登录界面,并提示需要管理员账号才能注册本机控制。
    - 选「进入本地」:保持原有行为,使用本机 Web 服务。
    - 勾选「记住选择,之后启动不再询问」会把选择写入偏好(`startupChoice`);也可在菜单「桌面主机 → 启动时询问使用方式」恢复每次询问。
-3. 使用中可随时通过菜单「桌面主机 → 登录远程服务器… / 进入本地服务」切换;切换会相应启停本机 Web 服务,并记住最后的选择。导入、重连、忘记配置仍在同一菜单。
+2. 后续启动直接进入远程模式;若登录会话已过期,App 会重新弹出登录窗口(服务器地址已预填)。会话 Cookie 保存在 App 的会话存储中,密码不落盘。使用中可随时通过菜单「桌面主机 → 登录远程服务器… / 进入本地服务」切换;「忘记配置」会移除本机的桌面主机凭据,下次登录时重新注册。
+3. 服务器管理侧(可选):「设置 → Hermes 连接 → 桌面主机」列出各台已注册主机(在线状态、机器名、最后在线)并可停用;停用立即断开那台 Mac。手动注册并下载 `desktop-host.json` 仍可用于不便登录的机器(菜单「桌面主机 → 导入主机配置…」),但常规流程不再需要。
 4. 管理员在机器人聊天右侧电脑面板选择「本机」或「仅浏览器」后,用「操作的电脑」选择器把机器人绑定到某台主机(`agent.desktopHost`);未绑定时自动解析:优先本机回环,其次唯一在线的远程主机,多台在线必须显式选择。
 5. 首次授权:管理员点「在桌面端授权」,那台 Mac 弹出原生授权对话框;确认后引导开启屏幕录制与辅助功能。授权按账号哈希(ownerKey)记录在各 Mac 的 `computer-permissions.json`,各机独立;菜单「撤销本机控制授权」同时作用于本机与远程通道。
 
@@ -18,7 +18,7 @@
 - 机器端点 `POST /api/desktop-host/v1/<hostId>/exchange`:Bearer 令牌(sha256 后 timingSafeEqual)+ `x-desktop-host-protocol: 1`;拒绝任何携带 Origin 的请求,不进入用户会话、CSRF 或 CORS 体系;Host 白名单照常生效。
 - 交换协议沿用桌面端 `{host, results} → {commands}` 格式:每轮最多 8 条命令、20 条结果,请求体上限 24MB。App 成功后 400ms 轮询,网络错误指数退避至 5s;令牌失效(401/403)即停止并提示重新导入。
 - 每台主机独立持有命令队列、截图缓存、接管凭据与授权状态;宿主 epoch(App 每次启动/撤销时轮换的 host.id)变化即围栏在途命令。人工接管的 generation/epoch/30 秒续期语义与单主机时一致,接管记录以 `desktop:<hostKey>:<resource>` 区分。
-- 远程明文 HTTP 需在注册时勾选「可信局域网 HTTP」(配置写入 `allowInsecureLan: true`),与 Runner 规则一致;公网部署应使用 HTTPS 并把域名加入外网访问地址。
+- 远程明文 HTTP(服务器地址以 `http://` 登录)时,App 保存的主机配置自动带 `allowInsecureLan: true`,与 Runner 规则一致;公网部署应使用 HTTPS 并把域名加入外网访问地址。登录凭据只走该服务器地址,密码不落盘。
 - 停用主机立即断开那台 Mac 并围栏其在途命令;配置丢失时停用并重新注册,旧令牌不能恢复。
 
 ## 范围
@@ -30,5 +30,5 @@
 ## 验证
 
 - 服务端:`tests/server/desktopHosts.test.ts`(注册令牌一次性、准入与协议头、撤销围栏)、`tests/server/desktopEnvironments.test.ts`(多主机隔离、绑定解析、按机围栏、接管按机独立)。
-- App:`desktop/host-manager.test.mjs`(配置校验、加密导入、交换往返、403 停止、退避重试)、`desktop/preferences.test.mjs`(启动选择偏好持久化)。启动选择对话框与远程窗口模式需按上述流程手工验收(自动化桌面测试通过 fixture 环境固定走本地路径)。
+- App:`desktop/remote-login.test.mjs`(地址归一化、账号密码登录的 CSRF/Origin 往返、管理员注册桌面主机、错误路径)、`desktop/host-manager.test.mjs`(配置校验、加密导入、交换往返、403 停止、退避重试)、`desktop/preferences.test.mjs`(启动选择与远程地址偏好持久化)。启动选择/登录窗口需按上述流程手工验收(自动化桌面测试通过 fixture 环境固定走本地路径)。
 - 浏览器:`tests/e2e/desktop-hosts.spec.ts`(注册/下载/停用;模拟远程主机连接后从电脑面板绑定机器人)。真实多机链路需按上述流程手工验收。