Quellcode durchsuchen

完善桌面版本核对和可选登录启动

LSamien vor 1 Monat
Ursprung
Commit
7cfac2caad

+ 7 - 0
desktop/lifecycle.test.mjs

@@ -86,6 +86,11 @@ test('real desktop launches its bundled service, survives window close and recov
     await until(() => localJSON(`http://127.0.0.1:${port}/healthz`), r => r.ok)
     assert.notEqual(replacement.instanceId, owner.instanceId)
     await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    const updateStatus=await page.evaluate(async()=>await (await fetch('/api/app/system/update/status')).json())
+    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})
     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()
@@ -96,6 +101,8 @@ test('real desktop launches its bundled service, survives window close and recov
     desktop=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`}})
     const reopened=await desktop.firstWindow();await reopened.waitForURL(`http://127.0.0.1:${port}/**`)
     await until(()=>reopened.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),true)
+    assert.equal(await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible()),true,'manual reopening still shows the window')
     servicePID=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8')).pid
     runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
     await desktop.close();desktop=null

+ 13 - 2
desktop/main.mjs

@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { DesktopServiceManager } from './service-manager.mjs'
 import { DesktopRunnerManager } from './runner-manager.mjs'
+import { DesktopPreferences } from './preferences.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -21,6 +22,8 @@ else {
   const logFile = join(logRoot, 'server.log')
   const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
   const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
+  const preferences = new DesktopPreferences(home)
+  let startHidden = false
   const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
   if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
 
@@ -79,7 +82,7 @@ else {
       } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
     })
     window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
-    window.once('ready-to-show', () => window.show())
+    window.once('ready-to-show', () => { if (!startHidden) window.show() })
     if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
     else void window.loadFile(join(desktopRoot, 'boot.html'))
   }
@@ -110,6 +113,8 @@ else {
     })()
   })
   app.whenReady().then(async () => {
+    await preferences.load()
+    startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       fork: ({ home, port }) => {
@@ -139,7 +144,13 @@ else {
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
-        { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' },
+        { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
+        { 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-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-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`当前服务:${manager.state.version||'尚未就绪'}${manager.state.external?'(已有外部服务)':'(App 托管)'}\n\n更新:先停止任务并退出 App,备份数据目录,保留旧 App,再用经过签名验证的新 App 替换。新 App 打开后核对服务版本及任务状态。\n\n回退:退出新 App,恢复旧 App;若版本包含数据迁移,同时恢复更新前的数据备份。App 不会自动替换已有外部服务。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
+        { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
       {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},

+ 12 - 0
desktop/preferences.mjs

@@ -0,0 +1,12 @@
+import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+export class DesktopPreferences {
+  constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false}}
+  async load(){try{const value=JSON.parse(await readFile(this.path,'utf8'));this.value={backgroundAtLogin:value.backgroundAtLogin===true}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
+  async setBackgroundAtLogin(enabled){
+    const value={backgroundAtLogin:enabled===true},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})}
+  }
+}

+ 18 - 0
desktop/preferences.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,rm,stat} from 'node:fs/promises'
+import {join} from 'node:path'
+import {tmpdir} from 'node:os'
+import {DesktopPreferences} from './preferences.mjs'
+test('background login remains opt-in and persists without changing system login permission',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-preferences-'))
+  try{
+    const preferences=new DesktopPreferences(home)
+    assert.deepEqual(await preferences.load(),{backgroundAtLogin:false})
+    await preferences.setBackgroundAtLogin(true)
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true})
+    assert.equal((await stat(preferences.path)).mode&0o777,0o600)
+    await preferences.setBackgroundAtLogin(false)
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false})
+  }finally{await rm(home,{recursive:true,force:true})}
+})

+ 5 - 2
desktop/service-manager.mjs

@@ -60,8 +60,9 @@ export class DesktopServiceManager {
     const url = new URL(record.url)
     if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
     const actual = await localJSON(new URL('/desktop/service', url), { 'x-yaoyao-desktop-token': record.token })
-    if (actual.protocol !== 1 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== dataKey(this.root))
+    if (actual.protocol !== 1 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== dataKey(this.root) || actual.version !== record.version || typeof actual.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(actual.version))
       throw new Error('服务身份与本地数据不匹配')
+    if (this.child?.pid === actual.pid && actual.version !== this.options.version) throw new Error('App 内置服务版本不匹配,请重新安装完整 App')
     return { ...actual, url: url.origin }
   }
   async legacyService() {
@@ -101,7 +102,7 @@ export class DesktopServiceManager {
       if (record?.url) {
         const service = await this.verify(record)
         if (generation !== this.generation || this.stopping) return
-        this.publish({ phase: 'ready', message: '已连接已有服务', ...service, external: !this.child })
+        this.publish({ phase: 'ready', message: `已连接已有服务(${service.version})`, ...service, external: !this.child })
         return this.state
       }
       const legacy = !record ? await this.legacyService() : null
@@ -159,6 +160,7 @@ export class DesktopServiceManager {
   }
   async check() {
     if (this.state.phase !== 'ready') return
+    const generation = this.generation
     try {
       if (this.state.legacy) {
         if (!isAlive(this.state.pid)) throw new Error('已有服务已停止')
@@ -169,6 +171,7 @@ export class DesktopServiceManager {
         await this.verify(record)
       }
     } catch {
+      if (this.stopping || generation !== this.generation) return
       this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
     }
   }

+ 19 - 0
desktop/service-manager.test.mjs

@@ -0,0 +1,19 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {createServer} from 'node:http'
+import {dataKey,DesktopServiceManager} from './service-manager.mjs'
+test('checks recorded and bundled versions while permitting a separately owned older service',async()=>{
+  const root='/fixture/desktop',record={protocol:1,instanceId:'fixture',pid:1234,dataKey:dataKey(root),version:'0.3.32',token:'fixture'}
+  let actual={...record};delete actual.token
+  const server=createServer((request,response)=>{response.setHeader('Content-Type','application/json');response.end(JSON.stringify(actual))})
+  await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+  record.url=`http://127.0.0.1:${server.address().port}`
+  const manager=new DesktopServiceManager({version:'0.3.33'});manager.root=root
+  try{
+    assert.equal((await manager.verify(record)).version,'0.3.32')
+    actual={...actual,version:'0.3.31'}
+    await assert.rejects(manager.verify(record),/身份/)
+    actual={...actual,version:'0.3.32'};manager.child={pid:record.pid}
+    await assert.rejects(manager.verify(record),/版本不匹配/)
+  }finally{await new Promise(resolve=>server.close(resolve))}
+})

+ 2 - 0
docs/agent-platform-implementation.md

@@ -89,3 +89,5 @@
 
 - 镜像与维护阶段:本仓库提供固定来源的镜像配方、健康/截图验证、校验归档导入导出、停止后的工作区备份/恢复/重建及内容恢复中断后的回退。新镜像不会自动替换运行环境;保留旧镜像与恢复前数据。真实 arm64 Docker 构建、归档往返通过。详见 `docs/computer-maintenance.md`。
 - 维护阶段最终验证:类型检查和 767 项全量测试通过;真实新镜像的工作区恢复后重启读写通过;打包 Runner / 新镜像的完整 Worker 验收 2 项通过,包含公网、临时助手和人工接管。恢复保留挂载根目录以兼容 macOS 文件共享,并测试中断回退。
+
+- 桌面补齐阶段:内置服务版本与 App 对齐校验,已有外部服务显示实际版本;登录启动为原生显式开关,支持登录时仅驻留菜单栏。App 内置服务禁用源码部署更新器,提供 App 手动更新/数据回退说明。6 项桌面生命周期及凭据/偏好/版本测试、14 项更新器定向测试通过。真实开机与最后完整包验收仍需区分,见 `docs/macos-app.md`。

+ 43 - 0
docs/macos-app.md

@@ -0,0 +1,43 @@
+# macOS App 与本地服务
+
+App、Web 服务、Vue 界面和 Runner 同仓维护。打开 App 会先核对数据目录及已有监听服务;有可信的已有服务则连接,没有则启动包内服务。默认数据目录为 `~/.hermes-yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
+
+## 生命周期与权限
+
+- App 单实例;服务和 Runner 各自对实际数据目录持有 SQLite 独占锁。
+- 使用私有随机凭据核对服务实例、PID、数据目录、协议及版本。App 自己启动的服务必须匹配内置版本。连接旧版本外部服务时使用其界面和能力声明。
+- 老 CLI 服务没有描述文件时,只有确认它同时持有目标 SQLite 文件和监听端口才连接。
+- 关闭窗口保留任务;菜单栏和 Dock 可恢复。明确退出等待自己托管的 Runner 和服务结束,不停止外部服务。停止无法确认时显示实际清理错误。
+- 服务崩溃有界重启,睡眠恢复后重新核对连接;错误页面提供重试和日志。
+- Runner 配置由原生文件选择器导入,通过系统加密存储及私有进程消息传递。Web 页面没有配置解密或桌面启动权限,外部页面不能调用原生桥。
+
+## 登录启动
+
+“夭夭 → 登录 macOS 时启动”使用系统登录项,默认不开启。用户可另选“登录启动时仅驻留菜单栏”;这个偏好只影响登录启动,手动打开 App 仍显示窗口。正常安装不会改写系统登录项。
+
+源码开发 App 不提供登录项开关;安装包中的开关由系统管理。实际开机/重新登录验收尚未执行,当前验证的是偏好持久化和手动重新打开行为。
+
+## 更新与回退
+
+App 内置服务随整个 App 更新,不使用源码部署的 LaunchAgent 更新器。菜单“App 更新与回退”显示当前 App/服务版本、托管归属和数据目录。当前采用手动替换 App 的流程,没有启用自动下载安装:
+
+1. 停止任务并退出 App。若连接已有外部服务,单独按它的原有方式停止,确认没有进程写入数据目录。
+2. 私有备份整个数据目录和正在使用的 App。电脑工作区可使用 `docs/computer-maintenance.md` 中的校验备份。
+3. 核对新包架构、签名和校验值,再替换 App;启动后核对服务实际版本、健康状态、会话和任务。
+4. 出现问题时退出新 App,恢复原 App;包含数据迁移的升级须同时恢复升级前的数据。App 不主动删除原镜像和工作区备份。
+
+此流程保护签名后的 App 资源不被 Web 更新器改写。正式数据升级/回退必须在停止写入的条件下操作,不能拿运行中数据库的普通文件复制充当一致备份。
+
+## 构建与验收
+
+```sh
+npm run desktop:build
+npm run desktop:test
+npm run desktop:pack
+codesign --verify --deep --strict desktop-release/mac-arm64/夭夭.app
+hdiutil verify desktop-release/Yaoyao-0.3.32-arm64.dmg
+```
+
+开发 App 的 6 项测试通过:真实内置服务启动、账号与 Runner 连接、关闭窗口保持运行、服务崩溃恢复、外部客户端不误停服务、退出清理、加密配置再次启动、偏好保存及手动重新打开、版本不符拒绝、钥匙串失败保护。另有更新器 14 项定向测试通过,包含 App 禁用源码更新及回退。
+
+`desktop-release` 中现有旧包仍是此前阶段快照。最终完整平台尚需重新打包、严格签名验证并对实际 App 重跑生命周期测试;开发签名、公证、安装、发布分别记录,不混为已交付。

+ 1 - 1
src/client/api/systemUpdate.ts

@@ -33,7 +33,7 @@ export interface UpdateJob {
 
 export interface SystemUpdateStatus {
   current: ReleaseManifest
-  installationMode: 'source' | 'release'
+  installationMode: 'source' | 'release' | 'desktop'
   latest?: ReleaseManifest
   updateAvailable: boolean
   supported: boolean

+ 11 - 5
src/server/updateManager.ts

@@ -60,7 +60,7 @@ interface StoredUpdateJob extends UpdateJob {
 
 export interface SystemUpdateStatus {
   current: ReleaseManifest
-  installationMode: 'source' | 'release'
+  installationMode: 'source' | 'release' | 'desktop'
   latest?: ReleaseManifest
   updateAvailable: boolean
   supported: boolean
@@ -84,6 +84,7 @@ export interface SystemUpdateManagerOptions {
   inspectRemote?: InspectRemote
   launchUpdater?: LaunchUpdater
   platform?: NodeJS.Platform
+  desktopOwned?: boolean
 }
 
 function run(command: string, args: string[], timeout = 30_000): Promise<string> {
@@ -200,6 +201,7 @@ export class SystemUpdateManager {
   private readonly inspectRemote: InspectRemote
   private readonly launchUpdater: LaunchUpdater
   private readonly platform: NodeJS.Platform
+  private readonly desktopOwned: boolean
 
   constructor(private readonly config: ServerConfig, options: SystemUpdateManagerOptions = {}) {
     this.projectRoot = resolve(options.projectRoot ?? process.cwd())
@@ -210,6 +212,7 @@ export class SystemUpdateManager {
     this.releaseSource = config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE
     this.inspectRemote = options.inspectRemote ?? inspectGitRemote
     this.platform = options.platform ?? process.platform
+    this.desktopOwned = options.desktopOwned ?? process.env.HERMES_YAOYAO_DESKTOP === '1'
     this.launchUpdater = options.launchUpdater ?? ((jobPath) => {
       const child = spawn(process.execPath, [this.updaterPath, 'run', '--job', jobPath], {
         detached: true,
@@ -257,18 +260,19 @@ export class SystemUpdateManager {
     const job = this.latestJob()
     return {
       current,
-      installationMode: this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
+      installationMode: this.desktopOwned ? 'desktop' : this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
       latest,
       updateAvailable: Boolean(latest && compareReleaseVersions(latest.releaseVersion, current.releaseVersion) > 0),
-      supported: this.platform === 'darwin',
-      unsupportedReason: this.platform === 'darwin' ? undefined : '容器和非 macOS 环境请通过替换部署镜像升级',
-      canRollback: existsSync(join(this.updateHome, 'last-success.json')),
+      supported: !this.desktopOwned && this.platform === 'darwin',
+      unsupportedReason: this.desktopOwned ? 'App 内置服务随 App 一起更新,请使用夭夭菜单中的 App 更新与回退。' : this.platform === 'darwin' ? undefined : '容器和非 macOS 环境请通过替换部署镜像升级',
+      canRollback: !this.desktopOwned && existsSync(join(this.updateHome, 'last-success.json')),
       job: publicJob(job),
     }
   }
 
   async check(): Promise<SystemUpdateStatus> {
     const current = this.currentManifest()
+    if (this.desktopOwned) return this.status()
     const latest = await this.inspectRemote(this.releaseSource, current)
     return this.status(latest?.manifest)
   }
@@ -348,6 +352,7 @@ export class SystemUpdateManager {
   }
 
   async startUpdate(targetVersion: string): Promise<UpdateJob> {
+    if (this.desktopOwned) throw new Error('App 内置服务随 App 一起更新或回退')
     if (this.platform !== 'darwin') throw new Error('当前环境不支持服务内升级')
     const current = this.currentManifest()
     const latest = await this.inspectRemote(this.releaseSource, current)
@@ -359,6 +364,7 @@ export class SystemUpdateManager {
   }
 
   startRollback(): UpdateJob {
+    if (this.desktopOwned) throw new Error('App 内置服务随 App 一起更新或回退')
     if (this.platform !== 'darwin') throw new Error('当前环境不支持服务内回滚')
     if (!existsSync(join(this.updateHome, 'last-success.json'))) throw new Error('没有可回滚的上一版本')
     return publicJob(this.createJob('rollback'))!

+ 10 - 0
tests/server/updateManager.test.ts

@@ -48,6 +48,16 @@ function fixture() {
 }
 
 describe('system release contract', () => {
+  it('keeps packaged App updates outside the source service updater', async () => {
+    const {root,config}=fixture()
+    let inspected=false,launched=false
+    const manager=new SystemUpdateManager(config,{projectRoot:root,platform:'darwin',desktopOwned:true,inspectRemote:async()=>{inspected=true;return latestRemote},launchUpdater:()=>{launched=true}})
+    expect(await manager.check()).toMatchObject({installationMode:'desktop',supported:false,canRollback:false})
+    await expect(manager.startUpdate(latest.releaseVersion)).rejects.toThrow('App')
+    expect(()=>manager.startRollback()).toThrow('App')
+    expect(inspected).toBe(false);expect(launched).toBe(false)
+  })
+
   it('maps an HTTPS Git source to the lightweight tagged release manifest', () => {
     expect(releaseManifestURL(
       'https://git.samien.cn/samien/hermes-yaoyao.git',