瀏覽代碼

支持当前 App 强制覆盖同版本 Web

Samien 1 月之前
父節點
當前提交
be27f72477

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

@@ -12,6 +12,7 @@ let input = ''
 for await (const bytes of process.stdin) { input += bytes; if (input.length > 32768) throw new Error('同步参数过大') }
 try {
   const options = JSON.parse(input)
+  if (options.force !== undefined && typeof options.force !== 'boolean') throw new Error('强制覆盖参数无效')
   const home = resolve(options.home), port = Number(options.port)
   if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('本机服务端口无效')
   if (options.action === 'migrate') {
@@ -32,7 +33,7 @@ try {
   const onProgress = message => process.stdout.write(JSON.stringify({ message }) + '\n')
   const result = options.action==='stop'
     ? await stopDesktopService({home,driver,onProgress})
-    : await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver, onProgress })
+    : await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver, onProgress, force: options.force === true })
   const current = result.current
   process.stdout.write(JSON.stringify({ result: { action: result.action, current: current && {
     version: current.version, commit: current.commit, buildNumber: current.buildNumber, artifactDigest: current.artifactDigest,

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

@@ -280,7 +280,8 @@ export async function transitionService({ home, releaseRoot, driver, finalRoot,
   }
 }
 
-export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, driver, onProgress = () => {} }) {
+export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, driver, onProgress = () => {}, force = false }) {
+  if (typeof force !== 'boolean') throw new Error('强制覆盖参数无效')
   mkdirSync(home, { recursive: true, mode: 0o700 })
   const unlock = updateMutex(home), id = randomUUID()
   let release
@@ -291,13 +292,16 @@ export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, drive
     const target = verifyRuntimePackage(runtimeRoot)
     const marker = join(home, 'updates', 'desktop-sync.json')
     const previous = driver.snapshot(), current = previous ? readRuntime(previous.root) : undefined
-    if (existsSync(marker) && readJSON(marker).artifactDigest === target.artifactDigest && previous) {
+    if (force && current && current.version !== target.version)
+      throw Object.assign(new Error('强制覆盖仅支持相同版本;跨版本更新或回退请使用对应入口'), { code: 'service_force_version_mismatch' })
+    if (!force && existsSync(marker) && readJSON(marker).artifactDigest === target.artifactDigest && previous) {
       if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
       await driver.verify(undefined)
       return { action: 'already-synced', current }
     }
-    const decision = syncDecision(target, current)
-    if (decision === 'unknown') throw new Error('App 与 Web 的构建先后关系无法确定,已保留当前服务;请使用明确的新发布版本')
+    const comparison = syncDecision(target, current)
+    const decision = force && current && comparison !== 'same' ? 'overwrite' : comparison
+    if (decision === 'unknown') throw Object.assign(new Error('App 与 Web 版本相同,但构建先后关系无法确定。可使用当前 App 覆盖 Web;聊天、设置和文件会保留,并先备份。'), { code: 'service_build_unknown' })
     if (decision === 'same' || decision === 'newer') {
       if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
       await driver.verify(undefined)

+ 1 - 1
desktop/boot.html

@@ -2,4 +2,4 @@
 <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'; style-src 'self'; script-src 'self'; img-src 'self'">
 <title>夭夭</title><link rel="stylesheet" href="boot.css"></head>
-<body><main><img src="icon.png" alt="" width="72" height="72"><h1>夭夭</h1><p id="status" role="status" aria-live="polite">正在启动本地服务…</p><div id="actions" hidden><button id="retry">重试</button><button id="logs">查看日志</button></div></main><script src="boot.js"></script></body></html>
+<body><main><img src="icon.png" alt="" width="72" height="72"><h1>夭夭</h1><p id="status" role="status" aria-live="polite">正在启动本地服务…</p><div id="actions" hidden><button id="force-sync" hidden>使用当前 App 覆盖同版本 Web</button><button id="retry">重试</button><button id="logs">查看日志</button></div></main><script src="boot.js"></script></body></html>

+ 8 - 0
desktop/boot.js

@@ -3,8 +3,16 @@ function display(state) {
   document.getElementById('actions').hidden = !['error','stopped','disconnected'].includes(state.phase)
   document.getElementById('retry').textContent = state.phase === 'disconnected' ? '重新连接' : state.phase === 'stopped' ? '启动后台服务' : '重试'
   document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
+  document.getElementById('force-sync').hidden = state.phase !== 'error' || state.canForceSync !== true
   document.querySelector('main').setAttribute('aria-busy', String(['starting','restarting','stopping'].includes(state.phase)))
 }
+document.getElementById('force-sync').onclick = async () => {
+  const controls = [...document.querySelectorAll('#actions button')]
+  for (const button of controls) button.disabled = true
+  try { await window.yaoyaoDesktop.forceSync(); await render() }
+  catch { display({ phase: 'error', message: '覆盖未完成,请重试或查看服务日志。' }) }
+  finally { for (const button of controls) button.disabled = false }
+}
 async function render() {
   try { display(await window.yaoyaoDesktop.status()) }
   catch { display({ phase: 'error', message: '无法读取启动状态,请重试或查看服务日志。' }) }

+ 12 - 2
desktop/main.mjs

@@ -41,7 +41,7 @@ else {
   const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
   ipcMain.handle('desktop:status', event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
-    return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…' }
+    return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…', canForceSync: manager?.state.canForceSync === true }
   })
   ipcMain.handle('desktop:retry', async event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
@@ -52,6 +52,12 @@ else {
     if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
     shell.showItemInFolder(logFile)
   })
+  ipcMain.handle('desktop:force-sync', async event => {
+    if (!trustedBoot(event) || event.senderFrame !== window.webContents.mainFrame || closing || quitting || !manager?.canForceSynchronization)
+      throw new Error('不允许此页面覆盖本机 Web')
+    try { await manager.retrySynchronization({ force: true }); return true }
+    catch (error) { log(error.message); return false }
+  })
   const trustedUpdate = event => event.sender === updateWindow?.webContents
     && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
   ipcMain.handle('desktop:updates', async event => {
@@ -153,6 +159,8 @@ else {
     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)
+    const forceSync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-force')
+    if (forceSync) forceSync.enabled = !closing && !quitting && manager?.canForceSynchronization === true
     tray?.setToolTip(`夭夭 · ${state.message}`)
     if (!window || window.isDestroyed()) return
     if (state.phase === 'ready') {
@@ -214,7 +222,7 @@ else {
       stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
         ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
       synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
-        ? onProgress => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress,
+        ? (onProgress, { force = false } = {}) => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress, force,
           environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
         : undefined,
       fork: ({ home, port }) => {
@@ -252,6 +260,8 @@ else {
         { 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-sync-force', label:'使用当前 App 覆盖同版本 Web', enabled:false,
+          click:async()=>{if(closing||quitting||!manager.canForceSynchronization)return;try{await manager.retrySynchronization({force:true})}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,

+ 1 - 0
desktop/preload.cjs

@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron')
 contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   status: () => ipcRenderer.invoke('desktop:status'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
+  forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
   openUpdates: () => ipcRenderer.invoke('desktop:updates'),
   openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),

+ 11 - 7
desktop/service-manager.mjs

@@ -52,10 +52,14 @@ export class DesktopServiceManager {
     if (value.phase === 'ready' && this.updateNotice) value = { ...value, updateNotice: this.updateNotice, message: `${value.message},Web 同步待完成` }
     this.state = value; this.options.onState?.(value)
   }
-  async retrySynchronization() {
+  get canForceSynchronization() {
+    return Boolean(this.options.synchronize) && (this.state.canForceSync === true || this.state.phase === 'ready' && this.state.version === this.options.version)
+  }
+  async retrySynchronization({ force = false } = {}) {
     if (this.starting) return this.starting
+    if (force && !this.canForceSynchronization) throw new Error('当前状态不能覆盖同版本 Web')
     this.state = { phase: 'stopped', message: '正在准备同步 Web…' }
-    return this.start()
+    return this.start({ force })
   }
   async reconnect() {
     const generation=this.generation
@@ -102,14 +106,14 @@ export class DesktopServiceManager {
     if (status.server_kind !== 'yaoyao-web') throw new Error('已有端口不是夭夭服务。')
     return { url, pid: Number([...holders][0]), external: true, legacy: true, version: '已有服务' }
   }
-  async start() {
+  async start({ force = false } = {}) {
     if (this.starting) return this.starting
     if (this.state.phase === 'ready' && !this.stopping) return this.state
     this.stopping = false
-    this.starting = this.boot().finally(() => { this.starting = null })
+    this.starting = this.boot({ force }).finally(() => { this.starting = null })
     return this.starting
   }
-  async boot() {
+  async boot({ force = false } = {}) {
     const generation = ++this.generation
     this.publish({ phase: 'starting', message: '正在启动本地服务…' })
     try {
@@ -118,7 +122,7 @@ export class DesktopServiceManager {
       this.root = await realpath(this.options.home)
       this.updateNotice = undefined
       if (this.options.synchronize) {
-        try { await this.options.synchronize(message => this.publish({ phase: 'starting', message })) }
+        try { await this.options.synchronize(message => this.publish({ phase: 'starting', message }), { force }) }
         catch (error) {
           // Busy is reported before stopping or switching. Other failures still
           // fail closed; the existing service must pass the usual identity checks.
@@ -174,7 +178,7 @@ export class DesktopServiceManager {
     } catch (error) {
       if (generation !== this.generation || this.stopping) return
       await this.stopChild()
-      this.publish({ phase: 'error', message: error.message })
+      this.publish({ phase: 'error', message: error.message, canForceSync: error.code === 'service_build_unknown' })
       throw error
     }
   }

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

@@ -69,3 +69,20 @@ test('a late activate health check cannot reconnect after quit has begun',async(
  await manager.stop();finish();await reconnect
  assert.equal(starts,0);assert.equal(manager.state.phase,'disconnected')
 })
+test('unknown same-version builds offer an explicit one-shot overwrite without changing ordinary retries',async()=>{
+ const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-force-sync-'))),attempts=[]
+ const manager=new DesktopServiceManager({home,version:'0.4.18',synchronize:async(_progress,options)=>{
+  attempts.push(options.force)
+  if(attempts.length===1)throw Object.assign(new Error('同版本构建需要选择'),{code:'service_build_unknown'})
+ }})
+ manager.readRecord=async()=>({url:'http://127.0.0.1:1'})
+ manager.verify=async()=>({url:'http://127.0.0.1:1',version:'0.4.18',pid:process.pid})
+ try{
+  await assert.rejects(manager.start(),/需要选择/)
+  assert.equal(manager.state.canForceSync,true);assert.equal(manager.canForceSynchronization,true)
+  await manager.retrySynchronization({force:true});assert.equal(manager.state.phase,'ready')
+  await manager.retrySynchronization();assert.deepEqual(attempts,[false,true,false])
+  manager.state={phase:'error',message:'签名校验失败'}
+  await assert.rejects(manager.retrySynchronization({force:true}),/当前状态不能覆盖/)
+ }finally{await rm(home,{recursive:true,force:true})}
+})

+ 2 - 2
desktop/service-sync.mjs

@@ -6,7 +6,7 @@ import { join } from 'node:path'
 export const synchronizeLocalService = options => serviceCommand({...options,action:'sync'})
 export const stopLocalService = options => serviceCommand({...options,action:'stop'})
 export const migrateLocalData = options => serviceCommand({...options,action:'migrate'})
-function serviceCommand({ home, port, root, fixture, environment, onProgress = () => {}, action }) {
+function serviceCommand({ home, port, root, fixture, environment, onProgress = () => {}, action, force = false }) {
   return new Promise((done, reject) => {
     const runtimeRoot = join(root, 'web-service')
     const helper = spawn(join(runtimeRoot, 'node'), [join(runtimeRoot, 'bin', 'desktop-service-sync.mjs')], {
@@ -27,6 +27,6 @@ function serviceCommand({ home, port, root, fixture, environment, onProgress = (
     helper.once('error', reject)
     helper.once('exit', code => code === 0 && result ? done(result) : reject(Object.assign(new Error(failure || (action==='stop'?'后台服务停止未完成':'本机 Web 同步未完成,请重试')), { code: failureCode })))
     helper.stdin.on('error', () => {})
-    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment, action }))
+    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment, action, force }))
   })
 }

+ 37 - 2
desktop/startup-feedback.test.mjs

@@ -1,12 +1,13 @@
 import {test} from 'node:test'
 import assert from 'node:assert/strict'
-import {mkdtemp,realpath,readFile,rm,mkdir} from 'node:fs/promises'
+import {mkdtemp,realpath,readFile,rm,mkdir,cp,writeFile,readdir} 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'
+import {LaunchAgentService,localRequest,synchronizeDesktop} from '../bin/lib/service-update.mjs'
+import {sealRuntime,verifyRuntimePackage} from '../bin/lib/runtime-release.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()=>{
@@ -42,3 +43,37 @@ test('built startup page shows a real helper error and Retry recovers into the W
     await driver.stop();await rm(home,{recursive:true,force:true})
   }
 })
+test('same-version unknown ancestry offers an explicit overwrite and preserves existing user data',{timeout:90000},async()=>{
+ const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-force-overwrite-')))
+ const reservation=createServer();await new Promise(done=>reservation.listen(0,'127.0.0.1',done))
+ const port=reservation.address().port;await new Promise(done=>reservation.close(done))
+ const payload=process.env.DESKTOP_TEST_EXECUTABLE?resolve(process.env.DESKTOP_TEST_EXECUTABLE,'../../Resources/runtime/web-service'):join(root,'.desktop-build/web-service')
+ const original=verifyRuntimePackage(payload),old=join(home,'updates/old-runtime')
+ await cp(payload,old,{recursive:true})
+ await writeFile(join(old,'build-info.json'),JSON.stringify({commit:'f'.repeat(40),ancestors:[],buildNumber:1,dirty:false}))
+ sealRuntime(old)
+ const releaseRoot=join(home,'updates','fixture-releases')
+ const driver=new LaunchAgentService({home,port,releaseRoot,label:`cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0,16)}`,plistPath:join(home,'updates','fixture-service.plist'),environment:{HERMES_YAOYAO_UPSTREAM:'http://127.0.0.1:1',HERMES_YAOYAO_SUPERVISE_DASHBOARD:'0'}})
+ let app
+ try{
+  await synchronizeDesktop({home,runtimeRoot:old,releaseRoot,driver})
+  await writeFile(join(home,'user-proof.txt'),'preserve existing user files')
+  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(),button=page.getByRole('button',{name:'使用当前 App 覆盖同版本 Web',exact:true})
+  await button.waitFor({timeout:30000})
+  assert.match(await page.locator('#status').textContent(),/构建先后关系无法确定/)
+  assert.equal(verifyRuntimePackage(driver.snapshot().root).commit,'f'.repeat(40))
+  await mkdir(join(root,'test-results/desktop'),{recursive:true});await page.screenshot({path:join(root,'test-results/desktop/same-version-force.png')})
+  await button.click();await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
+  const record=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'))
+  const identity=await localRequest(`http://127.0.0.1:${port}/desktop/service`,record.token)
+  assert.equal(identity.body.build.artifactDigest,original.artifactDigest)
+  assert.equal(await readFile(join(home,'user-proof.txt'),'utf8'),'preserve existing user files')
+  assert.equal(JSON.parse(await readFile(join(home,'updates/desktop-sync.json'),'utf8')).action,'overwrite')
+  assert.ok((await readdir(join(home,'updates/backups'))).length>=2)
+  const denied=await page.evaluate(()=>window.yaoyaoDesktop.forceSync().then(()=>false,()=>true))
+  assert.equal(denied,true,'the Web renderer cannot invoke the boot-only overwrite channel')
+  await page.screenshot({path:join(root,'test-results/desktop/same-version-recovered.png')})
+ }finally{await app?.close().catch(()=>{});await driver.stop();await rm(home,{recursive:true,force:true})}
+})

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

@@ -10,12 +10,20 @@
 | 版本较旧,或同版本且提交是 App 构建的祖先 | 等待空闲后向上同步 |
 | 同一构建/同一干净提交 | 直接连接 |
 | 发布版本或同版本提交更新 | 保留当前 Web |
-| 同版本分支关系不明、无法识别的工作区快照 | 保留服务并说明无法自动判断 |
+| 同版本分支关系不明、无法识别的工作区快照 | 启动页提供“使用当前 App 覆盖同版本 Web”,由用户明确选择 |
 
 `build-info.json` 包含提交、构建序号、祖先提交、工作区状态和构建时间;构建序号仅用于展示,提交的先后必须有祖先关系,不能比较哈希字典序或文件时间。`runtime-manifest.json` 对完整服务包逐文件计算 SHA-256,并拒绝额外文件、丢失文件和符号链接。App 内置 Node 保持原签名,避免签名后改变已封存的服务包。许可证位于 `web-service/NODE-LICENSE.txt`。
 
 成功后在数据目录的 `updates/desktop-sync.json` 记录本次 App 服务包校验值。再次打开同一构建不会重新同步,允许 Web 继续自行升级或经兼容性检查后单独回滚。该记录属于数据目录,不是全局 App 偏好。失败不写成功记录,启动页可重试。
 
+## 强制覆盖同版本 Web
+
+App 与 Web 的版本号相同,即使提交来自不同历史或属于工作区快照,也可以使用当前 App 的配套服务覆盖。启动页遇到构建关系不明时显示 **使用当前 App 覆盖同版本 Web**;进入 Web 后也可从 macOS 的 **夭夭** 菜单选择同名操作。
+
+覆盖是一次性的明确操作:它跳过构建先后判定和旧的同步成功记录,使用当前 App 自带的完整服务包。仍须校验安装包、服务归属和空闲状态,保留聊天、设置、文件及已有服务配置,并先备份;失败自动恢复。服务忙碌时延后覆盖,不中断进行中的任务。后续普通启动恢复自动同步规则,不持续强制覆盖。不同版本号不使用此入口,升级或回退沿用原有流程。
+
+同步助手通过 `force: true` 传递此选择;只有受信任的原生启动页和 App 菜单可以触发,Web 页面及其他窗口不能调用覆盖 IPC。`service_build_unknown` 仅表示可人工处理的同版本构建冲突,包损坏、权限或身份错误不会提供强制入口。
+
 ## 统一后台服务
 
 App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务、发布目录、SQLite 更新互斥和原有 `active.lock`。App 同步使用包内服务,不下载或现场运行 npm 构建;Web 的更新入口仍按发布源准备新版本。两条路径都切换同一个 `current`。
@@ -51,4 +59,6 @@ App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务
 
 启动页的 HTML、JavaScript、CSS 与 preload 都是必需打包资源,构建时逐一核验。`desktop/startup-feedback.test.mjs` 在真实 App 中制造同步助手失败,确认错误文本与“重试 / 查看日志”可见,再解除测试阻塞并点击重试进入 Web。只有 `service_busy` 会允许连接已验证的原服务;包校验、身份核对及其他错误继续显示在启动页。
 
+2026-09-14 验证:25 项桌面测试及 23 项同步/服务实例/更新接口测试通过。新增真实 App 流程先安装同版本但无共同提交历史的服务,验证启动页明确提供覆盖入口,点击后构建切换成功、原文件和备份保留;Web 页面不能调用覆盖 IPC。另覆盖旧同步记录、忙碌服务、损坏包、跨版本拒绝及覆盖失败的数据恢复。
+
 2026-09-09 验证:类型检查和生产/桌面构建通过;全量测试 787 项通过、6 项按环境跳过。原有 8 项桌面测试通过,新增真实 App/LaunchAgent 同步流程通过,覆盖退出后服务保留、较新 Web 保留、旧配置保留以及服务停止后再次打开 App 恢复独立服务。开发签名 App 的严格签名、服务包完整性及实际同步流程均已验证。此次修改不包含替换本机已安装 App 或正式 Web 服务。

+ 1 - 1
docs/github-updates.md

@@ -16,7 +16,7 @@ macOS 独立 Web 继续使用现有升级事务:构建新版本、等待任务
 
 新版提供当前架构的 `Yaoyao-{版本}-{架构}.dmg` 和 `SHA256SUMS.txt` 后,窗口显示下载按钮。下载可取消或重试,不会停止聊天、Runner 或后台服务。文件写入 Web 数据目录下的 `updates/desktop-downloads`,通过大小、SHA-256 和 DMG 完整性检查后才提供“打开安装包”。GitHub 附件摘要存在时也必须一致,缓存重新使用和打开前会核对文件。
 
-打开 DMG 后手动拖入“应用程序”安装。不会自动覆盖正在运行的 App、申请提权或自动重启;开发签名包尚未公证,请遵循 macOS 提示。安装后首次启动仍只向上同步较旧的本机 Web,保留较新的独立服务。
+打开 DMG 后手动拖入“应用程序”安装。不会自动覆盖正在运行的 App、申请提权或自动重启;开发签名包尚未公证,请遵循 macOS 提示。安装后首次启动自动同步较旧的本机 Web,保留较新的独立服务。同版本构建关系不明时,可在启动页或夭夭菜单选择“使用当前 App 覆盖同版本 Web”,详见 [同步与覆盖规则](app-web-sync.md)。
 
 下载和打开接口只授权给本地更新窗口,远程页面、普通聊天页面和启动页不能调用。发布说明按纯文本显示,不执行远端 HTML 或脚本。
 

+ 1 - 1
docs/macos-app.md

@@ -24,7 +24,7 @@ App 新增“检查 App 更新…”窗口,直接从官方 GitHub 检查稳定
 正式 App 的同步在安装/更新后的**首次启动**执行;复制 App 文件本身不执行服务操作。详见 [App 与 Web 版本同步](app-web-sync.md)。
 
 - 同步仅针对当前用户、相同数据目录和端口的本机 LaunchAgent;保留已有服务的地址、TLS、上游及通知配置。
-- 使用版本、提交关系和完整服务包校验值识别构建。同版本不同提交可以向上同步;不同分支且无法确认先后时保留现有服务并提示原因。
+- 使用版本、提交关系和完整服务包校验值识别构建。同版本不同提交可以向上同步;无法确认先后时,启动页提供“使用当前 App 覆盖同版本 Web”。也可从夭夭菜单执行,保留数据、先备份,服务忙碌时不切换。
 - App 携带独立 Node 运行时及预编译 Web 服务,首次同步不依赖联网、npm 或源码构建。Node 的许可证随服务包交付。
 - 同步成功按 App 服务包校验值记账;同一构建再次打开不会反复覆盖 Web。Web 可以继续通过原有更新入口升级。
 - 新版服务空闲时短暂暂停请求,停止后备份数据、切换版本,验证实际构建、健康及关键接口后开放请求。失败恢复程序与数据;进程中断由下一次同步或更新恢复。

+ 36 - 1
tests/server/desktopSync.test.ts

@@ -14,7 +14,7 @@ const homes: string[] = []
 afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) })
 const temporary = () => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-sync-unit-'))); homes.push(home); return home }
 function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ancestors = ['a'.repeat(40)]) {
-  mkdirSync(join(root, 'bin'), { recursive: true }); mkdirSync(join(root, 'ui'))
+  mkdirSync(join(root, 'bin'), { recursive: true }); mkdirSync(join(root, 'ui'), { recursive: true })
   for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs']) writeFileSync(join(root, file), file)
   writeJSON(join(root, 'release.json'), { schemaVersion: 1, releaseVersion: version, webVersion: version, gitTag: `v${version}` })
   writeJSON(join(root, 'build-info.json'), { commit, ancestors, buildNumber: ancestors.length + 1, dirty: false })
@@ -140,6 +140,41 @@ describe('transactional service switching', () => {
     expect(f.counts()).toEqual({ stops: 0, starts: 0 })
     expect(existsSync(join(f.home, 'updates', 'desktop-sync.json'))).toBe(false)
   })
+  it('explicitly overwrites an unrelated same-version build, preserving data and bypassing an old sync receipt', async () => {
+    const f=fixture()
+    runtime(f.old,'c'.repeat(40),'0.3.32',[])
+    await expect(synchronizeDesktop({...f,runtimeRoot:f.target})).rejects.toMatchObject({code:'service_build_unknown'})
+    expect(f.counts()).toEqual({stops:0,starts:0})
+    const target=verifyRuntimePackage(f.target)
+    writeJSON(join(f.home,'updates','desktop-sync.json'),{artifactDigest:target.artifactDigest,action:'newer'})
+    const result=await synchronizeDesktop({...f,runtimeRoot:f.target,force:true})
+    expect(result.action).toBe('overwrite');expect(result.current.artifactDigest).toBe(target.artifactDigest)
+    expect(f.counts()).toEqual({stops:1,starts:1})
+    expect(readFileSync(join(f.home,'settings.json'),'utf8')).toBe('original')
+    const database=new DatabaseSync(join(f.home,'workspace.sqlite3'),{readOnly:true})
+    expect(database.prepare('SELECT value FROM messages').get()?.value).toBe('before');database.close()
+    expect(existsSync(JSON.parse(readFileSync(join(f.home,'updates','last-success.json'),'utf8')).snapshot)).toBe(true)
+    expect((await synchronizeDesktop({...f,runtimeRoot:f.target})).action).toBe('already-synced')
+    expect(f.counts()).toEqual({stops:1,starts:1})
+  })
+  it('does not use force to cross versions, bypass idle checks or accept a damaged package', async () => {
+    const f=fixture()
+    await expect(synchronizeDesktop({...f,runtimeRoot:f.target,force:true})).rejects.toMatchObject({code:'service_force_version_mismatch'})
+    runtime(f.old,'c'.repeat(40),'0.3.32',[])
+    f.driver.quiesce=async()=>{throw new Error('busy')}
+    await expect(synchronizeDesktop({...f,runtimeRoot:f.target,force:true})).rejects.toThrow('busy')
+    expect(f.counts()).toEqual({stops:0,starts:0})
+    writeFileSync(join(f.target,'server.mjs'),'broken')
+    await expect(synchronizeDesktop({...f,runtimeRoot:f.target,force:true})).rejects.toThrow('校验失败')
+    expect(f.counts()).toEqual({stops:0,starts:0})
+  })
+  it('restores the previous same-version build and its data when a forced replacement fails', async () => {
+    const f=fixture();runtime(f.old,'c'.repeat(40),'0.3.32',[]);f.fail()
+    await expect(synchronizeDesktop({...f,runtimeRoot:f.target,force:true})).rejects.toThrow('wrong running build')
+    expect(currentRelease(f.releaseRoot)).toBe(f.old)
+    expect(readFileSync(join(f.home,'settings.json'),'utf8')).toBe('original')
+    expect(existsSync(join(f.home,'updates','desktop-sync.json'))).toBe(false)
+  })
   it('synchronizes an App build once, allowing Web to move independently afterwards', async () => {
     const f = fixture()
     await synchronizeDesktop({ ...f, runtimeRoot: f.target })