Browse Source

将桌面更新改为原地进度与重启入口

Samien 4 weeks ago
parent
commit
4b40e3b64b

+ 21 - 23
desktop/auto-update-window.test.mjs

@@ -6,7 +6,7 @@ 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 () => {
+test('inline native updater shows circular progress, preserves staging across reload 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
@@ -19,13 +19,13 @@ 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.downloadUpdate=()=>new Promise(resolve=>{ready=resolve;driver.emit('download-progress',{transferred:50,total:100})});
 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'));
+const manager=new DesktopAutoUpdateManager({driver,version:'0.4.62'});await manager.check();
+fixture.stage=()=>driver.emit('update-downloaded');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;
+const open=async()=>{win=new BrowserWindow({width:1000,height:800,webPreferences:{contextIsolation:true,sandbox:true,preload:${JSON.stringify(join(root, 'desktop/preload.cjs'))}}});await win.loadFile(${JSON.stringify(join(root, 'desktop/boot.html'))})};
+fixture.open=open;ipcMain.handle('desktop:status',()=>({phase:'idle',mode:null}));
 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);
 `)
@@ -33,23 +33,22 @@ app.on('window-all-closed',()=>{});app.whenReady().then(open);
   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('准备安装')
+    await expect(page.getByRole('button', { name: '可更新', exact: true })).toBeEnabled()
+    await page.getByRole('button', { name: '可更新', exact: true }).click()
+    await expect(page.getByRole('progressbar', { name: '正在下载 · 50%' })).toHaveAttribute('aria-valuenow', '50')
+    assert.equal(app.windows().length, 1)
+    await mkdir(join(root, 'test-results/desktop'), { recursive: true })
+    await page.screenshot({ path: join(root, 'test-results/desktop/auto-update-downloading.png') })
+    await app.evaluate(() => fixture.stage())
+    await expect(page.locator('#desktop-update-label')).toHaveText('正在准备…')
+    await expect(page.getByRole('button', { name: '重新启动', exact: true })).toHaveCount(0)
+    await page.reload()
+    await expect(page.locator('#desktop-update-label')).toHaveText('正在准备…')
     assert.equal(await app.evaluate(() => fixture.checks), 1)
     await app.evaluate(() => fixture.ready())
-    const install = page.getByRole('button', { name: '重启更新', exact: true })
+    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)
+    assert.equal(await app.evaluate(() => fixture.installs), 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' })
@@ -60,8 +59,7 @@ app.on('window-all-closed',()=>{});app.whenReady().then(open);
     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()
+    await expect(page.locator('#desktop-update-error')).toContainText('签名验证失败')
+    await expect(page.getByRole('button', { name: '更新未完成', exact: true })).toBeDisabled()
   } finally { await app?.close().catch(() => {}); await rm(home, { recursive: true, force: true }) }
 })

+ 2 - 0
desktop/boot.css

@@ -8,12 +8,14 @@
   --solid:#20201e;
   --on-solid:#fff;
   --success:#377a54;
+  --accent:var(--success);
   --danger:#b33d33;
   --focus:rgba(31,31,29,.16);
   font:14px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
   color:var(--text);
   background:var(--canvas)
 }
+
 * {
   box-sizing:border-box
 }

+ 6 - 2
desktop/boot.html

@@ -3,7 +3,7 @@
 <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">
+  <title>夭夭</title><link rel="stylesheet" href="boot.css"><link rel="stylesheet" href="inline-update.css">
 </head>
 <body>
 <main id="session-loading" class="session-loading" role="status" aria-live="polite" aria-busy="true">
@@ -71,5 +71,9 @@
   <div id="help" class="help" hidden><p><strong>本机运行:</strong>自动准备服务并以管理员身份进入,无需创建账号或登录。</p><p><strong>远程连接:</strong>填写夭夭服务器地址,检测成功后使用该服务器的账号登录。登录授权保存在此设备,主动退出或密码修改后需要重新登录。</p><p>更换服务器不会迁移原服务器上的数据。遇到安装问题,可展开安装详情查看日志。</p></div>
 </div>
 </main>
-<script src="boot.js"></script>
+<section id="desktop-update-entry" class="desktop-update-entry" hidden aria-label="App 更新">
+  <button id="desktop-update-action" type="button"><svg id="desktop-update-ring" viewBox="0 0 24 24" aria-hidden="true"><circle class="update-track" cx="12" cy="12" r="9"/><circle id="desktop-update-value" cx="12" cy="12" r="9" pathLength="100"/></svg><span id="desktop-update-label" aria-live="polite">检测更新</span><span id="desktop-update-dot" hidden aria-hidden="true"></span></button>
+  <p id="desktop-update-error" role="alert" hidden></p>
+</section>
+<script src="boot.js"></script><script src="inline-update.js"></script>
 </body></html>

+ 14 - 0
desktop/inline-update.css

@@ -0,0 +1,14 @@
+#desktop-update-entry{max-width:360px;margin:16px auto;padding:0 16px;font:13px/1.5 system-ui,-apple-system,"PingFang SC",sans-serif;color:var(--text-primary,var(--text,#20201e))}
+#desktop-update-entry button{display:flex;align-items:center;gap:10px;width:100%;min-height:40px;padding:8px 12px;border:1px solid var(--line,#ddddda);border-radius:9px;background:var(--surface,var(--canvas,#fff));color:inherit;font:inherit;cursor:pointer}
+#desktop-update-entry button:disabled{cursor:default}
+#desktop-update-entry button:focus-visible{outline:2px solid var(--accent,var(--success,#377a54));outline-offset:2px}
+#desktop-update-label{flex:1;text-align:left}
+#desktop-update-ring{width:20px;height:20px;flex:0 0 20px;transform:rotate(-90deg);color:var(--accent,var(--success,#377a54))}
+#desktop-update-ring circle{fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round}.update-track{opacity:.18}
+#desktop-update-dot{width:6px;height:6px;border-radius:50%;background:var(--accent,var(--success,#377a54))}
+#desktop-update-error{font-size:12px;color:var(--danger,#b33d33);overflow-wrap:anywhere}
+.update-spinning{animation:desktop-update-spin 1s linear infinite}
+@keyframes desktop-update-spin{to{transform:rotate(270deg)}}
+@media(prefers-reduced-motion:reduce){.update-spinning{animation:none}}
+#desktop-update-entry.desktop-update-fallback{position:fixed;left:12px;bottom:12px;z-index:40;width:224px;max-width:calc(100vw - 24px);margin:0;padding:0}
+#desktop-update-entry.desktop-update-sidebar{max-width:none;margin:0 0 8px;padding:0}

+ 86 - 0
desktop/inline-update.js

@@ -0,0 +1,86 @@
+// The startup/login page uses the same process-wide updater as the workspace.
+// It remains usable even when no server is reachable.
+(() => {
+  const desktop = window.yaoyaoDesktop
+  if (!desktop?.updateState || document.querySelector('[data-desktop-update-inline]') || window.__yaoyaoInlineUpdate) return
+  window.__yaoyaoInlineUpdate = true
+  let entry = document.getElementById('desktop-update-entry')
+  const fallback = !entry
+  if (fallback) {
+    entry = document.createElement('section')
+    entry.id = 'desktop-update-entry'
+    entry.className = 'desktop-update-entry desktop-update-fallback'
+    entry.hidden = true
+    entry.setAttribute('aria-label', 'App 更新')
+    entry.innerHTML = '<button id="desktop-update-action" type="button"><svg id="desktop-update-ring" viewBox="0 0 24 24" aria-hidden="true"><circle class="update-track" cx="12" cy="12" r="9"/><circle id="desktop-update-value" cx="12" cy="12" r="9" pathLength="100"/></svg><span id="desktop-update-label" aria-live="polite">检测更新</span><span id="desktop-update-dot" hidden aria-hidden="true"></span></button><p id="desktop-update-error" role="alert" hidden></p>'
+    document.body.append(entry)
+  }
+  const button = document.getElementById('desktop-update-action')
+  const label = document.getElementById('desktop-update-label')
+  const ring = document.getElementById('desktop-update-ring')
+  const value = document.getElementById('desktop-update-value')
+  const dot = document.getElementById('desktop-update-dot')
+  const error = document.getElementById('desktop-update-error')
+  let state, pending = false, closed = false, requested = false, reading = false, localError = ''
+  function render() {
+    if (!state || closed) return
+    const busy = pending || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(state.phase)
+    const percent = state.phase === 'downloading' && state.total > 0 ? Math.max(0, Math.min(100, Math.floor(state.received / state.total * 100))) : undefined
+    entry.hidden = !requested && !localError && !state.available && !['downloading', 'verifying', 'preparing', 'ready', 'installing', 'failed'].includes(state.phase)
+    label.textContent = state.phase === 'ready' ? state.installMode === 'restart' ? '重新启动' : '打开安装包'
+      : state.phase === 'installing' ? '正在重启…'
+      : state.phase === 'downloading' ? percent === undefined ? '正在下载…' : `正在下载 · ${percent}%`
+      : ['verifying', 'preparing'].includes(state.phase) ? '正在准备…'
+      : busy ? '正在检测…' : state.retryable === false ? '更新未完成'
+      : state.available ? '可更新' : state.phase === 'failed' || localError ? '重试更新'
+      : state.phase === 'checked' ? '已是最新版本' : '检测更新'
+    button.disabled = busy || state.retryable === false
+    button.setAttribute('aria-label', label.textContent)
+    button.setAttribute('aria-busy', String(busy))
+    button.title = localError || state.error || state.message
+    ring.classList.toggle('update-spinning', busy && percent === undefined)
+    ring.setAttribute('role', busy ? 'progressbar' : 'img')
+    ring.setAttribute('aria-label', label.textContent)
+    if (busy) ring.removeAttribute('aria-hidden')
+    else ring.setAttribute('aria-hidden', 'true')
+    ring.setAttribute('aria-valuemin', '0'); ring.setAttribute('aria-valuemax', '100')
+    if (percent === undefined) ring.removeAttribute('aria-valuenow')
+    else ring.setAttribute('aria-valuenow', String(percent))
+    value.setAttribute('stroke-dasharray', `${busy ? percent ?? 25 : 100} 100`)
+    dot.hidden = !state.available || busy
+    error.textContent = localError || state.error || ''; error.hidden = !error.textContent
+  }
+  async function refresh() {
+    if (reading || closed) return
+    if (fallback && document.querySelector('[data-desktop-update-inline]')) {
+      closed = true; clearInterval(timer); detach(); entry.remove()
+      return
+    }
+    if (fallback) {
+      const sidebar = document.querySelector('.desktop-sidebar .sidebar-footer')
+      if (sidebar && entry.parentElement !== sidebar) {
+        sidebar.prepend(entry)
+        entry.classList.replace('desktop-update-fallback', 'desktop-update-sidebar')
+      }
+    }
+    reading = true
+    try { state = await desktop.updateState(); render() } catch { /* Poll again after navigation settles. */ }
+    finally { reading = false }
+  }
+  button.addEventListener('click', async () => {
+    if (pending || !state || button.disabled) return
+    const action = state.phase === 'ready' ? state.installMode === 'restart' ? 'install' : 'open' : state.available ? 'download' : 'check'
+    pending = true; localError = ''; render()
+    try {
+      const operation = desktop.updateAction(action)
+      void refresh()
+      const next = await operation
+      if (next) state = next
+    } catch (cause) { localError = cause.message || '更新未完成,请重试' }
+    finally { pending = false; render() }
+  })
+  const detach = desktop.onUpdateRequested(() => { requested = true; void refresh() })
+  const timer = setInterval(() => void refresh(), 500)
+  window.addEventListener('unload', () => { closed = true; clearInterval(timer); detach() })
+  void refresh()
+})()

+ 93 - 0
desktop/inline-update.test.mjs

@@ -0,0 +1,93 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { createServer } from 'node:net'
+import { spawn } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { _electron as electron, expect } from '@playwright/test'
+
+async function freePort() {
+  const server = createServer()
+  await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const port = server.address().port
+  await new Promise(done => server.close(done))
+  return port
+}
+
+test('the real desktop workspace downloads in place and waits for an explicit restart', { timeout: 60000 }, async () => {
+  const root = resolve(import.meta.dirname, '..'), home = await mkdtemp(join(tmpdir(), 'yaoyao-inline-update-'))
+  const port = await freePort(), upstream = await freePort(), origin = `http://127.0.0.1:${port}`
+  await mkdir(join(home, 'server'))
+  const server = spawn(process.execPath, ['--import', 'tsx', 'tests/fixtures/workspace-server.ts'], {
+    cwd: root, env: { ...process.env, WORKSPACE_FIXTURE_HOME: join(home, 'server'), WORKSPACE_FIXTURE_PORT: String(port), WORKSPACE_FIXTURE_UPSTREAM_PORT: String(upstream) }, stdio: 'ignore',
+  })
+  const desktopHome = join(home, 'desktop'); await mkdir(desktopHome)
+  await writeFile(join(desktopHome, 'desktop-preferences.json'), JSON.stringify({ startupChoice: 'remote', remoteServer: origin }))
+  let app
+  try {
+    await expect.poll(async () => { try { return (await fetch(origin + '/healthz')).status } catch { return 0 } }, { timeout: 30000 }).toBe(200)
+    app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_CLIENT_ONLY: '1', HERMES_YAOYAO_DESKTOP_TEST_HOME: desktopHome, HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1' } })
+    const page = await app.firstWindow()
+    await app.evaluate(({ app }, root) => {
+      const { createRequire } = process.getBuiltinModule('node:module')
+      const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build-client/electron-updater.cjs')
+      globalThis.inlineFixture = { installs: 0 }
+      autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '99.0.0' } })
+      autoUpdater.downloadUpdate = () => new Promise(resolve => {
+        inlineFixture.ready = () => resolve([])
+        inlineFixture.stage = () => autoUpdater.emit('update-downloaded')
+        autoUpdater.emit('download-progress', { transferred: 37, total: 100 })
+      })
+      autoUpdater.quitAndInstall = () => { inlineFixture.installs++ }
+    }, root)
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.status().then(state => state.phase))).toBe('ready')
+    await page.reload()
+    await page.locator('#username').fill('fixture')
+    await page.locator('#password').fill('fixture-pass')
+    await page.locator('#submit').click()
+    await page.waitForURL(origin + '/**')
+    await page.goto(origin + '/conversations')
+    await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
+    const footer = page.locator('.desktop-sidebar .desktop-update-entry')
+    await expect(footer.getByRole('button', { name: '可更新', exact: true })).toBeVisible()
+    await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
+    assert.equal(app.windows().length, 1)
+    const output = join(root, 'test-results/desktop/inline-workspace'); await mkdir(output, { recursive: true })
+    await page.screenshot({ path: join(output, 'available.png') })
+    await footer.getByRole('button', { name: '可更新', exact: true }).click()
+    await expect(footer.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
+    await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
+    const menu = page.locator('.workspace-settings-menu .desktop-update-entry')
+    await expect(menu).toContainText('正在下载 · 37%')
+    await page.screenshot({ path: join(output, 'downloading-menu.png') })
+    await page.keyboard.press('Escape')
+    await expect(menu).toHaveCount(0)
+    await app.evaluate(() => inlineFixture.stage())
+    await expect(footer).toContainText('正在准备…')
+    await page.reload()
+    await expect(footer).toContainText('正在准备…')
+    await expect(footer.getByRole('button')).toBeDisabled()
+    await app.evaluate(() => inlineFixture.ready())
+    await expect(footer.getByRole('button', { name: '重新启动', exact: true })).toBeEnabled()
+    assert.equal(await app.evaluate(() => inlineFixture.installs), 0)
+    assert.equal(app.windows().length, 1)
+    await page.screenshot({ path: join(output, 'ready.png') })
+    await page.evaluate(() => document.documentElement.classList.add('dark'))
+    await page.screenshot({ path: join(output, 'ready-dark.png') })
+    await footer.getByRole('button', { name: '重新启动', exact: true }).click()
+    await expect.poll(() => app.evaluate(() => inlineFixture.installs)).toBe(1)
+    await expect(footer.getByRole('button', { name: '正在重启…', exact: true })).toBeDisabled()
+    assert.equal((await fetch(origin + '/healthz')).status, 200)
+  } finally {
+    await app?.close().catch(() => {})
+    server.kill('SIGTERM')
+    await new Promise(done => {
+      if (server.exitCode !== null) return done()
+      const timer = setTimeout(() => { server.kill('SIGKILL'); done() }, 3000)
+      server.once('exit', () => { clearTimeout(timer); done() })
+    })
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 28 - 26
desktop/main.mjs

@@ -44,7 +44,7 @@ if (platform.windows) app.setAppUserModelId('cn.samien.yaoyao.desktop')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
-  let updateWindow, updater, environmentHost, hostManager, onboarding
+  let updater, environmentHost, hostManager, onboarding
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   let recoveringService = false
   let resumeLocalAfterUpdate = false
@@ -62,8 +62,9 @@ else {
   const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
     preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
   app.on('will-quit', closeComputerViewer)
-  const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
   const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
+  const inlineUpdateScript = readFileSync(join(desktopRoot, 'inline-update.js'), 'utf8')
+  const inlineUpdateStyle = readFileSync(join(desktopRoot, 'inline-update.css'), 'utf8')
   const root = app.isPackaged ? join(process.resourcesPath, 'runtime')
     : join(app.getAppPath(), clientOnly ? '.desktop-build-client' : '.desktop-build')
   const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
@@ -103,20 +104,19 @@ else {
       return onboarding[action](input)
     })
   }
-  const trustedUpdate = event => event.sender === updateWindow?.webContents
-    && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
+  const trustedUpdate = event => {
+    if (event.sender !== window?.webContents || event.senderFrame !== window.webContents.mainFrame) return false
+    if (event.senderFrame.url === bootURL) return true
+    try { return serviceOrigins().includes(new URL(event.senderFrame.url).origin) }
+    catch { return false }
+  }
   ipcMain.handle('desktop:updates', async event => {
-    let trusted = false
-    try {
-      trusted = event.sender === window?.webContents && event.senderFrame === window.webContents.mainFrame
-        && 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 更新')
+    if (!trustedUpdate(event) || closing || quitting || !updater) throw new Error('不允许此页面检测 App 更新')
     await showUpdates()
   })
   for (const action of ['state', 'check', 'download', 'cancel', 'install', 'open', 'folder', 'release']) {
     ipcMain.handle(`desktop-update:${action}`, async event => {
-      if (!trustedUpdate(event)) throw new Error('不允许此页面操作 App 更新')
+      if (!trustedUpdate(event) || !updater) throw new Error('不允许此页面操作 App 更新')
       if (action === 'state') return updater.snapshot()
       if (closing || quitting) throw new Error('App 正在重启或退出')
       if (action === 'check') return updater.check()
@@ -131,18 +131,11 @@ else {
     })
   }
   async function showUpdates() {
-    if (closing || quitting) return
-    if (updateWindow && !updateWindow.isDestroyed()) { updateWindow.show(); updateWindow.focus(); return }
-    updateWindow = new BrowserWindow({ title: 'App 更新 · 夭夭', width: 640, height: 620, minWidth: 360, minHeight: 440, show: false,
-      webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true,
-        preload: join(desktopRoot, 'update-preload.cjs') } })
-    updateWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
-    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', () => { updateWindow = undefined })
-    updateWindow.once('ready-to-show', () => updateWindow?.show())
-    await updateWindow.loadURL(updateURL)
+    if (closing || quitting || !updater) return
+    if (!window || window.isDestroyed()) await createWindow()
+    window.show(); window.focus()
+    window?.webContents.send('desktop:update-requested')
+    if (!updater.busy && updater.snapshot().phase !== 'ready') await updater.check()
   }
 
   function show() {
@@ -176,6 +169,15 @@ else {
     window.webContents.on('will-navigate', guardNavigation)
     window.webContents.on('will-redirect', guardNavigation)
     window.webContents.on('will-attach-webview', event => event.preventDefault())
+    window.webContents.on('did-finish-load', () => {
+      // Older remote Web builds have no inline updater component. The local
+      // renderer supplies it until a current workspace claims the entry.
+      const contents = window.webContents
+      try {
+        if (!serviceOrigins().includes(new URL(contents.getURL()).origin)) return
+        void contents.insertCSS(inlineUpdateStyle).then(() => contents.executeJavaScript(inlineUpdateScript)).catch(error => log(error.message))
+      } catch { /* The local startup page already loads the widget itself. */ }
+    })
     const trustedService = (contents, url) => {
       try { return contents === window?.webContents && serviceOrigins().includes(new URL(url).origin) }
       catch { return false }
@@ -240,7 +242,7 @@ else {
     if (closing || quitting) return
     const resumeLocal = serverModeActive && manager?.activationRequested === true
     closing = true; unreadBadge.clear(); clearInterval(timer)
-    updater?.stopChecking?.(); updater?.cancel(); updateWindow?.close()
+    updater?.stopChecking?.(); updater?.cancel()
     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.
@@ -304,7 +306,7 @@ else {
     }
     clearInterval(timer)
     timer = setInterval(() => { if (serverModeActive) void manager?.check(); else void checkRemoteAuthorization() }, 10000); timer.unref()
-    window?.show(); updateWindow?.show()
+    window?.show()
   }
   app.on('before-quit', event => {
     if (quitting) return
@@ -642,7 +644,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\n通过“检查 App 更新”下载新版,准备完成后点击“重启更新”。此操作只更新当前电脑的 App,不升级远程服务器。开发运行时提供手动安装包下载。${platform.server ? "\n\n独立后台服务继续运行。服务器模式下,首次启动同步较旧的本机 Web,已有较新的 Web 保留。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,不升级远程服务器。开发运行时提供手动安装包下载。${platform.server ? "\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' },

+ 7 - 0
desktop/preload.cjs

@@ -11,6 +11,13 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
   openUpdates: () => ipcRenderer.invoke('desktop:updates'),
+  updateState: () => ipcRenderer.invoke('desktop-update:state'),
+  updateAction: action => ipcRenderer.invoke(`desktop-update:${action}`),
+  onUpdateRequested: callback => {
+    const listener = () => callback()
+    ipcRenderer.on('desktop:update-requested', listener)
+    return () => ipcRenderer.removeListener('desktop:update-requested', listener)
+  },
   modeState: () => ipcRenderer.invoke('desktop:mode-state'),
   switchMode: mode => ipcRenderer.invoke('desktop:mode-switch', mode),
   openRemoteLogin: () => ipcRenderer.invoke('desktop:remote-login'),

+ 12 - 13
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('the main local app can open the native updater and repeated requests reuse its window', { timeout: 60000 }, async () => {
+test('the main local app checks updates in place and denies other renderers', { 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
@@ -32,12 +32,11 @@ test('the main local app can open the native updater and repeated requests reuse
     releaseIdentity()
     await page.waitForURL(origin + '/**')
     await app.evaluate(({ net }) => { net.fetch = async () => new Response('', { status: 403 }) })
-    const opened = app.waitForEvent('window')
     await page.getByRole('button', { name: '检测更新', exact: true }).click()
-    const updates = await opened
-    await expect(updates.getByRole('alert')).toContainText('GitHub 请求受限')
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.error))).toContain('GitHub 请求受限')
+    await expect(page.locator('#desktop-update-error')).toContainText('GitHub 请求受限')
     await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
-    assert.equal(app.windows().length, 2)
+    assert.equal(app.windows().length, 1)
     const denied = await app.evaluate(async ({ BrowserWindow }, { origin, preload }) => {
       const outsider = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, preload } })
       try {
@@ -55,7 +54,7 @@ test('the main local app can open the native updater and repeated requests reuse
   }
 })
 
-test('client mode opens local App updates without a local service or admin role', { timeout: 60000 }, async () => {
+test('client mode checks local App updates in place 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) => {
@@ -74,18 +73,18 @@ test('client mode opens local App updates without a local service or admin role'
     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 请求受限')
+    await page.getByRole('button', { name: 'App update' }).click()
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.error))).toContain('GitHub 请求受限')
+    await expect(page.locator('#desktop-update-error')).toContainText('GitHub 请求受限')
+    assert.equal(app.windows().length, 1)
     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)))')
+        return await outsider.webContents.executeJavaScript("Promise.all([window.yaoyaoDesktop.updateState(),window.yaoyaoDesktop.updateAction('check'),window.yaoyaoDesktop.updateAction('download'),window.yaoyaoDesktop.updateAction('install')].map(p=>p.then(()=>false,()=>true)))")
       } finally { outsider.destroy() }
-    }, { origin, preload: join(root, 'desktop/update-preload.cjs') })
-    assert.deepEqual(denied, [true, true, true])
+    }, { origin, preload: join(root, 'desktop/preload.cjs') })
+    assert.deepEqual(denied, [true, true, true, true])
     assert.equal(remote.listening, true)
   } finally {
     await app?.close().catch(() => {})

+ 0 - 4
desktop/update-preload.cjs

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

+ 12 - 8
desktop/update-restart.test.mjs

@@ -56,17 +56,21 @@ for (const scenario of ['remote', 'local', 'remote-failure', 'local-failure', 'r
         app.quit()
       }
     }, { root, home, fail })
-    const opened = app.waitForEvent('window')
-    if (checking || mode === 'local' && fail) await app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click())
-    else 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()
+    if (checking || mode === 'local' && fail) {
+      await app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click())
+      await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.available))).toBe(true)
+    } else await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    assert.equal(app.windows().length, 1)
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase))).toBe('ready')
     const closed = fail ? undefined : app.waitForEvent('close')
     if (mode === 'local' && fail) activationRequired = true
-    await updates.getByRole('button', { name: '重启更新', exact: true }).click().catch(error => { if (!updates.isClosed()) throw error })
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('install')).catch(error => {
+      // Recovery can replace the main renderer while this IPC is completing.
+      if (!page.isClosed() && !(fail && /Execution context was destroyed/.test(error.message))) throw error
+    })
     if (fail) {
-      await expect(updates.getByRole('alert')).toContainText('fixture restart failed')
+      await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.error)).catch(() => '')).toContain('fixture restart failed')
       assert.equal(await app.evaluate(({ app }) => app.hasSingleInstanceLock()), true)
       if (checking) {
         assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).active, true)

+ 13 - 15
desktop/update-window.test.mjs

@@ -10,7 +10,7 @@ import { createHash } from 'node:crypto'
 import { _electron as electron, expect } from '@playwright/test'
 
 const root = resolve(import.meta.dirname, '..')
-test('native update window works without a Web service, verifies a real DMG and denies the boot renderer', { timeout: 90000 }, async () => {
+test('inline boot updater works without a Web service, verifies a real DMG and denies other renderers', { timeout: 90000 }, async () => {
   const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-window-'))
   const blocked = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}') })
   await new Promise(done => blocked.listen(0, '127.0.0.1', done))
@@ -27,19 +27,19 @@ test('native update window works without a Web service, verifies a real DMG and
       args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root,
       env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_SYNC: '1', HERMES_YAOYAO_DESKTOP_PORT: String(blocked.address().port), HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:1' } })
     const boot = await app.firstWindow()
-    await boot.locator('#status[role="alert"]').waitFor()
+    await expect.poll(() => boot.evaluate(() => window.yaoyaoDesktop.status().then(state => state.phase))).toBe('error')
+    await boot.reload()
     assert.equal(await boot.evaluate(() => typeof window.yaoyaoUpdate), 'undefined')
-    assert.equal(await boot.evaluate(() => window.yaoyaoDesktop.openUpdates().then(() => false, () => true)), true)
+    assert.equal(await boot.evaluate(() => typeof window.yaoyaoDesktop.updateState), 'function')
     // Even another renderer carrying the real bridge cannot acquire update privileges.
     const denied = await app.evaluate(async ({ BrowserWindow }, preload) => {
       const outsider = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, preload } })
       try {
         await outsider.loadURL('data:text/html,<html><body>Untrusted renderer</body></html>')
-        return await outsider.webContents.executeJavaScript("window.yaoyaoUpdate.check().then(()=>false,()=>true)")
+        return await outsider.webContents.executeJavaScript("window.yaoyaoDesktop.updateAction('check').then(()=>false,()=>true)")
       } finally { outsider.destroy() }
-    }, join(root, 'desktop/update-preload.cjs'))
+    }, join(root, 'desktop/preload.cjs'))
     assert.equal(denied, true)
-    const opened = app.waitForEvent('window')
     await app.evaluate(({ net, shell, Menu }, fixture) => {
       const tag = `v${fixture.version}`, api = 'https://api.github.com/repos/Lsamien/yaoyao'
       const prefix = `https://github.com/Lsamien/yaoyao/releases/download/${tag}/`
@@ -59,12 +59,11 @@ test('native update window works without a Web service, verifies a real DMG and
       shell.openPath = async path => { process.env.YAOYAO_TEST_OPENED_UPDATE = path; return '' }
       Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click()
     }, { version, name, sums, data: bytes.toString('base64'), size: bytes.length, digest })
-    const page = await opened
-    await page.getByRole('button', { name: '下载安装包', exact: true }).waitFor()
-    await page.getByRole('button', { name: '下载安装包', exact: true }).click()
+    const page = boot
+    await page.getByRole('button', { name: '可更新', exact: true }).click()
+    assert.equal(app.windows().length, 1)
     await page.getByRole('button', { name: '打开安装包', exact: true }).waitFor({ timeout: 30000 })
-    await page.getByRole('button', { name: '检查更新', exact: true }).focus()
-    await page.keyboard.press('Tab')
+    await page.getByRole('button', { name: '打开安装包', exact: true }).focus()
     await expect(page.getByRole('button', { name: '打开安装包', exact: true })).toBeFocused()
     assert.equal(await app.evaluate(() => process.env.YAOYAO_TEST_OPENED_UPDATE), undefined)
     await mkdir(join(root, 'test-results/desktop'), { recursive: true })
@@ -77,12 +76,11 @@ test('native update window works without a Web service, verifies a real DMG and
     await page.getByRole('button', { name: '打开安装包', exact: true }).click()
     await expect.poll(() => app.evaluate(() => process.env.YAOYAO_TEST_OPENED_UPDATE)).toBe(join(home, 'updates', 'desktop-downloads', version, name))
     await app.evaluate(({ net }) => { net.fetch = async () => new Response('', { status: 403 }) })
-    await page.getByRole('button', { name: '检查更新', exact: true }).click()
-    await expect(page.getByRole('alert')).toContainText('GitHub 请求受限')
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
+    await expect(page.locator('#desktop-update-error')).toContainText('GitHub 请求受限')
     await expect(page.getByRole('button', { name: '打开安装包', exact: true })).toBeHidden()
     assert.equal(blocked.listening, true)
-    await page.keyboard.press('Escape').catch(error => { if (!page.isClosed()) throw error })
-    await expect.poll(() => page.isClosed()).toBe(true)
+    assert.equal(app.windows().length, 1)
   } finally {
     await app?.close().catch(() => {})
     await new Promise(done => blocked.close(done))

+ 0 - 3
desktop/update.css

@@ -1,3 +0,0 @@
-:root{color-scheme:light dark;--surface:#f7f7f7;--card:#fff;--text:#202124;--muted:#5d626a;--line:#d3d6dc;--accent:#1765c1;--on-accent:#fff;--danger:#a32626}
-@media(prefers-color-scheme:dark){:root{--surface:#1c1d20;--card:#26282c;--text:#f3f4f6;--muted:#b6bdc9;--line:#505663;--accent:#8bbbff;--on-accent:#13233a;--danger:#ffaaaa}}
-*{box-sizing:border-box}body{margin:0;background:var(--surface);color:var(--text);font:14px/1.6 system-ui,-apple-system,sans-serif}main{max-width:720px;margin:auto;padding:28px}h1{font-size:25px;margin:0 0 6px}p{margin:8px 0}.eyebrow,.muted,header>p,footer{color:var(--muted)}.eyebrow{font-size:12px;letter-spacing:.04em}.versions{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:18px 0;margin-top:14px;border-block:1px solid var(--line)}.versions div{display:grid;gap:5px}.versions span{color:var(--muted)}.versions strong{font-size:19px}#status{min-height:24px}#error{color:var(--danger);overflow-wrap:anywhere}progress{width:100%;accent-color:var(--accent)}.actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}button{min-height:40px;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--text);font:inherit;cursor:pointer}button:hover:not(:disabled){border-color:var(--accent)}button:disabled{opacity:.55;cursor:default}.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.link{padding:6px 0;border:0;background:transparent;color:var(--accent)}details{border-block:1px solid var(--line);padding:12px 0}summary{cursor:pointer}pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto}footer{font-size:12px;margin-top:16px}[hidden]{display:none!important}@media(max-width:420px){main{padding:20px}.versions{gap:8px}.versions strong{font-size:17px}}

+ 0 - 13
desktop/update.html

@@ -1,13 +0,0 @@
-<!doctype html>
-<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
-<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'">
-<title>App 更新 · 夭夭</title><link rel="stylesheet" href="update.css"></head>
-<body><main>
-<header><p class="eyebrow">夭夭 · 桌面客户端</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>下载更新</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>此处仅更新本机 App。远程服务器版本由服务器管理员管理。</p><p>下载期间可继续使用;重启时会断开本机连接。</p></footer>
-</main><script src="update.js"></script></body></html>

+ 0 - 52
desktop/update.js

@@ -1,52 +0,0 @@
-const byId = id => document.getElementById(id)
-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', '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('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 = automatic ? '下载更新' : state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载安装包'
-  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(automatic ? 'install' : 'open').focus()
-    else if (!busy) byId(state.available ? 'download' : 'check').focus()
-  }
-  previousPhase = state.phase
-}
-async function action(name) {
-  if (name !== 'state') localError = ''
-  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', '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() })
-// 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 } }
-})()

+ 11 - 10
desktop/windows-upgrade.test.mjs

@@ -89,19 +89,20 @@ test('installed NSIS client rejects a corrupt update, upgrades and preserves set
       fs.writeFileSync(home + '/upgrade-token.enc', await safeStorage.encryptStringAsync('upgrade-device-identity'))
     }, { origin, home })
     requireCookie = true
-    const opened = application.waitForEvent('window'); await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
-    const updates = await opened
-    await expect(updates.locator('#download')).toBeEnabled({ timeout: 30000 })
-    await updates.locator('#download').click()
-    await expect(updates.locator('#error')).toBeVisible({ timeout: 120000 })
-    assert.equal(await updates.evaluate(async () => (await window.yaoyaoUpdate.state()).phase), 'failed')
+    await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    assert.equal(application.windows().length, 1)
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.available)), { timeout: 30000 }).toBe(true)
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase)), { timeout: 120000 }).toBe('failed')
     console.log('已拒绝损坏安装包,重试完整下载')
     corrupt = false
-    await updates.locator('#check').click(); await expect(updates.locator('#download')).toBeEnabled()
-    await updates.locator('#download').click(); await expect(updates.locator('#install')).toBeEnabled({ timeout: 120000 })
-    await updates.screenshot({ path: join(evidence, 'upgrade-ready.png') })
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
+    await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase)), { timeout: 120000 }).toBe('ready')
+    await page.screenshot({ path: join(evidence, 'upgrade-ready.png') })
     console.log('完整更新包已就绪,开始重启替换')
-    const closed = application.waitForEvent('close'); await updates.locator('#install').click().catch(error => { if (!updates.isClosed()) throw error })
+    const closed = application.waitForEvent('close')
+    await page.evaluate(() => window.yaoyaoDesktop.updateAction('install')).catch(error => { if (!page.isClosed()) throw error })
     await closed; application = undefined
     await expect.poll(async () => readFile(join(installed, 'resources/runtime/release.json'), 'utf8').then(text => JSON.parse(text).webVersion).catch(() => ''),
       { timeout: 120000 }).toBe(nextVersion)

+ 4 - 4
docs/github-updates.md

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

+ 60 - 0
src/client/components/app/DesktopUpdateEntry.vue

@@ -0,0 +1,60 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { DesktopUpdateState } from '@shared/desktopUpdate'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = defineProps<{ state?: DesktopUpdateState; pending?: boolean; error?: string; menu?: boolean; compact?: boolean }>()
+const emit = defineEmits<{ activate: [] }>()
+const busy = computed(() => Boolean(props.pending || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(props.state?.phase ?? '')))
+const progress = computed(() => props.state?.phase === 'downloading' && props.state.total > 0
+  ? Math.max(0, Math.min(100, Math.floor(props.state.received / props.state.total * 100))) : undefined)
+const label = computed(() => {
+  const state = props.state
+  if (state?.phase === 'ready') return state.installMode === 'restart' ? '重新启动' : '打开安装包'
+  if (state?.phase === 'installing') return '正在重启…'
+  if (state?.phase === 'downloading') return progress.value === undefined ? '正在下载…' : `正在下载 · ${progress.value}%`
+  if (['verifying', 'preparing'].includes(state?.phase ?? '')) return '正在准备…'
+  if (state?.phase === 'checking' || props.pending) return '正在检测…'
+  if (state?.retryable === false) return '更新未完成'
+  if (state?.available) return '可更新'
+  if (state?.phase === 'failed' || props.error) return '重试更新'
+  if (state?.phase === 'checked') return '已是最新版本'
+  return '检测更新'
+})
+const detail = computed(() => props.error || props.state?.error || (props.state?.phase === 'ready'
+  ? '更新已下载,点击重新启动完成安装' : props.state?.available ? `App ${props.state.latestVersion ?? ''} 可更新,点击下载` : props.state?.message))
+</script>
+
+<template>
+  <div class="desktop-update-entry" :class="{ 'desktop-update-entry--compact': compact }">
+    <button type="button" :role="menu ? 'menuitem' : undefined" :title="detail" :aria-label="label" :aria-busy="busy" :disabled="busy || state?.retryable === false" @click="emit('activate')">
+      <span v-if="busy" class="desktop-update-ring" :class="{ 'desktop-update-ring--spinning': progress === undefined }" role="progressbar" :aria-label="label" :aria-valuemin="0" :aria-valuemax="100" :aria-valuenow="progress">
+        <svg viewBox="0 0 24 24" aria-hidden="true"><circle class="desktop-update-ring__track" cx="12" cy="12" r="9" /><circle class="desktop-update-ring__value" cx="12" cy="12" r="9" pathLength="100" :stroke-dasharray="`${progress ?? 25} 100`" /></svg>
+      </span>
+      <AppIcon v-else :name="state?.phase === 'ready' ? 'refresh' : state?.available ? 'download' : 'refresh'" :size="18" />
+      <span class="desktop-update-entry__label" aria-live="polite">{{ label }}</span>
+      <span v-if="state?.available && !busy" class="desktop-update-dot" aria-hidden="true" />
+    </button>
+    <p v-if="error || state?.error" class="desktop-update-error" role="alert">{{ error || state?.error }}</p>
+  </div>
+</template>
+
+<style scoped>
+.desktop-update-entry{width:100%;min-width:0;color:var(--text-primary)}
+.desktop-update-entry button{display:flex;align-items:center;gap:9px;width:100%;min-height:36px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:inherit;font:13px var(--font-ui);text-align:left;cursor:pointer}
+.desktop-update-entry button:hover{background:var(--surface-hover)}
+.desktop-update-entry button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.desktop-update-entry button:disabled{cursor:default}
+.desktop-update-entry__label{flex:1;white-space:nowrap}
+.desktop-update-dot{width:6px;height:6px;border-radius:50%;background:var(--accent)}
+.desktop-update-ring{display:inline-flex;flex:0 0 18px;width:18px;height:18px;color:var(--accent)}
+.desktop-update-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
+.desktop-update-ring circle{fill:none;stroke:currentColor;stroke-width:2.5}
+.desktop-update-ring__track{opacity:.18}.desktop-update-ring__value{stroke-linecap:round}
+.desktop-update-ring--spinning svg{animation:desktop-update-spin 1s linear infinite}
+.desktop-update-error{margin:4px 10px;color:var(--danger);font-size:12px;line-height:1.5;overflow-wrap:anywhere}
+.desktop-update-entry--compact button{justify-content:center;padding:9px}
+.desktop-update-entry--compact .desktop-update-entry__label,.desktop-update-entry--compact .desktop-update-dot,.desktop-update-entry--compact .desktop-update-error{display:none}
+@keyframes desktop-update-spin{to{transform:rotate(270deg)}}
+@media(prefers-reduced-motion:reduce){.desktop-update-ring--spinning svg{animation:none}}
+</style>

+ 11 - 2
src/client/components/app/WorkspaceShell.vue

@@ -12,6 +12,8 @@ import BrandMark from '@/components/common/BrandMark.vue'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import { rememberInterfacePath } from '@/utils/interfaceMode'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+import DesktopUpdateEntry from './DesktopUpdateEntry.vue'
+import { useDesktopUpdate } from '@/composables/useDesktopUpdate'
 const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
 const UpdateCheckDialog = defineAsyncComponent(() => import('./UpdateCheckDialog.vue'))
 const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
@@ -141,6 +143,8 @@ const createMenu = ref<HTMLElement | null>(null)
 let createTrigger: HTMLElement | null = null
 const createPosition = ref({ left: '8px', top: '58px' })
 const settingsMenuOpen = ref(false)
+const desktopUpdate = useDesktopUpdate()
+const { state: desktopUpdateState, error: desktopUpdateError, pending: desktopUpdatePending, visible: desktopUpdateVisible } = desktopUpdate
 const canCheckUpdates = computed(() => Boolean(window.yaoyaoDesktop?.openUpdates) || props.isAdmin)
 const openingUpdate = ref(false)
 const updateError = ref('')
@@ -251,6 +255,7 @@ async function openSettingsMenu(event: MouseEvent) {
   createMenuOpen.value = false
   toolsMenuOpen.value = false
   updateError.value = ''
+  void desktopUpdate.refresh()
   settingsMenuOpen.value = true
   await nextTick()
   settingsMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
@@ -258,6 +263,7 @@ async function openSettingsMenu(event: MouseEvent) {
 async function checkForUpdates() {
   if (openingUpdate.value || !canCheckUpdates.value) return
   updateError.value = ''
+  if (desktopUpdate.supported) { await desktopUpdate.run(); return }
   if (window.yaoyaoDesktop?.openUpdates) {
     openingUpdate.value = true
     try {
@@ -466,7 +472,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="(unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)) ? true : undefined">
+  <div class="workspace-shell" :data-desktop-update-inline="desktopUpdate.supported ? '' : undefined" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="(unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)) ? true : undefined">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
@@ -571,6 +577,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
       </section>
 
       <div class="sidebar-footer">
+        <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible && !settingsMenuOpen" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" :compact="sidebarCollapsed && !applicationWorkspace" @activate="desktopUpdate.run()" />
         <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
           <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
@@ -679,6 +686,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
       </section>
 
       <div class="sidebar-footer mobile-drawer__footer">
+        <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible && !settingsMenuOpen" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" @activate="desktopUpdate.run()" />
         <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
           <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
@@ -764,7 +772,8 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="账号菜单" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />我的设置</button>
           <button type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
-          <button v-if="canCheckUpdates" type="button" role="menuitem" :disabled="openingUpdate" :aria-busy="openingUpdate" @click="checkForUpdates"><AppIcon name="refresh" :size="17" />{{ openingUpdate ? '正在打开…' : '检测更新' }}</button>
+          <DesktopUpdateEntry v-if="desktopUpdate.supported" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" menu @activate="desktopUpdate.run()" />
+          <button v-else-if="canCheckUpdates" type="button" role="menuitem" :disabled="openingUpdate" :aria-busy="openingUpdate" @click="checkForUpdates"><AppIcon name="refresh" :size="17" />{{ openingUpdate ? '正在打开…' : '检测更新' }}</button>
           <p v-if="updateError" class="update-menu-error" role="alert">{{ updateError }}</p>
           <a role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>

+ 55 - 0
src/client/composables/useDesktopUpdate.ts

@@ -0,0 +1,55 @@
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import type { DesktopUpdateAction, DesktopUpdateState } from '@shared/desktopUpdate'
+
+export function useDesktopUpdate() {
+  const desktop = window.yaoyaoDesktop
+  const supported = Boolean(desktop?.updateState && desktop?.updateAction)
+  const state = ref<DesktopUpdateState>()
+  const error = ref('')
+  const pending = ref(false)
+  const requested = ref(false)
+  const visible = computed(() => Boolean(requested.value || pending.value || state.value?.available || error.value
+    || ['downloading', 'verifying', 'preparing', 'ready', 'installing', 'failed'].includes(state.value?.phase ?? '')))
+  let stopped = false, reading = false
+  let timer: ReturnType<typeof setInterval> | undefined
+  let detach: (() => void) | undefined
+
+  async function refresh() {
+    if (!supported || stopped || reading) return
+    reading = true
+    try {
+      const next = await desktop!.updateState!()
+      if (!stopped) state.value = next
+    } catch { /* Navigation may briefly disconnect the bridge; retry on the next poll. */ }
+    finally { reading = false }
+  }
+
+  async function run(action?: DesktopUpdateAction) {
+    if (!supported || stopped || pending.value) return
+    const current = state.value
+    const nextAction = action ?? (current?.phase === 'ready'
+      ? current.installMode === 'restart' ? 'install' : 'open'
+      : current?.available ? 'download' : 'check')
+    if (!action && (current?.retryable === false || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(current?.phase ?? ''))) return
+    pending.value = true
+    error.value = ''
+    try {
+      const operation = desktop!.updateAction!(nextAction)
+      // Read the running state while the download IPC is still pending.
+      void refresh()
+      const next = await operation
+      if (!stopped && next) state.value = next
+    } catch (cause) {
+      if (!stopped) error.value = cause instanceof Error ? cause.message : '更新未完成,请重试'
+    } finally { pending.value = false }
+  }
+
+  onMounted(() => {
+    if (!supported) return
+    void refresh()
+    timer = setInterval(() => void refresh(), 500)
+    detach = desktop?.onUpdateRequested?.(() => { requested.value = true; void refresh() })
+  })
+  onBeforeUnmount(() => { stopped = true; clearInterval(timer); detach?.() })
+  return { supported, state, error, pending, visible, refresh, run }
+}

+ 3 - 0
src/client/env.d.ts

@@ -15,6 +15,9 @@ interface Window {
     openLogin?(): Promise<unknown>
     forgetLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>
+    updateState?(): Promise<import('@shared/desktopUpdate').DesktopUpdateState>
+    updateAction?(action: import('@shared/desktopUpdate').DesktopUpdateAction): Promise<import('@shared/desktopUpdate').DesktopUpdateState | undefined>
+    onUpdateRequested?(callback: () => void): () => void
     modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean; platform?: string; supportedModes?: ('client' | 'server')[] }>
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
     openRemoteLogin?(): Promise<void>

+ 15 - 0
src/shared/desktopUpdate.ts

@@ -0,0 +1,15 @@
+export type DesktopUpdateAction = 'check' | 'download' | 'cancel' | 'install' | 'open' | 'folder' | 'release'
+
+export interface DesktopUpdateState {
+  phase: 'idle' | 'checking' | 'checked' | 'downloading' | 'verifying' | 'preparing' | 'ready' | 'installing' | 'cancelled' | 'failed'
+  installMode: 'restart' | 'manual'
+  currentVersion: string
+  latestVersion?: string
+  available: boolean
+  received: number
+  total: number
+  message: string
+  error?: string
+  retryable?: boolean
+  canCancel?: boolean
+}

+ 63 - 0
tests/client/workspaceShell.test.ts

@@ -6,6 +6,7 @@ import { createPinia } from 'pinia'
 import { useUnreadStore } from '@/stores/unread'
 import type { Profile } from '@shared/types'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
+import type { DesktopUpdateState } from '@shared/desktopUpdate'
 
 const profiles: Profile[] = [
   { name: 'default', agentName: '丫头', isDefault: true },
@@ -289,6 +290,68 @@ it('opens the native updater, prevents duplicate clicks and lets a failed openin
   wrapper.unmount()
 })
 
+it('keeps desktop downloads and restart in the same entry, including after closing the menu', async () => {
+  vi.useFakeTimers()
+  let state: DesktopUpdateState = { phase: 'checked', installMode: 'restart', currentVersion: '0.4.80', latestVersion: '0.4.81', available: true, received: 0, total: 100, message: '发现更新' }
+  let finish!: (state: DesktopUpdateState) => void
+  const openUpdates = vi.fn()
+  const updateAction = vi.fn().mockImplementation(action => {
+    if (action === 'download') {
+      state = { ...state, phase: 'downloading', received: 42 }
+      return new Promise<DesktopUpdateState>(resolve => { finish = resolve })
+    }
+    state = { ...state, phase: 'installing' }
+    return Promise.resolve(state)
+  })
+  window.yaoyaoDesktop = { updateState: vi.fn(async () => state), updateAction, openUpdates, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
+  const wrapper = await mountShell('/conversations')
+  try {
+    await wrapper.setProps({ isAdmin: false })
+    await flushPromises()
+    const entry = () => wrapper.get('.desktop-sidebar .desktop-update-entry')
+    expect(entry().text()).toBe('可更新')
+    await entry().get('button').trigger('click')
+    await entry().get('button').trigger('click')
+    await flushPromises()
+    expect(updateAction).toHaveBeenCalledExactlyOnceWith('download')
+    expect(entry().get('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
+    await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
+    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry')?.textContent).toContain('正在下载 · 42%')
+    state = { ...state, phase: 'preparing', received: 100 }
+    await vi.advanceTimersByTimeAsync(500)
+    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry')?.textContent).toContain('正在准备…')
+    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry button')?.hasAttribute('disabled')).toBe(true)
+    await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
+    state = { ...state, phase: 'ready', available: false }
+    finish(state); await flushPromises()
+    expect(entry().text()).toBe('重新启动')
+    expect(updateAction).toHaveBeenCalledTimes(1)
+    await entry().get('button').trigger('click'); await flushPromises()
+    expect(updateAction).toHaveBeenLastCalledWith('install')
+    expect(openUpdates).not.toHaveBeenCalled()
+    expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(false)
+  } finally { wrapper.unmount(); vi.useRealTimers() }
+})
+
+it('shows desktop check errors in place and allows retry without opening a window', async () => {
+  const checked: DesktopUpdateState = { phase: 'checked', installMode: 'restart', currentVersion: '0.4.80', available: false, received: 0, total: 0, message: '已是最新版本' }
+  const updateAction = vi.fn().mockRejectedValueOnce(new Error('连接失败')).mockResolvedValueOnce(checked)
+  window.yaoyaoDesktop = { updateState: vi.fn(async () => ({ ...checked, phase: 'idle' as const })), updateAction, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
+  const wrapper = await mountShell('/conversations')
+  try {
+    await flushPromises()
+    await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
+    const button = () => document.querySelector<HTMLButtonElement>('.workspace-settings-menu .desktop-update-entry button')!
+    button().click(); button().click(); await flushPromises()
+    expect(updateAction).toHaveBeenCalledExactlyOnceWith('check')
+    expect(document.querySelector('.workspace-settings-menu [role="alert"]')?.textContent).toBe('连接失败')
+    button().click(); await flushPromises()
+    expect(button().textContent).toContain('已是最新版本')
+    expect(document.querySelector('.workspace-settings-menu')).not.toBeNull()
+    expect(document.querySelector('.workspace-settings-menu [role="alert"]')).toBeNull()
+  } finally { wrapper.unmount() }
+})
+
 it('does not expose server update controls to a non-admin browser account', async () => {
   const wrapper = await mountShell('/conversations')
   await wrapper.setProps({ isAdmin: false })