Просмотр исходного кода

新增账号菜单检测更新并精简 Bot 模式入口

Samien 1 месяц назад
Родитель
Сommit
2b4234c0b9

+ 11 - 2
desktop/main.mjs

@@ -54,6 +54,15 @@ else {
   })
   const trustedUpdate = event => event.sender === updateWindow?.webContents
     && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
+  ipcMain.handle('desktop:updates', async event => {
+    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. */ }
+    if (!trusted || closing || quitting || !updater) throw new Error('不允许此页面打开 App 更新')
+    await showUpdates()
+  })
   for (const action of ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release']) {
     ipcMain.handle(`desktop-update:${action}`, async event => {
       if (!trustedUpdate(event) || closing || quitting) throw new Error('不允许此页面操作 App 更新')
@@ -68,7 +77,7 @@ else {
       else { const error = await shell.openPath(path); if (error) throw new Error(error) }
     })
   }
-  function showUpdates() {
+  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,
@@ -80,7 +89,7 @@ else {
     updateWindow.webContents.on('will-attach-webview', event => event.preventDefault())
     updateWindow.on('closed', () => { updater.cancel(); updateWindow = undefined })
     updateWindow.once('ready-to-show', () => updateWindow?.show())
-    void updateWindow.loadURL(updateURL)
+    await updateWindow.loadURL(updateURL)
   }
 
   function show() {

+ 1 - 0
desktop/preload.cjs

@@ -3,6 +3,7 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   status: () => ipcRenderer.invoke('desktop:status'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
+  openUpdates: () => ipcRenderer.invoke('desktop:updates'),
   openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),
   computerClosed: () => ipcRenderer.invoke('desktop-computer:closed'),
   onComputerClose: callback => {

+ 53 - 0
desktop/update-entry.test.mjs

@@ -0,0 +1,53 @@
+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'
+
+test('only 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
+  const identity = { protocol: 1, instanceId: 'update-entry', pid: process.pid, dataKey: dataKey(home), version }
+  // Let Playwright attach to the boot renderer before the fixture changes its URL.
+  let releaseIdentity
+  const identityReady = new Promise(resolve => { releaseIdentity = resolve })
+  const server = createServer(async (req, res) => {
+    if (req.url === '/desktop/service') { await identityReady; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(identity)) }
+    else { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end('<title>Update entry fixture</title><button onclick="window.yaoyaoDesktop.openUpdates()">检测更新</button>') }
+  })
+  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, '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_PORT: String(server.address().port), HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
+    const page = await app.firstWindow()
+    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 page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    assert.equal(app.windows().length, 2)
+    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('window.yaoyaoDesktop.openUpdates().then(() => false, () => true)')
+      } finally { outsider.destroy() }
+    }, { origin, preload: join(root, 'desktop/preload.cjs') })
+    assert.equal(denied, true)
+    assert.equal(server.listening, true)
+  } finally {
+    releaseIdentity()
+    await app?.close().catch(() => {})
+    await new Promise(done => server.close(done))
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 1 - 0
desktop/update-window.test.mjs

@@ -29,6 +29,7 @@ test('native update window works without a Web service, verifies a real DMG and
     const boot = await app.firstWindow()
     await boot.locator('#status[role="alert"]').waitFor()
     assert.equal(await boot.evaluate(() => typeof window.yaoyaoUpdate), 'undefined')
+    assert.equal(await boot.evaluate(() => window.yaoyaoDesktop.openUpdates().then(() => false, () => true)), true)
     // 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 } })

+ 61 - 0
src/client/components/app/UpdateCheckDialog.vue

@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { checkSystemUpdate, systemUpdateStatus, type SystemUpdateStatus } from '@/api/systemUpdate'
+import AppIcon from '@/components/common/AppIcon.vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+
+const emit = defineEmits<{ close: []; manage: [] }>()
+const status = ref<SystemUpdateStatus>()
+const checking = ref(true)
+const checked = ref(false)
+const error = ref('')
+let disposed = false
+const message = computed(() => checking.value ? '正在检测更新…'
+  : error.value ? '检测失败,请重试'
+    : status.value?.updateAvailable ? `发现新版本 ${status.value.latest?.webVersion}` : '当前已是最新版本')
+const releasePage = computed(() => {
+  try {
+    const url = new URL(status.value?.releasePageUrl || '')
+    return url.protocol === 'https:' ? url.href : undefined
+  } catch { return undefined }
+})
+
+async function check() {
+  checking.value = true
+  checked.value = false
+  error.value = ''
+  try {
+    const next = await checkSystemUpdate()
+    if (disposed) return
+    status.value = next
+    checked.value = true
+  } catch (cause) {
+    if (!disposed) error.value = cause instanceof Error ? cause.message : '无法连接更新服务'
+  } finally { if (!disposed) checking.value = false }
+}
+onMounted(async () => {
+  // Keep the installed version visible even when the remote check fails.
+  try { const current = await systemUpdateStatus(); if (!disposed) status.value = current } catch { /* The check below reports actionable errors. */ }
+  if (!disposed) await check()
+})
+onBeforeUnmount(() => { disposed = true })
+</script>
+<template>
+  <StandaloneDialog title="检测更新" compact @close="emit('close')">
+    <section class="update-check" :aria-busy="checking">
+      <div class="update-check__icon" :class="{ spinning: checking }"><AppIcon :name="checking ? 'refresh' : error ? 'alert' : status?.updateAvailable ? 'download' : 'check'" :size="28" /></div>
+      <p class="update-check__status" role="status" aria-live="polite">{{ message }}</p>
+      <dl aria-label="Web 版本信息"><div><dt>当前 Web 版本</dt><dd>{{ status?.current.webVersion || '—' }}</dd></div><div><dt>最新版本</dt><dd>{{ checking ? '检测中…' : !checked ? '未获取' : status?.latest?.webVersion || status?.current.webVersion }}</dd></div></dl>
+      <p v-if="error" class="update-check__error" role="alert">{{ error }}</p>
+      <p v-if="checked && status?.updateAvailable && !status.supported" class="update-check__note">{{ status.unsupportedReason || '请通过部署环境更新 Web。' }}</p>
+      <a v-if="checked && releasePage" :href="releasePage" target="_blank" rel="noopener noreferrer">查看发布说明<AppIcon name="external" :size="14" /></a>
+      <div class="update-check__actions">
+        <button type="button" :disabled="checking" @click="check">{{ checking ? '正在检测…' : error ? '重试' : '重新检测' }}</button>
+        <button v-if="checked && status?.updateAvailable && status.supported" class="primary" type="button" @click="emit('manage')">前往更新</button>
+      </div>
+    </section>
+  </StandaloneDialog>
+</template>
+<style scoped>
+.update-check{display:grid;gap:18px;text-align:center}.update-check__icon{display:grid;place-items:center;color:var(--accent)}.update-check__status{margin:0;font-size:16px;font-weight:600;line-height:1.5}dl{display:grid;gap:12px;margin:0;padding:16px 0;border-block:1px solid var(--line);font-size:13px}dl div{display:flex;justify-content:space-between;gap:12px}dt{color:var(--text-secondary)}dd{margin:0;font-variant-numeric:tabular-nums}.update-check__error,.update-check__note{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.update-check__error{color:var(--danger)}.update-check__note{color:var(--text-secondary)}a{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;color:var(--accent);font-size:13px;text-decoration:none}a:hover{text-decoration:underline}.update-check__actions{display:flex;gap:10px;flex-wrap:wrap}button{flex:1;min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--surface-raised);color:var(--text-primary);font:500 13px var(--font-ui);cursor:pointer}button:hover{background:var(--surface-hover)}button.primary{background:var(--accent);border-color:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.6;cursor:wait}button:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.spinning{animation:update-spin 1s linear infinite}@keyframes update-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.spinning{animation:none}}
+</style>

+ 38 - 5
src/client/components/app/WorkspaceShell.vue

@@ -11,6 +11,7 @@ import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import { rememberInterfacePath } from '@/utils/interfaceMode'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
+const UpdateCheckDialog = defineAsyncComponent(() => import('./UpdateCheckDialog.vue'))
 const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
 
 type NavItem = {
@@ -102,7 +103,7 @@ const router = useRouter()
 const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const settingsOpen = ref(false)
-const standalone = ref<'plugins' | 'about' | null>(null)
+const standalone = ref<'plugins' | 'about' | 'update' | null>(null)
 const standaloneReturnFocus = ref<HTMLElement>()
 const settingsPage = ref<SettingsPage>('agent-identity')
 const settingsReturnFocus = ref<HTMLButtonElement>()
@@ -116,6 +117,9 @@ const createMenu = ref<HTMLElement | null>(null)
 let createTrigger: HTMLElement | null = null
 const createPosition = ref({ left: '8px', top: '58px' })
 const settingsMenuOpen = ref(false)
+const canCheckUpdates = computed(() => Boolean(window.yaoyaoDesktop?.openUpdates) || props.isAdmin)
+const openingUpdate = ref(false)
+const updateError = ref('')
 const settingsMenu = ref<HTMLElement | null>(null)
 let settingsTrigger: HTMLButtonElement | null = null
 const settingsMenuPosition = ref({ left: '8px', top: '8px' })
@@ -205,7 +209,7 @@ function chooseCreate(kind: 'agent' | 'group' | 'remote-agent') {
 function actionMenuKeydown(event: KeyboardEvent) {
   if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
   event.preventDefault()
-  const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role="menuitem"]')]
+  const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)')]
   const index = buttons.indexOf(document.activeElement as HTMLElement)
   buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length]?.focus()
 }
@@ -218,15 +222,40 @@ async function openSettingsMenu(event: MouseEvent) {
   if (settingsMenuOpen.value) { closeSettingsMenu(); return }
   settingsTrigger = event.currentTarget as HTMLButtonElement
   const rect = settingsTrigger.getBoundingClientRect()
-  const height = (props.isAdmin ? 4 : 3) * (window.innerWidth < 768 ? 44 : 36) + 18
+  const height = ((props.isAdmin ? 4 : 3) + Number(canCheckUpdates.value)) * (window.innerWidth < 768 ? 44 : 36) + 18
   settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - height)}px` }
   profileMenuOpen.value = false
   createMenuOpen.value = false
   toolsMenuOpen.value = false
+  updateError.value = ''
   settingsMenuOpen.value = true
   await nextTick()
   settingsMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
 }
+async function checkForUpdates() {
+  if (openingUpdate.value || !canCheckUpdates.value) return
+  updateError.value = ''
+  if (window.yaoyaoDesktop?.openUpdates) {
+    openingUpdate.value = true
+    try {
+      await window.yaoyaoDesktop.openUpdates()
+      closeSettingsMenu()
+    } catch (cause) {
+      updateError.value = cause instanceof Error ? cause.message : '无法打开更新窗口,请重试'
+    } finally { openingUpdate.value = false }
+    return
+  }
+  standaloneReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
+    ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
+  settingsMenuOpen.value = false
+  mobileDrawerOpen.value = false
+  standalone.value = 'update'
+}
+function openUpdateSettings() {
+  settingsReturnFocus.value = standaloneReturnFocus.value as HTMLButtonElement | undefined
+  standalone.value = null
+  openSettings('system-update')
+}
 function chooseSettingsAction(action: 'settings' | 'bots' | 'about') {
   settingsMenuOpen.value = false
   if (action === 'bots') { switchInterfaceMode(); return }
@@ -515,7 +544,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
+          <button v-if="!applicationWorkspace" class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -623,7 +652,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
+          <button v-if="!applicationWorkspace" class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -664,6 +693,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
     </Transition>
 
     <AboutDialog v-if="standalone === 'about'" @close="closeStandalone" />
+    <UpdateCheckDialog v-if="standalone === 'update' && isAdmin" @close="closeStandalone" @manage="openUpdateSettings" />
     <BotPluginsDialog v-if="standalone === 'plugins' && applicationWorkspace" @close="closeStandalone" />
     <SettingsCenterDialog
       :open="settingsOpen"
@@ -695,6 +725,8 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
         <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>
+          <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>
         </div>
@@ -712,6 +744,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 </template>
 
 <style scoped>
+.update-menu-error{margin:4px 10px;color:var(--danger);font-size:12px;line-height:1.5;overflow-wrap:anywhere}.workspace-settings-menu button:disabled{opacity:.6;cursor:wait}
 .sidebar-tools-trigger{display:flex;width:100%;min-height:44px;align-items:center;gap:10px;padding:8px 12px;margin:0 0 8px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);font:500 13px var(--font-ui);cursor:pointer}.sidebar-tools-trigger span{flex:1;text-align:left}.sidebar-tools-trigger:hover,.sidebar-tools-trigger[aria-expanded=true]{background:var(--surface-hover)}.sidebar-tools-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.workspace-settings-menu a{display:flex;align-items:center;gap:8px;min-height:36px;padding:8px 10px;box-sizing:border-box;border-radius:7px;color:var(--text-primary);text-decoration:none;font-size:13px}.workspace-settings-menu a:hover{background:var(--surface-hover)}.workspace-settings-menu a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 .workspace-shell {
   display: grid;

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

@@ -11,6 +11,7 @@ interface ImportMeta {
 
 interface Window {
   yaoyaoDesktop?: {
+    openUpdates?(): Promise<void>
     openComputer(id: string): Promise<boolean>
     computerClosed(): Promise<void>
     onComputerClose(callback: () => void): () => void

+ 56 - 0
tests/client/updateCheckDialog.test.ts

@@ -0,0 +1,56 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import UpdateCheckDialog from '@/components/app/UpdateCheckDialog.vue'
+import { checkSystemUpdate, systemUpdateStatus, type SystemUpdateStatus } from '@/api/systemUpdate'
+
+vi.mock('@/api/systemUpdate', () => ({ checkSystemUpdate: vi.fn(), systemUpdateStatus: vi.fn() }))
+const current = { schemaVersion: 1 as const, webVersion: '0.4.17', releaseVersion: '0.4.17', gitTag: 'v0.4.17' }
+const status: SystemUpdateStatus = { current, latest: current, updateAvailable: false, installationMode: 'source', supported: true, canRollback: false }
+const render = () => mount(UpdateCheckDialog, { global: { stubs: { StandaloneDialog: { template: '<div><slot /></div>' }, AppIcon: true } } })
+beforeEach(() => { vi.mocked(systemUpdateStatus).mockResolvedValue(status); vi.mocked(checkSystemUpdate).mockResolvedValue(status) })
+afterEach(() => vi.resetAllMocks())
+
+it('checks immediately, keeps the installed version visible and disables repeat checks until finished', async () => {
+  let finish!: (value: SystemUpdateStatus) => void
+  vi.mocked(checkSystemUpdate).mockReturnValue(new Promise(resolve => { finish = resolve }))
+  const wrapper = render()
+  await flushPromises()
+  expect(wrapper.text()).toContain('正在检测更新')
+  expect(wrapper.text()).toContain('0.4.17')
+  expect(wrapper.get('button').attributes()).toHaveProperty('disabled')
+  finish(status); await flushPromises()
+  expect(wrapper.get('[role="status"]').text()).toBe('当前已是最新版本')
+  expect(checkSystemUpdate).toHaveBeenCalledTimes(1)
+  wrapper.unmount()
+})
+
+it('shows a new version and opens update management only on request', async () => {
+  vi.mocked(checkSystemUpdate).mockResolvedValue({ ...status, latest: { ...current, webVersion: '0.4.18' }, updateAvailable: true, releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases/tag/v0.4.18' })
+  const wrapper = render(); await flushPromises()
+  expect(wrapper.text()).toContain('发现新版本 0.4.18')
+  expect(wrapper.get('a').attributes('href')).toContain('/releases/tag/v0.4.18')
+  expect(wrapper.emitted('manage')).toBeUndefined()
+  await wrapper.findAll('button')[1]!.trigger('click')
+  expect(wrapper.emitted('manage')).toHaveLength(1)
+  wrapper.unmount()
+})
+
+it('reports failed checks without claiming the cached version is latest and supports retry', async () => {
+  vi.mocked(checkSystemUpdate).mockRejectedValueOnce(new Error('无法连接 GitHub'))
+  const wrapper = render(); await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toBe('无法连接 GitHub')
+  expect(wrapper.text()).not.toContain('已是最新')
+  expect(wrapper.text()).toContain('未获取')
+  await wrapper.get('button').trigger('click'); await flushPromises()
+  expect(wrapper.text()).toContain('当前已是最新版本')
+  wrapper.unmount()
+})
+
+it('explains externally managed installations without offering an unsupported update', async () => {
+  vi.mocked(checkSystemUpdate).mockResolvedValue({ ...status, latest: { ...current, webVersion: '0.4.18' }, updateAvailable: true, supported: false, unsupportedReason: '请使用 Docker Compose 更新', releasePageUrl: 'javascript:alert(1)' })
+  const wrapper = render(); await flushPromises()
+  expect(wrapper.text()).toContain('请使用 Docker Compose 更新')
+  expect(wrapper.text()).not.toContain('前往更新')
+  expect(wrapper.find('a').exists()).toBe(false)
+  wrapper.unmount()
+})

+ 48 - 4
tests/client/workspaceShell.test.ts

@@ -50,6 +50,7 @@ async function mountShell(start = '/chat') {
       stubs: {
         SettingsCenterDialog: SettingsCenterDialogStub,
         BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-dialog"><button @click="$emit(\'close\')">关闭已连接应用</button></div>'}),
+        UpdateCheckDialog: defineComponent({emits:['close', 'manage'],template:'<div data-testid="update-dialog"><button @click="$emit(\'close\')">关闭检测更新</button><button @click="$emit(\'manage\')">前往更新</button></div>'}),
         AboutDialog: defineComponent({emits:['close'],template:'<div data-testid="about-dialog"><button @click="$emit(\'close\')">关闭关于</button></div>'}),
         AgentAvatar: true,
         AppIcon: true,
@@ -62,6 +63,7 @@ async function mountShell(start = '/chat') {
 
 beforeEach(() => {
   localStorage.clear()
+  delete window.yaoyaoDesktop
 })
 
 afterEach(() => {
@@ -117,7 +119,7 @@ describe('Workspace shell account controls', () => {
     expect(settingsTrigger.attributes('aria-label')).toBe('设置与模式')
     await settingsTrigger.trigger('click')
     const actions = document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')
-    expect([...actions].map(button => button.textContent?.trim())).toEqual(['设置', '关于', '帮助', '进入 Bot 模式'])
+    expect([...actions].map(button => button.textContent?.trim())).toEqual(['设置', '关于', '检测更新', '帮助', '进入 Bot 模式'])
     actions[0]!.click()
     await wrapper.vm.$nextTick()
     const settings = wrapper.get('[data-testid="settings-center"]')
@@ -142,7 +144,7 @@ describe('Workspace shell account controls', () => {
 
     expect(desktop.findAll('.sidebar-feature-nav button').map(button => button.text())).not.toContain('聊天')
     await settingsTrigger.trigger('click')
-    document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[3]!.click()
+    document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[4]!.click()
     await vi.waitFor(() => expect(wrapper.classes()).toContain('workspace-shell--conversations'))
     expect(document.querySelector('.workspace-settings-menu')).toBeNull()
     wrapper.unmount()
@@ -152,6 +154,7 @@ describe('Workspace shell account controls', () => {
 it('uses the reference Bot list header and keeps mode changes in settings', async () => {
   const wrapper = await mountShell('/conversations')
   const rail = wrapper.get('.desktop-sidebar')
+  expect(wrapper.find('.sidebar-settings-trigger').exists()).toBe(false)
   expect(rail.find('.sidebar-feature-nav').exists()).toBe(false)
   expect(rail.find('.sidebar-primary-action').exists()).toBe(false)
   expect(rail.find('.sidebar-footer').exists()).toBe(true)
@@ -164,7 +167,7 @@ it('uses the reference Bot list header and keeps mode changes in settings', asyn
   expect(rail.find('.sidebar-context__heading').exists()).toBe(false)
   await rail.get('.sidebar-account-switcher__main').trigger('click')
   const accountItems = [...document.querySelectorAll<HTMLElement>('.workspace-settings-menu [role="menuitem"]')]
-  expect(accountItems.map(item => item.textContent?.trim())).toEqual(['设置', '关于', '帮助', '进入聊天模式'])
+  expect(accountItems.map(item => item.textContent?.trim())).toEqual(['设置', '关于', '检测更新', '帮助', '进入聊天模式'])
   expect(document.querySelector<HTMLAnchorElement>('.workspace-settings-menu a')?.href).toBe('https://yaoyao.samien.cn/')
   accountItems[0]!.click(); await wrapper.vm.$nextTick()
   expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-security')
@@ -210,7 +213,7 @@ it('shows Bot tools only in Bot mode and restores keyboard focus after closing',
 
 it.each(['/chat', '/history', '/conversations', '/kanban', '/files'])('opens an independent About dialog from the menu in %s', async path => {
   const wrapper = await mountShell(path)
-  const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-settings-trigger')
+  const trigger = wrapper.get<HTMLButtonElement>(path === '/conversations' ? '.desktop-sidebar .sidebar-account-switcher__main' : '.desktop-sidebar .sidebar-settings-trigger')
   await trigger.trigger('click')
   const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '关于')!
   item.click(); await flushPromises()
@@ -230,3 +233,44 @@ it('navigates to the independent automation route without opening settings', asy
   expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
   wrapper.unmount()
 })
+
+
+it('checks Web updates from the Bot account menu and preserves focus through update settings', async () => {
+  const wrapper = await mountShell('/conversations')
+  const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-account-switcher__main')
+  await trigger.trigger('click')
+  ;[...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')!.click()
+  await flushPromises()
+  expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(true)
+  await wrapper.get('[data-testid="update-dialog"] button:nth-child(2)').trigger('click')
+  expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('system-update')
+  await wrapper.get('[data-testid="close-settings"]').trigger('click')
+  await flushPromises()
+  expect(document.activeElement).toBe(trigger.element)
+  wrapper.unmount()
+})
+
+it('opens the native updater, prevents duplicate clicks and lets a failed opening retry', async () => {
+  const openUpdates = vi.fn().mockRejectedValueOnce(new Error('窗口打开失败')).mockResolvedValueOnce(undefined)
+  window.yaoyaoDesktop = { openUpdates, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
+  const wrapper = await mountShell('/conversations')
+  await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
+  const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')!
+  item.click(); item.click()
+  await flushPromises()
+  expect(openUpdates).toHaveBeenCalledTimes(1)
+  expect(document.querySelector('[role="alert"]')?.textContent).toContain('窗口打开失败')
+  item.click(); await flushPromises()
+  expect(openUpdates).toHaveBeenCalledTimes(2)
+  expect(document.querySelector('.workspace-settings-menu')).toBeNull()
+  expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(false)
+  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 })
+  await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
+  expect(document.querySelector('.workspace-settings-menu')?.textContent).not.toContain('检测更新')
+  wrapper.unmount()
+})

+ 2 - 2
tests/e2e/serverIdentity.spec.ts

@@ -1,6 +1,6 @@
 import {expect,test,type Page} from '@playwright/test'
-async function login(page:Page){await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('button',{name:'设置与模式'}).first()).toBeVisible()}
-async function settings(page:Page){await page.getByRole('button',{name:'设置与模式'}).first().click();await page.getByRole('menuitem',{name:'进入设置',exact:true}).click();await expect(page.locator('input[name=server-display-name]')).toBeVisible();await expect(page.locator('.settings-center-layer')).toHaveCSS('opacity','1')}
+async function login(page:Page){await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()}
+async function settings(page:Page){await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click();await page.getByRole('menuitem',{name:'进入设置',exact:true}).click();await expect(page.locator('input[name=server-display-name]')).toBeVisible();await expect(page.locator('.settings-center-layer')).toHaveCSS('opacity','1')}
 test('shares a server name across two clients without reloading',async({page,browser},testInfo)=>{
   await login(page);await settings(page)
   const observerContext=await browser.newContext({baseURL:'http://127.0.0.1:18804',viewport:{width:1100,height:850}})

+ 3 - 3
tests/e2e/team-tools.spec.ts

@@ -147,7 +147,7 @@ test('registers an execution node, downloads its private config and disables it'
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
   await page.getByRole('button',{name:'登录',exact:true}).click()
-  await page.getByRole('button',{name:/^(打开设置中心|设置与模式)$/}).first().click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
   await page.getByRole('menuitem',{name:'设置',exact:true}).click()
   const dialog=page.getByRole('dialog',{name:'设置中心'})
   await dialog.getByRole('button',{name:'Hermes 连接',exact:true}).click()
@@ -207,7 +207,7 @@ async function login(page:any,ready=false){
 }
 test('persists idle-stop settings and the host environment checkbox on desktop and mobile',async({page},testInfo)=>{
  await login(page,true)
- await page.getByRole('button',{name:'设置与模式',exact:true}).first().click();await page.getByRole('menuitem',{name:'设置',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click();await page.getByRole('menuitem',{name:'设置',exact:true}).click()
  const settings=page.getByRole('dialog',{name:'设置中心'});await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
  const timeout=settings.getByRole('combobox',{name:'虚拟机空闲停止时间',exact:true})
  await timeout.selectOption('30');await expect(timeout).toHaveValue('30')
@@ -249,7 +249,7 @@ test('cloud VM keeps its own selection while the host option can be checked and
 test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{
  await login(page)
  await expect(page.getByRole('button',{name:'电脑与环境',exact:true})).toHaveCount(0)
- await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+ await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
  await page.getByRole('menuitem',{name:'设置',exact:true}).click()
  const settings=page.getByRole('dialog',{name:'设置中心'})
  await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()

+ 55 - 6
tests/e2e/workspace-chat.spec.ts

@@ -222,8 +222,8 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.locator('.sidebar-footer')).toBeVisible()
   await expect(rail.locator('.sidebar-account-switcher__main')).toContainText('fixture')
   await expect(rail.locator('.sidebar-account-switcher__main .account-initial-avatar')).toHaveText('F')
-  await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
-  await expect(page.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['设置', '关于', '帮助', '进入聊天模式'])
+  await rail.locator('.sidebar-account-switcher__main').click()
+  await expect(page.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['设置', '关于', '检测更新', '帮助', '进入聊天模式'])
   await page.getByRole('menuitem', { name: '关于', exact: true }).click()
   const about = page.getByRole('dialog', { name: '关于夭夭 AI', exact: true })
   await expect(about).toBeVisible()
@@ -249,7 +249,7 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.screenshot({ path: testInfo.outputPath('independent-automations.png') })
   await page.getByRole('button', { name: '返回聊天', exact: true }).click()
   await expect(page).toHaveURL(/\/conversations$/)
-  await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
+  await rail.locator('.sidebar-account-switcher__main').click()
   await page.getByRole('menuitem', { name: '设置', exact: true }).click()
   const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
   await expect(settings.locator('.settings-account-summary')).toContainText('fixture')
@@ -287,7 +287,7 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.getByRole('button', { name: '聊天', exact: true })).toHaveCount(0)
   await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
   const settingsMenu = page.getByRole('menu', { name: '账号菜单', exact: true })
-  await expect(settingsMenu.getByRole('menuitem')).toHaveText(['设置', '关于', '帮助', '进入 Bot 模式'])
+  await expect(settingsMenu.getByRole('menuitem')).toHaveText(['设置', '关于', '检测更新', '帮助', '进入 Bot 模式'])
   await page.screenshot({ path: testInfo.outputPath('chat-settings-mode-menu.png') })
   await settingsMenu.getByRole('menuitem', { name: '设置', exact: true }).click()
   await expect(page.getByRole('dialog', { name: '设置中心', exact: true })).toBeVisible()
@@ -542,7 +542,7 @@ test('created roles and editable teams share a durable chat list without plugin
 
 test('subaccount allocation and Bot-only navigation work through the browser', async ({ page, browser }, testInfo) => {
   await login(page)
-  await page.locator('.desktop-sidebar').getByRole('button', { name: '设置与模式', exact: true }).click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
   await page.getByRole('menuitem', { name: '设置', exact: true }).click()
   const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
   await settings.getByRole('button', { name: '用户与权限', exact: true }).click()
@@ -566,7 +566,7 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   await child.getByRole('button', { name: '保存并进入夭夭', exact: true }).click()
   await expect(child).toHaveURL(/\/conversations$/)
   await expect(child.getByRole('heading', { name: '还没有聊天' })).toBeVisible()
-  await child.locator('.desktop-sidebar').getByRole('button', { name: '设置与模式', exact: true }).click()
+  await child.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
   await expect(child.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['设置', '关于', '帮助'])
   await child.keyboard.press('Escape')
   await openCreate(child, '新建 Bot')
@@ -703,3 +703,52 @@ test('Bot transcript switches from cache and preserves the viewport during histo
     await page.request.delete(`/api/app/agents/${seed.agentId}`, { headers })
   }
 })
+
+test('account update checks show loading, available versions and retry on desktop and phone', async ({ page }, testInfo) => {
+  await login(page)
+  const current = { schemaVersion: 1, releaseVersion: '0.4.17', webVersion: '0.4.17', gitTag: 'v0.4.17' }
+  const status = { current, latest: { ...current, webVersion: '0.4.18', releaseVersion: '0.4.18', gitTag: 'v0.4.18' }, supported: true, installationMode: 'source', updateAvailable: true, canRollback: false, releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases/tag/v0.4.18' }
+  await page.route('**/api/app/system/update/status', route => route.fulfill({ json: status }))
+  let failed = false
+  let releaseCheck: (() => void) | undefined
+  await page.route('**/api/app/system/update/check', async route => {
+    await new Promise<void>(resolve => { releaseCheck = resolve })
+    await route.fulfill(failed ? { status: 502, json: { error: '暂时无法连接更新服务' } } : { json: status })
+  })
+  for (const width of [1280, 390]) {
+    await page.setViewportSize({ width, height: 866 })
+    await page.emulateMedia({ colorScheme: width === 390 ? 'dark' : 'light', reducedMotion: 'reduce' })
+    const account = page.locator('.desktop-sidebar .sidebar-account-switcher__main')
+    await expect(page.locator('.sidebar-settings-trigger')).toHaveCount(0)
+    await account.click()
+    // The menu's dismiss layer receives the second pointer click over the account.
+    const accountRect = (await account.boundingBox())!
+    await page.mouse.click(accountRect.x + accountRect.width / 2, accountRect.y + accountRect.height / 2)
+    await expect(page.getByRole('menu', { name: '账号菜单' })).toHaveCount(0)
+    await account.click()
+    await page.screenshot({ path: testInfo.outputPath(`update-menu-${width}.png`) })
+    await page.getByRole('menuitem', { name: '检测更新', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '检测更新', exact: true })
+    await expect(dialog.getByRole('status')).toHaveText('正在检测更新…')
+    await expect(dialog.getByRole('button', { name: '正在检测…', exact: true })).toBeDisabled()
+    await expect.poll(() => Boolean(releaseCheck)).toBe(true)
+    releaseCheck!(); releaseCheck = undefined
+    await expect(dialog.getByRole('status')).toHaveText('发现新版本 0.4.18')
+    await page.screenshot({ path: testInfo.outputPath(`update-available-${width}.png`) })
+    expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+    failed = true
+    await dialog.getByRole('button', { name: '重新检测', exact: true }).click()
+    await expect.poll(() => Boolean(releaseCheck)).toBe(true)
+    releaseCheck!(); releaseCheck = undefined
+    await expect(dialog.getByRole('alert')).toContainText('暂时无法连接更新服务')
+    await expect(dialog.getByRole('button', { name: '前往更新', exact: true })).toHaveCount(0)
+    failed = false
+    await dialog.getByRole('button', { name: '重试', exact: true }).click()
+    await expect.poll(() => Boolean(releaseCheck)).toBe(true)
+    releaseCheck!(); releaseCheck = undefined
+    await expect(dialog.getByRole('button', { name: '前往更新', exact: true })).toBeVisible()
+    await page.keyboard.press('Escape')
+    await expect(dialog).toHaveCount(0)
+    await expect(account).toBeFocused()
+  }
+})

+ 1 - 1
tests/live/dockerPlatform.live.test.ts

@@ -69,7 +69,7 @@ it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE
     await page.getByRole('textbox',{name:'账号',exact:true}).fill('docker-fixture')
     await page.getByRole('textbox',{name:'密码',exact:true}).fill('docker-fixture-password')
     await page.getByRole('button',{name:'登录',exact:true}).click()
-    await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+    await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
     await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
     const settings=page.getByRole('dialog',{name:'设置中心'})
     await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()

+ 2 - 2
tests/live/localVm.live.test.ts

@@ -51,7 +51,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
   await page.getByRole('button',{name:'登录',exact:true}).click()
-  await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
   await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
   const settings=page.getByRole('dialog',{name:'设置中心'})
   await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
@@ -121,7 +121,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   const ordinaryWorkId='sharing-policy-ordinary-chat'
   app.workspace.put(owner,'turn',ordinaryWorkId,{agentId:chatting.id,status:'running'})
   try{
-    await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+    await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
     await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
     await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
     page.once('dialog',dialog=>{void dialog.accept()})