Browse Source

修复启动页脚本漏打包并允许同步延后时进入现有 Web

LSamien 1 tháng trước cách đây
mục cha
commit
4117ca60ef

+ 1 - 1
bin/desktop-service-sync.mjs

@@ -29,5 +29,5 @@ try {
     version: current.version, commit: current.commit, buildNumber: current.buildNumber, artifactDigest: current.artifactDigest,
   } } }) + '\n')
 } catch (error) {
-  process.stdout.write(JSON.stringify({ error: error.message }) + '\n'); process.exitCode = 1
+  process.stdout.write(JSON.stringify({ error: error.message, code: error.code }) + '\n'); process.exitCode = 1
 }

+ 8 - 5
bin/lib/service-update.mjs

@@ -109,7 +109,9 @@ export class LaunchAgentService {
     catch { return new Set() }
   }
   listeners(established = false) {
-    try { return new Set(execFileSync('/usr/sbin/lsof', ['-nP', `-iTCP:${this.port}`, '-sTCP:' + (established ? 'ESTABLISHED' : 'LISTEN'), '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
+    const pid = established ? this.pid() : undefined
+    if (established && !pid) return new Set()
+    try { return new Set(execFileSync('/usr/sbin/lsof', [...(pid ? ['-a', '-p', String(pid)] : []), '-nP', `-iTCP:${this.port}`, '-sTCP:' + (established ? 'ESTABLISHED' : 'LISTEN'), '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
     catch { return new Set() }
   }
   record() {
@@ -139,9 +141,10 @@ export class LaunchAgentService {
         return
       }
       onProgress('正在等待聊天和后台任务结束后同步 Web…')
+      if (Date.now() >= deadline) break
       await sleep(1000)
     } while (Date.now() < deadline)
-    throw new Error('当前仍有任务或旧版客户端连接,已延后同步;任务结束后可重试')
+    throw Object.assign(new Error('当前仍有任务或旧版客户端连接,已延后同步;任务结束并关闭其他客户端后可重试'), { code: 'service_busy' })
   }
   async resume() {
     const record = this.record()
@@ -234,7 +237,7 @@ export async function recoverTransition({ home, releaseRoot, driver, onProgress
   rmSync(path)
   await driver.resume()
 }
-export async function transitionService({ home, releaseRoot, driver, finalRoot, onProgress = () => {}, rollback = false }) {
+export async function transitionService({ home, releaseRoot, driver, finalRoot, onProgress = () => {}, rollback = false, waitForIdleMs }) {
   const previous = driver.snapshot(), previousCurrent = currentRelease(releaseRoot)
   const path = join(home, 'updates', 'transition.json'), id = randomUUID()
   const success = join(home, 'updates', 'last-success.json')
@@ -243,7 +246,7 @@ export async function transitionService({ home, releaseRoot, driver, finalRoot,
   const expected = readRuntime(finalRoot)
   try {
     if (previous?.wasRunning) await driver.verify(undefined, 5000)
-    await driver.quiesce(onProgress)
+    await driver.quiesce(onProgress, waitForIdleMs)
     onProgress('正在备份数据并切换 Web 服务…')
     // Persist before stopping; recovery never mistakes a partial backup for a complete snapshot.
     writeJSON(path, journal)
@@ -306,7 +309,7 @@ export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, drive
       finally { rmSync(staging, { recursive: true, force: true }) }
     }
     verifyRuntimePackage(finalRoot)
-    await transitionService({ home, releaseRoot, driver, finalRoot, onProgress })
+    await transitionService({ home, releaseRoot, driver, finalRoot, onProgress, waitForIdleMs: 0 })
     writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
     return { action: decision, current: target }
   } finally { release?.(); unlock() }

+ 9 - 3
desktop/boot.js

@@ -1,12 +1,18 @@
-async function render() {
-  const state = await window.yaoyaoDesktop.status()
+function display(state) {
   document.getElementById('status').textContent = state.message
   document.getElementById('actions').hidden = state.phase !== 'error'
   document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
+  document.querySelector('main').setAttribute('aria-busy', String(state.phase === 'starting' || state.phase === 'restarting'))
+}
+async function render() {
+  try { display(await window.yaoyaoDesktop.status()) }
+  catch { display({ phase: 'error', message: '无法读取启动状态,请重试或查看服务日志。' }) }
 }
 document.getElementById('retry').onclick = async () => {
   document.getElementById('retry').disabled = true
-  try { await window.yaoyaoDesktop.retry() } finally { document.getElementById('retry').disabled = false; await render() }
+  try { await window.yaoyaoDesktop.retry(); await render() }
+  catch { display({ phase: 'error', message: '启动未完成,请重试或查看服务日志。' }) }
+  finally { document.getElementById('retry').disabled = false }
 }
 document.getElementById('logs').onclick = () => window.yaoyaoDesktop.logs()
 void render();setInterval(() => { void render() }, 500)

+ 7 - 0
desktop/main.mjs

@@ -90,6 +90,10 @@ else {
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
+    const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
+    if (notice) notice.visible = Boolean(state.updateNotice)
+    const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
+    if (retrySync) retrySync.enabled = state.phase === 'ready' && Boolean(manager?.options.synchronize)
     tray?.setToolTip(`夭夭 · ${state.message}`)
     if (!window || window.isDestroyed()) return
     if (state.phase === 'ready') {
@@ -155,6 +159,9 @@ else {
       { 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) },
+        { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
+        { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
+          click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
         { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin,
           click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
         { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,

+ 17 - 2
desktop/service-manager.mjs

@@ -48,7 +48,15 @@ export class DesktopServiceManager {
     this.stopping = false
     this.restarts = []
   }
-  publish(value) { this.state = value; this.options.onState?.(value) }
+  publish(value) {
+    if (value.phase === 'ready' && this.updateNotice) value = { ...value, updateNotice: this.updateNotice, message: `${value.message},Web 同步待完成` }
+    this.state = value; this.options.onState?.(value)
+  }
+  async retrySynchronization() {
+    if (this.starting) return this.starting
+    this.state = { phase: 'stopped', message: '正在准备同步 Web…' }
+    return this.start()
+  }
   async readRecord() {
     try {
       const r = JSON.parse(await readFile(join(this.root, 'service-instance.json'), 'utf8'))
@@ -97,8 +105,15 @@ export class DesktopServiceManager {
     try {
       await mkdir(this.options.home, { recursive: true, mode: 0o700 })
       this.root = await realpath(this.options.home)
+      this.updateNotice = undefined
       if (this.options.synchronize) {
-        await this.options.synchronize(message => this.publish({ phase: 'starting', message }))
+        try { await this.options.synchronize(message => this.publish({ phase: 'starting', message })) }
+        catch (error) {
+          // Busy is reported before stopping or switching. Other failures still
+          // fail closed; the existing service must pass the usual identity checks.
+          if (error.code !== 'service_busy') throw error
+          this.updateNotice = error.message
+        }
         if (generation !== this.generation || this.stopping) return
       }
       let record = await this.readRecord()

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

@@ -2,6 +2,9 @@ import {test} from 'node:test'
 import assert from 'node:assert/strict'
 import {createServer} from 'node:http'
 import {dataKey,DesktopServiceManager} from './service-manager.mjs'
+import {mkdtemp,realpath,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
 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
@@ -17,3 +20,23 @@ test('checks recorded and bundled versions while permitting a separately owned o
     await assert.rejects(manager.verify(record),/版本不匹配/)
   }finally{await new Promise(resolve=>server.close(resolve))}
 })
+test('busy synchronization connects the verified existing Web and remains retryable without accepting other errors',async()=>{
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-startup-busy-')))
+  const record={protocol:1,instanceId:'busy-fixture',pid:process.pid,dataKey:dataKey(home),version:'0.3.31',token:'fixture'}
+  const server=createServer((request,response)=>{const {token,...identity}=record;response.setHeader('Content-Type','application/json');response.end(JSON.stringify(identity))})
+  await new Promise(done=>server.listen(0,'127.0.0.1',done))
+  record.url=`http://127.0.0.1:${server.address().port}`
+  await writeFile(join(home,'service-instance.json'),JSON.stringify(record))
+  let attempts=0
+  const options={home,version:'0.3.32',fork:()=>{throw new Error('must not fork')},synchronize:async()=>{if(++attempts===1)throw Object.assign(new Error('已有任务,同步待完成'),{code:'service_busy'})}}
+  const manager=new DesktopServiceManager(options)
+  try{
+    const state=await manager.start()
+    assert.equal(state.phase,'ready');assert.equal(state.external,true);assert.match(state.updateNotice,/同步待完成/)
+    assert.equal((await manager.retrySynchronization()).updateNotice,undefined);assert.equal(attempts,2)
+    await manager.stop()
+    const invalid=new DesktopServiceManager({...options,synchronize:async()=>{throw new Error('invalid package')}})
+    await assert.rejects(invalid.start(),/invalid package/)
+    assert.equal(invalid.state.phase,'error')
+  }finally{await new Promise(done=>server.close(done));await rm(home,{recursive:true,force:true})}
+})

+ 3 - 3
desktop/service-sync.mjs

@@ -10,19 +10,19 @@ export function synchronizeLocalService({ home, port, root, fixture, environment
       cwd: runtimeRoot, detached: true, stdio: ['pipe', 'pipe', 'ignore'],
       env: { HOME: process.env.HOME, PATH: process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin', NODE_USE_ENV_PROXY: '0' },
     })
-    let buffer = '', result, failure
+    let buffer = '', result, failure, failureCode
     helper.stdout.on('data', bytes => {
       buffer += bytes
       if (buffer.length > 256 * 1024) { failure = '同步进程输出异常'; return }
       for (;;) {
         const index = buffer.indexOf('\n'); if (index < 0) break
         const line = buffer.slice(0, index); buffer = buffer.slice(index + 1)
-        try { const value = JSON.parse(line); if (value.message) onProgress(value.message); if (value.result) result = value.result; if (value.error) failure = value.error }
+        try { const value = JSON.parse(line); if (value.message) onProgress(value.message); if (value.result) result = value.result; if (value.error) { failure = value.error; failureCode = value.code } }
         catch { failure = '同步进程响应无效' }
       }
     })
     helper.once('error', reject)
-    helper.once('exit', code => code === 0 && result ? done(result) : reject(new Error(failure || '本机 Web 同步未完成,请重试')))
+    helper.once('exit', code => code === 0 && result ? done(result) : reject(Object.assign(new Error(failure || '本机 Web 同步未完成,请重试'), { code: failureCode })))
     helper.stdin.on('error', () => {})
     helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment }))
   })

+ 44 - 0
desktop/startup-feedback.test.mjs

@@ -0,0 +1,44 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,realpath,readFile,rm,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:http'
+import {createHash} from 'node:crypto'
+import {_electron as electron} from '@playwright/test'
+import {LaunchAgentService,localRequest} from '../bin/lib/service-update.mjs'
+
+const root=resolve(import.meta.dirname,'..')
+test('built startup page shows a real helper error and Retry recovers into the Web',{timeout:90000},async()=>{
+  // The production packaging filter previously omitted this file entirely.
+  assert.ok((await readFile(join(root,'.desktop-build/shell/boot.js'),'utf8')).includes('yaoyaoDesktop.status'))
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-startup-feedback-')))
+  const blocker=createServer((_request,response)=>{response.setHeader('Content-Type','application/json');response.end('{"ok":true}')})
+  await new Promise(done=>blocker.listen(0,'127.0.0.1',done))
+  const port=blocker.address().port
+  const driver=new LaunchAgentService({home,port,releaseRoot:join(home,'updates','fixture-releases'),
+    label:`cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0,16)}`,
+    plistPath:join(home,'updates','fixture-service.plist')})
+  let app
+  try{
+    app=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_DESKTOP_TEST_SYNC:'1',HERMES_YAOYAO_UPSTREAM:'http://127.0.0.1:1'},timeout:30000})
+    const page=await app.firstWindow()
+    await page.locator('#status[role="alert"]').waitFor({timeout:30000})
+    assert.match(await page.locator('#status').textContent(),/已有服务未注册/)
+    assert.equal(await page.getByRole('button',{name:'重试',exact:true}).isVisible(),true)
+    assert.equal(await page.getByRole('button',{name:'查看日志',exact:true}).isVisible(),true)
+    await mkdir(join(root,'test-results/desktop'),{recursive:true})
+    await page.screenshot({path:join(root,'test-results/desktop/startup-error-retry.png')})
+    await new Promise(done=>blocker.close(done))
+    await page.getByRole('button',{name:'重试',exact:true}).click()
+    await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok,true)
+    await page.screenshot({path:join(root,'test-results/desktop/startup-recovered.png')})
+  }finally{
+    await app?.close().catch(()=>{})
+    if(blocker.listening)await new Promise(done=>blocker.close(done))
+    await driver.stop();await rm(home,{recursive:true,force:true})
+  }
+})

+ 3 - 1
docs/app-web-sync.md

@@ -24,7 +24,7 @@ App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务
 
 新服务通过仅限本机、无 Origin、私有随机凭据的接口协商暂停。只有没有进行中的普通聊天、团队运行、Runner 操作及 HTTP 写请求时才暂停新请求;正在运行的任务继续完成。暂停租约在没有持久更新事务时 15 秒过期,避免更新器在切换前退出导致永久暂停。
 
-旧版没有暂停协议时,检查持久任务、普通聊天未完成事件和活动客户端连接。有未完成任务、活动连接或不能确认状态时,最多等待一分钟后延后同步。旧客户端关闭后可重试,不强行打断任务。
+旧版没有暂停协议时,检查持久任务、普通聊天未完成事件和本机服务进程的活动连接。App 发现服务忙碌时立即延后同步,核验原服务身份后继续进入现有 Web;菜单显示“Web 同步待完成”,并提供“重试同步本机 Web”。不写同步成功记录。Web 自身的更新器仍最多等待一分钟。旧客户端关闭后可重试,不强行打断任务。
 
 ## 数据与恢复
 
@@ -49,4 +49,6 @@ App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务
 
 原生验收使用临时数据目录、端口、发布目录和由目录哈希派生的测试 LaunchAgent 标签;清理只作用于该测试服务,不替换正式 15300 服务。
 
+启动页的 HTML、JavaScript、CSS 与 preload 都是必需打包资源,构建时逐一核验。`desktop/startup-feedback.test.mjs` 在真实 App 中制造同步助手失败,确认错误文本与“重试 / 查看日志”可见,再解除测试阻塞并点击重试进入 Web。只有 `service_busy` 会允许连接已验证的原服务;包校验、身份核对及其他错误继续显示在启动页。
+
 2026-09-09 验证:类型检查和生产/桌面构建通过;全量测试 787 项通过、6 项按环境跳过。原有 8 项桌面测试通过,新增真实 App/LaunchAgent 同步流程通过,覆盖退出后服务保留、较新 Web 保留、旧配置保留以及服务停止后再次打开 App 恢复独立服务。开发签名 App 的严格签名、服务包完整性及实际同步流程均已验证。此次修改不包含替换本机已安装 App 或正式 Web 服务。

+ 1 - 1
docs/macos-app.md

@@ -26,7 +26,7 @@ App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构
 - App 携带独立 Node 运行时及预编译 Web 服务,首次同步不依赖联网、npm 或源码构建。Node 的许可证随服务包交付。
 - 同步成功按 App 服务包校验值记账;同一构建再次打开不会反复覆盖 Web。Web 可以继续通过原有更新入口升级。
 - 新版服务空闲时短暂暂停请求,停止后备份数据、切换版本,验证实际构建、健康及关键接口后开放请求。失败恢复程序与数据;进程中断由下一次同步或更新恢复。
-- 原有旧版服务缺少协作暂停接口时,需要确认无未完成任务及已建立的客户端连接,最长等待一分钟后延后同步,不强行中断聊天。不能验证身份、TLS 或数据归属的服务不接管。
+- 原有旧版服务缺少协作暂停接口时,需要确认无未完成任务及本机服务进程的客户端连接。忙碌时 App 先连接已验证的原 Web,在菜单提示同步待完成并提供重试,不强行中断聊天。不能验证身份、TLS 或数据归属的服务不接管,启动页显示错误及恢复操作。
 - 回退单独通过 Web 的回滚入口处理。只在数据库结构兼容时切回程序并保留现有数据;结构已变化或缺少兼容性记录时拒绝直接降级,不能用旧备份覆盖升级后的新消息。
 
 正式后台服务的程序位于 `~/.local/share/hermes-yaoyao/releases`,`current` 指向启用的构建;数据仍在 `~/.hermes-yaoyao`。App 签名资源不被 Web 更新器改写。开发模式的包内服务仍随开发 App 管理,不提供服务内更新。

+ 2 - 1
scripts/build-desktop.mjs

@@ -23,9 +23,10 @@ await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon
 const shell = resolve(out, 'shell')
 await mkdir(shell)
 for (const file of await readdir(resolve(root, 'desktop'))) {
-  if (file.endsWith('.test.mjs') || !/\.(mjs|cjs|html|css|png)$/.test(file)) continue
+  if (file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }
+for (const file of ['boot.html', 'boot.js', 'boot.css', 'preload.cjs']) await readFile(resolve(shell, file))
 await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const service = resolve(out, 'web-service')