Browse Source

修复客户端更新入口并接入桌面自动升级

Samien 1 month ago
parent
commit
062259a

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -10,3 +10,7 @@ Avatar silhouette paths and original two-eye expressions from zhulin025/LaoA-Gro
 ## OpenMausBot MCP transport
 
 `src/server/botPlugins/stdioMcp.ts` is adapted from OpenMausBot `server/stdio-mcp.ts` at commit 8b9f1dbb (Apache-2.0). The local integration uses per-account configuration, explicit Bot grants, a minimal child-process environment and the Yaoyao client name. See `licenses/OpenMausBot/LICENSE` and `NOTICE`.
+
+## OpenMausBot macOS updater preparation
+
+`scripts/patch-mac-updater.mjs` is adapted from OpenMausBot commit 13c5005b (Apache-2.0), Copyright 2026 Milind Soni and OpenMausBot contributors. It waits for Squirrel.Mac's native readiness before allowing a restart. The packaged electron-updater bundle retains its dependencies' license comments. See `licenses/OpenMausBot/LICENSE` and `NOTICE`.

+ 140 - 0
desktop/auto-update-manager.mjs

@@ -0,0 +1,140 @@
+const releasePageUrl = 'https://github.com/Lsamien/yaoyao/releases'
+const busyPhases = new Set(['checking', 'downloading', 'preparing', 'installing'])
+
+function errorMessage(error) {
+  const message = String(error?.message || error || '未知错误')
+  if (/404|ERR_UPDATER_CHANNEL_FILE_NOT_FOUND|ZIP file not provided/i.test(message))
+    return '该版本尚未提供完整的自动更新包,请到发布页下载安装。'
+  if (/signature|code.?sign|指定的要求|签名/i.test(message))
+    return '安装包签名验证失败,请到发布页安装正式签名版本。'
+  if (/ENOTFOUND|ECONN|ETIMEDOUT|ERR_INTERNET|net::|timeout/i.test(message))
+    return '无法连接更新服务器,请检查网络后重试。'
+  if (/sha512|checksum|digest/i.test(message)) return '安装包完整性校验失败,请重新下载。'
+  // Keep HTTP dumps and remote HTML out of the product's error area.
+  return message.split('\n')[0].slice(0, 240)
+}
+
+/** Process-wide desktop updates; independent of server identity and login.
+ * The patched MacUpdater resolves downloadUpdate only after native staging.
+ * Native staging cannot be canceled or retried safely in the same process. */
+export class DesktopAutoUpdateManager {
+  constructor({ driver, version, arch = process.arch, prepareInstall = async () => {}, recoverInstall = async () => {}, stagingTimeout = 300000, restartTimeout = 120000 }) {
+    Object.assign(this, { driver, prepareInstall, recoverInstall, stagingTimeout, restartTimeout })
+    this.state = { phase: 'idle', installMode: 'restart', currentVersion: version, arch, available: false,
+      received: 0, total: 0, message: '点击检查 App 更新', releasePageUrl, retryable: true }
+    driver.autoDownload = false
+    driver.autoInstallOnAppQuit = true
+    driver.autoRunAppAfterInstall = true
+    driver.allowPrerelease = false
+    driver.allowDowngrade = false
+    driver.on('error', error => {
+      if (this.operation || this.nativeStarted) this.failure(error)
+    })
+    driver.on('download-progress', progress => {
+      if (this.operation !== 'downloading' || this.nativeStarted || this.recoveryRequired || this.cancelled) return
+      this.set({ received: progress.transferred || 0, total: progress.total || 0, message: '正在下载 App 更新…' })
+    })
+    driver.on('update-downloaded', () => {
+      if (this.operation !== 'downloading' || this.recoveryRequired || this.cancelled) return
+      this.nativeStarted = true
+      this.set({ phase: 'preparing', message: '下载完成,正在验证签名并准备安装…' })
+      this.stagingTimer = setTimeout(() => this.failure(new Error('准备更新超时')), this.stagingTimeout)
+      this.stagingTimer.unref?.()
+    })
+  }
+  set(patch) { this.state = { ...this.state, ...patch } }
+  snapshot() { return { ...this.state, canCancel: this.state.phase === 'downloading' && !this.nativeStarted } }
+  get busy() { return busyPhases.has(this.state.phase) }
+  startChecking() {
+    if (this.firstCheck || this.checkTimer) return
+    this.firstCheck = setTimeout(() => { this.firstCheck = undefined; void this.check(false) }, 15000)
+    this.checkTimer = setInterval(() => void this.check(false), 3600000)
+    this.firstCheck.unref?.(); this.checkTimer.unref?.()
+  }
+  stopChecking() {
+    clearTimeout(this.firstCheck); clearInterval(this.checkTimer)
+    this.firstCheck = this.checkTimer = undefined
+  }
+  async check(manual = true) {
+    if (this.operation === 'checking' && manual) this.manual = true
+    if (this.operation || this.nativeStarted || this.recoveryRequired || (!manual && ['failed', 'cancelled'].includes(this.state.phase))) return this.snapshot()
+    this.operation = 'checking'; this.manual = manual; this.cancelled = false; this.failed = false
+    this.set({ phase: 'checking', available: false, latestVersion: undefined, notes: '', error: undefined,
+      retryable: true, received: 0, total: 0, message: '正在检查 App 更新…' })
+    try {
+      const result = await this.driver.checkForUpdates()
+      if (this.failed) return this.snapshot()
+      if (!result) throw new Error('当前运行环境不支持自动更新,请使用正式安装的 App')
+      this.token = result.cancellationToken
+      const info = result.updateInfo
+      const available = result.isUpdateAvailable === true
+      const notes = typeof info?.releaseNotes === 'string' ? info.releaseNotes
+        : Array.isArray(info?.releaseNotes) ? info.releaseNotes.map(note => note.note || '').join('\n\n') : ''
+      this.set({ phase: 'checked', latestVersion: info?.version, available, notes,
+        message: available ? `发现 App ${info.version},下载后可重启更新` : '当前 App 已是最新版本' })
+    } catch (error) { this.failure(error) }
+    finally { this.operation = undefined }
+    return this.snapshot()
+  }
+  cancel() {
+    if (this.state.phase !== 'downloading' || this.nativeStarted || !this.token) return
+    this.cancelled = true
+    this.token.cancel()
+  }
+  async download() {
+    if (this.operation || this.nativeStarted || this.recoveryRequired || !this.state.available) return this.snapshot()
+    this.operation = 'downloading'; this.cancelled = false; this.manual = true; this.failed = false
+    this.set({ phase: 'downloading', received: 0, total: 0, error: undefined, message: '正在连接下载服务器…' })
+    try {
+      await this.driver.downloadUpdate(this.token)
+      if (this.cancelled) this.set({ phase: 'cancelled', message: '下载已取消,可重新检查更新后下载', available: false })
+      else if (!this.recoveryRequired && !this.failed) {
+        if (!this.nativeStarted) throw new Error('安装包尚未完成准备,请重新检查更新')
+        this.set({ phase: 'ready', available: false, message: '更新已准备完成,点击“重启更新”完成安装。' })
+      }
+    } catch (error) {
+      if (this.cancelled && !this.nativeStarted) this.set({ phase: 'cancelled', message: '下载已取消,可重新检查更新后下载', available: false, error: undefined })
+      else this.failure(error)
+    } finally { clearTimeout(this.stagingTimer); this.operation = undefined }
+    return this.snapshot()
+  }
+  async recover() {
+    if (this.recovery) return this.recovery
+    if (!this.installAttempted || !this.installPrepared) return
+    this.installAttempted = false
+    this.recovery = Promise.resolve().then(() => this.recoverInstall()).catch(error => this.driver.logger?.error?.(error))
+    return this.recovery
+  }
+  failure(error) {
+    if (this.recoveryRequired || this.cancelled) return
+    this.failed = true
+    clearTimeout(this.stagingTimer); clearTimeout(this.restartTimer)
+    this.recoveryRequired = Boolean(this.nativeStarted)
+    const suffix = this.recoveryRequired ? ' 请退出并重新打开 App 后再尝试。' : ''
+    const silent = this.manual === false && !this.nativeStarted
+    this.set({ phase: silent ? 'idle' : 'failed',
+      available: false, retryable: !this.recoveryRequired, error: silent ? undefined : errorMessage(error) + suffix,
+      message: silent ? '点击检查 App 更新' : this.nativeStarted ? '更新未完成' : '检查或下载更新失败,可重试' })
+    void this.recover()
+  }
+  async install() {
+    if (this.operation || this.state.phase !== 'ready' || !this.nativeStarted || this.recoveryRequired) return this.snapshot()
+    this.operation = 'installing'; this.manual = true; this.installAttempted = true
+    this.stopChecking()
+    this.set({ phase: 'installing', message: '正在关闭 App 连接并重启更新…', error: undefined })
+    try {
+      try { await this.prepareInstall() } finally { this.installPrepared = true }
+      // A native error may have arrived while our app was releasing resources.
+      if (this.recoveryRequired) { await this.recover(); return this.snapshot() }
+      this.driver.quitAndInstall()
+      if (this.recoveryRequired) return this.snapshot()
+      this.restartTimer = setTimeout(() => {
+        this.set({ message: '重启用时较长,请退出并重新打开 App 完成更新。' })
+      }, this.restartTimeout)
+      this.restartTimer.unref?.()
+    } catch (error) { this.failure(error); await this.recover() }
+    // Do not unlock another install after dispatch: quitAndInstall is not cancelable.
+    return this.snapshot()
+  }
+  verifiedFile() { throw new Error('自动更新包由系统安装,请使用“重启更新”') }
+}

+ 163 - 0
desktop/auto-update-manager.test.mjs

@@ -0,0 +1,163 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { EventEmitter } from 'node:events'
+import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
+
+const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
+function fixture(t, options = {}) {
+  const driver = new EventEmitter(), calls = []
+  driver.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '0.4.63', releaseNotes: '<b>纯文本说明</b>' }, cancellationToken: { cancel() { calls.push('cancel') } } })
+  driver.downloadUpdate = async () => { driver.emit('update-downloaded'); return ['update.zip'] }
+  driver.quitAndInstall = () => calls.push('install')
+  const manager = new DesktopAutoUpdateManager({ driver, version: '0.4.62', ...options })
+  t.after(() => { manager.stopChecking(); clearTimeout(manager.stagingTimer); clearTimeout(manager.restartTimer) })
+  return { manager, driver, calls }
+}
+
+test('checks app version, waits for native staging and installs only once after cleanup', async t => {
+  const cleanup = deferred(), staged = deferred(), events = []
+  const { manager, driver } = fixture(t, { prepareInstall: async () => { events.push('cleanup'); await cleanup.promise } })
+  driver.downloadUpdate = () => { driver.emit('download-progress', { transferred: 10, total: 20 }); driver.emit('update-downloaded'); return staged.promise }
+  driver.quitAndInstall = () => events.push('install')
+  const checked = await manager.check()
+  assert.equal(checked.available, true)
+  assert.equal(checked.currentVersion, '0.4.62')
+  assert.equal(driver.autoDownload, false)
+  const download = manager.download()
+  assert.equal(manager.snapshot().phase, 'preparing')
+  assert.equal(manager.snapshot().canCancel, false)
+  await manager.install(); await manager.check(); manager.cancel()
+  assert.deepEqual(events, [])
+  staged.resolve(['update.zip']); await download
+  assert.equal(manager.snapshot().phase, 'ready')
+  await manager.check(false)
+  assert.equal(manager.snapshot().phase, 'ready')
+  const install = manager.install()
+  await manager.install()
+  assert.deepEqual(events, ['cleanup'])
+  cleanup.resolve(); await install
+  assert.deepEqual(events, ['cleanup', 'install'])
+  await manager.install(); assert.deepEqual(events, ['cleanup', 'install'])
+})
+
+test('no update and invalid environments never offer installation', async t => {
+  const { manager, driver, calls } = fixture(t)
+  driver.checkForUpdates = async () => ({ isUpdateAvailable: false, updateInfo: { version: '0.4.62' } })
+  assert.equal((await manager.check()).available, false)
+  await manager.download(); await manager.install(); assert.deepEqual(calls, [])
+  driver.checkForUpdates = async () => null
+  assert.equal((await manager.check()).phase, 'failed')
+})
+
+test('check failures cannot masquerade as up-to-date or erase a manual error', async t => {
+  const { manager, driver } = fixture(t)
+  driver.checkForUpdates = async () => { throw new Error('HTTP 404 latest-mac.yml') }
+  const failed = await manager.check()
+  assert.equal(failed.phase, 'failed'); assert.equal(failed.available, false)
+  assert.match(failed.error, /尚未提供完整/)
+  await manager.check(false); assert.deepEqual(manager.snapshot(), failed)
+  driver.checkForUpdates = async () => { driver.emit('error', new Error('network')); return { isUpdateAvailable: false } }
+  assert.equal((await manager.check()).phase, 'failed')
+})
+
+test('background failures remain silent and checks are single-flight', async t => {
+  const pending = deferred(); const { manager, driver } = fixture(t)
+  let calls = 0
+  driver.checkForUpdates = () => { calls++; return pending.promise }
+  const checking = manager.check(false)
+  await manager.check(false); assert.equal(calls, 1)
+  pending.reject(new Error('ENOTFOUND'))
+  assert.equal((await checking).phase, 'idle')
+})
+
+test('manual checking promotes a pending background error to a visible failure', async t => {
+  const { manager, driver } = fixture(t), pending = deferred()
+  driver.checkForUpdates = () => pending.promise
+  const checking = manager.check(false)
+  await manager.check(true)
+  pending.reject(new Error('ENOTFOUND'))
+  assert.equal((await checking).phase, 'failed')
+})
+
+test('canceling a download requires a fresh check and can then recover', async t => {
+  const { manager, driver } = fixture(t), pending = deferred()
+  const normal = driver.downloadUpdate
+  driver.downloadUpdate = () => pending.promise
+  await manager.check()
+  manager.token.cancel = () => pending.reject(new Error('cancelled'))
+  const downloading = manager.download()
+  assert.equal(manager.snapshot().canCancel, true)
+  manager.cancel(); assert.equal((await downloading).phase, 'cancelled')
+  driver.downloadUpdate = normal
+  await manager.check(); assert.equal((await manager.download()).phase, 'ready')
+})
+
+test('download errors reset availability and allow a new check', async t => {
+  const { manager, driver } = fixture(t)
+  driver.downloadUpdate = async () => { const error = new Error('sha512 mismatch'); driver.emit('error', error); throw error }
+  await manager.check(); const failed = await manager.download()
+  assert.equal(failed.phase, 'failed'); assert.match(failed.error, /完整性/)
+  assert.equal(failed.retryable, true)
+  assert.equal((await manager.check()).available, true)
+})
+
+test('native failures and late ready events never offer retry or installation', async t => {
+  const { manager, driver, calls } = fixture(t), pending = deferred()
+  driver.downloadUpdate = () => { driver.emit('update-downloaded'); return pending.promise }
+  await manager.check(); const downloading = manager.download()
+  driver.emit('error', new Error('signature validation failed'))
+  assert.equal(manager.snapshot().retryable, false)
+  assert.match(manager.snapshot().error, /签名验证失败.*重新打开/)
+  pending.resolve([]); await downloading
+  driver.emit('update-downloaded'); driver.emit('download-progress', { transferred: 100, total: 100 })
+  await manager.install(); await manager.check(); await manager.download()
+  assert.equal(manager.snapshot().phase, 'failed'); assert.deepEqual(calls, [])
+})
+
+test('staging timeout stays failed after a late native completion', async t => {
+  t.mock.timers.enable({ apis: ['setTimeout'] })
+  const { manager, driver } = fixture(t, { stagingTimeout: 50 }), pending = deferred()
+  driver.downloadUpdate = () => { driver.emit('update-downloaded'); return pending.promise }
+  await manager.check(); const downloading = manager.download()
+  t.mock.timers.tick(51)
+  assert.equal(manager.snapshot().phase, 'failed')
+  pending.resolve([]); await downloading
+  assert.equal(manager.snapshot().phase, 'failed')
+})
+
+test('cleanup failure restores the app and never dispatches install', async t => {
+  let restored = 0
+  const { manager, calls } = fixture(t, { prepareInstall: async () => { throw new Error('cleanup failed') }, recoverInstall: async () => { restored++ } })
+  await manager.check(); await manager.download(); await manager.install()
+  assert.deepEqual(calls, []); assert.equal(restored, 1)
+  assert.equal(manager.snapshot().phase, 'failed')
+})
+
+test('a native error during cleanup waits for cleanup before restoring', async t => {
+  const pending = deferred(); let restored = 0
+  const { manager, driver, calls } = fixture(t, { prepareInstall: () => pending.promise, recoverInstall: async () => { restored++ } })
+  await manager.check(); await manager.download()
+  const installing = manager.install()
+  driver.emit('error', new Error('native staging lost'))
+  assert.equal(restored, 0)
+  pending.resolve(); await installing
+  assert.equal(restored, 1); assert.deepEqual(calls, [])
+})
+
+test('quit dispatch failure restores app access and keeps installation locked', async t => {
+  let restored = 0
+  const { manager, driver } = fixture(t, { recoverInstall: async () => { restored++ } })
+  driver.quitAndInstall = () => { throw new Error('native restart failed') }
+  await manager.check(); await manager.download(); await manager.install()
+  assert.equal(restored, 1); assert.equal(manager.snapshot().retryable, false)
+})
+
+test('automatic checks start once and stop cleanly', async t => {
+  t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] })
+  const { manager, driver } = fixture(t); let checks = 0
+  driver.checkForUpdates = async () => { checks++; return { isUpdateAvailable: false, updateInfo: { version: '0.4.62' } } }
+  manager.startChecking(); manager.startChecking()
+  t.mock.timers.tick(15000); await Promise.resolve(); assert.equal(checks, 1)
+  t.mock.timers.tick(3600000); await Promise.resolve(); assert.equal(checks, 2)
+  manager.stopChecking(); t.mock.timers.tick(3600000); assert.equal(checks, 2)
+})

+ 67 - 0
desktop/auto-update-window.test.mjs

@@ -0,0 +1,67 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { _electron as electron, expect } from '@playwright/test'
+
+test('native updater shows preparation, preserves staged state on reopen and restarts once', { timeout: 60000 }, async () => {
+  const root = resolve(import.meta.dirname, '..'), home = await mkdtemp(join(tmpdir(), 'yaoyao-update-ui-'))
+  const entry = join(home, 'main.mjs')
+  // The real view/preload/controller run in disposable Electron; only the
+  // downloader and native installer are fake, so no installed app is changed.
+  await writeFile(entry, `
+import {app,BrowserWindow,ipcMain} from 'electron';
+import {EventEmitter} from 'node:events';
+import {DesktopAutoUpdateManager} from ${JSON.stringify(pathToFileURL(join(root, 'desktop/auto-update-manager.mjs')).href)};
+app.setPath('userData',${JSON.stringify(join(home, 'user-data'))});
+const driver=new EventEmitter();let ready,win;
+globalThis.fixture={checks:0,installs:0};
+driver.checkForUpdates=async()=>{fixture.checks++;return {isUpdateAvailable:true,updateInfo:{version:'0.4.63',releaseNotes:'<b>原样显示</b>'}}};
+driver.downloadUpdate=()=>new Promise(resolve=>{ready=resolve;driver.emit('download-progress',{transferred:50,total:100});driver.emit('update-downloaded')});
+driver.quitAndInstall=()=>{fixture.installs++};
+const manager=new DesktopAutoUpdateManager({driver,version:'0.4.62'});
+fixture.ready=()=>ready([]);fixture.fail=()=>driver.emit('error',new Error('signature failed'));
+fixture.state=()=>manager.snapshot();
+const open=async()=>{win=new BrowserWindow({width:640,height:620,webPreferences:{contextIsolation:true,sandbox:true,preload:${JSON.stringify(join(root, 'desktop/update-preload.cjs'))}}});await win.loadFile(${JSON.stringify(join(root, 'desktop/update.html'))})};
+fixture.open=open;
+for(const action of ['state','check','download','cancel','install'])ipcMain.handle('desktop-update:'+action,()=>action==='state'?manager.snapshot():manager[action]());
+app.on('window-all-closed',()=>{});app.whenReady().then(open);
+`)
+  let app
+  try {
+    app = await electron.launch({ args: [entry], cwd: root })
+    let page = await app.firstWindow()
+    await expect(page.getByRole('button', { name: '下载更新', exact: true })).toBeEnabled()
+    await expect(page.locator('#current')).toContainText('0.4.62')
+    await page.getByRole('button', { name: '下载更新', exact: true }).click()
+    await expect(page.locator('#status')).toContainText('准备安装')
+    await expect(page.getByRole('button', { name: '重启更新', exact: true })).toBeHidden()
+    await expect(page.locator('#cancel')).toBeHidden()
+    await page.evaluate(() => window.close())
+    const opened = app.waitForEvent('window'); await app.evaluate(() => fixture.open()); page = await opened
+    await expect(page.locator('#status')).toContainText('准备安装')
+    assert.equal(await app.evaluate(() => fixture.checks), 1)
+    await app.evaluate(() => fixture.ready())
+    const install = page.getByRole('button', { name: '重启更新', exact: true })
+    await expect(install).toBeEnabled()
+    await expect(page.locator('#check')).toBeDisabled()
+    await page.locator('summary').click()
+    await expect(page.locator('#notes')).toHaveText('<b>原样显示</b>')
+    assert.equal(await page.locator('#notes b').count(), 0)
+    await mkdir(join(root, 'test-results/desktop'), { recursive: true })
+    await page.screenshot({ path: join(root, 'test-results/desktop/auto-update-ready.png') })
+    await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' })
+    await page.setViewportSize({ width: 380, height: 620 })
+    assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
+    await page.screenshot({ path: join(root, 'test-results/desktop/auto-update-ready-dark.png') })
+    await install.click()
+    await expect(page.getByRole('button', { name: '正在重启…' })).toBeDisabled()
+    assert.equal(await app.evaluate(() => fixture.installs), 1)
+    await app.evaluate(() => fixture.fail())
+    await expect(page.getByRole('alert')).toContainText('签名验证失败')
+    await expect(page.locator('#install')).toBeHidden()
+    await expect(page.locator('#check')).toBeDisabled()
+  } finally { await app?.close().catch(() => {}); await rm(home, { recursive: true, force: true }) }
+})

+ 85 - 0
desktop/mac-auto-update.test.mjs

@@ -0,0 +1,85 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { EventEmitter } from 'node:events'
+import Module, { createRequire } from 'node:module'
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
+import { get } from 'node:http'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { bundleDesktopUpdater } from '../scripts/bundle-desktop-updater.mjs'
+import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
+
+const directory = mkdtempSync(join(tmpdir(), 'yaoyao-native-update-'))
+const bundle = join(directory, 'electron-updater.cjs')
+before(async () => bundleDesktopUpdater(bundle))
+after(() => rmSync(directory, { recursive: true, force: true }))
+const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
+const httpGet = (url, headers) => new Promise((resolve, reject) => {
+  get(url, { headers, agent: false }, response => {
+    const chunks = []; response.on('data', chunk => chunks.push(chunk)); response.on('error', reject)
+    response.on('end', () => resolve({ status: response.statusCode, text: Buffer.concat(chunks).toString() }))
+  }).on('error', reject)
+})
+
+function fixture(t) {
+  const native = new EventEmitter(), transferred = deferred(), calls = []
+  let feed
+  native.setFeedURL = value => { feed = value }
+  native.quitAndInstall = () => calls.push('install')
+  native.checkForUpdates = () => {
+    void (async () => {
+      assert.equal((await httpGet(feed.url)).status, 401)
+      const metadata = await httpGet(feed.url, feed.headers)
+      assert.equal(metadata.status, 200)
+      const zip = await httpGet(JSON.parse(metadata.text).url)
+      assert.equal(zip.text, 'fixture ZIP bytes')
+      transferred.resolve()
+    })().catch(transferred.reject)
+  }
+  const load = Module._load
+  let driver
+  try {
+    Module._load = (name, ...args) => name === 'electron' ? { autoUpdater: native } : load(name, ...args)
+    const { MacUpdater } = createRequire(import.meta.url)(bundle)
+    driver = new MacUpdater(null, { version: '0.4.62', quit: () => assert.fail('unexpected quit') })
+  } finally { Module._load = load }
+  driver.logger = { info() {}, warn() {}, error() {}, debug() {} }
+  const file = join(directory, 'update.zip'); writeFileSync(file, 'fixture ZIP bytes')
+  driver.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '0.4.63' } })
+  driver.downloadUpdate = () => driver.updateDownloaded({ info: { size: 17 }, url: new URL('https://fixture.invalid/update.zip') }, { version: '0.4.63', downloadedFile: file })
+  const manager = new DesktopAutoUpdateManager({ driver, version: '0.4.62', prepareInstall: async () => calls.push('cleanup') })
+  t.after(async () => {
+    clearTimeout(manager.stagingTimer); clearTimeout(manager.restartTimer); manager.stopChecking()
+    if (driver.server?.listening) await new Promise(done => driver.server.close(done))
+    native.removeAllListeners()
+  })
+  return { native, driver, manager, transferred, calls }
+}
+
+test('bundled Mac updater waits for native validation after the real ZIP proxy transfer', async t => {
+  const h = fixture(t)
+  await h.manager.check(); const downloading = h.manager.download()
+  await h.transferred.promise
+  assert.equal(h.manager.snapshot().phase, 'preparing')
+  assert.equal(h.driver.squirrelDownloadedUpdate, false)
+  await h.manager.install(); assert.deepEqual(h.calls, [])
+  assert.throws(() => h.driver.quitAndInstall(), /not ready/)
+  h.native.emit('update-downloaded'); await downloading
+  assert.equal(h.manager.snapshot().phase, 'ready')
+  assert.equal(h.native.listenerCount('update-downloaded'), 1)
+  await h.manager.install(); await h.manager.install()
+  assert.deepEqual(h.calls, ['cleanup', 'install'])
+})
+
+test('native signature rejection leaves no restart action and cleans temporary listeners', async t => {
+  const h = fixture(t)
+  await h.manager.check(); const downloading = h.manager.download()
+  await h.transferred.promise
+  h.native.emit('error', new Error('signature validation failed'))
+  await downloading
+  assert.equal(h.manager.snapshot().phase, 'failed')
+  assert.equal(h.manager.snapshot().retryable, false)
+  assert.equal(h.native.listenerCount('update-downloaded'), 1)
+  h.native.emit('update-downloaded'); await h.manager.install()
+  assert.deepEqual(h.calls, [])
+})

+ 48 - 10
desktop/main.mjs

@@ -14,6 +14,8 @@ import { DesktopHostManager } from './host-manager.mjs'
 import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer } from './remote-login.mjs'
 import { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
+import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
+import { createRequire } from 'node:module'
 import { installComputerViewer } from './computer-viewer.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
@@ -80,17 +82,19 @@ else {
     let trusted = false
     try {
       trusted = event.sender === window?.webContents && event.senderFrame === window.webContents.mainFrame
-        && manager?.state.phase === 'ready' && new URL(event.senderFrame.url).origin === manager.state.url
-    } catch { /* Only the main local application page may open the updater. */ }
+        && serviceOrigins().includes(new URL(event.senderFrame.url).origin)
+    } catch { /* Only the active server's main page may open the local updater. */ }
     if (!trusted || closing || quitting || !updater) throw new Error('不允许此页面打开 App 更新')
     await showUpdates()
   })
-  for (const action of ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release']) {
+  for (const action of ['state', 'check', 'download', 'cancel', 'install', 'open', 'folder', 'release']) {
     ipcMain.handle(`desktop-update:${action}`, async event => {
-      if (!trustedUpdate(event) || closing || quitting) throw new Error('不允许此页面操作 App 更新')
+      if (!trustedUpdate(event)) throw new Error('不允许此页面操作 App 更新')
       if (action === 'state') return updater.snapshot()
+      if (closing || quitting) throw new Error('App 正在重启或退出')
       if (action === 'check') return updater.check()
       if (action === 'download') return updater.download()
+      if (action === 'install') return updater.install()
       if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
       if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/yaoyao/releases'); return }
       const path = await updater.verifiedFile()
@@ -109,7 +113,7 @@ else {
     updateWindow.webContents.on('will-navigate', event => event.preventDefault())
     updateWindow.webContents.on('will-redirect', event => event.preventDefault())
     updateWindow.webContents.on('will-attach-webview', event => event.preventDefault())
-    updateWindow.on('closed', () => { updater.cancel(); updateWindow = undefined })
+    updateWindow.on('closed', () => { updateWindow = undefined })
     updateWindow.once('ready-to-show', () => updateWindow?.show())
     await updateWindow.loadURL(updateURL)
   }
@@ -206,7 +210,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     closing = true; clearInterval(timer)
-    updater?.cancel(); updateWindow?.close()
+    updater?.stopChecking?.(); updater?.cancel(); updateWindow?.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.
@@ -233,11 +237,37 @@ else {
           }
           stateChanged(manager.state)
           timer=setInterval(()=>{if(serverModeActive)void manager.check()},10000);timer.unref()
+          updater?.startChecking?.()
           show()
         }
       }
     })() })
   }
+  async function prepareUpdateRestart() {
+    if (closing || quitting) throw new Error('App 正在退出,请稍后重试')
+    closing = true; clearInterval(timer)
+    await environmentHost?.close()
+    await hostManager?.stop()
+    await runnerManager?.stop()
+    await manager?.stop()
+    // Cleanup has completed before Squirrel begins quitting. Releasing the
+    // single-instance lock lets the newly installed app start immediately.
+    quitting = true
+    app.releaseSingleInstanceLock()
+  }
+  async function recoverUpdateRestart() {
+    quitting = false; closing = false
+    if (!app.requestSingleInstanceLock()) { quitting = true; app.quit(); return }
+    if (remoteMode) await hostManager?.start().catch(error => log(error.message))
+    else if (serverModeActive) {
+      await manager?.reconnect().catch(error => log(error.message))
+      environmentHost?.start()
+      await runnerManager?.start().catch(error => log(error.message))
+    }
+    clearInterval(timer)
+    timer = setInterval(() => { if (serverModeActive) void manager?.check() }, 10000); timer.unref()
+    window?.show(); updateWindow?.show()
+  }
   app.on('before-quit', event => {
     if (quitting) return
     event.preventDefault()
@@ -249,9 +279,17 @@ else {
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
     const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
     const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
-    updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
-      source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
-      fetchImpl: (...args) => net.fetch(...args) })
+    if ((app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE === '1')) {
+      const { autoUpdater } = createRequire(import.meta.url)(join(root, 'electron-updater.cjs'))
+      autoUpdater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, (...values) => log(`[更新:${level}] ${values.join(' ')}`)]))
+      updater = new DesktopAutoUpdateManager({ driver: autoUpdater, version: packageVersion,
+        prepareInstall: prepareUpdateRestart, recoverInstall: recoverUpdateRestart })
+      updater.startChecking()
+    } else {
+      updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
+        source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
+        fetchImpl: (...args) => net.fetch(...args) })
+    }
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
@@ -470,7 +508,7 @@ else {
         { 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-check', label:'检查 App 更新…', click:showUpdates },
-        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n通过“检查 App 更新”从 GitHub 下载并校验 DMG,手动拖入应用程序安装。开发签名包尚未公证,请遵循 macOS 的安装提示。\n\n安装后首次启动同步较旧的本机 Web,已有较新的 Web 保留。Web 可以独立升级,退出 App 保留独立后台服务。\n\n降级通过 Web 的回滚入口处理;数据库结构不兼容时不能直接降级。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
+        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n\n通过“检查 App 更新”下载新版,准备完成后点击“重启更新”。此操作只更新当前电脑的 App,不升级远程服务器;独立后台服务继续运行。开发运行时提供手动安装包下载。\n\n服务器模式下,首次启动同步较旧的本机 Web,已有较新的 Web 保留。Web 降级通过服务器的回滚入口处理;数据库结构不兼容时不能直接降级。\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' },

+ 35 - 0
desktop/release-artifacts.test.mjs

@@ -0,0 +1,35 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { dump } from 'js-yaml'
+import { releaseSigningOptions, verifyUpdateArtifacts } from '../scripts/desktop-release.mjs'
+
+test('formal desktop releases require a distribution identity and notarization credentials', () => {
+  assert.throws(() => releaseSigningOptions({}), /Developer ID/)
+  assert.throws(() => releaseSigningOptions({ CSC_NAME: 'Apple Development: Test' }), /Developer ID/)
+  assert.throws(() => releaseSigningOptions({ CSC_NAME: 'Developer ID Application: Test' }), /公证/)
+  const options = releaseSigningOptions({ CSC_NAME: 'Developer ID Application: Test', APPLE_API_KEY: '/fixture/key.p8', APPLE_API_KEY_ID: 'key', APPLE_API_ISSUER: 'issuer' })
+  assert.equal(options.forceCodeSigning, true); assert.equal(options.mac.notarize, true)
+  assert.equal(options.mac.identity, 'Test'); assert.equal(options.mac.type, 'distribution')
+})
+
+test('release verification rejects incomplete, mixed-version and corrupt updater artifacts', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'yaoyao-update-artifacts-'))
+  const bytes = Buffer.from('signed fixture'), version = '0.4.63'
+  const files = ['zip', 'dmg'].map(ext => ({ url: `Yaoyao-${version}-arm64.${ext}`, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }))
+  try {
+    for (const file of files) { await writeFile(join(directory, file.url), bytes); await writeFile(join(directory, file.url + '.blockmap'), 'map') }
+    await writeFile(join(directory, 'latest-mac.yml'), dump({ version, files }))
+    assert.equal((await verifyUpdateArtifacts(directory, version)).length, 5)
+    await assert.rejects(verifyUpdateArtifacts(directory, '0.4.64'), /版本/)
+    await writeFile(join(directory, files[0].url), 'tampered')
+    await assert.rejects(verifyUpdateArtifacts(directory, version), /大小/)
+    await writeFile(join(directory, files[0].url), Buffer.alloc(bytes.length))
+    await assert.rejects(verifyUpdateArtifacts(directory, version), /SHA-512/)
+    await writeFile(join(directory, 'latest-mac.yml'), dump({ version, files: [files[1]] }))
+    await assert.rejects(verifyUpdateArtifacts(directory, version), /缺少唯一/)
+  } finally { await rm(directory, { recursive: true, force: true }) }
+})

+ 38 - 1
desktop/update-entry.test.mjs

@@ -7,7 +7,7 @@ import { createServer } from 'node:http'
 import { _electron as electron, expect } from '@playwright/test'
 import { dataKey } from './service-manager.mjs'
 
-test('only the main local app can open the native updater and repeated requests reuse its window', { timeout: 60000 }, async () => {
+test('the main local app can open the native updater and repeated requests reuse its window', { timeout: 60000 }, async () => {
   const root = resolve(import.meta.dirname, '..')
   const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-update-entry-')))
   const version = JSON.parse(await readFile(join(root, '.desktop-build/release.json'), 'utf8')).webVersion
@@ -52,3 +52,40 @@ test('only the main local app can open the native updater and repeated requests
     await rm(home, { recursive: true, force: true })
   }
 })
+
+test('client mode opens local App updates without a local service or admin role', { timeout: 60000 }, async () => {
+  const root = resolve(import.meta.dirname, '..')
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-client-update-')))
+  const remote = createServer((req, res) => {
+    if (req.url === '/api/app/bootstrap') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ authenticated: true, csrfToken: 'fixture', user: { role: 'member' } })) }
+    else { res.setHeader('Content-Type', 'text/html'); res.end('<title>Remote member</title><button onclick="window.yaoyaoDesktop.openUpdates()">App update</button>') }
+  })
+  await new Promise(done => remote.listen(0, '127.0.0.1', done))
+  const origin = `http://127.0.0.1:${remote.address().port}`
+  await writeFile(join(home, 'desktop-preferences.json'), JSON.stringify({ startupChoice: 'remote', remoteServer: origin }))
+  let app
+  try {
+    app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_PORT: '1', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
+    const page = await app.firstWindow(); await page.waitForURL(origin + '/**')
+    assert.equal((await page.evaluate(() => window.yaoyaoDesktop.modeState())).mode, 'client')
+    assert.equal(await page.evaluate(() => typeof window.yaoyaoUpdate), 'undefined')
+    await app.evaluate(({ net }) => { net.fetch = async () => new Response('', { status: 403 }) })
+    const opened = app.waitForEvent('window'); await page.getByRole('button', { name: 'App update' }).click()
+    const updates = await opened
+    await expect(updates.getByRole('heading', { name: 'App 更新' })).toBeVisible()
+    await expect(updates.getByRole('alert')).toContainText('GitHub 请求受限')
+    const denied = await app.evaluate(async ({ BrowserWindow }, { origin, preload }) => {
+      const outsider = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, preload } })
+      try {
+        await outsider.loadURL(origin)
+        return await outsider.webContents.executeJavaScript('Promise.all([window.yaoyaoUpdate.check(),window.yaoyaoUpdate.download(),window.yaoyaoUpdate.install()].map(p=>p.then(()=>false,()=>true)))')
+      } finally { outsider.destroy() }
+    }, { origin, preload: join(root, 'desktop/update-preload.cjs') })
+    assert.deepEqual(denied, [true, true, true])
+    assert.equal(remote.listening, true)
+  } finally {
+    await app?.close().catch(() => {})
+    await new Promise(done => { remote.close(done); remote.closeAllConnections() })
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 2 - 1
desktop/update-manager.mjs

@@ -37,9 +37,10 @@ export class DesktopUpdateManager {
   constructor({ version, arch = process.arch, platform = process.platform, cacheRoot, inspect, compare, source, fetchImpl = fetch,
     verifyImage = (path, signal) => promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
     Object.assign(this, { version, arch, platform, cacheRoot, inspect, compare, source, fetchImpl, verifyImage })
-    this.state = { phase: 'idle', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
+    this.state = { phase: 'idle', installMode: 'manual', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
   }
   snapshot() { return { ...this.state } }
+  install() { throw new Error('开发运行请下载并手动安装正式 App') }
   get busy() { return !!this.controller }
   cancel() { this.controller?.abort() }
   async check() {

+ 1 - 1
desktop/update-preload.cjs

@@ -1,4 +1,4 @@
 const { contextBridge, ipcRenderer } = require('electron')
 contextBridge.exposeInMainWorld('yaoyaoUpdate', Object.freeze(Object.fromEntries(
-  ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release'].map(action => [action, () => ipcRenderer.invoke(`desktop-update:${action}`)]),
+  ['state', 'check', 'download', 'cancel', 'install', 'open', 'folder', 'release'].map(action => [action, () => ipcRenderer.invoke(`desktop-update:${action}`)]),
 )))

+ 60 - 0
desktop/update-restart.test.mjs

@@ -0,0 +1,60 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, writeFile, realpath, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { createServer } from 'node:http'
+import { _electron as electron, expect } from '@playwright/test'
+import { dataKey } from './service-manager.mjs'
+
+for (const scenario of ['remote', 'local', 'remote-failure']) test(`${scenario} restart releases the lock, preserves independent servers and recovers on failure`, { timeout: 60000 }, async () => {
+  const mode = scenario === 'local' ? 'local' : 'remote', fail = scenario === 'remote-failure'
+  const root = resolve(import.meta.dirname, '..'), home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-update-restart-')))
+  const version = JSON.parse(await readFile(join(root, 'release.json'), 'utf8')).webVersion
+  const identity = { protocol: 1, instanceId: 'update-restart', pid: process.pid, dataKey: dataKey(home), version }
+  let releaseIdentity; const identityReady = new Promise(resolve => { releaseIdentity = resolve })
+  const server = createServer(async (req, res) => {
+    res.setHeader('Content-Type', 'application/json')
+    if (req.url === '/desktop/service') { await identityReady; res.end(JSON.stringify(identity)) }
+    else if (req.url === '/api/app/bootstrap') res.end(JSON.stringify({ authenticated: true, csrfToken: 'fixture', user: { role: 'member' } }))
+    else { res.setHeader('Content-Type', 'text/html'); res.end('<title>Connected server</title>') }
+  })
+  await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const origin = `http://127.0.0.1:${server.address().port}`
+  await writeFile(join(home, 'desktop-preferences.json'), JSON.stringify({ startupChoice: mode, remoteServer: origin }))
+  if (mode === 'local') await writeFile(join(home, 'service-instance.json'), JSON.stringify({ ...identity, url: origin, token: 'fixture' }))
+  let app
+  try {
+    app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home,
+      HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0', HERMES_YAOYAO_DESKTOP_PORT: mode === 'local' ? String(server.address().port) : '1' } })
+    const page = await app.firstWindow(); releaseIdentity(); await page.waitForURL(origin + '/**')
+    await app.evaluate(async ({ app }, { root, home, fail }) => {
+      const { createRequire } = process.getBuiltinModule('node:module'), { writeFileSync } = process.getBuiltinModule('node:fs')
+      const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build/electron-updater.cjs')
+      autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '99.0.0' } })
+      autoUpdater.downloadUpdate = async () => { autoUpdater.emit('update-downloaded'); return [] }
+      autoUpdater.quitAndInstall = () => {
+        writeFileSync(home + '/restart-proof.json', JSON.stringify({ holdsAppLock: app.hasSingleInstanceLock() }))
+        if (fail) throw new Error('fixture restart failed')
+        app.quit()
+      }
+    }, { root, home, fail })
+    const opened = app.waitForEvent('window'); await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    const updates = await opened
+    await updates.getByRole('button', { name: '下载更新', exact: true }).click()
+    await expect(updates.getByRole('button', { name: '重启更新', exact: true })).toBeEnabled()
+    const closed = fail ? undefined : app.waitForEvent('close')
+    await updates.getByRole('button', { name: '重启更新', exact: true }).click().catch(error => { if (!updates.isClosed()) throw error })
+    if (fail) {
+      await expect(updates.getByRole('alert')).toContainText('fixture restart failed')
+      assert.equal(await app.evaluate(({ app }) => app.hasSingleInstanceLock()), true)
+      assert.equal((await page.evaluate(() => window.yaoyaoDesktop.modeState())).mode, 'client')
+    } else { await closed; app = undefined }
+    assert.deepEqual(JSON.parse(await readFile(join(home, 'restart-proof.json'), 'utf8')), { holdsAppLock: false })
+    assert.equal((await fetch(origin + '/api/app/bootstrap')).status, 200)
+  } finally {
+    releaseIdentity(); await app?.close().catch(() => {})
+    await new Promise(done => { server.close(done); server.closeAllConnections() })
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 3 - 3
desktop/update.html

@@ -3,11 +3,11 @@
 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'">
 <title>App 更新 · 夭夭</title><link rel="stylesheet" href="update.css"></head>
 <body><main>
-<header><p class="eyebrow">夭夭 · macOS</p><h1>App 更新</h1><p>从 GitHub 下载,校验后手动安装。</p></header>
+<header><p class="eyebrow">夭夭 · macOS</p><h1>App 更新</h1><p id="subtitle">更新当前电脑的 App,下载完成后重启安装。</p></header>
 <section class="versions" aria-label="版本信息"><div><span>当前版本</span><strong id="current">—</strong></div><div><span>最新版本</span><strong id="latest">待检查</strong></div></section>
 <p id="status" role="status" aria-live="polite">正在检查…</p><p id="error" role="alert" hidden></p>
 <progress id="progress" aria-label="安装包下载进度" hidden></progress><p id="bytes" class="muted"></p>
-<div class="actions"><button id="check">检查更新</button><button id="download" class="primary" disabled>下载 DMG</button><button id="cancel" hidden>取消</button><button id="open" class="primary" hidden>打开安装包</button><button id="folder" hidden>打开所在文件夹</button></div>
+<div class="actions"><button id="check">检查更新</button><button id="download" class="primary" disabled>下载更新</button><button id="cancel" hidden>取消</button><button id="install" class="primary" hidden>重启更新</button><button id="open" class="primary" hidden>打开安装包</button><button id="folder" hidden>打开所在文件夹</button></div>
 <details id="notes-section" hidden><summary>发布说明</summary><pre id="notes"></pre></details>
-<footer><button id="release" class="link">查看 GitHub 发布页</button><p>下载不会停止聊天或后台服务。安装时将“夭夭”拖入“应用程序”;首次启动会同步较旧的本机 Web。</p><p>当前桌面包为开发签名,未公证。请遵循 macOS 安装提示。</p></footer>
+<footer><button id="release" class="link">查看 GitHub 发布页</button><p>此处仅更新本机 App。远程服务器版本由服务器管理员管理。</p><p>下载期间可继续使用;重启时会断开本机连接,独立后台服务继续运行。</p></footer>
 </main><script src="update.js"></script></body></html>

+ 26 - 10
desktop/update.js

@@ -3,23 +3,31 @@ let closed = false
 let localError = '', previousPhase = ''
 const sizeLabel = value => value < 1048576 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1048576).toFixed(1)} MB`
 function render(state) {
-  const busy = ['checking', 'downloading', 'verifying'].includes(state.phase)
+  const busy = ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(state.phase)
   const ready = state.phase === 'ready'
+  const automatic = state.installMode === 'restart'
   byId('current').textContent = `${state.currentVersion} · ${state.arch}`
   byId('latest').textContent = state.latestVersion || (state.phase === 'failed' ? '检查失败' : '待检查')
   byId('status').textContent = state.message
   byId('error').hidden = !(state.error || localError); byId('error').textContent = state.error || localError
-  byId('check').disabled = busy
+  byId('subtitle').textContent = automatic ? '更新当前电脑的 App,下载完成后重启安装。' : '开发运行:从 GitHub 下载,校验后手动安装。'
+  byId('check').disabled = busy || (automatic && ready) || state.retryable === false
   byId('download').disabled = busy || !state.available; byId('download').hidden = ready
-  byId('download').textContent = state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载 DMG'
-  byId('cancel').hidden = !busy
-  byId('open').hidden = !ready; byId('folder').hidden = !ready
-  byId('progress').hidden = !['downloading', 'verifying'].includes(state.phase)
-  byId('progress').max = state.total || 1; byId('progress').value = state.received || 0
+  byId('download').textContent = automatic ? '下载更新' : state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载 DMG'
+  byId('cancel').hidden = automatic ? !state.canCancel : !busy
+  byId('open').hidden = !ready || automatic; byId('folder').hidden = !ready || automatic
+  byId('install').hidden = !automatic || (!ready && state.phase !== 'installing')
+  byId('install').disabled = !ready || busy
+  byId('install').textContent = state.phase === 'installing' ? '正在重启…' : '重启更新'
+  byId('progress').hidden = !['downloading', 'verifying', 'preparing'].includes(state.phase)
+  byId('progress').max = state.total || 1
+  if (state.total && state.phase === 'downloading') byId('progress').value = state.received || 0
+  else byId('progress').removeAttribute('value')
   byId('bytes').textContent = state.received ? `${sizeLabel(state.received)} / ${sizeLabel(state.total)}` : ''
+  byId('bytes').hidden = !['downloading', 'verifying', 'preparing'].includes(state.phase)
   byId('notes-section').hidden = !state.notes; byId('notes').textContent = state.notes || ''
   if (state.phase !== previousPhase && ['download', 'cancel'].includes(document.activeElement?.id)) {
-    if (ready) byId('open').focus()
+    if (ready) byId(automatic ? 'install' : 'open').focus()
     else if (!busy) byId(state.available ? 'download' : 'check').focus()
   }
   previousPhase = state.phase
@@ -29,8 +37,16 @@ async function action(name) {
   try { const state = await window.yaoyaoUpdate[name](); if (state && !closed) render(state) }
   catch (error) { if (!closed) { localError = error.message; byId('error').hidden = false; byId('error').textContent = localError } }
 }
-for (const name of ['check', 'download', 'cancel', 'open', 'folder', 'release']) byId(name).addEventListener('click', () => void action(name))
+for (const name of ['check', 'download', 'cancel', 'install', 'open', 'folder', 'release']) byId(name).addEventListener('click', () => void action(name))
 const timer = setInterval(() => void action('state'), 500)
 window.addEventListener('unload', () => { closed = true; clearInterval(timer) })
 document.addEventListener('keydown', event => { if (event.key === 'Escape') window.close() })
-void action('check')
+// Reopening the window must preserve an active download or staged update.
+void (async () => {
+  try {
+    const state = await window.yaoyaoUpdate.state()
+    if (closed) return
+    render(state)
+    if (state.phase === 'idle') await action('check')
+  } catch (error) { if (!closed) { byId('error').hidden = false; byId('error').textContent = error.message } }
+})()

+ 2 - 0
docs/desktop-lifecycle.md

@@ -12,6 +12,8 @@
 
 重新打开 App 会核对已有后台的身份和健康状态;后台运行时连接现有实例,已停止时重新启动。退出期间不切回启动页,也不接受重复打开/退出请求。应用菜单可查看“后台服务运行中”。只有确认服务停止才显示“后台服务已停止”;断开连接单独表示为“App 已断开后台连接”。
 
+“重启更新”只更新本机 App,在 macOS 完成更新包准备后,先关闭 App 的设备通道和自有子进程、断开本机服务连接,再释放单实例锁并交给原生安装器。客户端模式无需启动本机 Web;远程服务器和独立后台服务继续运行。关闭更新窗口保留下载和准备状态,重新打开可继续操作。准备或安装异常会显示错误并禁止重复安装,需要退出重开后重试。
+
 普通退出仍会停止由 App 自身启动的高级外部 Runner;“本地虚拟机”使用的托管 Runner 在独立 Web 内运行,不受普通退出影响。服务停止只针对已核对数据目录、端口、进程和 LaunchAgent 的本机实例,不停止用户另外启动的 Hermes、Docker 或其他服务。
 
 停止操作使用与升级相同的互斥保护,更新进行中会拒绝停止。通过 `launchctl bootout` 卸载服务,避免 KeepAlive 重启;启动配置保留,下次打开 App 可重新启动。后台停止期间拒绝新应用请求,并在清理虚拟机后关闭 HTTP 长连接。

+ 10 - 4
docs/github-updates.md

@@ -12,13 +12,19 @@ macOS 独立 Web 继续使用现有升级事务:构建新版本、等待任务
 
 ## macOS App
 
-使用“夭夭 → 检查 App 更新…”打开独立窗口。它直接连接官方 GitHub,即使 Web 或 9119 未启动也可使用,不受 Web 自定义源设置影响。
+使用页面中的“检测更新”或“夭夭 → 检查 App 更新…”打开独立窗口。客户端与服务器模式均可使用,不依赖账号的管理员权限、本机 Web 或 Hermes。窗口显示的是当前电脑的 App 版本;远程服务器升级仍由服务器管理员管理。更新直接连接官方 GitHub,不受 Web 自定义源设置影响。
 
-新版提供当前架构的 `Yaoyao-{版本}-{架构}.dmg` 和 `SHA256SUMS.txt` 后,窗口显示下载按钮。下载可取消或重试,不会停止聊天、Runner 或后台服务。文件写入 Web 数据目录下的 `updates/desktop-downloads`,通过大小、SHA-256 和 DMG 完整性检查后才提供“打开安装包”。GitHub 附件摘要存在时也必须一致,缓存重新使用和打开前会核对文件。
+正式安装的 App 使用 `electron-updater`,从 `app-update.yml` 配置的官方源读取稳定版本的 `latest-mac.yml`,下载当前架构的 ZIP 并验证 SHA-512,再交由 Squirrel.Mac 检查签名、准备安装。启动约 15 秒后检查一次,随后每小时检查;下载由用户点击开始。下载期间可取消,进入原生准备阶段后不可取消。准备成功后提供“重启更新”,先关闭 App 的设备通道和子进程,再安装和重新启动;独立 Web 后台服务不随客户端更新而停止。
 
-打开 DMG 后手动拖入“应用程序”安装。不会自动覆盖正在运行的 App、申请提权或自动重启;开发签名包尚未公证,请遵循 macOS 提示。安装后首次启动自动同步较旧的本机 Web,保留较新的独立服务。同版本构建关系不明时,可在启动页或夭夭菜单选择“使用当前 App 覆盖同版本 Web”,详见 [同步与覆盖规则](app-web-sync.md)。
+关闭更新窗口不取消下载,重新打开可继续查看进度或重启。后台检查不会覆盖下载、已准备的更新或用户操作失败的状态。网络失败可重新检查;原生准备或安装失败后需要退出并重新打开 App,避免晚到的原生回调触发重复安装。缺少更新清单或 ZIP 时显示明确错误,并保留“查看 GitHub 发布页”作为手动安装入口。
 
-下载和打开接口只授权给本地更新窗口,远程页面、普通聊天页面和启动页不能调用。发布说明按纯文本显示,不执行远端 HTML 或脚本。
+开发运行和隔离测试保留 DMG 手动下载:发布需要 `Yaoyao-{版本}-{架构}.dmg` 与 `SHA256SUMS.txt`;通过大小、SHA-256 和 DMG 完整性检查后才提供“打开安装包”。文件保存在应用数据目录的 `updates/desktop-downloads`。正式自动更新缓存由 electron-updater 管理,不使用 Web 的升级事务。
+
+服务器模式安装后首次启动会同步较旧的本机 Web,保留较新的独立服务;客户端模式只连接原先的远程服务器。同版本构建关系不明时,可使用“使用当前 App 覆盖同版本 Web”,详见 [同步与覆盖规则](app-web-sync.md)。
+
+当前已连接服务器的主页面只获准打开本地更新窗口,下载、取消、安装等接口只授权给该窗口的主框架;其他窗口、子框架和远程页面不能直接调用。发布说明按纯文本显示,不执行远端 HTML 或脚本。
+
+旧版 App 没有自动安装代码,首次需要手动安装包含此功能的正式签名包。历史 Apple Development 包不能作为正式自动更新发布基线;改用 Developer ID Application 签名并公证后,再以相同应用标识和持续有效的签名身份发布后续版本。旧标签和旧附件不改写。
 
 ## 旧安装迁移
 

+ 13 - 0
docs/release-process.md

@@ -9,6 +9,19 @@
 3. 构建 Web 源码包、macOS 桌面安装包和适用的 Android 正式签名 APK,验证签名、架构、启动及完整性,生成 `SHA256SUMS.txt`。
 4. 推送源码与版本标签,上传 GitHub Release 附件,核对远端附件状态、大小和 SHA-256。已发布的 Git 标签与旧附件保持不变。
 
+### macOS 自动更新产物
+
+`npm run desktop:pack` 用于本地打包;正式发布使用 `npm run desktop:release`,其上传行为固定为 `never`,先生成可检查的本地产物。需要:
+
+- `CSC_NAME` 指定 `Developer ID Application: ...` 分发签名身份。
+- 配置 `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`,或 `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID` 供公证使用。凭据通过环境注入,不写入仓库或发布说明。
+
+正式命令启用强制签名与公证,并校验包内 `app-update.yml` 指向 `Lsamien/yaoyao`、签名完整性、公证票据,以及 `latest-mac.yml` 的版本、附件名、大小和 SHA-512。当前仍只发布 Apple Silicon 桌面包;不要上传指向不存在的 Intel 包的清单。
+
+GitHub Release 必须同时上传同一次构建生成的 `Yaoyao-{版本}-arm64.dmg`、`Yaoyao-{版本}-arm64.zip`、各自 `.blockmap`、`latest-mac.yml` 和 `SHA256SUMS.txt`。ZIP 是自动更新载荷,DMG 是首次安装与旧客户端迁移载荷。命令生成的 SHA-256 清单包含桌面附件;本次有源码包等其他附件时,发布前一并补入清单。不能在生成 ZIP 和更新摘要之后再修改应用或重新签名。
+
+既有手动安装版客户端需先手动安装一次正式包,之后才能使用“下载更新 → 重启更新”。发布前应使用同一签名身份的两个版本,在隔离机器上验证实际替换与重新启动;单元测试或未签名包的打包成功不能代替这项验收。
+
 ## Docker Hub 远程镜像
 
 固定仓库为 `samienluo/yaoyao`。以每次发布的 `vX.Y.Z` 为版本标签:

+ 8 - 1
electron-builder.yml

@@ -28,6 +28,7 @@ extraResources:
       - release.json
       - build-info.json
       - github-release.mjs
+      - electron-updater.cjs
       - keychain-helper
       - computer-helper
       - ui/**/*
@@ -45,6 +46,8 @@ mac:
       arch: [arm64]
     - target: dmg
       arch: [arm64]
+    - target: zip
+      arch: [arm64]
   hardenedRuntime: true
   gatekeeperAssess: false
   extendInfo:
@@ -52,4 +55,8 @@ mac:
 dmg:
   title: 夭夭 ${version}
 artifactName: Yaoyao-${version}-${arch}.${ext}
-publish: null
+publish:
+  - provider: github
+    owner: Lsamien
+    repo: yaoyao
+    releaseType: release

+ 69 - 0
package-lock.json

@@ -63,7 +63,9 @@
         "cross-env": "^10.1.0",
         "electron": "43.4.0",
         "electron-builder": "26.15.3",
+        "electron-updater": "6.8.9",
         "esbuild": "0.28.2",
+        "js-yaml": "4.3.2",
         "jsdom": "^27.0.1",
         "supertest": "^7.1.4",
         "tsx": "^4.21.0",
@@ -5294,6 +5296,51 @@
         "node": ">=12"
       }
     },
+    "node_modules/electron-updater": {
+      "version": "6.8.9",
+      "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
+      "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "builder-util-runtime": "9.7.0",
+        "fs-extra": "^10.1.0",
+        "js-yaml": "^4.1.0",
+        "lazy-val": "^1.0.5",
+        "lodash.escaperegexp": "^4.1.2",
+        "lodash.isequal": "^4.5.0",
+        "semver": "~7.7.3",
+        "tiny-typed-emitter": "^2.1.0"
+      }
+    },
+    "node_modules/electron-updater/node_modules/fs-extra": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/electron-updater/node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/electron-winstaller": {
       "version": "5.4.0",
       "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@@ -7172,6 +7219,21 @@
       "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
       "license": "MIT"
     },
+    "node_modules/lodash.escaperegexp": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
+      "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9394,6 +9456,13 @@
         "semver": "bin/semver"
       }
     },
+    "node_modules/tiny-typed-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
+      "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",

+ 3 - 0
package.json

@@ -31,6 +31,7 @@
     "desktop:build": "npm run build && node scripts/build-desktop.mjs",
     "desktop:dev": "npm run desktop:build && electron .",
     "desktop:pack": "npm run desktop:build && electron-builder --mac --arm64 --publish never",
+    "desktop:release": "npm run desktop:build && node scripts/desktop-release.mjs",
     "desktop:test": "node --test desktop/*.test.mjs",
     "runner": "node dist-server/runner/index.js",
     "runner:dev": "tsx src/runner/index.ts",
@@ -87,7 +88,9 @@
     "cross-env": "^10.1.0",
     "electron": "43.4.0",
     "electron-builder": "26.15.3",
+    "electron-updater": "6.8.9",
     "esbuild": "0.28.2",
+    "js-yaml": "4.3.2",
     "jsdom": "^27.0.1",
     "supertest": "^7.1.4",
     "tsx": "^4.21.0",

+ 2 - 0
scripts/build-desktop.mjs

@@ -4,10 +4,12 @@ import { buildRunner } from './build-runner.mjs'
 import { cp, mkdir, rm, readdir, readFile, writeFile, realpath, chmod } from 'node:fs/promises'
 import { resolve } from 'node:path'
 import { sealRuntime } from '../bin/lib/runtime-release.mjs'
+import { bundleDesktopUpdater } from './bundle-desktop-updater.mjs'
 
 const root = resolve(import.meta.dirname, '..'), out = resolve(root, '.desktop-build')
 await rm(out, { recursive: true, force: true })
 await mkdir(out, { recursive: true })
+await bundleDesktopUpdater(resolve(out, 'electron-updater.cjs'))
 execFileSync('xcrun', ['swift', resolve(root, 'scripts/build-desktop-icon.swift'),
   resolve(root, 'public/brand/AppIcon-1024.png'), resolve(out, 'branding')], { stdio: 'inherit' })
 execFileSync('iconutil', ['-c', 'icns', resolve(out, 'branding/icon.iconset'),

+ 16 - 0
scripts/bundle-desktop-updater.mjs

@@ -0,0 +1,16 @@
+import { build } from 'esbuild'
+import { createRequire } from 'node:module'
+import { readFile, writeFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { patchMacUpdater } from './patch-mac-updater.mjs'
+
+const require = createRequire(import.meta.url)
+export async function bundleDesktopUpdater(outfile) {
+  await build({ entryPoints: [require.resolve('electron-updater')], outfile,
+    bundle: true, platform: 'node', target: 'node24', format: 'cjs', external: ['electron'], legalComments: 'eof' })
+  // Pin electron-updater and fail the build if its native staging code changes.
+  await writeFile(outfile, patchMacUpdater(await readFile(outfile, 'utf8')))
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href)
+  await bundleDesktopUpdater(resolve(import.meta.dirname, '../.desktop-build/electron-updater.cjs'))

+ 68 - 0
scripts/desktop-release.mjs

@@ -0,0 +1,68 @@
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { createReadStream } from 'node:fs'
+import { readFile, stat, writeFile } from 'node:fs/promises'
+import { basename, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { load } from 'js-yaml'
+
+export function releaseSigningOptions(env = process.env) {
+  if (!env.CSC_NAME?.startsWith('Developer ID Application:'))
+    throw new Error('正式自动更新包需要 CSC_NAME 指定 Developer ID Application 签名身份')
+  const apiKey = env.APPLE_API_KEY && env.APPLE_API_KEY_ID && env.APPLE_API_ISSUER
+  const appleId = env.APPLE_ID && env.APPLE_APP_SPECIFIC_PASSWORD && env.APPLE_TEAM_ID
+  if (!apiKey && !appleId) throw new Error('请配置 Apple 公证凭据:APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER,或 APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID')
+  // electron-builder selects the certificate type itself and rejects its prefix
+  // in the identity qualifier. Enforce a distribution identity, then strip it.
+  const identity = env.CSC_NAME.slice('Developer ID Application:'.length).trim()
+  if (!identity) throw new Error('CSC_NAME 缺少分发签名身份名称')
+  return { forceCodeSigning: true, mac: { identity, type: 'distribution', notarize: true } }
+}
+
+async function digest(file, algorithm, encoding) {
+  const hash = createHash(algorithm)
+  for await (const chunk of createReadStream(file)) hash.update(chunk)
+  return hash.digest(encoding)
+}
+
+export async function verifyUpdateArtifacts(directory, version, arch = 'arm64') {
+  const metadata = load(await readFile(join(directory, 'latest-mac.yml'), 'utf8'))
+  if (metadata?.version !== version || !Array.isArray(metadata.files)) throw new Error('自动更新清单版本无效')
+  const required = [`Yaoyao-${version}-${arch}.zip`, `Yaoyao-${version}-${arch}.dmg`]
+  for (const name of required) {
+    const matches = metadata.files.filter(file => file.url === name)
+    if (matches.length !== 1) throw new Error(`自动更新清单缺少唯一附件:${name}`)
+  }
+  const names = ['latest-mac.yml']
+  for (const file of metadata.files) {
+    if (typeof file.url !== 'string' || basename(file.url) !== file.url || !required.includes(file.url)) throw new Error('自动更新清单包含非本次发布的附件')
+    const fullPath = join(directory, file.url), info = await stat(fullPath)
+    if (file.size !== info.size || !info.size || !info.isFile()) throw new Error(`安装包大小无效:${file.url}`)
+    if (file.sha512 !== await digest(fullPath, 'sha512', 'base64')) throw new Error(`安装包 SHA-512 不匹配:${file.url}`)
+    names.push(file.url)
+    const blockmap = `${file.url}.blockmap`
+    if (!(await stat(join(directory, blockmap))).size) throw new Error(`差分更新文件为空:${blockmap}`)
+    names.push(blockmap)
+  }
+  return names
+}
+
+export async function packageDesktopRelease() {
+  const signing = releaseSigningOptions()
+  const root = resolve(import.meta.dirname, '..')
+  const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
+  const { build, Platform, Arch } = await import('electron-builder')
+  await build({ projectDir: root, targets: Platform.MAC.createTarget(['dmg', 'zip'], Arch.arm64),
+    publish: 'never', config: signing })
+  const directory = join(root, 'desktop-release')
+  const names = await verifyUpdateArtifacts(directory, version)
+  const app = join(directory, 'mac-arm64', '夭夭.app')
+  const feed = load(await readFile(join(app, 'Contents/Resources/app-update.yml'), 'utf8'))
+  if (feed.provider !== 'github' || feed.owner !== 'Lsamien' || feed.repo !== 'yaoyao') throw new Error('安装包中的更新源不正确')
+  execFileSync('codesign', ['--verify', '--deep', '--strict', app], { stdio: 'inherit' })
+  execFileSync('xcrun', ['stapler', 'validate', app], { stdio: 'inherit' })
+  const sums = await Promise.all(names.map(async name => `${await digest(join(directory, name), 'sha256', 'hex')}  ${name}`))
+  await writeFile(join(directory, 'SHA256SUMS.txt'), sums.join('\n') + '\n')
+  console.log(`正式桌面产物已验证:${[...names, 'SHA256SUMS.txt'].join('、')}。尚未上传发布。`)
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await packageDesktopRelease()

+ 87 - 0
scripts/patch-mac-updater.mjs

@@ -0,0 +1,87 @@
+// Adapted from OpenMausBot 13c5005b (Apache-2.0). See licenses/OpenMausBot.
+// electron-updater 6.8.9 resolves the Mac download when its local HTTP response
+// finishes, before Squirrel validates/extracts the ZIP. Wait for native ready
+// instead, and never arm a future quit from a premature Restart request.
+// Exact, single-site replacements deliberately fail closed on upstream changes.
+export function patchMacUpdater(source) {
+  function replace(before, after) {
+    const count = source.split(before).length - 1;
+    if (count !== 1) throw new Error(`Expected one Mac updater site to patch, found ${count}: ${before.slice(0, 80)}`);
+    source = source.replace(before, after);
+  }
+
+  replace(
+    '        return await new Promise((resolve, reject) => {\n          const pass =',
+    `        this.squirrelDownloadedUpdate = false;
+        return await new Promise((resolve, reject) => {
+          const cleanup = () => {
+            this.nativeUpdater.removeListener("error", fail);
+            this.nativeUpdater.removeListener("update-downloaded", ready);
+            this.server?.removeListener("error", fail);
+          };
+          const fail = (error) => { cleanup(); reject(error); };
+          const ready = () => { cleanup(); resolve([]); };
+          this.server.once("error", fail);
+          const pass =`,
+  );
+  replace(
+    `            let errorOccurred = false;
+            response.on("finish", () => {
+              if (!errorOccurred) {
+                this.nativeUpdater.removeListener("error", reject);
+                resolve([]);
+              }
+            });
+`,
+    "",
+  );
+  replace(
+    `              errorOccurred = true;
+              this.nativeUpdater.removeListener("error", reject);
+              reject(new Error(\`Cannot pipe "\${downloadedFile}": \${error}\`));`,
+    '              fail(new Error(`Cannot pipe "${downloadedFile}": ${error}`));',
+  );
+  replace(
+    `            this.nativeUpdater.setFeedURL({
+              url: getServerUrl(this.server),
+              headers: {
+                "Cache-Control": "no-cache",
+                Authorization: \`Basic \${authInfo.toString("base64")}\`
+              }
+            });
+            this.dispatchUpdateDownloaded(event);
+            if (this.autoInstallOnAppQuit) {
+              this.nativeUpdater.once("error", reject);
+              this.nativeUpdater.checkForUpdates();
+            } else {
+              resolve([]);
+            }`,
+    `            try {
+              this.nativeUpdater.setFeedURL({
+                url: getServerUrl(this.server),
+                headers: {
+                  "Cache-Control": "no-cache",
+                  Authorization: \`Basic \${authInfo.toString("base64")}\`
+                }
+              });
+              this.dispatchUpdateDownloaded(event);
+              if (this.autoInstallOnAppQuit) {
+                this.nativeUpdater.once("error", fail);
+                this.nativeUpdater.once("update-downloaded", ready);
+                this.nativeUpdater.checkForUpdates();
+              } else {
+                ready();
+              }
+            } catch (error) {
+              fail(error);
+            }`,
+  );
+  replace(
+    `          this.nativeUpdater.on("update-downloaded", () => this.handleUpdateDownloaded());
+          if (!this.autoInstallOnAppQuit) {
+            this.nativeUpdater.checkForUpdates();
+          }`,
+    '          throw new Error("The native Mac update is not ready to install.");',
+  );
+  return source;
+}