From 7190c50548f78558cef9b3e72ea5b0fa590460a9 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Tue, 29 Sep 2026 16:19:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E6=B7=BB=E5=8A=A0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E6=9B=B4=E6=96=B0=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .playwright/scripts/__tests__/updates.e2e.mjs | 153 +++++++++++ .../__tests__/desktopUpdateService.spec.ts | 33 +++ .../electron/main/app/DesktopIntegrations.ts | 6 +- .../electron/main/config/LexoraConfigStore.ts | 4 + .../__tests__/LexoraConfigStore.spec.ts | 12 + .../electron/main/desktopUpdateService.ts | 48 ++-- .../main/updates/DesktopUpdateStore.ts | 67 +++++ .../electron/main/updates/DesktopUpdates.ts | 252 ++++++++++++++++++ .../__tests__/DesktopUpdateStore.spec.ts | 28 ++ .../updates/__tests__/DesktopUpdates.spec.ts | 220 +++++++++++++++ .../main/updates/registerDesktopUpdates.ts | 78 ++++++ apps/buddy/electron/preload/desktop.ts | 7 + apps/buddy/electron/shared/desktopApi.ts | 18 +- .../electron/shared/desktopApiSchemas.ts | 1 + apps/buddy/electron/shared/desktopUpdates.ts | 61 +++++ apps/buddy/service/src/BuddyService.ts | 1 + .../src/agent/execution/ActiveRunRegistry.ts | 4 + .../src/agent/execution/BuddyAgentRunner.ts | 4 + apps/buddy/service/src/runs/registerRunRpc.ts | 2 + .../service/src/storage/runRepository.ts | 4 + apps/buddy/shared/runs/runApi.ts | 1 + .../src/app/bootstrap/DesktopAppProvider.vue | 5 + .../__tests__/useDesktopAppState.spec.ts | 7 + .../__tests__/useDesktopNavigation.spec.ts | 4 +- .../src/app/bootstrap/useDesktopAppState.ts | 7 +- .../src/app/bootstrap/useDesktopNavigation.ts | 14 +- .../buddy/src/app/shell/DesktopAppSidebar.vue | 8 +- .../src/app/shell/window/DesktopTitleBar.vue | 25 +- .../app/shell/window/DesktopUpdateDialog.vue | 99 ------- apps/buddy/src/i18n/locales/en-US/app.ts | 10 + apps/buddy/src/i18n/locales/zh-CN/app.ts | 10 + .../src/modules/notifications/contracts.ts | 4 + .../useNotificationCenterStore.spec.ts | 65 ++++- .../state/useNotificationCenterStore.ts | 55 +++- .../widgets/DesktopNotificationCenter.vue | 6 +- .../widgets/DesktopNotificationItem.vue | 14 +- .../pages/DesktopAboutSettingsView.vue | 9 + .../widgets/app/DesktopAboutSettings.vue | 27 +- .../widgets/app/DesktopApplicationToggle.vue | 2 +- .../app/__tests__/settingsFeedback.spec.ts | 29 +- .../state/__tests__/useTaskCapability.spec.ts | 5 +- apps/buddy/src/modules/updates/contracts.ts | 1 + apps/buddy/src/modules/updates/index.ts | 2 + .../__tests__/updateReleaseNotes.spec.ts | 8 + .../updates/model/updateReleaseNotes.ts | 12 + .../state/__tests__/useDesktopUpdates.spec.ts | 54 ++++ .../state/__tests__/useUpdateReminder.spec.ts | 164 ++++++++++++ .../updates/state/useDesktopUpdates.ts | 65 +++++ .../updates/state/useUpdateReminder.ts | 76 ++++++ apps/buddy/src/modules/updates/ui.ts | 1 + .../src/modules/updates/updatesContext.ts | 12 + .../updates/widgets/DesktopUpdateDialog.vue | 132 +++++++++ .../updates/widgets/DesktopUpdateHost.vue | 60 +++++ .../updates/widgets/DesktopUpdateReminder.vue | 49 ++++ 54 files changed, 1835 insertions(+), 210 deletions(-) create mode 100644 .playwright/scripts/__tests__/updates.e2e.mjs create mode 100644 apps/buddy/electron/main/updates/DesktopUpdateStore.ts create mode 100644 apps/buddy/electron/main/updates/DesktopUpdates.ts create mode 100644 apps/buddy/electron/main/updates/__tests__/DesktopUpdateStore.spec.ts create mode 100644 apps/buddy/electron/main/updates/__tests__/DesktopUpdates.spec.ts create mode 100644 apps/buddy/electron/main/updates/registerDesktopUpdates.ts create mode 100644 apps/buddy/electron/shared/desktopUpdates.ts delete mode 100644 apps/buddy/src/app/shell/window/DesktopUpdateDialog.vue create mode 100644 apps/buddy/src/modules/notifications/contracts.ts create mode 100644 apps/buddy/src/modules/updates/contracts.ts create mode 100644 apps/buddy/src/modules/updates/index.ts create mode 100644 apps/buddy/src/modules/updates/model/__tests__/updateReleaseNotes.spec.ts create mode 100644 apps/buddy/src/modules/updates/model/updateReleaseNotes.ts create mode 100644 apps/buddy/src/modules/updates/state/__tests__/useDesktopUpdates.spec.ts create mode 100644 apps/buddy/src/modules/updates/state/__tests__/useUpdateReminder.spec.ts create mode 100644 apps/buddy/src/modules/updates/state/useDesktopUpdates.ts create mode 100644 apps/buddy/src/modules/updates/state/useUpdateReminder.ts create mode 100644 apps/buddy/src/modules/updates/ui.ts create mode 100644 apps/buddy/src/modules/updates/updatesContext.ts create mode 100644 apps/buddy/src/modules/updates/widgets/DesktopUpdateDialog.vue create mode 100644 apps/buddy/src/modules/updates/widgets/DesktopUpdateHost.vue create mode 100644 apps/buddy/src/modules/updates/widgets/DesktopUpdateReminder.vue diff --git a/.playwright/scripts/__tests__/updates.e2e.mjs b/.playwright/scripts/__tests__/updates.e2e.mjs new file mode 100644 index 00000000..6989a8ed --- /dev/null +++ b/.playwright/scripts/__tests__/updates.e2e.mjs @@ -0,0 +1,153 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +const version = '999.1.0' +const result = { + currentVersion: '0.0.0', + latestVersion: version, + status: 'update_available', + releaseUrl: `https://github.com/useLexora/Lexora/releases/tag/v${version}`, + releaseNotes: '## 更新亮点\n- 改善任务恢复\n- 修复文件操作\n- 优化资源使用\n![external](https://example.invalid/image.png)', +} + +async function seedUpdate(instance, enabled = true) { + await fs.mkdir(path.join(instance.home, 'buddy'), { recursive: true }) + await fs.writeFile(path.join(instance.home, 'buddy/updates.json'), JSON.stringify({ + version: 1, + lastCheckedAt: Date.now(), + lastNotifiedAt: null, + notifiedVersion: null, + ignoredVersion: null, + seenVersion: null, + discoveredAt: Date.now(), + result, + })) + if (!enabled) { + const config = path.join(instance.home, 'config.toml') + await fs.writeFile(config, (await fs.readFile(config, 'utf8')).replace('[desktop]\n', '[desktop]\nupdate_notifications_enabled = false\n')) + } +} + +async function about(page) { + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '设置', exact: true }).click() + await page.locator('.desktop-settings-sidebar').getByRole('link', { name: '关于', exact: true }).click() +} + +function toggle(page) { + return page.locator('.desktop-application-toggle').filter({ hasText: '更新通知' }).getByRole('switch') +} + +async function focus(app) { + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows().find(window => window.webContents.getURL().startsWith('lexora-app:')) + window.show() + window.focus() + }) +} + +async function decisions(instance) { + return JSON.parse(await fs.readFile(path.join(instance.home, 'buddy/updates.json'), 'utf8')) +} + +test('updates stay discoverable after dismissal, persist skips and opt-out, and keep manual checks available', async ({ buddy }) => { + const instance = await buddy.createInstance('updates') + await seedUpdate(instance) + let desktop = await instance.launch() + await focus(desktop.app) + const reminder = () => desktop.page.getByTestId('update-reminder') + await expect(reminder()).toBeVisible() + await expect.poll(() => decisions(instance)).toMatchObject({ notifiedVersion: version, seenVersion: null }) + await reminder().locator('.n-alert__close').click() + await expect(reminder()).toHaveCount(0) + await desktop.page.locator('.desktop-app-sidebar__notification-trigger').click() + await desktop.page.locator('.desktop-notification-item').filter({ hasText: `Lexora ${version}` }).click() + const dialog = () => desktop.page.locator('.desktop-update-dialog') + await expect(dialog()).toBeVisible() + await expect(dialog()).toContainText('改善任务恢复') + await expect(dialog().locator('img')).toHaveCount(0) + await expect.poll(() => decisions(instance)).toMatchObject({ seenVersion: version }) + await desktop.app.evaluate(({ shell }) => { + globalThis.updateOpenedUrls = [] + shell.openExternal = async (url) => { + globalThis.updateOpenedUrls.push(url) + } + }) + await dialog().getByRole('button', { name: '前往下载', exact: true }).click() + await expect.poll(() => desktop.app.evaluate(() => globalThis.updateOpenedUrls)).toEqual([result.releaseUrl]) + await dialog().getByRole('button', { name: '忽略此版本', exact: true }).click() + await expect(dialog()).toHaveCount(0) + await expect.poll(() => decisions(instance)).toMatchObject({ ignoredVersion: version }) + await instance.stop() + desktop = await instance.launch() + expect((await desktop.page.evaluate(() => window.lexoraDesktop.app.updates.getState())).notification).toBeNull() + await expect(reminder()).toHaveCount(0) + await about(desktop.page) + await expect(toggle(desktop.page)).toBeChecked() + await toggle(desktop.page).click() + await expect(toggle(desktop.page)).not.toBeChecked() + await instance.stop() + desktop = await instance.launch() + await about(desktop.page) + await expect(toggle(desktop.page)).not.toBeChecked() + expect((await desktop.page.evaluate(() => window.lexoraDesktop.app.updates.getState())).enabled).toBe(false) + await desktop.app.evaluate(({ ipcMain, app }, result) => { + ipcMain.removeHandler('lexora:app:check-for-updates') + ipcMain.handle('lexora:app:check-for-updates', () => ({ ...result, currentVersion: app.getVersion() })) + }, result) + await desktop.page.getByRole('button', { name: '检查更新', exact: true }).click() + await expect(dialog()).toContainText(version) + await expect(reminder()).toHaveCount(0) + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'update-details.png'), animations: 'disabled' }) + expect(desktop.diagnostics.console.filter(entry => entry.type === 'pageerror')).toEqual([]) +}) + +test('updates defer for all running tasks and pending approvals, then notify when the window is idle', async ({ buddy }) => { + const instance = await buddy.createInstance('updates-busy') + await seedUpdate(instance, false) + const { app, page, diagnostics } = await instance.launch() + const databasePath = path.join(instance.home, 'buddy/buddy.sqlite3') + await app.evaluate((_electron, databasePath) => { + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(databasePath) + try { + const now = new Date().toISOString() + db.prepare('INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)').run('update-fixture-task', 'Isolated running task', now, now) + db.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run('update-fixture-branch', 'update-fixture-task', now) + db.prepare('INSERT INTO runs (id, conversation_id, branch_id, triggering_message_id, provider, model, purpose, status, started_at) VALUES (?, ?, ?, ?, ?, ?, \'chat\', \'running\', ?)').run('update-fixture-run', 'update-fixture-task', 'update-fixture-branch', 'update-fixture-input', 'fixture', 'fixture', now) + db.prepare('INSERT INTO approvals (id, run_id, tool_call_id, kind, status, summary, payload_json, created_at) VALUES (?, ?, ?, \'test\', \'pending\', \'Isolated approval\', \'{}\', ?)').run('update-fixture-approval', 'update-fixture-run', 'fixture-tool', now) + } + finally { db.close() } + }, databasePath) + await about(page) + await toggle(page).click() + await expect(toggle(page)).toBeChecked() + await focus(app) + expect(await page.evaluate(() => window.lexoraDesktop.app.updates.takeReminder())).toBeNull() + await expect(page.getByTestId('update-reminder')).toHaveCount(0) + await app.evaluate((_electron, databasePath) => { + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(databasePath) + try { + db.exec('UPDATE runs SET status = \'completed\' WHERE id = \'update-fixture-run\'') + } + finally { db.close() } + }, databasePath) + expect(await page.evaluate(() => window.lexoraDesktop.app.updates.takeReminder())).toBeNull() + expect((await decisions(instance)).notifiedVersion).toBeNull() + await app.evaluate(({ BrowserWindow }, databasePath) => { + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(databasePath) + try { + db.exec('UPDATE approvals SET status = \'approved\' WHERE id = \'update-fixture-approval\'') + } + finally { db.close() } + const window = BrowserWindow.getAllWindows().find(window => window.webContents.getURL().startsWith('lexora-app:')) + window.webContents.send('lexora:buddy:runs:event', { type: 'run.completed', runId: 'update-fixture-run', sequence: 1, createdAt: new Date().toISOString(), payload: {} }) + }, databasePath) + await expect(page.getByTestId('update-reminder')).toBeVisible() + await toggle(page).click() + await expect(page.getByTestId('update-reminder')).toHaveCount(0) + expect((await page.evaluate(() => window.lexoraDesktop.app.updates.getState())).notification).toBeNull() + expect(diagnostics.console.filter(entry => entry.type === 'pageerror')).toEqual([]) +}) diff --git a/apps/buddy/electron/main/__tests__/desktopUpdateService.spec.ts b/apps/buddy/electron/main/__tests__/desktopUpdateService.spec.ts index 7c259f06..e0c4c6e4 100644 --- a/apps/buddy/electron/main/__tests__/desktopUpdateService.spec.ts +++ b/apps/buddy/electron/main/__tests__/desktopUpdateService.spec.ts @@ -25,6 +25,7 @@ describe('checkForDesktopUpdate', () => { currentVersion: '0.1.0', latestVersion: '0.2.0', releaseUrl: 'https://github.com/useLexora/Lexora/releases/tag/v0.2.0', + releaseNotes: '', status: 'update_available', }) }) @@ -46,6 +47,38 @@ describe('checkForDesktopUpdate', () => { }) }) + it('selects the highest trusted stable version and bounds the untrusted release notes', async () => { + const release = (version: string, fields = {}) => ({ draft: false, prerelease: false, tag_name: `v${version}`, html_url: `https://github.com/useLexora/Lexora/releases/tag/v${version}`, ...fields }) + const result = await checkForDesktopUpdate({ + currentVersion: '1.2.0', + fetchRelease: async () => new Response(JSON.stringify([ + release('1.9.0'), + release('1.10.0', { body: 'x'.repeat(20_000) }), + release('9.0.0', { html_url: 'https://example.invalid/download' }), + release('8.0.0', { draft: true }), + release('7.0.0', { prerelease: true }), + release('6.0.0-beta'), + ])), + }) + expect(result).toMatchObject({ latestVersion: '1.10.0', status: 'update_available' }) + expect(result.releaseNotes).toHaveLength(8_000) + }) + + it('returns a stable failure for an oversized response or an aborted request', async () => { + await expect(checkForDesktopUpdate({ + currentVersion: '1.0.0', + fetchRelease: async () => new Response('x'.repeat(4 * 1024 * 1024 + 1)), + })).rejects.toMatchObject({ code: 'UPDATE_CHECK_FAILED' }) + await expect(checkForDesktopUpdate({ + currentVersion: '1.0.0', + signal: AbortSignal.abort(), + fetchRelease: async (_url, init) => { + init?.signal?.throwIfAborted() + return new Response('[]') + }, + })).rejects.toMatchObject({ code: 'UPDATE_CHECK_FAILED' }) + }) + it('returns a stable failure for unavailable or invalid release data', async () => { await expect(checkForDesktopUpdate({ currentVersion: '0.1.0', diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index 169ecc1f..f79c958a 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -24,13 +24,13 @@ import { registerBrowserDesktopIpc } from '../browser/registerBrowserDesktopIpc' import { registerContextPanelIpc } from '../context-panel/registerContextPanelIpc' import { createDesktopCommandExecutor } from '../desktopCommands' import { DesktopNotificationService } from '../DesktopNotificationService' -import { checkForDesktopUpdate } from '../desktopUpdateService' import { ApplicationLogReader } from '../diagnostics/ApplicationLogReader' import { registerExtensionIpc } from '../extensions/registerExtensionIpc' import { createFeedbackIssueUrl } from '../feedbackIssue' import { registerDesktopIpc } from '../ipc' import { registerLocalChatIpc } from '../localChatIpc' import { createDesktopTray } from '../tray' +import { registerDesktopUpdates } from '../updates/registerDesktopUpdates' import { registerWorkbenchIpc } from '../workbench/registerWorkbenchIpc' import { WorkbenchStateStore } from '../workbench/WorkbenchStateStore' import { registerApplicationLogIpc } from './registerApplicationLogIpc' @@ -159,10 +159,12 @@ export class DesktopIntegrations { diagnostics.record({ scope: 'desktop', level: 'warn', event: 'notification.failed', error }) }) })) + const updates = registerDesktopUpdates(this.#environment, runtime, windows) + this.#subscriptions.push(updates.dispose) registerDesktopIpc({ getSandboxStatus: () => this.#runtime.getSandboxStatus(), setupSandbox: () => this.#runtime.setupSandbox(), - checkForUpdates: () => checkForDesktopUpdate({ currentVersion: app.getVersion(), fetchRelease: runtime.network.get }), + checkForUpdates: updates.check, configPath: paths.configPath, runtimeProfile: paths.profile, configStore: runtime.configStore, diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index be2802d5..6637b64e 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -68,6 +68,7 @@ const desktopConfigSchema = z.object({ language: z.enum(['zh-CN', 'en-US']).default('zh-CN'), launch_at_login: z.boolean().default(false), notifications_enabled: z.boolean().default(true), + update_notifications_enabled: z.boolean().default(true), notify_when_focused: z.boolean().default(false), sidebar_collapsed: z.boolean().default(false), theme: z.enum(['system', 'light', 'dark']).default('system'), @@ -89,6 +90,7 @@ const desktopConfigSchema = z.object({ language: 'zh-CN', launch_at_login: false, notifications_enabled: true, + update_notifications_enabled: true, notify_when_focused: false, sidebar_collapsed: false, theme: 'system', @@ -314,6 +316,7 @@ function decodeConfig(value: unknown): LexoraConfig { language: config.desktop.language, launchAtLogin: config.desktop.launch_at_login, notificationsEnabled: config.desktop.notifications_enabled, + updateNotificationsEnabled: config.desktop.update_notifications_enabled, notifyWhenFocused: config.desktop.notify_when_focused, sidebarCollapsed: config.desktop.sidebar_collapsed, theme: config.desktop.theme, @@ -364,6 +367,7 @@ function encodeConfig(config: LexoraConfig) { language: config.desktop.language, launch_at_login: config.desktop.launchAtLogin, notifications_enabled: config.desktop.notificationsEnabled, + update_notifications_enabled: config.desktop.updateNotificationsEnabled, notify_when_focused: config.desktop.notifyWhenFocused, sidebar_collapsed: config.desktop.sidebarCollapsed, theme: config.desktop.theme, diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index 81e658c6..f0be128b 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -18,6 +18,17 @@ async function createConfigStore() { } describe('lexoraConfigStore', () => { + it('enables update notifications for old profiles and preserves an opt-out across other settings and restart', async () => { + const { store, configPath } = await createConfigStore() + await mkdir(dirname(configPath), { recursive: true }) + await writeFile(configPath, '[desktop]\nnotifications_enabled = false\ntheme = "dark"\n[custom]\nkeep = true\n') + expect((await store.read()).desktop.updateNotificationsEnabled).toBe(true) + await store.update({ desktop: { updateNotificationsEnabled: false } }) + await store.update({ desktop: { language: 'en-US' } }) + expect((await new LexoraConfigStore({ configPath }).read()).desktop).toMatchObject({ updateNotificationsEnabled: false, notificationsEnabled: false, theme: 'dark' }) + expect(await readFile(configPath, 'utf8')).toContain('keep = true') + }) + it('defaults existing profiles to three retries and round-trips disabled, finite and unlimited limits', async () => { const { store, configPath } = await createConfigStore() await mkdir(dirname(configPath), { recursive: true }) @@ -208,6 +219,7 @@ describe('lexoraConfigStore', () => { language: 'zh-CN', launchAtLogin: false, notificationsEnabled: true, + updateNotificationsEnabled: true, notifyWhenFocused: false, profile: { avatar: '', diff --git a/apps/buddy/electron/main/desktopUpdateService.ts b/apps/buddy/electron/main/desktopUpdateService.ts index 56939a69..8e1e485b 100644 --- a/apps/buddy/electron/main/desktopUpdateService.ts +++ b/apps/buddy/electron/main/desktopUpdateService.ts @@ -1,8 +1,11 @@ import type { DesktopUpdateCheckResult } from '../shared/desktopApi' +import { readResponseBytes } from '../../platform/network/publicWebTransport' +import { compareDesktopVersions, desktopUpdateResultSchema, desktopVersionSchema } from '../shared/desktopUpdates' import { isLexoraReleaseUrl, RELEASES_API_URL } from '../shared/productLinks' export interface CheckForDesktopUpdateOptions { currentVersion: string + signal?: AbortSignal fetchRelease?: (url: string, init?: RequestInit) => Promise } @@ -11,10 +14,10 @@ interface GithubRelease { html_url: string prerelease: boolean tag_name: string + body?: string } const RELEASE_TAG_PREFIX = 'v' -const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/ export class DesktopUpdateCheckError extends Error { readonly code = 'UPDATE_CHECK_FAILED' @@ -29,7 +32,9 @@ export async function checkForDesktopUpdate( options: CheckForDesktopUpdateOptions, ): Promise { try { + const timeout = AbortSignal.timeout(15_000) const response = await (options.fetchRelease ?? fetch)(RELEASES_API_URL, { + signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout, headers: { 'accept': 'application/vnd.github+json', 'user-agent': 'Lexora-Buddy', @@ -38,16 +43,18 @@ export async function checkForDesktopUpdate( if (!response.ok) throw new Error(`GitHub release request failed with ${response.status}`) - const release = parseLatestBuddyRelease(await response.json()) - const current = parseVersion(options.currentVersion) - return { - currentVersion: options.currentVersion, - latestVersion: release.version.raw, + const body = await readResponseBytes(response, 4 * 1024 * 1024) + const release = parseLatestBuddyRelease(JSON.parse(new TextDecoder().decode(body))) + const current = desktopVersionSchema.parse(options.currentVersion) + return desktopUpdateResultSchema.parse({ + currentVersion: current, + latestVersion: release.version, releaseUrl: release.metadata.html_url, - status: compareVersions(release.version.parts, current.parts) > 0 + releaseNotes: typeof release.metadata.body === 'string' ? release.metadata.body.trim().slice(0, 8_000) : '', + status: compareDesktopVersions(release.version, current) > 0 ? 'update_available' : 'up_to_date', - } + }) } catch (error) { throw new DesktopUpdateCheckError({ cause: error }) @@ -56,7 +63,7 @@ export async function checkForDesktopUpdate( function parseLatestBuddyRelease(value: unknown): { metadata: GithubRelease - version: ReturnType + version: string } { if (!Array.isArray(value)) throw new Error('GitHub release response is invalid') @@ -79,7 +86,7 @@ function parseLatestBuddyRelease(value: unknown): { try { return [{ metadata: release as GithubRelease, - version: parseVersion(release.tag_name.slice(RELEASE_TAG_PREFIX.length)), + version: desktopVersionSchema.parse(release.tag_name.slice(RELEASE_TAG_PREFIX.length)), }] } catch { @@ -90,25 +97,6 @@ function parseLatestBuddyRelease(value: unknown): { throw new Error('GitHub release response is invalid') return releases.reduce((latest, release) => ( - compareVersions(release.version.parts, latest.version.parts) > 0 ? release : latest + compareDesktopVersions(release.version, latest.version) > 0 ? release : latest )) } - -function parseVersion(value: string): { parts: readonly number[], raw: string } { - const match = SEMVER_PATTERN.exec(value) - if (!match) - throw new Error('Lexora Buddy version is invalid') - return { - parts: match.slice(1).map(Number), - raw: value, - } -} - -function compareVersions(left: readonly number[], right: readonly number[]): number { - for (let index = 0; index < 3; index += 1) { - const difference = left[index]! - right[index]! - if (difference !== 0) - return difference - } - return 0 -} diff --git a/apps/buddy/electron/main/updates/DesktopUpdateStore.ts b/apps/buddy/electron/main/updates/DesktopUpdateStore.ts new file mode 100644 index 00000000..775f1b68 --- /dev/null +++ b/apps/buddy/electron/main/updates/DesktopUpdateStore.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, open, readFile, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { fileStorage } from '../../../platform/filesystem/fileStorage' +import { desktopUpdateResultSchema, desktopVersionSchema } from '../../shared/desktopUpdates' + +const timestamp = z.number().int().nonnegative().nullable() +const version = desktopVersionSchema.nullable() +const updateRecordSchema = z.object({ + version: z.literal(1), + lastCheckedAt: timestamp, + lastNotifiedAt: timestamp, + notifiedVersion: version, + ignoredVersion: version, + seenVersion: version, + discoveredAt: timestamp, + result: desktopUpdateResultSchema.nullable(), +}).strict() + +export type DesktopUpdateRecord = z.infer + +export function emptyUpdateRecord(): DesktopUpdateRecord { + return { version: 1, lastCheckedAt: null, lastNotifiedAt: null, notifiedVersion: null, ignoredVersion: null, seenVersion: null, discoveredAt: null, result: null } +} + +export class DesktopUpdateStore { + readonly #directory: string + + constructor(directory: string) { + this.#directory = directory + } + + async read(): Promise { + const path = join(this.#directory, 'updates.json') + try { + if ((await stat(path)).size > 128 * 1024) + throw new Error('UPDATE_STATE_TOO_LARGE') + return updateRecordSchema.parse(JSON.parse(await readFile(path, 'utf8'))) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return emptyUpdateRecord() + throw new Error('UPDATE_STATE_UNAVAILABLE', { cause: error }) + } + } + + async write(record: DesktopUpdateRecord): Promise { + const data = JSON.stringify(updateRecordSchema.parse(record)) + await mkdir(this.#directory, { recursive: true, mode: 0o700 }) + const temporary = join(this.#directory, `updates-${randomUUID()}.tmp`) + try { + const file = await open(temporary, 'wx', 0o600) + try { + await file.writeFile(data) + await file.sync() + } + finally { + await file.close() + } + await fileStorage.replace(temporary, join(this.#directory, 'updates.json')) + } + finally { + await rm(temporary, { force: true }) + } + } +} diff --git a/apps/buddy/electron/main/updates/DesktopUpdates.ts b/apps/buddy/electron/main/updates/DesktopUpdates.ts new file mode 100644 index 00000000..32523bc7 --- /dev/null +++ b/apps/buddy/electron/main/updates/DesktopUpdates.ts @@ -0,0 +1,252 @@ +import type { DesktopUpdateAction, DesktopUpdateCheckResult, DesktopUpdateState } from '../../shared/desktopUpdates' +import type { DesktopUpdateRecord, DesktopUpdateStore } from './DesktopUpdateStore' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { compareDesktopVersions } from '../../shared/desktopUpdates' +import { emptyUpdateRecord } from './DesktopUpdateStore' + +export const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000 +export const UPDATE_REMINDER_INTERVAL = 3 * UPDATE_CHECK_INTERVAL +export const UPDATE_STARTUP_DELAY = 30_000 + +interface UpdateRequest { + controller: AbortController + manual: boolean + promise: Promise +} + +export interface DesktopUpdatesOptions { + currentVersion: string + automatic: boolean + enabled: boolean + store: Pick + check: (signal: AbortSignal) => Promise + canPresent: () => boolean + isIdle: () => Promise + reportFailure: () => void +} + +export class DesktopUpdates { + readonly #options: DesktopUpdatesOptions + readonly #changes: Emitter + readonly onDidChange: Emitter['event'] + #record = emptyUpdateRecord() + #revision = 0 + #enabled: boolean + #ready = false + #stopped = false + #automaticAfter = 0 + #attemptAt: number | null = null + #request: UpdateRequest | null = null + #timer: ReturnType | undefined + #initialization: Promise | undefined + #tail: Promise = Promise.resolve() + + constructor(options: DesktopUpdatesOptions) { + this.#options = options + this.#enabled = options.enabled + this.#changes = new Emitter(options.reportFailure) + this.onDidChange = this.#changes.event + } + + start(): Promise { + this.#initialization ??= this.#options.store.read().then((record) => { + if (this.#stopped) + return + const result = record.result + this.#record = { + ...record, + result: result + ? { + ...result, + currentVersion: this.#options.currentVersion, + status: compareDesktopVersions(result.latestVersion, this.#options.currentVersion) > 0 ? 'update_available' : 'up_to_date', + } + : null, + } + this.#publish() + this.#schedule() + }) + return this.#initialization + } + + get state(): DesktopUpdateState { + const { result, discoveredAt, seenVersion, ignoredVersion, notifiedVersion, lastNotifiedAt } = this.#record + const available = this.#enabled && result?.status === 'update_available' && !covers(ignoredVersion, result.latestVersion) + const unseen = available && !covers(seenVersion, result.latestVersion) + return copyEventSnapshot({ + revision: this.#revision, + checking: this.#request !== null, + enabled: this.#enabled, + result, + notification: available + ? { + id: 'desktop.update', + revision: result.latestVersion, + kind: 'app.update-available', + origin: 'desktop', + attention: unseen ? 'unseen' : 'seen', + occurredAt: new Date(discoveredAt ?? Date.now()).toISOString(), + action: { type: 'open-app-update' }, + payload: { version: result.latestVersion }, + } + : null, + reminderDueAt: this.#ready && unseen && !covers(notifiedVersion, result.latestVersion) + ? lastNotifiedAt === null ? 0 : Math.min(lastNotifiedAt, Date.now()) + UPDATE_REMINDER_INTERVAL + : null, + }) + } + + setReady(ready: boolean): void { + if (this.#ready === ready || this.#stopped) + return + this.#ready = ready + if (ready && !this.#automaticAfter) + this.#automaticAfter = Date.now() + UPDATE_STARTUP_DELAY + this.#publish() + this.#schedule() + } + + setEnabled(enabled: boolean): void { + if (this.#enabled === enabled || this.#stopped) + return + this.#enabled = enabled + if (!enabled && this.#request && !this.#request.manual) + this.#request.controller.abort() + this.#publish() + this.#schedule() + } + + check(manual = true): Promise { + if (this.#stopped) + return Promise.reject(new Error('UPDATE_SERVICE_STOPPED')) + if (this.#request && !this.#request.controller.signal.aborted) { + this.#request.manual ||= manual + return this.#request.promise + } + const request: UpdateRequest = { + controller: new AbortController(), + manual, + promise: Promise.resolve().then(() => this.#check(request)).finally(() => { + if (this.#request === request) { + this.#request = null + this.#publish() + this.#schedule() + } + }), + } + this.#request = request + this.#publish() + return request.promise + } + + acknowledge(input: DesktopUpdateAction): Promise { + return this.#enqueue(async () => { + const result = this.#record.result + if (result?.latestVersion !== input.version || result.status !== 'update_available') + return this.state + if (input.action === 'reminded') { + if (!covers(this.#record.notifiedVersion, input.version)) + await this.#commit({ ...this.#record, notifiedVersion: input.version, lastNotifiedAt: Date.now() }) + return this.state + } + await this.#commit({ + ...this.#record, + seenVersion: input.version, + ...(input.action === 'ignore' ? { ignoredVersion: input.version } : {}), + }) + return this.state + }) + } + + takeReminder(): Promise { + return this.#enqueue(async () => { + if (!this.#canRemind() || !await this.#options.isIdle() || !this.#canRemind()) + return null + return this.#record.result + }) + } + + async dispose(): Promise { + this.#stopped = true + clearTimeout(this.#timer) + this.#request?.controller.abort() + await this.#request?.promise.catch(() => {}) + await this.#initialization?.catch(() => {}) + await this.#tail.catch(() => {}) + this.#changes.dispose() + } + + async #check(request: UpdateRequest): Promise { + const signal = request.controller.signal + this.#attemptAt = Date.now() + await this.start() + signal.throwIfAborted() + await this.#enqueue(async () => { + signal.throwIfAborted() + await this.#commit({ ...this.#record, lastCheckedAt: this.#attemptAt }) + }) + signal.throwIfAborted() + const result = await this.#options.check(signal) + return this.#enqueue(async () => { + signal.throwIfAborted() + const previous = this.#record.result + await this.#commit({ + ...this.#record, + result, + discoveredAt: previous?.latestVersion === result.latestVersion ? this.#record.discoveredAt : Date.now(), + ...(request.manual ? { seenVersion: result.latestVersion } : {}), + }) + if (request.manual && !covers(this.#record.seenVersion, result.latestVersion)) + await this.#commit({ ...this.#record, seenVersion: result.latestVersion }) + return result + }) + } + + #canRemind(): boolean { + const due = this.state.reminderDueAt + return !this.#stopped && this.#ready && due !== null && due <= Date.now() && this.#options.canPresent() + } + + #enqueue(operation: () => Promise): Promise { + const pending = this.#tail.then(async () => { + await this.start() + if (this.#stopped) + throw new Error('UPDATE_SERVICE_STOPPED') + return operation() + }) + this.#tail = pending.catch(() => {}) + return pending + } + + async #commit(record: DesktopUpdateRecord): Promise { + await this.#options.store.write(record) + this.#record = record + this.#publish() + } + + #publish(): void { + this.#revision++ + if (!this.#stopped) + this.#changes.fire(this.state) + } + + #schedule(): void { + clearTimeout(this.#timer) + if (this.#stopped || !this.#enabled || !this.#ready || !this.#options.automatic || this.#request) + return + const checked = this.#attemptAt ?? this.#record.lastCheckedAt + const due = Math.max(this.#automaticAfter, checked === null ? 0 : Math.min(checked, Date.now()) + UPDATE_CHECK_INTERVAL) + this.#timer = setTimeout(() => { + void this.check(false).catch(() => { + if (this.#enabled && !this.#stopped) + this.#options.reportFailure() + }) + }, Math.max(0, due - Date.now())) + this.#timer.unref?.() + } +} + +function covers(saved: string | null, candidate: string): boolean { + return saved !== null && compareDesktopVersions(saved, candidate) >= 0 +} diff --git a/apps/buddy/electron/main/updates/__tests__/DesktopUpdateStore.spec.ts b/apps/buddy/electron/main/updates/__tests__/DesktopUpdateStore.spec.ts new file mode 100644 index 00000000..ef4d741a --- /dev/null +++ b/apps/buddy/electron/main/updates/__tests__/DesktopUpdateStore.spec.ts @@ -0,0 +1,28 @@ +import { readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' +import { expect, it } from 'vitest' +import { DesktopUpdateStore, emptyUpdateRecord } from '../DesktopUpdateStore' + +it('round-trips update decisions in a private file without changing configuration', async () => { + const directory = await createTemporaryDirectory('desktop-updates-') + const store = new DesktopUpdateStore(directory) + expect(await store.read()).toEqual(emptyUpdateRecord()) + const record = { ...emptyUpdateRecord(), ignoredVersion: '1.2.0', notifiedVersion: '1.1.0', lastNotifiedAt: 1000 } + await store.write(record) + expect(await new DesktopUpdateStore(directory).read()).toEqual(record) + if (process.platform !== 'win32') + expect((await stat(join(directory, 'updates.json'))).mode & 0o777).toBe(0o600) +}) + +it('preserves corrupt or unknown-version state rather than silently resetting opt-outs', async () => { + const directory = await createTemporaryDirectory('desktop-updates-') + const path = join(directory, 'updates.json') + const store = new DesktopUpdateStore(directory) + for (const content of ['{broken', JSON.stringify({ ...emptyUpdateRecord(), version: 2 })]) { + await writeFile(path, content) + await expect(store.read()).rejects.toThrow('UPDATE_STATE_UNAVAILABLE') + expect(await readFile(path, 'utf8')).toBe(content) + } +}) diff --git a/apps/buddy/electron/main/updates/__tests__/DesktopUpdates.spec.ts b/apps/buddy/electron/main/updates/__tests__/DesktopUpdates.spec.ts new file mode 100644 index 00000000..e23e19f4 --- /dev/null +++ b/apps/buddy/electron/main/updates/__tests__/DesktopUpdates.spec.ts @@ -0,0 +1,220 @@ +import type { DesktopUpdateCheckResult } from '../../../shared/desktopUpdates' +import type { DesktopUpdatesOptions } from '../DesktopUpdates' +import type { DesktopUpdateRecord } from '../DesktopUpdateStore' +import { deferred } from '@buddy-tests/deferred' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopUpdates, UPDATE_CHECK_INTERVAL, UPDATE_REMINDER_INTERVAL, UPDATE_STARTUP_DELAY } from '../DesktopUpdates' +import { emptyUpdateRecord } from '../DesktopUpdateStore' + +const services: DesktopUpdates[] = [] +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) +}) +afterEach(async () => { + await Promise.all(services.splice(0).map(service => service.dispose())) + vi.useRealTimers() +}) + +function release(version = '1.1.0'): DesktopUpdateCheckResult { + return { currentVersion: '1.0.0', latestVersion: version, status: 'update_available', releaseUrl: `https://github.com/useLexora/Lexora/releases/tag/v${version}`, releaseNotes: '- Restore tasks safely' } +} + +async function fixture(options: Partial = {}, initial = emptyUpdateRecord()) { + let saved = structuredClone(initial) + const environment = { focused: true, idle: true, latest: '1.1.0' } + const store = { + read: async () => structuredClone(saved), + write: async (record: DesktopUpdateRecord) => { + saved = structuredClone(record) + }, + } + const errors: string[] = [] + const input: DesktopUpdatesOptions = { + currentVersion: '1.0.0', + automatic: true, + enabled: true, + store, + check: async () => release(environment.latest), + canPresent: () => environment.focused, + isIdle: async () => environment.idle, + reportFailure: () => errors.push('background'), + ...options, + } + const service = new DesktopUpdates(input) + services.push(service) + await service.start() + service.setReady(true) + return { service, input, environment, errors, saved: () => saved } +} + +async function discover(f: Awaited>) { + await vi.advanceTimersByTimeAsync(UPDATE_STARTUP_DELAY) + expect(f.service.state.result?.status).toBe('update_available') +} + +describe('desktop update ownership and notification policy', () => { + it('delays startup checks, persists the daily limit, and coalesces repeated manual requests', async () => { + const f = await fixture() + await vi.advanceTimersByTimeAsync(UPDATE_STARTUP_DELAY - 1) + expect(f.service.state.result).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(f.service.state.notification?.attention).toBe('unseen') + const checkedAt = f.saved().lastCheckedAt + await f.service.dispose() + const restored = await fixture({}, f.saved()) + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_INTERVAL - 1) + expect(restored.saved().lastCheckedAt).toBe(checkedAt) + await vi.advanceTimersByTimeAsync(1) + expect(restored.saved().lastCheckedAt).toBe(Date.now()) + const first = restored.service.check() + expect(restored.service.check()).toBe(first) + await first + expect(restored.service.state.notification?.attention).toBe('seen') + expect(restored.service.state.reminderDueAt).toBeNull() + }) + + it('defers to foreground idle time and only consumes a version after display is acknowledged', async () => { + const f = await fixture() + await discover(f) + f.environment.focused = false + expect(await f.service.takeReminder()).toBeNull() + f.environment.focused = true + f.environment.idle = false + expect(await f.service.takeReminder()).toBeNull() + expect(f.saved().notifiedVersion).toBeNull() + f.environment.idle = true + expect((await f.service.takeReminder())?.latestVersion).toBe('1.1.0') + expect(f.saved()).toMatchObject({ notifiedVersion: null, lastNotifiedAt: null }) + expect((await f.service.takeReminder())?.latestVersion).toBe('1.1.0') + f.environment.focused = false + await f.service.acknowledge({ action: 'reminded', version: '1.1.0' }) + f.environment.focused = true + const notifiedAt = f.saved().lastNotifiedAt + await vi.advanceTimersByTimeAsync(1000) + await f.service.acknowledge({ action: 'reminded', version: '1.1.0' }) + expect(f.saved().lastNotifiedAt).toBe(notifiedAt) + expect(f.service.state.notification?.attention).toBe('unseen') + expect(await f.service.takeReminder()).toBeNull() + const restored = await fixture({}, f.saved()) + expect(await restored.service.takeReminder()).toBeNull() + expect(restored.service.state.notification?.payload.version).toBe('1.1.0') + }) + + it('revalidates focus and opt-out after an asynchronous idle query', async () => { + const idle = deferred() + const f = await fixture({ isIdle: () => idle.promise }) + await discover(f) + const claiming = f.service.takeReminder() + await vi.advanceTimersByTimeAsync(0) + f.environment.focused = false + f.service.setEnabled(false) + idle.resolve(true) + expect(await claiming).toBeNull() + expect(f.saved().notifiedVersion).toBeNull() + expect(f.service.state.notification).toBeNull() + }) + + it('keeps only the latest notification and respects the cross-version reminder cooldown', async () => { + const f = await fixture() + await discover(f) + await f.service.takeReminder() + await f.service.acknowledge({ action: 'reminded', version: '1.1.0' }) + const notifiedAt = Date.now() + f.environment.latest = '1.2.0' + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_INTERVAL) + expect(f.service.state.notification?.payload.version).toBe('1.2.0') + await f.service.acknowledge({ action: 'reminded', version: '1.1.0' }) + expect(f.saved().notifiedVersion).toBe('1.1.0') + expect(f.saved().lastNotifiedAt).toBe(notifiedAt) + expect(f.service.state.reminderDueAt).toBe(notifiedAt + UPDATE_REMINDER_INTERVAL) + expect(await f.service.takeReminder()).toBeNull() + await vi.advanceTimersByTimeAsync(UPDATE_REMINDER_INTERVAL - UPDATE_CHECK_INTERVAL) + expect((await f.service.takeReminder())?.latestVersion).toBe('1.2.0') + }) + + it('persists skip and read decisions, ignores stale actions, and permits explicit checks of skipped versions', async () => { + const f = await fixture() + await discover(f) + await f.service.acknowledge({ action: 'ignore', version: '1.1.0' }) + const restored = await fixture({}, f.saved()) + expect(restored.service.state.notification).toBeNull() + expect((await restored.service.check()).latestVersion).toBe('1.1.0') + expect(restored.service.state.notification).toBeNull() + restored.environment.latest = '1.2.0' + await restored.service.check(false) + await restored.service.acknowledge({ action: 'ignore', version: '1.1.0' }) + expect(restored.service.state.notification?.attention).toBe('unseen') + await restored.service.acknowledge({ action: 'seen', version: '1.2.0' }) + expect(restored.service.state.notification?.attention).toBe('seen') + expect(restored.service.state.reminderDueAt).toBeNull() + const read = await fixture({}, restored.saved()) + expect(read.service.state.notification?.attention).toBe('seen') + }) + + it('cancels background work on opt-out and fences a late response even after re-enabling', async () => { + const response = deferred() + const f = await fixture({ check: () => response.promise }) + const checking = f.service.check(false) + const rejected = expect(checking).rejects.toThrow() + await vi.advanceTimersByTimeAsync(0) + f.service.setEnabled(false) + f.service.setEnabled(true) + response.resolve(release()) + await rejected + expect(f.service.state.result).toBeNull() + expect(f.service.state.notification).toBeNull() + expect(f.saved().result).toBeNull() + }) + + it('keeps manual checking available while disabled without reminders or automatic network work', async () => { + const f = await fixture({ enabled: false }) + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_INTERVAL * 2) + expect(f.saved().lastCheckedAt).toBeNull() + expect((await f.service.check()).latestVersion).toBe('1.1.0') + expect(f.service.state.notification).toBeNull() + f.service.setEnabled(true) + expect(f.service.state.reminderDueAt).toBeNull() + }) + + it('does not auto-check development builds and clears obsolete availability after upgrading', async () => { + const f = await fixture({ automatic: false }) + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_INTERVAL * 2) + expect(f.service.state.result).toBeNull() + await f.service.check(false) + const upgraded = await fixture({ currentVersion: '1.1.0', automatic: false }, f.saved()) + expect(upgraded.service.state.result).toMatchObject({ currentVersion: '1.1.0', status: 'up_to_date' }) + expect(upgraded.service.state.notification).toBeNull() + }) + + it('keeps valid cached results on network failure and never converts failure into up-to-date', async () => { + const f = await fixture({ + check: async () => { + throw new Error('offline') + }, + }) + await vi.advanceTimersByTimeAsync(UPDATE_STARTUP_DELAY) + expect(f.service.state).toMatchObject({ checking: false, result: null, notification: null }) + expect(f.errors).toEqual(['background']) + expect(f.saved().lastCheckedAt).toBe(Date.now()) + await expect(f.service.check()).rejects.toThrow('offline') + expect(f.service.state.result).toBeNull() + }) + + it('does not mark a reminder delivered when persistence fails', async () => { + const initial = { ...emptyUpdateRecord(), result: release(), discoveredAt: Date.now() } + const f = await fixture({ + automatic: false, + store: { + read: async () => initial, + write: async () => { + throw new Error('disk unavailable') + }, + }, + }) + expect((await f.service.takeReminder())?.latestVersion).toBe('1.1.0') + await expect(f.service.acknowledge({ action: 'reminded', version: '1.1.0' })).rejects.toThrow('disk unavailable') + expect(f.service.state.reminderDueAt).not.toBeNull() + expect(f.service.state.notification?.attention).toBe('unseen') + }) +}) diff --git a/apps/buddy/electron/main/updates/registerDesktopUpdates.ts b/apps/buddy/electron/main/updates/registerDesktopUpdates.ts new file mode 100644 index 00000000..534c3c90 --- /dev/null +++ b/apps/buddy/electron/main/updates/registerDesktopUpdates.ts @@ -0,0 +1,78 @@ +import type { DesktopRuntimeHost } from '../app/DesktopRuntimeHost' +import type { DesktopWindowHost } from '../app/DesktopWindowHost' +import type { DesktopEnvironment } from '../app/typing' +import { app, ipcMain } from 'electron' +import { runsRpc } from '../../../shared/runs/runApi' +import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' +import { desktopUpdateActionSchema } from '../../shared/desktopUpdates' +import { checkForDesktopUpdate } from '../desktopUpdateService' +import { assertTrustedSender } from '../ipc' +import { DesktopUpdates } from './DesktopUpdates' +import { DesktopUpdateStore } from './DesktopUpdateStore' + +export function registerDesktopUpdates(environment: DesktopEnvironment, runtime: DesktopRuntimeHost, windows: DesktopWindowHost) { + let activityRevision = 0 + const service = runtime.service + const updates = new DesktopUpdates({ + currentVersion: app.getVersion(), + automatic: app.isPackaged && environment.paths.profile !== 'development' && !environment.isSmokeTest, + enabled: runtime.config?.desktop.updateNotificationsEnabled ?? true, + store: new DesktopUpdateStore(environment.paths.buddyHome), + check: signal => checkForDesktopUpdate({ currentVersion: app.getVersion(), fetchRelease: runtime.network.get, signal }), + canPresent: () => environment.startup.state.status === 'ready' && !!windows.window?.isFocused() && windows.window.isVisible(), + isIdle: async () => { + if (service.state.status !== 'ready') + return false + const revision = activityRevision + const busy = runsRpc.isBusy.response.parse(await service.request(runsRpc.isBusy.method, {}, { timeoutMs: 5_000 })) + return !busy && service.state.status === 'ready' && revision === activityRevision + }, + reportFailure: () => environment.events.publish({ component: 'desktop.updates', event: 'update.background.failed', level: 'warn', errorCode: 'UPDATE_CHECK_FAILED' }), + }) + const changes = updates.onDidChange((state) => { + const window = windows.window + if (window && !window.isDestroyed()) + window.webContents.send(DESKTOP_IPC_CHANNELS.appUpdatesChanged, state) + }) + const config = runtime.configStore.onDidChange((change) => { + if (change.kind === 'committed') + updates.setEnabled(change.config.desktop.updateNotificationsEnabled) + }) + const stopRuntime = service.onStateChange(() => { + activityRevision++ + }) + const stopEvents = service.onNotification(({ method }) => { + if (method === 'run.event') + activityRevision++ + }) + const stopStartup = environment.startup.onStateChange(state => updates.setReady(state.status === 'ready')) + updates.setReady(environment.startup.state.status === 'ready') + const channels = [DESKTOP_IPC_CHANNELS.appUpdatesState, DESKTOP_IPC_CHANNELS.appUpdatesAcknowledge, DESKTOP_IPC_CHANNELS.appUpdatesTakeReminder] + ipcMain.handle(channels[0]!, async (event) => { + assertTrustedSender(event, windows.window) + await updates.start() + return updates.state + }) + ipcMain.handle(channels[1]!, (event, input: unknown) => { + assertTrustedSender(event, windows.window) + return updates.acknowledge(desktopUpdateActionSchema.parse(input)) + }) + ipcMain.handle(channels[2]!, (event) => { + assertTrustedSender(event, windows.window) + return updates.takeReminder() + }) + void updates.start().catch(() => environment.events.publish({ component: 'desktop.updates', event: 'update.restore.failed', level: 'warn', errorCode: 'UPDATE_STATE_UNAVAILABLE' })) + return { + check: () => updates.check(), + async dispose() { + stopStartup() + stopEvents() + stopRuntime() + config.dispose() + changes.dispose() + for (const channel of channels) + ipcMain.removeHandler(channel) + await updates.dispose() + }, + } +} diff --git a/apps/buddy/electron/preload/desktop.ts b/apps/buddy/electron/preload/desktop.ts index 2c20405e..794aff95 100644 --- a/apps/buddy/electron/preload/desktop.ts +++ b/apps/buddy/electron/preload/desktop.ts @@ -6,6 +6,7 @@ import type { RendererDiagnosticReport } from '../../shared/diagnostics/renderer import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' import type { DesktopAppInfo, DesktopOpenTarget, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi' import type { DesktopCommandId } from '../shared/desktopCommands' +import type { DesktopUpdateAction, DesktopUpdateState } from '../shared/desktopUpdates' import { ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC_CHANNELS } from '../shared/desktopApi' import { subscribe } from './subscribe' @@ -43,6 +44,12 @@ export function createDesktopApi(): Pick void) => subscribe(DESKTOP_IPC_CHANNELS.appStartupStateChanged, listener), reportLifecycle: (report: RendererLifecycleReport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appStartupReport, report), }), + updates: Object.freeze({ + getState: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appUpdatesState), + onChanged: (listener: (state: DesktopUpdateState) => void) => subscribe(DESKTOP_IPC_CHANNELS.appUpdatesChanged, listener), + acknowledge: (input: DesktopUpdateAction) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appUpdatesAcknowledge, { version: input.version, action: input.action }), + takeReminder: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appUpdatesTakeReminder), + }), checkForUpdates: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appCheckForUpdates), getInfo: (): Promise => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appGetInfo), getSandboxStatus: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appGetSandboxStatus), diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index 29c2addf..f9e117f4 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -8,9 +8,12 @@ import type { SandboxEnvironmentStatus, SandboxSetupResult } from '../../shared/ import type { BuddyCapabilities } from '../../shared/platform' import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' import type { DesktopCommandId, DesktopPlatform } from './desktopCommands' +import type { DesktopUpdateApi, DesktopUpdateCheckResult } from './desktopUpdates' import type { LocalChatApi } from './localChatApi' import { BUDDY_DEFAULT_PERMISSION_MODE } from '../../shared/permissions/permissionMode' +export { DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from '../../shared/browser/browserDesktopApi' + export const DESKTOP_IPC_CHANNELS = { workbenchRead: 'lexora:workbench:read', workbenchWrite: 'lexora:workbench:write', @@ -26,6 +29,10 @@ export const DESKTOP_IPC_CHANNELS = { appStartupReport: 'lexora:app:startup:report', appStartupStateChanged: 'lexora:app:startup:state-changed', appCheckForUpdates: 'lexora:app:check-for-updates', + appUpdatesState: 'lexora:app:updates:state', + appUpdatesChanged: 'lexora:app:updates:changed', + appUpdatesAcknowledge: 'lexora:app:updates:acknowledge', + appUpdatesTakeReminder: 'lexora:app:updates:take-reminder', appGetInfo: 'lexora:app:get-info', appGetSandboxStatus: 'lexora:app:get-sandbox-status', appSetupSandbox: 'lexora:app:setup-sandbox', @@ -67,13 +74,6 @@ export const DESKTOP_IPC_CHANNELS = { windowToggleMaximize: 'lexora:window:toggle-maximize', } as const -export interface DesktopUpdateCheckResult { - currentVersion: string - latestVersion: string - releaseUrl: string - status: 'up_to_date' | 'update_available' -} - export interface DesktopOpenTarget { conversationId: string runId: string @@ -178,6 +178,7 @@ export interface LexoraConfig { language: 'zh-CN' | 'en-US' launchAtLogin: boolean notificationsEnabled: boolean + updateNotificationsEnabled: boolean notifyWhenFocused: boolean profile: DesktopUserProfileConfig pluginAuthor: string @@ -215,6 +216,7 @@ export interface LexoraDesktopApi { onStateChanged: (listener: (state: ApplicationStartupState) => void) => () => void reportLifecycle: (report: RendererLifecycleReport) => Promise } + updates: DesktopUpdateApi checkForUpdates: () => Promise getInfo: () => Promise getSandboxStatus: () => Promise @@ -247,6 +249,6 @@ export interface LexoraDesktopApi { localChat: LocalChatApi } -export { DESKTOP_BROWSER_ERROR_CODES, DESKTOP_BROWSER_PROFILE_MODES, DESKTOP_BROWSER_SECURITY_KINDS } from '../../shared/browser/browserDesktopApi' export type { DesktopBrowserApi, DesktopBrowserAttachGuestInput, DesktopBrowserEnsureSessionInput, DesktopBrowserError, DesktopBrowserErrorCode, DesktopBrowserGuestDescriptor, DesktopBrowserNavigateInput, DesktopBrowserOpenArtifactInput, DesktopBrowserProfileMode, DesktopBrowserSecurityKind, DesktopBrowserSecurityState, DesktopBrowserSessionInput, DesktopBrowserSetProfileModeInput, DesktopBrowserSetSurfaceInput, DesktopBrowserState, DesktopBrowserStatus } from '../../shared/browser/browserDesktopApi' export type { DesktopCommandId, DesktopPlatform } from './desktopCommands' +export type { DesktopUpdateCheckResult } from './desktopUpdates' diff --git a/apps/buddy/electron/shared/desktopApiSchemas.ts b/apps/buddy/electron/shared/desktopApiSchemas.ts index 25556ca8..abc356e8 100644 --- a/apps/buddy/electron/shared/desktopApiSchemas.ts +++ b/apps/buddy/electron/shared/desktopApiSchemas.ts @@ -80,6 +80,7 @@ export const lexoraConfigPatchSchema: z.ZodType = z.object({ language: z.enum(['zh-CN', 'en-US']).optional(), launchAtLogin: z.boolean().optional(), notificationsEnabled: z.boolean().optional(), + updateNotificationsEnabled: z.boolean().optional(), notifyWhenFocused: z.boolean().optional(), sidebarCollapsed: z.boolean().optional(), theme: z.enum(['system', 'light', 'dark']).optional(), diff --git a/apps/buddy/electron/shared/desktopUpdates.ts b/apps/buddy/electron/shared/desktopUpdates.ts new file mode 100644 index 00000000..1a459cb4 --- /dev/null +++ b/apps/buddy/electron/shared/desktopUpdates.ts @@ -0,0 +1,61 @@ +import type { DeepReadonly } from '../../shared/runtime/apiValidation' +import { z } from 'zod' +import { isLexoraReleaseUrl } from './productLinks' + +export const desktopVersionSchema = z.string().max(32).regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/) + +export const desktopUpdateResultSchema = z.object({ + currentVersion: desktopVersionSchema, + latestVersion: desktopVersionSchema, + releaseUrl: z.url().refine(isLexoraReleaseUrl), + releaseNotes: z.string().max(8_000), + status: z.enum(['up_to_date', 'update_available']), +}).strict() + +export type DesktopUpdateCheckResult = DeepReadonly> + +export interface DesktopUpdateNotification { + readonly id: 'desktop.update' + readonly revision: string + readonly kind: 'app.update-available' + readonly origin: 'desktop' + readonly attention: 'seen' | 'unseen' + readonly occurredAt: string + readonly action: { readonly type: 'open-app-update' } + readonly payload: { readonly version: string } +} + +export interface DesktopUpdateState { + readonly revision: number + readonly checking: boolean + readonly enabled: boolean + readonly result: DesktopUpdateCheckResult | null + readonly notification: DesktopUpdateNotification | null + readonly reminderDueAt: number | null +} + +export const desktopUpdateActionSchema = z.object({ + version: desktopVersionSchema, + action: z.enum(['seen', 'ignore', 'reminded']), +}).strict() + +export type DesktopUpdateAction = z.infer + +export interface DesktopUpdateApi { + getState: () => Promise + onChanged: (listener: (state: DesktopUpdateState) => void) => () => void + acknowledge: (input: DesktopUpdateAction) => Promise + takeReminder: () => Promise +} + +export function compareDesktopVersions(left: string, right: string): number { + const a = left.split('.').map(BigInt) + const b = right.split('.').map(BigInt) + for (let index = 0; index < 3; index++) { + if (a[index]! > b[index]!) + return 1 + if (a[index]! < b[index]!) + return -1 + } + return 0 +} diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 909098a3..5537b9f9 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -1199,6 +1199,7 @@ export async function startBuddyService( ) register( registerRunRpc({ + isBusy: () => runner.hasActiveExecutions || runs.hasIncomplete() || approvalsRepository.list({ status: 'pending', limit: 1 }).length > 0, getCacheWarmingStatus: (conversationId) => { const branchId = conversations.findById(conversationId)?.activeBranchId return branchId ? sessions.getReady(conversationId, branchId)?.getCacheWarmingStatus?.() ?? null : null diff --git a/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts b/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts index 51d3d97d..c75c5ba3 100644 --- a/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts +++ b/apps/buddy/service/src/agent/execution/ActiveRunRegistry.ts @@ -70,6 +70,10 @@ export class ActiveRunRegistry { return [...this.#executions.values()].some(execution => execution.state.identity.conversationId === conversationId) } + get hasActiveExecutions(): boolean { + return this.#executions.size > 0 + } + get isStopping(): boolean { return this.#disposed } diff --git a/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts b/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts index 94ffb1d6..b291de9b 100644 --- a/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts +++ b/apps/buddy/service/src/agent/execution/BuddyAgentRunner.ts @@ -104,6 +104,10 @@ export class BuddyAgentRunner { return this.#activeRuns.hasActiveExecution(conversationId) } + get hasActiveExecutions(): boolean { + return this.#activeRuns.hasActiveExecutions + } + get isStopping(): boolean { return this.#activeRuns.isStopping } diff --git a/apps/buddy/service/src/runs/registerRunRpc.ts b/apps/buddy/service/src/runs/registerRunRpc.ts index 869638ff..561d5b1d 100644 --- a/apps/buddy/service/src/runs/registerRunRpc.ts +++ b/apps/buddy/service/src/runs/registerRunRpc.ts @@ -13,6 +13,7 @@ import { ConversationStatusService } from './ConversationStatusService' import { toPublicRun } from './publicRun' export interface RegisterRunRpcOptions { + isBusy: () => boolean getCacheWarmingStatus?: ConversationStatusOptions['getCacheWarmingStatus'] eventLog: Pick inputs: Pick @@ -36,6 +37,7 @@ export function registerRunRpc(options: RegisterRunRpcOptions): () => void { usage: options.usage, }) const disposers = [ + registerRuntimeRequest(options.rpc, runsRpc.isBusy, () => options.isBusy()), registerRuntimeRequest(options.rpc, runsStatusRpc.status, input => status.status(input.conversationId)), registerRuntimeRequest(options.rpc, runsRpc.list, (input) => { const records = input.conversationId diff --git a/apps/buddy/service/src/storage/runRepository.ts b/apps/buddy/service/src/storage/runRepository.ts index cf67a75e..d19779ce 100644 --- a/apps/buddy/service/src/storage/runRepository.ts +++ b/apps/buddy/service/src/storage/runRepository.ts @@ -25,6 +25,7 @@ export interface RunRepository { create: (input: CreateRunInput) => RunRecord findById: (id: string) => RunRecord | null findLatestForBranch: (conversationId: string, branchId: string) => RunRecord | null + hasIncomplete: () => boolean listIncomplete: () => RunRecord[] listRecent: (limit?: number) => RunRecord[] listForConversation: (conversationId: string, limit?: number) => RunRecord[] @@ -164,6 +165,9 @@ export function createRunRepository(database: DatabaseSync): RunRepository { listIdsForConversation(conversationId) { return (listIds.all(conversationId) as unknown as Array<{ id: string }>).map(row => row.id) }, + hasIncomplete() { + return !!database.prepare('SELECT 1 FROM runs WHERE status IN (\'queued\', \'running\') LIMIT 1').get() + }, listIncomplete() { return (listIncomplete.all() as unknown as RunRow[]).map(toRunRecord) }, diff --git a/apps/buddy/shared/runs/runApi.ts b/apps/buddy/shared/runs/runApi.ts index bfdf6519..3903f900 100644 --- a/apps/buddy/shared/runs/runApi.ts +++ b/apps/buddy/shared/runs/runApi.ts @@ -74,6 +74,7 @@ export const runsResponseSchemas = { } as const export const runsRpc = { + isBusy: { method: 'runs.isBusy', input: z.object({}).strict(), response: z.boolean() }, list: { method: 'runs.list', input: z.object({ conversationId: idSchema.nullable().optional(), limit: optionalLimitSchema }).strict(), response: runsResponseSchemas.runs }, get: { method: 'runs.get', input: runsRequestSchemas.runId, response: runsResponseSchemas.run }, listEvents: { method: 'runs.listEvents', input: runsRequestSchemas.runEvents, response: z.array(runsRequestSchemas.runStateEvent) }, diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 02d9f7eb..14624c75 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -12,6 +12,8 @@ import { DesktopExtensionControl, DesktopExtensionFrameHost, DesktopExtensionMen import { usePluginSettings, useProvideSettingsContext, useSettingsRegistry } from '@/modules/settings' import { useProvideSkillsContext } from '@/modules/skills' import { useProvideTaskEnvironment, useTaskIndex, useTaskResourcePanel } from '@/modules/tasks' +import { useProvideDesktopUpdates } from '@/modules/updates' +import { DesktopUpdateHost } from '@/modules/updates/ui' import DesktopBrowserGuestHost from '@/platform/browser/DesktopBrowserGuestHost.vue' import { useBrowserGuestHost } from '@/platform/browser/useBrowserGuestHost' import { requireDesktopApi } from '@/platform/desktop/desktopApi' @@ -47,6 +49,7 @@ const router = useRouter() const message = useMessage() const appState = useDesktopAppState({ api }) const { stores } = appState +useProvideDesktopUpdates(stores.updates) const taskIndex = useTaskIndex({ api: api.localChat, applicationSettings: stores.applicationSettings, @@ -187,6 +190,7 @@ const navigation = useDesktopNavigation({ startTask: spaceId => workbench.newTask(spaceId), }, notifications: stores.notifications, + openUpdate: stores.updates.openDetails, getRun: api.localChat.runs.get, activateRunBranch: async (run) => { const task = selectedTask.value @@ -295,5 +299,6 @@ watch(() => stores.applicationSettings.config.value?.desktop.theme, (theme) => { + diff --git a/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts b/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts index 6a09fbb4..1fad05c6 100644 --- a/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts +++ b/apps/buddy/src/app/bootstrap/__tests__/useDesktopAppState.spec.ts @@ -17,6 +17,7 @@ describe('useDesktopAppState', () => { providers: { list: async () => [], listModels: async () => [], getDefaultModel: async () => null, onChanged: () => () => {}, onAuthChallenge: () => () => {} }, runtime: { getStatus: () => status.promise, onStateChanged: () => () => {} }, }, + app: emptyUpdateApp(), settings: { get: async () => { throw new Error('unavailable') } }, } as unknown as LexoraDesktopApi const appState = useDesktopAppState({ api }) @@ -50,6 +51,7 @@ describe('useDesktopAppState', () => { }, }, }, + app: emptyUpdateApp(), settings: { update: async () => ({ desktop: { @@ -105,6 +107,7 @@ describe('useDesktopAppState', () => { }, }, }, + app: emptyUpdateApp(), settings: { get: () => Promise.reject(new Error('settings unavailable')), }, @@ -133,6 +136,10 @@ describe('useDesktopAppState', () => { }) }) +function emptyUpdateApp() { + return { updates: { onChanged: () => () => {}, getState: async () => ({ revision: 0, enabled: true, checking: false, result: null, notification: null, reminderDueAt: null }) } } +} + function emptyNotificationApi() { return { onChanged: () => () => {}, diff --git a/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts b/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts index 8cbbb7d0..6aa931fe 100644 --- a/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts +++ b/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts @@ -1,4 +1,5 @@ import type { LocalNotification } from '@buddy-shared/notifications/notificationApi' +import type { DesktopNotification } from '@/modules/notifications/contracts' import { deferred } from '@buddy-tests/deferred' import { afterEach, describe, expect, it, vi } from 'vitest' import { effectScope, nextTick, shallowRef } from 'vue' @@ -24,7 +25,7 @@ async function fixture(ready = Promise.resolve()) { activeTaskId.value = id spaceId.value = space } - const markSeen = vi.fn(async (_value: LocalNotification) => true) + const markSeen = vi.fn(async (_value: DesktopNotification) => true) const getRun = vi.fn(async (id: string) => ({ conversationId: id, branchId: `branch-${id}`, triggeringMessageId: `message-${id}` })) const activeBranchId = shallowRef(null) const activateRunBranch = vi.fn(async (run: { branchId: string }) => { @@ -44,6 +45,7 @@ async function fixture(ready = Promise.resolve()) { getRun, activateRunBranch, notifications: { markSeen }, + openUpdate: async () => {}, onError: error => errors.push(error), session: { activeTaskId, spaceId, navigationVersion: () => version, openTask, startTask: async id => select(null, id) }, }))! diff --git a/apps/buddy/src/app/bootstrap/useDesktopAppState.ts b/apps/buddy/src/app/bootstrap/useDesktopAppState.ts index 49fad0da..14919b62 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopAppState.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopAppState.ts @@ -3,6 +3,7 @@ import type { ServiceHost } from '@buddy-shared/lifecycle/ServiceHost' import { useModelProvidersStore } from '@/modules/models' import { useNotificationCenterStore } from '@/modules/notifications' import { useApplicationSettingsStore, useUsageStore } from '@/modules/settings' +import { useDesktopUpdates } from '@/modules/updates' import { useRuntimeSupervisorStore } from '@/platform/runtime/useRuntimeSupervisorStore' import { requireInitialState } from './requireInitialState' @@ -12,7 +13,8 @@ export interface UseDesktopAppStateOptions { export function useDesktopAppState(options: UseDesktopAppStateOptions) { const applicationSettings = useApplicationSettingsStore(options.api.settings) - const notifications = useNotificationCenterStore(options.api.localChat) + const updates = useDesktopUpdates(options.api.app) + const notifications = useNotificationCenterStore(options.api.localChat, updates) const modelProviders = useModelProvidersStore({ api: options.api.localChat.providers, language: applicationSettings.language, @@ -30,6 +32,7 @@ export function useDesktopAppState(options: UseDesktopAppStateOptions) { modelProviders, notifications, runtimeSupervisor, + updates, usage, } as const @@ -47,6 +50,7 @@ export function useDesktopAppState(options: UseDesktopAppStateOptions) { } async function loadInitialState(): Promise { + void updates.load().catch(() => {}) const results = await Promise.allSettled([ applicationSettings.load(), runtimeSupervisor.loadStatus(), @@ -71,6 +75,7 @@ export function useDesktopAppState(options: UseDesktopAppStateOptions) { applicationSettings.dispose() modelProviders.dispose() notifications.dispose() + updates.dispose() runtimeSupervisor.dispose() } diff --git a/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts b/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts index 8e3e4ca4..e83d29cd 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts @@ -1,8 +1,8 @@ import type { DesktopOpenTarget } from '@buddy-electron/shared/desktopApi' -import type { LocalNotification } from '@buddy-shared/notifications/notificationApi' import type { LocalRun } from '@buddy-shared/runs/runApi' import type { RouteLocationRaw, Router } from 'vue-router' import type { NotificationCenterStore } from '@/modules/notifications' +import type { DesktopNotification } from '@/modules/notifications/contracts' import type { TaskSession } from '@/modules/tasks/contracts' import { useTimeoutFn } from '@vueuse/core' import { onScopeDispose, readonly, shallowRef, watch } from 'vue' @@ -13,6 +13,7 @@ interface DesktopNavigationOptions { ready: Promise session: Pick notifications: Pick + openUpdate: () => Promise getRun: (runId: string) => Promise activateRunBranch: (run: RunTarget) => Promise onError: (error: unknown) => void @@ -99,7 +100,16 @@ export function useDesktopNavigation(options: DesktopNavigationOptions) { } } - function openNotification(notification: LocalNotification) { + async function openNotification(notification: DesktopNotification) { + if (notification.action.type === 'open-app-update') { + try { + await options.openUpdate() + } + catch (error) { + options.onError(error) + } + return + } void options.notifications.markSeen(notification) return notification.action.type === 'open-model-settings' ? navigate(desktopRouteLocations.settings('models')) diff --git a/apps/buddy/src/app/shell/DesktopAppSidebar.vue b/apps/buddy/src/app/shell/DesktopAppSidebar.vue index 24f324e6..aeef0f02 100644 --- a/apps/buddy/src/app/shell/DesktopAppSidebar.vue +++ b/apps/buddy/src/app/shell/DesktopAppSidebar.vue @@ -1,7 +1,7 @@ - - - - diff --git a/apps/buddy/src/i18n/locales/en-US/app.ts b/apps/buddy/src/i18n/locales/en-US/app.ts index d0be974f..f15d957a 100644 --- a/apps/buddy/src/i18n/locales/en-US/app.ts +++ b/apps/buddy/src/i18n/locales/en-US/app.ts @@ -68,6 +68,15 @@ export default { 'desktop.update.latestVersion': 'Latest version', 'desktop.update.openRelease': 'View release', 'desktop.update.title': 'Check for updates', + 'desktop.update.view': 'View update', + 'desktop.update.ignore': 'Skip this version', + 'desktop.update.download': 'Go to download', + 'desktop.update.highlights': 'Release notes excerpt', + 'desktop.update.notesUnavailable': 'No update summary is available. See the release page for details.', + 'desktop.update.notifications': 'Update notifications', + 'desktop.update.notificationsDescription': 'Check for new versions automatically and notify you when tasks are idle. Turning this off stops automatic checks; manual checks remain available.', + 'desktop.update.notificationTitle': 'Lexora {version} is available', + 'desktop.update.notificationDescription': 'Review what changed and choose when to download and install.', 'desktop.navigation.agent': 'Agent', 'desktop.navigation.account': 'Account', 'desktop.navigation.tasks': 'Tasks', @@ -86,6 +95,7 @@ export default { 'desktop.notifications.open': 'Open notifications', 'desktop.notifications.origin.account': 'Account', 'desktop.notifications.origin.local-runtime': 'This device', + 'desktop.notifications.origin.desktop': 'App update', 'desktop.notifications.origin.platform-public': 'Platform', 'desktop.notifications.title': 'Notifications', 'desktop.notifications.unseenEmptyTitle': 'No unread notifications', diff --git a/apps/buddy/src/i18n/locales/zh-CN/app.ts b/apps/buddy/src/i18n/locales/zh-CN/app.ts index 8bb75679..7acec7f0 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/app.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/app.ts @@ -66,6 +66,15 @@ export default { 'desktop.update.latestVersion': '最新版本', 'desktop.update.openRelease': '查看发布页', 'desktop.update.title': '检查更新', + 'desktop.update.view': '查看更新', + 'desktop.update.ignore': '忽略此版本', + 'desktop.update.download': '前往下载', + 'desktop.update.highlights': '发布说明摘录', + 'desktop.update.notesUnavailable': '此版本暂无更新摘要,可在发布页查看详情。', + 'desktop.update.notifications': '更新通知', + 'desktop.update.notificationsDescription': '自动检查新版本,在任务空闲时提醒。关闭后停止自动检查,仍可手动检查更新。', + 'desktop.update.notificationTitle': 'Lexora {version} 可更新', + 'desktop.update.notificationDescription': '查看本次更新内容,自行选择合适的时间下载安装。', 'desktop.navigation.agent': 'Agent', 'desktop.navigation.account': '用户', 'desktop.navigation.tasks': '任务', @@ -84,6 +93,7 @@ export default { 'desktop.notifications.open': '打开通知', 'desktop.notifications.origin.account': '账户', 'desktop.notifications.origin.local-runtime': '本机', + 'desktop.notifications.origin.desktop': '应用更新', 'desktop.notifications.origin.platform-public': '平台', 'desktop.notifications.title': '通知', 'desktop.notifications.unseenEmptyTitle': '暂无未读通知', diff --git a/apps/buddy/src/modules/notifications/contracts.ts b/apps/buddy/src/modules/notifications/contracts.ts new file mode 100644 index 00000000..8c36c379 --- /dev/null +++ b/apps/buddy/src/modules/notifications/contracts.ts @@ -0,0 +1,4 @@ +import type { DesktopUpdateNotification } from '@buddy-electron/shared/desktopUpdates' +import type { LocalNotification } from '@buddy-shared/notifications/notificationApi' + +export type DesktopNotification = LocalNotification | DesktopUpdateNotification diff --git a/apps/buddy/src/modules/notifications/state/__tests__/useNotificationCenterStore.spec.ts b/apps/buddy/src/modules/notifications/state/__tests__/useNotificationCenterStore.spec.ts index 188f3c80..6d1e9c89 100644 --- a/apps/buddy/src/modules/notifications/state/__tests__/useNotificationCenterStore.spec.ts +++ b/apps/buddy/src/modules/notifications/state/__tests__/useNotificationCenterStore.spec.ts @@ -1,6 +1,8 @@ +import type { DesktopUpdateState } from '@buddy-electron/shared/desktopUpdates' import type { LocalNotification, LocalNotificationList } from '@buddy-shared/notifications/notificationApi' import { deferred } from '@buddy-tests/deferred' import { describe, expect, it, vi } from 'vitest' +import { shallowRef } from 'vue' import { useNotificationCenterStore } from '../useNotificationCenterStore' function list(seen = false): LocalNotificationList { @@ -28,11 +30,70 @@ function fixture() { chat: { onRunEvent: subscribe }, automations: { onChanged: subscribe }, } - const store = useNotificationCenterStore(api) - return { api, store, changed: () => changed({ revision: 1 }), listeners: () => listeners } + const updates = { state: shallowRef(null), markSeen: vi.fn(async (_version: string) => {}) } + const store = useNotificationCenterStore(api, updates) + return { api, store, updates, changed: () => changed({ revision: 1 }), listeners: () => listeners } } describe('notification state ownership', () => { + it('preserves unread and active priority even without an update notification', async () => { + const f = fixture() + const item = list().items[0]! + const items: LocalNotification[] = [ + { ...item, id: 'unread-active' }, + { ...item, id: 'unread-resolved', lifecycle: 'resolved', occurredAt: '2026-09-10T00:00:00.000Z', resolvedAt: '2026-09-10T00:00:00.000Z' }, + { ...item, id: 'seen-active', attention: 'seen' }, + { ...item, id: 'seen-resolved', attention: 'seen', lifecycle: 'resolved', occurredAt: '2026-09-11T00:00:00.000Z', resolvedAt: '2026-09-11T00:00:00.000Z' }, + ] + f.api.notifications.list.mockResolvedValue({ items, unseenCount: 2 }) + await f.store.load() + expect(f.store.items.value).toEqual(items) + expect(f.store.unseenCount.value).toBe(2) + f.store.dispose() + }) + + it('composes independently owned update notifications, marks both sources seen, and removes opt-out attention', async () => { + const f = fixture() + await f.store.load() + f.updates.state.value = { + revision: 1, + enabled: true, + checking: false, + result: null, + reminderDueAt: 0, + notification: { + id: 'desktop.update', + revision: '1.1.0', + kind: 'app.update-available', + origin: 'desktop', + attention: 'unseen', + occurredAt: '2026-09-09T00:00:00.000Z', + action: { type: 'open-app-update' }, + payload: { version: '1.1.0' }, + }, + } + f.updates.markSeen.mockImplementation(async (version) => { + const state = f.updates.state.value! + if (state.notification?.revision === version) + f.updates.state.value = { ...state, notification: { ...state.notification, attention: 'seen' } } + }) + expect(f.store.items.value.map(item => item.id)).toEqual(['desktop.update', 'catalog']) + expect(f.store.unseenCount.value).toBe(2) + const unseen = f.updates.state.value + f.updates.state.value = { ...unseen, notification: { ...unseen.notification!, attention: 'seen' } } + expect(f.store.items.value.map(item => item.id)).toEqual(['catalog', 'desktop.update']) + expect(await f.store.markAllSeen()).toBe(true) + expect(f.store.unseenCount.value).toBe(0) + const state = f.updates.state.value! + f.updates.state.value = { ...state, notification: { ...state.notification!, revision: '1.2.0', attention: 'unseen', payload: { version: '1.2.0' } } } + expect(f.store.items.value).toHaveLength(2) + expect(f.store.unseenCount.value).toBe(1) + f.updates.state.value = { ...f.updates.state.value, enabled: false, notification: null } + expect(f.store.items.value).toEqual(list(true).items) + expect(f.store.unseenCount.value).toBe(0) + f.store.dispose() + }) + it('refreshes from notification facts and rejects a list taken before a newer fact', async () => { const f = fixture() const stale = deferred() diff --git a/apps/buddy/src/modules/notifications/state/useNotificationCenterStore.ts b/apps/buddy/src/modules/notifications/state/useNotificationCenterStore.ts index f8c1d5d0..ee6d2484 100644 --- a/apps/buddy/src/modules/notifications/state/useNotificationCenterStore.ts +++ b/apps/buddy/src/modules/notifications/state/useNotificationCenterStore.ts @@ -1,13 +1,15 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' import type { LocalNotification, LocalNotificationList } from '@buddy-shared/notifications/notificationApi' +import type { DesktopNotification } from '../contracts' +import type { DesktopUpdates } from '@/modules/updates/contracts' import { computed, readonly, shallowRef } from 'vue' export type NotificationFilter = 'all' | 'unseen' export function filterNotifications( - items: ReadonlyArray, + items: ReadonlyArray, filter: NotificationFilter, -): ReadonlyArray { +): ReadonlyArray { return filter === 'unseen' ? items.filter(item => item.attention === 'unseen') : items @@ -15,9 +17,14 @@ export function filterNotifications( export function useNotificationCenterStore(api: { notifications: LocalChatApi['notifications'] -}) { - const items = shallowRef>([]) - const unseenCount = shallowRef(0) +}, updates: Pick) { + const localItems = shallowRef>([]) + const items = computed(() => { + const update = updates.state.value?.notification + return [...(update ? [update] : []), ...localItems.value] + .sort(compareNotifications) + }) + const unseenCount = computed(() => items.value.filter(item => item.attention === 'unseen').length) const isLoading = shallowRef(false) const error = shallowRef(null) let loadPromise: Promise | null = null @@ -36,8 +43,7 @@ export function useNotificationCenterStore(api: { const hasNotifications = computed(() => items.value.length > 0) function apply(value: LocalNotificationList) { - items.value = value.items - unseenCount.value = value.unseenCount + localItems.value = value.items } function load(): Promise { @@ -120,12 +126,29 @@ export function useNotificationCenterStore(api: { return mutation } - function markSeen(notification: LocalNotification) { - return mutate(() => api.notifications.markSeen(notification.id, notification.revision)) + async function markSeen(notification: DesktopNotification): Promise { + if (stopped) + return false + if (notification.kind !== 'app.update-available') + return mutate(() => api.notifications.markSeen(notification.id, notification.revision)) + try { + await updates.markSeen(notification.revision) + return true + } + catch (markError) { + if (!stopped) + error.value = markError + return false + } } - function markAllSeen() { - return mutate(() => api.notifications.markAllSeen()) + async function markAllSeen() { + const update = updates.state.value?.notification + const results = await Promise.all([ + mutate(() => api.notifications.markAllSeen()), + update ? markSeen(update) : true, + ]) + return results.every(Boolean) } function dispose(): void { @@ -151,3 +174,13 @@ export function useNotificationCenterStore(api: { } export type NotificationCenterStore = ReturnType + +function compareNotifications(left: DesktopNotification, right: DesktopNotification): number { + if (left.attention !== right.attention) + return left.attention === 'unseen' ? -1 : 1 + const leftActive = left.kind === 'app.update-available' || left.lifecycle === 'active' + const rightActive = right.kind === 'app.update-available' || right.lifecycle === 'active' + if (leftActive !== rightActive) + return leftActive ? -1 : 1 + return right.occurredAt.localeCompare(left.occurredAt) || right.id.localeCompare(left.id) +} diff --git a/apps/buddy/src/modules/notifications/widgets/DesktopNotificationCenter.vue b/apps/buddy/src/modules/notifications/widgets/DesktopNotificationCenter.vue index 6df46a3c..2045819b 100644 --- a/apps/buddy/src/modules/notifications/widgets/DesktopNotificationCenter.vue +++ b/apps/buddy/src/modules/notifications/widgets/DesktopNotificationCenter.vue @@ -1,5 +1,5 @@