Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .playwright/scripts/__tests__/updates.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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([])
})
33 changes: 33 additions & 0 deletions apps/buddy/electron/main/__tests__/desktopUpdateService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
Expand All @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/buddy/electron/main/config/LexoraConfigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -208,6 +219,7 @@ describe('lexoraConfigStore', () => {
language: 'zh-CN',
launchAtLogin: false,
notificationsEnabled: true,
updateNotificationsEnabled: true,
notifyWhenFocused: false,
profile: {
avatar: '',
Expand Down
48 changes: 18 additions & 30 deletions apps/buddy/electron/main/desktopUpdateService.ts
Original file line number Diff line number Diff line change
@@ -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<Response>
}

Expand All @@ -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'
Expand All @@ -29,7 +32,9 @@ export async function checkForDesktopUpdate(
options: CheckForDesktopUpdateOptions,
): Promise<DesktopUpdateCheckResult> {
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',
Expand All @@ -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 })
Expand All @@ -56,7 +63,7 @@ export async function checkForDesktopUpdate(

function parseLatestBuddyRelease(value: unknown): {
metadata: GithubRelease
version: ReturnType<typeof parseVersion>
version: string
} {
if (!Array.isArray(value))
throw new Error('GitHub release response is invalid')
Expand All @@ -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 {
Expand All @@ -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
}
Loading