From 0d295d0ecc7be125a1c370c83134289ef7d92848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F-Relakkes?= Date: Fri, 9 Oct 2026 20:53:03 +0800 Subject: [PATCH] fix(desktop): respect update deferrals and add automatic update control (#1482) --- desktop/electron/main.ts | 11 +- desktop/electron/services/appIdentity.test.ts | 121 ++++++++- desktop/electron/services/appIdentity.ts | 41 +++ desktop/electron/services/updateSmoke.ts | 1 + desktop/electron/services/updater.test.ts | 117 +++++++++ desktop/electron/services/updater.ts | 24 +- .../services/updaterLifecycle.test.ts | 153 ++++++++++++ desktop/package.json | 5 +- desktop/scripts/assert-windows-icon.test.ts | 70 ++++++ desktop/scripts/assert-windows-icon.ts | 33 +++ .../components/layout/UpdateChecker.test.tsx | 30 ++- .../src/components/layout/UpdateChecker.tsx | 8 +- desktop/src/i18n/locales/en.ts | 7 +- desktop/src/i18n/locales/jp.ts | 7 +- desktop/src/i18n/locales/kr.ts | 7 +- desktop/src/i18n/locales/zh-TW.ts | 7 +- desktop/src/i18n/locales/zh.ts | 7 +- .../src/pages/settings/AboutSettings.test.tsx | 98 +++++++- desktop/src/pages/settings/AboutSettings.tsx | 33 ++- desktop/src/stores/settingsStore.test.ts | 147 +++++++++++ desktop/src/stores/settingsStore.ts | 40 +++ desktop/src/stores/updateStore.test.ts | 236 ++++++++++++++++++ desktop/src/stores/updateStore.ts | 9 +- desktop/src/types/settings.ts | 1 + .../quality-gate/package-smoke/index.test.ts | 52 +++- scripts/quality-gate/package-smoke/index.ts | 34 ++- src/server/__tests__/e2e/full-flow.test.ts | 4 +- .../__tests__/persistence-upgrade.test.ts | 30 +++ src/server/__tests__/settings.test.ts | 27 +- src/server/api/settings.ts | 4 +- src/server/services/settingsService.ts | 11 + 31 files changed, 1326 insertions(+), 49 deletions(-) create mode 100644 desktop/electron/services/updaterLifecycle.test.ts create mode 100644 desktop/scripts/assert-windows-icon.test.ts create mode 100644 desktop/scripts/assert-windows-icon.ts diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 56ed986c..79fbbb4a 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -57,7 +57,7 @@ import { } from './services/appMode' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' import { installStdioWriteFailureGuards } from './services/stdioGuards' -import { applyWindowsAppUserModelId } from './services/appIdentity' +import { applyWindowsAppUserModelId, applyWindowsTaskbarIdentity, resolveWindowsWindowIcon } from './services/appIdentity' import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards' import { installPreviewCleanupOnRendererNavigation } from './services/previewLifecycle' import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke' @@ -742,7 +742,7 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.updatePrepareInstall, async () => { await publicAccessManager?.stop(); getServerRuntime().stopAll() }) registerHandler(ELECTRON_IPC_CHANNELS.updateCancelInstall, () => getUpdaterService().cancelInstall()) registerHandler(ELECTRON_IPC_CHANNELS.updateRelaunch, () => { - if (getUpdaterService().hasDownloadedUpdate()) { + if (getUpdaterService().hasStagedUpdate()) { isQuitting = true getUpdaterService().quitAndInstallDownloadedUpdate() return @@ -894,8 +894,10 @@ function registerIpcHandlers() { async function createMainWindow() { const restoredState = readWindowState(app, screen.getAllDisplays()) const bounds = windowOptionsFromState(restoredState) + const identityPaths = { desktopRoot: appRoot(), resourcesPath: process.resourcesPath } mainWindow = new BrowserWindow({ ...bounds, + icon: resolveWindowsWindowIcon(identityPaths), minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT, show: false, @@ -913,6 +915,11 @@ async function createMainWindow() { webviewTag: true, }, }) + applyWindowsTaskbarIdentity(mainWindow, { + ...identityPaths, + isPackaged: app.isPackaged, + executablePath: process.execPath, + }) // Before any guest can exist: its session denies OS permissions, and its // preferences are replaced with the sandboxed set the policy pins. workspaceBrowserSession() diff --git a/desktop/electron/services/appIdentity.test.ts b/desktop/electron/services/appIdentity.test.ts index e0383065..0cdf4e5c 100644 --- a/desktop/electron/services/appIdentity.test.ts +++ b/desktop/electron/services/appIdentity.test.ts @@ -1,8 +1,10 @@ -import { readFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' import { describe, expect, it, vi } from 'vitest' -import { applyWindowsAppUserModelId, WINDOWS_APP_USER_MODEL_ID } from './appIdentity' +import { applyWindowsAppUserModelId, applyWindowsTaskbarIdentity, resolveWindowsWindowIcon, WINDOWS_APP_USER_MODEL_ID } from './appIdentity' describe('applyWindowsAppUserModelId', () => { it('sets the AppUserModelID on Windows so toast notifications are attributed to the app', () => { @@ -26,3 +28,118 @@ describe('applyWindowsAppUserModelId', () => { expect(WINDOWS_APP_USER_MODEL_ID).toBe(pkg.build?.appId) }) }) + +describe('Windows window and pinned taskbar identity', () => { + function fixture() { + const root = mkdtempSync(path.join(tmpdir(), 'electron-app-identity-')) + const desktopRoot = path.join(root, 'resources', 'app.asar') + const resourcesPath = path.join(root, 'resources') + const executablePath = path.join(root, 'Claude Code Haha.exe') + const externalIcon = path.join(resourcesPath, 'app-icon.ico') + const bundledIcon = path.join(desktopRoot, 'src-tauri', 'icons', 'icon.ico') + const write = (file: string) => { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, 'icon') + } + return { root, desktopRoot, resourcesPath, executablePath, externalIcon, bundledIcon, write } + } + + it('uses the real resource icon for a packaged Windows window and pin', () => { + const f = fixture() + try { + f.write(f.externalIcon) + f.write(f.bundledIcon) + const window = { setAppDetails: vi.fn() } + expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.externalIcon) + expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32')).toBe(true) + expect(window.setAppDetails).toHaveBeenCalledWith({ + appId: WINDOWS_APP_USER_MODEL_ID, + appIconPath: f.externalIcon, + appIconIndex: 0, + relaunchCommand: `"${f.executablePath}"`, + relaunchDisplayName: 'Claude Code Haha', + }) + } finally { + rmSync(f.root, { recursive: true, force: true }) + } + }) + + it('falls back to the bundled window icon while keeping ASAR paths away from the Windows shell', () => { + const f = fixture() + try { + f.write(f.bundledIcon) + const window = { setAppDetails: vi.fn() } + expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.bundledIcon) + applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32') + expect(window.setAppDetails.mock.calls[0]?.[0].appIconPath).toBe(f.executablePath) + } finally { + rmSync(f.root, { recursive: true, force: true }) + } + }) + + it('leaves pin relaunch metadata alone for development runs and other platforms', () => { + const f = fixture() + try { + f.write(f.bundledIcon) + const window = { setAppDetails: vi.fn() } + expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.bundledIcon) + expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: false }, 'win32')).toBe(false) + for (const platform of ['darwin', 'linux'] as const) { + expect(resolveWindowsWindowIcon(f, platform)).toBeUndefined() + expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, platform)).toBe(false) + } + expect(window.setAppDetails).not.toHaveBeenCalled() + } finally { + rmSync(f.root, { recursive: true, force: true }) + } + }) + + it('does not fail startup when an icon is absent', () => { + const f = fixture() + try { + const window = { setAppDetails: vi.fn() } + expect(resolveWindowsWindowIcon(f, 'win32')).toBeUndefined() + expect(() => applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32')).not.toThrow() + expect(window.setAppDetails.mock.calls[0]?.[0].appIconPath).toBe(f.executablePath) + } finally { + rmSync(f.root, { recursive: true, force: true }) + } + }) + + it('applies both identities to the actual main-window construction before it is shown', async () => { + const f = fixture() + try { + f.write(f.externalIcon) + const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + const source = readFileSync(path.join(desktopDir, 'electron', 'main.ts'), 'utf8') + const start = source.indexOf('async function createMainWindow()') + const end = source.indexOf(' // Before any guest can exist:', start) + const window = { setAppDetails: vi.fn() } + const BrowserWindow = vi.fn(function (_options: unknown) { return window }) + const context = { + app: { isPackaged: true }, + process: { resourcesPath: f.resourcesPath, execPath: f.executablePath, platform: 'win32' }, + appRoot: () => f.desktopRoot, + mainWindow: null, + screen: { getAllDisplays: () => [] }, + readWindowState: () => null, + windowOptionsFromState: () => ({}), + windowChromeOptionsForPlatform: () => ({}), + resolveStartupWindowBackground: () => '#ffffff', + preloadPath: () => 'preload.cjs', + MIN_WINDOW_WIDTH: 400, + MIN_WINDOW_HEIGHT: 300, + BrowserWindow, + resolveWindowsWindowIcon: (paths: Parameters[0]) => resolveWindowsWindowIcon(paths, 'win32'), + applyWindowsTaskbarIdentity: (host: Parameters[0], paths: Parameters[1]) => applyWindowsTaskbarIdentity(host, paths, 'win32'), + createMainWindow: undefined as undefined | (() => Promise), + } + await runInNewContext(`${source.slice(start, end)}\n}`, context) + await context.createMainWindow!() + expect(BrowserWindow.mock.calls[0]?.[0]).toMatchObject({ show: false, icon: f.externalIcon }) + expect(window.setAppDetails.mock.calls[0]?.[0]).toMatchObject({ appId: WINDOWS_APP_USER_MODEL_ID, appIconPath: f.externalIcon }) + } finally { + rmSync(f.root, { recursive: true, force: true }) + } + }) +}) diff --git a/desktop/electron/services/appIdentity.ts b/desktop/electron/services/appIdentity.ts index 23790a0f..c3264864 100644 --- a/desktop/electron/services/appIdentity.ts +++ b/desktop/electron/services/appIdentity.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import type { BrowserWindow } from 'electron' + export type AppUserModelIdHost = { setAppUserModelId(id: string): void } @@ -16,3 +20,40 @@ export function applyWindowsAppUserModelId( app.setAppUserModelId(appUserModelId) return true } + +type WindowsIconPaths = { + desktopRoot: string + resourcesPath: string +} + +// BrowserWindow can read the bundled icon through Electron's ASAR-aware file +// APIs, but Explorer cannot. Keep a real ICO beside app.asar for new taskbar pins. +export function resolveWindowsWindowIcon( + paths: WindowsIconPaths, + platform: NodeJS.Platform = process.platform, +): string | undefined { + if (platform !== 'win32') return undefined + return [ + path.join(paths.resourcesPath, 'app-icon.ico'), + path.join(paths.desktopRoot, 'src-tauri', 'icons', 'icon.ico'), + ].find(candidate => existsSync(candidate)) +} + +export function applyWindowsTaskbarIdentity( + window: Pick, + paths: WindowsIconPaths & { isPackaged: boolean, executablePath: string }, + platform: NodeJS.Platform = process.platform, +): boolean { + // A development launch targets electron.exe and needs the entry script and + // dev-server environment. Do not turn its button into a production shortcut. + if (platform !== 'win32' || !paths.isPackaged) return false + const externalIcon = path.join(paths.resourcesPath, 'app-icon.ico') + window.setAppDetails({ + appId: WINDOWS_APP_USER_MODEL_ID, + appIconPath: existsSync(externalIcon) ? externalIcon : paths.executablePath, + appIconIndex: 0, + relaunchCommand: `"${paths.executablePath}"`, + relaunchDisplayName: 'Claude Code Haha', + }) + return true +} diff --git a/desktop/electron/services/updateSmoke.ts b/desktop/electron/services/updateSmoke.ts index dd119a22..b3e431df 100644 --- a/desktop/electron/services/updateSmoke.ts +++ b/desktop/electron/services/updateSmoke.ts @@ -44,6 +44,7 @@ function writeLog(logPath: string | undefined, payload: Record) class UpdateSmokeUpdater implements ElectronUpdaterLike { autoDownload = true + autoInstallOnAppQuit = true logger: unknown = null private progressHandler: ProgressHandler | null = null diff --git a/desktop/electron/services/updater.test.ts b/desktop/electron/services/updater.test.ts index d3acf71b..64b885ee 100644 --- a/desktop/electron/services/updater.test.ts +++ b/desktop/electron/services/updater.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -13,6 +14,7 @@ function fakeUpdater(): ElectronUpdaterLike & { let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null const updater = { autoDownload: true, + autoInstallOnAppQuit: true, checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), quitAndInstall: vi.fn(), @@ -168,6 +170,34 @@ describe('Electron updater service', () => { await expect(service.checkForUpdates()).rejects.toThrow('feed unavailable') }) + it('does not offer metadata that electron-updater marks unavailable', async () => { + const localUpdater = fakeUpdater() + // A release can be newer but unavailable due to its minimum OS version + // or staged rollout. electron-updater still returns its metadata. + localUpdater.checkForUpdates.mockResolvedValue({ + isUpdateAvailable: false, + updateInfo: { version: '9.9.9', body: 'Not eligible for this installation' }, + }) + const service = new ElectronUpdaterService(localUpdater) + + await expect(service.checkForUpdates()).resolves.toBeNull() + await expect(service.downloadUpdate(() => {})).rejects.toThrow('No Electron update') + expect(localUpdater.downloadUpdate).not.toHaveBeenCalled() + }) + + it('offers metadata that electron-updater explicitly marks available', async () => { + const localUpdater = fakeUpdater() + localUpdater.checkForUpdates.mockResolvedValue({ + isUpdateAvailable: true, + updateInfo: { version: '1.2.4', body: 'Eligible update' }, + }) + const service = new ElectronUpdaterService(localUpdater) + + await expect(service.checkForUpdates()).resolves.toEqual({ version: '1.2.4', body: 'Eligible update' }) + await service.downloadUpdate(() => {}) + expect(localUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + }) + it('stages then installs through quitAndInstall only after an update has downloaded', async () => { const service = new ElectronUpdaterService(updater) updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) @@ -183,12 +213,98 @@ describe('Electron updater service', () => { expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) }) + it('keeps ordinary quits and cached-download restarts from installing an update', async () => { + const install = vi.fn() + const cache = { downloaded: false } + for (let restart = 0; restart < 3; restart += 1) { + const app = new EventEmitter() + const localUpdater = fakeUpdater() + localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) + localUpdater.downloadUpdate.mockImplementation(async () => { + cache.downloaded = true + // BaseUpdater registers this after either a fresh or cached download. + app.once('quit', () => { + if (localUpdater.autoInstallOnAppQuit && cache.downloaded) install() + }) + }) + const service = new ElectronUpdaterService(localUpdater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + app.emit('quit') + } + + expect(cache.downloaded).toBe(true) + expect(install).not.toHaveBeenCalled() + }) + + it('requires an explicit install request before the downloaded update may restart into installation', async () => { + const localUpdater = fakeUpdater() + localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) + localUpdater.downloadUpdate.mockResolvedValue(undefined) + const service = new ElectronUpdaterService(localUpdater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + + expect(service.hasStagedUpdate()).toBe(false) + expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested') + expect(localUpdater.quitAndInstall).not.toHaveBeenCalled() + + service.stageDownloadedUpdate() + expect(service.hasStagedUpdate()).toBe(true) + service.quitAndInstallDownloadedUpdate({}) + + expect(localUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + expect(localUpdater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(localUpdater.autoInstallOnAppQuit).toBe(false) + }) + + it('disarms canceled installs while keeping the download available for an explicit retry', async () => { + const localUpdater = fakeUpdater() + localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) + localUpdater.downloadUpdate.mockResolvedValue(undefined) + const service = new ElectronUpdaterService(localUpdater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + service.stageDownloadedUpdate() + service.cancelInstall() + + expect(service.hasDownloadedUpdate()).toBe(true) + expect(service.hasStagedUpdate()).toBe(false) + expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested') + + await service.downloadUpdate(() => {}) + service.stageDownloadedUpdate() + service.quitAndInstallDownloadedUpdate({}) + + expect(localUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + expect(localUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('does not carry install consent over to a newly checked version', async () => { + const localUpdater = fakeUpdater() + localUpdater.checkForUpdates + .mockResolvedValueOnce({ updateInfo: { version: '1.2.4' } }) + .mockResolvedValueOnce({ updateInfo: { version: '1.2.5' } }) + localUpdater.downloadUpdate.mockResolvedValue(undefined) + const service = new ElectronUpdaterService(localUpdater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + service.stageDownloadedUpdate() + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + + expect(service.hasStagedUpdate()).toBe(false) + expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested') + expect(localUpdater.quitAndInstall).not.toHaveBeenCalled() + }) + it('hands the spawned installer an environment without the app-managed portable selection', async () => { const service = new ElectronUpdaterService(updater) updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) updater.downloadUpdate.mockResolvedValue(undefined) await service.checkForUpdates() await service.downloadUpdate(() => {}) + service.stageDownloadedUpdate() const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data', @@ -208,6 +324,7 @@ describe('Electron updater service', () => { updater.downloadUpdate.mockResolvedValue(undefined) await service.checkForUpdates() await service.downloadUpdate(() => {}) + service.stageDownloadedUpdate() const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' } service.quitAndInstallDownloadedUpdate(env) diff --git a/desktop/electron/services/updater.ts b/desktop/electron/services/updater.ts index d430d85a..d3ed0fe4 100644 --- a/desktop/electron/services/updater.ts +++ b/desktop/electron/services/updater.ts @@ -9,6 +9,7 @@ export type ElectronUpdateInfo = { } export type ElectronUpdateCheckResult = { + isUpdateAvailable?: boolean updateInfo?: ElectronUpdateInfo } | null @@ -18,6 +19,7 @@ export type ElectronUpdateCheckOptions = { export type ElectronUpdaterLike = { autoDownload: boolean + autoInstallOnAppQuit: boolean disableDifferentialDownload?: boolean logger?: unknown checkForUpdates(): Promise @@ -93,6 +95,7 @@ export class ElectronUpdaterService { private readonly updateConfigPath?: string private pendingUpdate: ElectronUpdateMetadata | null = null private downloaded = false + private installRequested = false private proxyKey: string | null = null constructor( @@ -104,6 +107,10 @@ export class ElectronUpdaterService { this.proxyController = proxyController this.updateConfigPath = runtimeOptions.updateConfigPath this.updater.autoDownload = false + // Background downloads must stay pending after "Later", normal quits, + // and app-mode restarts. On macOS this also keeps the ZIP out of Squirrel + // until the user explicitly requests quitAndInstall. + this.updater.autoInstallOnAppQuit = false // Differential download issues many small sequential range requests and is // RTT-bound against the GitHub CDN, so it downloads far below line speed. this.updater.disableDifferentialDownload = true @@ -122,6 +129,7 @@ export class ElectronUpdaterService { } async checkForUpdates(options?: ElectronUpdateCheckOptions): Promise { + this.installRequested = false let result: ElectronUpdateCheckResult try { await this.applyProxy(options) @@ -134,7 +142,7 @@ export class ElectronUpdaterService { if (!isMissingUpdateMetadataError(error)) throw error result = null } - this.pendingUpdate = normalizeUpdateInfo(result?.updateInfo) + this.pendingUpdate = result?.isUpdateAvailable === false ? null : normalizeUpdateInfo(result?.updateInfo) this.downloaded = false return this.pendingUpdate } @@ -177,8 +185,9 @@ export class ElectronUpdaterService { } cancelInstall() { - this.pendingUpdate = null - this.downloaded = false + // The renderer retains the downloaded update on an install/relaunch + // failure. Revoke the install request, but keep the download retryable. + this.installRequested = false } stageDownloadedUpdate() { @@ -188,14 +197,21 @@ export class ElectronUpdaterService { if (!this.downloaded) { throw new Error('Electron update has not finished downloading') } + this.installRequested = true } hasDownloadedUpdate(): boolean { return !!this.pendingUpdate && this.downloaded } + hasStagedUpdate(): boolean { + return this.hasDownloadedUpdate() && this.installRequested + } + quitAndInstallDownloadedUpdate(env: NodeJS.ProcessEnv = process.env) { - this.stageDownloadedUpdate() + if (!this.hasStagedUpdate()) { + throw new Error('Electron update installation has not been requested') + } // The NSIS installer spawned here inherits this process's environment. // Hand it the same clean environment a manually launched setup gets, so // its legacy-data checks read the persisted app-mode.json instead of a diff --git a/desktop/electron/services/updaterLifecycle.test.ts b/desktop/electron/services/updaterLifecycle.test.ts new file mode 100644 index 00000000..b16bbecc --- /dev/null +++ b/desktop/electron/services/updaterLifecycle.test.ts @@ -0,0 +1,153 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { BaseUpdater } from 'electron-updater/out/BaseUpdater' +import { MacUpdater } from 'electron-updater/out/MacUpdater' +import type { AppAdapter } from 'electron-updater/out/AppAdapter' +import { ElectronUpdaterService, type ElectronUpdaterLike } from './updater' + +function fixtureApp(root: string, events: EventEmitter): AppAdapter { + return { + version: '1.0.0', + name: 'Updater fixture', + isPackaged: true, + appUpdateConfigPath: join(root, 'app-update.yml'), + userDataPath: root, + baseCachePath: root, + whenReady: async () => {}, + relaunch: vi.fn(), + quit: vi.fn(), + onQuit: handler => { events.once('quit', handler) }, + } +} + +// Keep the dependency's real quit handler, replacing only download transport +// and installer execution. These boundaries must never touch a real install. +class ExitUpdaterFixture extends BaseUpdater { + install = vi.fn(() => true) + + constructor(app: AppAdapter) { + super(undefined, app) + } + + async checkForUpdates() { + const updateInfo = { version: '1.2.4', files: [], releaseDate: '', path: 'fixture.exe', sha512: 'fixture' } + return { isUpdateAvailable: true, updateInfo, versionInfo: updateInfo } + } + + async downloadUpdate() { + this.addQuitHandler() + return [] + } + + protected async doDownloadUpdate() { return [] } + protected doInstall() { return true } +} + +describe('locked electron-updater lifecycle', () => { + it('reproduces the upstream Windows/Linux default install on normal exit', async () => { + const root = mkdtempSync(join(tmpdir(), 'updater-exit-default-')) + try { + const events = new EventEmitter() + const updater = new ExitUpdaterFixture(fixtureApp(root, events)) + updater.logger = null + await updater.downloadUpdate() + events.emit('quit', 0) + + expect(updater.install).toHaveBeenCalledWith(true, false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the real exit hook disarmed across three updater instances and normal exits', async () => { + const root = mkdtempSync(join(tmpdir(), 'updater-exit-manual-')) + try { + for (let restart = 0; restart < 3; restart += 1) { + const events = new EventEmitter() + const updater = new ExitUpdaterFixture(fixtureApp(root, events)) + const service = new ElectronUpdaterService(updater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + events.emit('quit', 0) + + expect(updater.install).not.toHaveBeenCalled() + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the macOS ZIP out of Squirrel until explicit installation', async () => { + const root = mkdtempSync(join(tmpdir(), 'updater-mac-manual-')) + const archive = join(root, 'update.zip') + writeFileSync(archive, 'fixture archive') + const nativeEvents = new EventEmitter() + let feed: { url: string, headers: Record } | null = null + let nativeDownload: Promise | null = null + const nativeUpdater = Object.assign(nativeEvents, { + setFeedURL: vi.fn((next: NonNullable) => { feed = next }), + checkForUpdates: vi.fn(() => { + nativeDownload = (async () => { + const metadata = await fetch(feed!.url, { headers: feed!.headers }) + const { url } = await metadata.json() as { url: string } + const response = await fetch(url) + expect(await response.text()).toBe('fixture archive') + nativeEvents.emit('update-downloaded') + })() + }), + quitAndInstall: vi.fn(), + }) + type MacFixture = ElectronUpdaterLike & { + server?: Server + squirrelDownloadedUpdate: boolean + updateDownloaded(fileInfo: unknown, event: unknown): Promise + closeServerIfExists(): void + } + // Skip the constructor's require('electron') so this runs on every OS. + // Download completion and quitAndInstall still use the locked dependency's + // real implementation; only Squirrel itself is replaced with loopback I/O. + const updater = Object.assign(Object.create(MacUpdater.prototype), { + autoDownload: true, + autoInstallOnAppQuit: true, + autoRunAppAfterInstall: true, + squirrelDownloadedUpdate: false, + nativeUpdater, + app: fixtureApp(root, new EventEmitter()), + dispatchUpdateDownloaded: vi.fn(), + checkForUpdates: vi.fn(async () => ({ updateInfo: { version: '1.2.4' } })), + }) as MacFixture + updater.downloadUpdate = vi.fn(() => updater.updateDownloaded({ + url: new URL('https://fixture.invalid/update.zip'), + info: { size: Buffer.byteLength('fixture archive') }, + }, { downloadedFile: archive, version: '1.2.4' })) + nativeEvents.on('update-downloaded', () => { updater.squirrelDownloadedUpdate = true }) + + try { + const service = new ElectronUpdaterService(updater) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + + expect(nativeUpdater.setFeedURL).toHaveBeenCalledTimes(1) + expect(nativeUpdater.checkForUpdates).not.toHaveBeenCalled() + expect(nativeUpdater.quitAndInstall).not.toHaveBeenCalled() + expect(service.hasStagedUpdate()).toBe(false) + + service.stageDownloadedUpdate() + service.quitAndInstallDownloadedUpdate({}) + await nativeDownload + + expect(nativeUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + expect(nativeUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + } finally { + await nativeDownload + if (updater.server?.listening) { + await new Promise(resolve => updater.server!.close(() => resolve())) + } + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/desktop/package.json b/desktop/package.json index 730432e3..32c55275 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -63,7 +63,10 @@ }, "win": { "target": "nsis", - "icon": "src-tauri/icons/icon.ico" + "icon": "src-tauri/icons/icon.ico", + "extraResources": [ + { "from": "src-tauri/icons/icon.ico", "to": "app-icon.ico" } + ] }, "nsis": { "oneClick": false, diff --git a/desktop/scripts/assert-windows-icon.test.ts b/desktop/scripts/assert-windows-icon.test.ts new file mode 100644 index 00000000..a72a64ba --- /dev/null +++ b/desktop/scripts/assert-windows-icon.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Data, NtExecutable, NtExecutableResource, Resource } from 'resedit' +import { describe, expect, it } from 'vitest' +import { assertWindowsExecutableIcon } from './assert-windows-icon' +import { createSandboxedTestEnvironment } from '../../scripts/pr/test-environment' + +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const applicationIcon = readFileSync(path.join(desktopRoot, 'src-tauri', 'icons', 'icon.ico')) + +function executableWithIcons(icon?: Uint8Array): Buffer { + const executable = NtExecutable.createEmpty(false, false) + const resources = NtExecutableResource.from(executable) + if (icon) { + Resource.IconGroupEntry.replaceIconsForResource( + resources.entries, 1, 1033, Data.IconFile.from(icon).icons.map(item => item.data), + ) + resources.outputResource(executable) + } + return Buffer.from(executable.generate()) +} + +describe('Windows packaged executable icon', () => { + it('accepts the application icon embedded into the actual PE resource table', () => { + expect(() => assertWindowsExecutableIcon(executableWithIcons(applicationIcon), applicationIcon)).not.toThrow() + }) + + it('rejects an executable with no embedded application icon', () => { + expect(() => assertWindowsExecutableIcon(executableWithIcons(), applicationIcon)).toThrow('no icon group') + }) + + it('rejects a different icon even when it is a valid PE with a valid ICO resource', () => { + const icon = Data.IconFile.from(applicationIcon) + icon.icons = icon.icons.slice(0, 1) + expect(() => assertWindowsExecutableIcon(executableWithIcons(Buffer.from(icon.generate())), applicationIcon)) + .toThrow('differs from the application ICO') + }) + + it('rejects arbitrary executable bytes', () => { + expect(() => assertWindowsExecutableIcon(Buffer.from('not a PE executable'), applicationIcon)).toThrow() + }) + + it.each([true, false])('runs the real CLI boundary with branded=%s and no writes to input artifacts', branded => { + const root = mkdtempSync(path.join(tmpdir(), 'windows-icon-cli-')) + try { + const executablePath = path.join(root, 'Fixture App.exe') + const iconPath = path.join(root, 'expected.ico') + const executable = executableWithIcons(branded ? applicationIcon : undefined) + writeFileSync(executablePath, executable) + writeFileSync(iconPath, applicationIcon) + const result = spawnSync('bun', [ + 'run', path.join(desktopRoot, 'scripts', 'assert-windows-icon.ts'), executablePath, iconPath, + ], { + cwd: root, + env: createSandboxedTestEnvironment(path.join(root, 'home')), + encoding: 'utf8', + timeout: 30_000, + }) + expect(result.status).toBe(branded ? 0 : 1) + expect(branded ? result.stdout : result.stderr).toContain(branded ? 'embeds the application ICO' : 'contains no icon group') + expect(readFileSync(executablePath)).toEqual(executable) + expect(readFileSync(iconPath)).toEqual(applicationIcon) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/desktop/scripts/assert-windows-icon.ts b/desktop/scripts/assert-windows-icon.ts new file mode 100644 index 00000000..78aaafec --- /dev/null +++ b/desktop/scripts/assert-windows-icon.ts @@ -0,0 +1,33 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { Data, NtExecutable, NtExecutableResource, Resource } from 'resedit' + +type IconItem = InstanceType | InstanceType + +function iconFingerprint(icon: IconItem): string { + return createHash('sha256').update(Buffer.from(icon.isRaw() ? icon.bin : icon.generate())).digest('hex') +} + +// Reuse electron-builder's PE resource parser. Inspect bytes from the finished +// executable, including signed builds, instead of trusting win.icon config. +export function assertWindowsExecutableIcon(executable: Uint8Array, expectedIcon: Uint8Array): void { + const expected = Data.IconFile.from(expectedIcon).icons.map(item => iconFingerprint(item.data)).sort() + if (expected.length === 0) throw new Error('The expected Windows ICO contains no icons') + const pe = NtExecutable.from(executable, { ignoreCert: true }) + const resources = NtExecutableResource.from(pe).entries + // Explorer uses icon index 0, the first icon group, when no index is supplied. + const group = Resource.IconGroupEntry.fromEntries(resources)[0] + if (!group) throw new Error('The Windows executable contains no icon group') + const actual = group.getIconItemsFromEntries(resources).map(iconFingerprint).sort() + if (group.icons.length !== expected.length || actual.length !== expected.length + || actual.some((hash, index) => hash !== expected[index])) { + throw new Error('The Windows executable icon differs from the application ICO') + } +} + +if (import.meta.main) { + const [executablePath, expectedIconPath] = process.argv.slice(2) + if (!executablePath || !expectedIconPath) throw new Error('Usage: bun run assert-windows-icon.ts ') + assertWindowsExecutableIcon(readFileSync(executablePath), readFileSync(expectedIconPath)) + console.log('Windows executable embeds the application ICO') +} diff --git a/desktop/src/components/layout/UpdateChecker.test.tsx b/desktop/src/components/layout/UpdateChecker.test.tsx index 9c4acf3b..35de2c4a 100644 --- a/desktop/src/components/layout/UpdateChecker.test.tsx +++ b/desktop/src/components/layout/UpdateChecker.test.tsx @@ -9,7 +9,7 @@ import { useUpdateStore } from '../../stores/updateStore' describe('UpdateChecker', () => { beforeEach(() => { - useSettingsStore.setState({ locale: 'en' }) + useSettingsStore.setState({ locale: 'en', autoUpdateEnabled: true }) Reflect.deleteProperty(window, '__TAURI__') window.desktopHost = { ...browserHost, @@ -44,7 +44,7 @@ describe('UpdateChecker', () => { render() expect(screen.getByText('Update ready')).toBeInTheDocument() - expect(screen.getByText('v0.1.5 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument() + expect(screen.getByText('v0.1.5 has been downloaded. Click “Install and restart” to apply it.')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument() const link = screen.getByRole('link', { name: 'Release notes' }) @@ -52,6 +52,30 @@ describe('UpdateChecker', () => { expect(link).toHaveAttribute('target', '_blank') }) + it('keeps automatic updates quiet when disabled and starts them when re-enabled', async () => { + const initialize = vi.fn().mockResolvedValue(undefined) + useSettingsStore.setState({ autoUpdateEnabled: false }) + useUpdateStore.setState({ status: 'downloaded', initialize }) + render() + + expect(initialize).not.toHaveBeenCalled() + expect(screen.queryByText('Update ready')).not.toBeInTheDocument() + + await act(async () => { + useSettingsStore.setState({ autoUpdateEnabled: true }) + }) + + expect(initialize).toHaveBeenCalledTimes(1) + expect(screen.getByText('Update ready')).toBeInTheDocument() + + await act(async () => { + useSettingsStore.setState({ autoUpdateEnabled: false }) + }) + + expect(screen.queryByText('Update ready')).not.toBeInTheDocument() + expect(initialize).toHaveBeenCalledTimes(1) + }) + it('renders the update prompt in Electron desktop runtime', () => { window.desktopHost = { ...browserHost, @@ -176,7 +200,7 @@ describe('UpdateChecker', () => { }) expect(await screen.findByText('Update ready')).toBeInTheDocument() - expect(screen.getByText('v0.2.0 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument() + expect(screen.getByText('v0.2.0 has been downloaded. Click “Install and restart” to apply it.')).toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByText('Install and restart')) }) diff --git a/desktop/src/components/layout/UpdateChecker.tsx b/desktop/src/components/layout/UpdateChecker.tsx index 9254dfdf..9f465377 100644 --- a/desktop/src/components/layout/UpdateChecker.tsx +++ b/desktop/src/components/layout/UpdateChecker.tsx @@ -4,9 +4,11 @@ import { useTranslation } from '../../i18n' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { isDesktopRuntime } from '../../lib/desktopRuntime' import { useUpdateStore } from '../../stores/updateStore' +import { useSettingsStore } from '../../stores/settingsStore' export function UpdateChecker() { const t = useTranslation() + const autoUpdateEnabled = useSettingsStore((s) => s.autoUpdateEnabled) const status = useUpdateStore((s) => s.status) const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) @@ -17,10 +19,10 @@ export function UpdateChecker() { const dismissPrompt = useUpdateStore((s) => s.dismissPrompt) useEffect(() => { - void initialize() - }, [initialize]) + if (autoUpdateEnabled) void initialize() + }, [autoUpdateEnabled, initialize]) - if (!isDesktopRuntime()) return null + if (!isDesktopRuntime() || !autoUpdateEnabled) return null const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded' diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5e42b6e8..5d9aa92f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -3573,6 +3573,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le // ─── Update Checker ────────────────────────────────────── 'update.available': 'v{version} available', + 'update.autoUpdate': 'Automatic updates', + 'update.autoUpdateDescription': 'Automatically check for new versions and download updates in the background. Manual checks and installation remain available when turned off.', + 'update.autoUpdateSaveFailed': 'Could not save automatic update settings: {error}', 'update.availableLabel': 'Available', 'update.checking': 'Checking for updates...', 'update.checkNow': 'Check now', @@ -3580,7 +3583,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'update.currentVersionUnknown': 'Unknown', 'update.newVersion': 'New version v{version} available', 'update.downloading': 'Downloading...', - 'update.downloaded': 'Update downloaded. Restart when you are ready to use it.', + 'update.downloaded': 'Update downloaded. Click “Install and restart” to apply it.', 'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.', 'update.installAndRestart': 'Install and restart', 'update.installing': 'Installing update...', @@ -3600,7 +3603,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.', 'update.proxyUrlRequired': 'Enter a proxy URL.', 'update.releaseNotes': 'Release Notes', - 'update.readyBody': 'v{version} has been downloaded. Restart when you are ready to use it.', + 'update.readyBody': 'v{version} has been downloaded. Click “Install and restart” to apply it.', 'update.readyTitle': 'Update ready', 'update.restarting': 'Restarting to finish update...', 'update.upToDate': 'You are up to date on v{version}.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 88c3ffc7..02b0f440 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -3574,6 +3574,9 @@ export const jp: Record = { // ─── Update Checker ────────────────────────────────────── 'update.available': 'v{version} が利用可能', + 'update.autoUpdate': '自動更新', + 'update.autoUpdateDescription': '新しいバージョンを自動で確認し、バックグラウンドでダウンロードします。オフにしても手動での確認とインストールは利用できます。', + 'update.autoUpdateSaveFailed': '自動更新の設定を保存できませんでした:{error}', 'update.availableLabel': '利用可能', 'update.checking': '更新を確認中...', 'update.checkNow': '今すぐ確認', @@ -3581,7 +3584,7 @@ export const jp: Record = { 'update.currentVersionUnknown': '不明', 'update.newVersion': '新しいバージョン v{version} が利用可能', 'update.downloading': 'ダウンロード中...', - 'update.downloaded': '更新をダウンロードしました。使用する準備ができたら再起動してください。', + 'update.downloaded': '更新をダウンロードしました。「インストールして再起動」をクリックして適用してください。', 'update.idle': '更新を確認して、インストール済みのバージョンを最新の GitHub Release と比較します。', 'update.installAndRestart': 'インストールして再起動', 'update.installing': '更新をインストール中...', @@ -3601,7 +3604,7 @@ export const jp: Record = { 'update.proxyUrlInvalid': 'HTTP または HTTPS のプロキシ URL を入力してください。', 'update.proxyUrlRequired': 'プロキシ URL を入力してください。', 'update.releaseNotes': 'リリースノート', - 'update.readyBody': 'v{version} をダウンロードしました。使用する準備ができたら再起動してください。', + 'update.readyBody': 'v{version} をダウンロードしました。「インストールして再起動」をクリックして適用してください。', 'update.readyTitle': '更新の準備完了', 'update.restarting': '更新を完了するために再起動中...', 'update.upToDate': 'v{version} は最新です。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 0d14cece..55350df0 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -3576,6 +3576,9 @@ export const kr: Record = { // ─── Update Checker ────────────────────────────────────── 'update.available': 'v{version} 사용 가능', + 'update.autoUpdate': '자동 업데이트', + 'update.autoUpdateDescription': '새 버전을 자동으로 확인하고 백그라운드에서 다운로드합니다. 꺼도 수동 확인과 설치는 계속 사용할 수 있습니다.', + 'update.autoUpdateSaveFailed': '자동 업데이트 설정을 저장하지 못했습니다: {error}', 'update.availableLabel': '사용 가능', 'update.checking': '업데이트 확인 중...', 'update.checkNow': '지금 확인', @@ -3583,7 +3586,7 @@ export const kr: Record = { 'update.currentVersionUnknown': '알 수 없음', 'update.newVersion': '새 버전 v{version} 사용 가능', 'update.downloading': '다운로드 중...', - 'update.downloaded': '업데이트를 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.', + 'update.downloaded': '업데이트를 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.', 'update.idle': '업데이트를 확인하여 설치된 버전을 최신 GitHub Release와 비교합니다.', 'update.installAndRestart': '설치하고 다시 시작', 'update.installing': '업데이트 설치 중...', @@ -3603,7 +3606,7 @@ export const kr: Record = { 'update.proxyUrlInvalid': 'HTTP 또는 HTTPS 프록시 URL을 입력하세요.', 'update.proxyUrlRequired': '프록시 URL을 입력하세요.', 'update.releaseNotes': '릴리스 노트', - 'update.readyBody': 'v{version}을(를) 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.', + 'update.readyBody': 'v{version}을(를) 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.', 'update.readyTitle': '업데이트 준비 완료', 'update.restarting': '업데이트를 완료하기 위해 다시 시작 중...', 'update.upToDate': 'v{version}은(는) 최신입니다.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 4f990439..2995a4b8 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -3573,6 +3573,9 @@ export const zh: Record = { // ─── 更新檢查 ────────────────────────────────────── 'update.available': 'v{version} 可用', + 'update.autoUpdate': '自動更新', + 'update.autoUpdateDescription': '自動檢查新版本並在背景下載;關閉後仍可手動檢查與安裝。', + 'update.autoUpdateSaveFailed': '自動更新設定儲存失敗:{error}', 'update.availableLabel': '可更新版本', 'update.checking': '正在檢查更新...', 'update.checkNow': '檢查更新', @@ -3580,7 +3583,7 @@ export const zh: Record = { 'update.currentVersionUnknown': '未知版本', 'update.newVersion': '新版本 v{version} 可用', 'update.downloading': '下載中...', - 'update.downloaded': '更新已下載。方便時重啟即可使用新版。', + 'update.downloaded': '更新已下載。點擊「安裝並重啟」後套用新版。', 'update.idle': '點選檢查更新,對比當前安裝版本和 GitHub Releases 的最新版本。', 'update.installAndRestart': '安裝並重啟', 'update.installing': '正在安裝更新...', @@ -3600,7 +3603,7 @@ export const zh: Record = { 'update.proxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。', 'update.proxyUrlRequired': '請輸入代理地址。', 'update.releaseNotes': '更新說明', - 'update.readyBody': 'v{version} 已下載。方便時重啟即可使用新版。', + 'update.readyBody': 'v{version} 已下載。點擊「安裝並重啟」後套用新版。', 'update.readyTitle': '更新已準備好', 'update.restarting': '正在重啟以完成更新...', 'update.upToDate': '當前已是最新版本 v{version}。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 3b1447ca..24b45126 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -3572,6 +3572,9 @@ export const zh: Record = { // ─── 更新检查 ────────────────────────────────────── 'update.available': 'v{version} 可用', + 'update.autoUpdate': '自动更新', + 'update.autoUpdateDescription': '自动检查新版本并在后台下载;关闭后仍可手动检查与安装。', + 'update.autoUpdateSaveFailed': '自动更新设置保存失败:{error}', 'update.availableLabel': '可更新版本', 'update.checking': '正在检查更新...', 'update.checkNow': '检查更新', @@ -3579,7 +3582,7 @@ export const zh: Record = { 'update.currentVersionUnknown': '未知版本', 'update.newVersion': '新版本 v{version} 可用', 'update.downloading': '下载中...', - 'update.downloaded': '更新已下载。方便时重启即可使用新版。', + 'update.downloaded': '更新已下载。点击「安装并重启」后应用新版。', 'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。', 'update.installAndRestart': '安装并重启', 'update.installing': '正在安装更新...', @@ -3599,7 +3602,7 @@ export const zh: Record = { 'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。', 'update.proxyUrlRequired': '请输入代理地址。', 'update.releaseNotes': '更新说明', - 'update.readyBody': 'v{version} 已下载。方便时重启即可使用新版。', + 'update.readyBody': 'v{version} 已下载。点击「安装并重启」后应用新版。', 'update.readyTitle': '更新已准备好', 'update.restarting': '正在重启以完成更新...', 'update.upToDate': '当前已是最新版本 v{version}。', diff --git a/desktop/src/pages/settings/AboutSettings.test.tsx b/desktop/src/pages/settings/AboutSettings.test.tsx index 0d766fce..3eed6b2f 100644 --- a/desktop/src/pages/settings/AboutSettings.test.tsx +++ b/desktop/src/pages/settings/AboutSettings.test.tsx @@ -1,19 +1,107 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useSettingsStore } from '@/stores/settingsStore' +import { useUpdateStore } from '@/stores/updateStore' +import { settingsApi } from '@/api/settings' import { AboutSettings } from './AboutSettings' -beforeEach(() => { - useSettingsStore.setState({ locale: 'zh' }) +const initialSettingsState = useSettingsStore.getState() +const initialUpdateState = useUpdateStore.getState() + +async function renderAboutSettings() { + render() + await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0') +} + +beforeEach(async () => { + useSettingsStore.setState({ locale: 'zh', autoUpdateEnabled: true }) + useUpdateStore.setState({ + status: 'idle', + availableVersion: null, + error: null, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + }) + vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true }) + // Establish the persisted fixture as well as the optimistic store value; + // failed saves must roll back to a successful save, not an arbitrary setState. + await useSettingsStore.getState().setAutoUpdateEnabled(true) + vi.mocked(settingsApi.updateUser).mockClear() }) afterEach(() => { cleanup() + useSettingsStore.setState(initialSettingsState) + useUpdateStore.setState(initialUpdateState) + vi.restoreAllMocks() +}) + +it('shows automatic updates enabled by default with the scope of the setting', async () => { + await renderAboutSettings() + expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked() + expect(screen.getByText('自动检查新版本并在后台下载;关闭后仍可手动检查与安装。')).toBeInTheDocument() + await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0') +}) + +it('saves automatic updates off and back on through the settings API', async () => { + await renderAboutSettings() + const toggle = screen.getByRole('switch', { name: '自动更新' }) + fireEvent.click(toggle) + await waitFor(() => expect(toggle).not.toBeChecked()) + await waitFor(() => expect(toggle).toBeEnabled()) + expect(settingsApi.updateUser).toHaveBeenCalledWith({ autoUpdateEnabled: false }) + + fireEvent.click(toggle) + await waitFor(() => expect(toggle).toBeChecked()) + await waitFor(() => expect(toggle).toBeEnabled()) + expect(settingsApi.updateUser).toHaveBeenLastCalledWith({ autoUpdateEnabled: true }) +}) + +it('disables the switch until saving finishes', async () => { + let finishSave: (result: { ok: true }) => void = () => {} + vi.mocked(settingsApi.updateUser).mockImplementationOnce(() => new Promise(resolve => { finishSave = resolve })) + await renderAboutSettings() + const toggle = screen.getByRole('switch', { name: '自动更新' }) + fireEvent.click(toggle) + expect(toggle).toBeDisabled() + await waitFor(() => expect(settingsApi.updateUser).toHaveBeenCalledOnce()) + await act(async () => { finishSave({ ok: true }) }) + expect(toggle).toBeEnabled() + expect(toggle).not.toBeChecked() +}) + +it('surfaces a failed save and restores the previous automatic update setting', async () => { + vi.mocked(settingsApi.updateUser).mockRejectedValueOnce(new Error('fixture save failed')) + await renderAboutSettings() + const toggle = screen.getByRole('switch', { name: '自动更新' }) + fireEvent.click(toggle) + expect(await screen.findByRole('alert')).toHaveTextContent('自动更新设置保存失败:fixture save failed') + expect(toggle).toBeChecked() + expect(toggle).toBeEnabled() +}) + +it('keeps manual checking and installing available while automatic updates are off', async () => { + useSettingsStore.setState({ autoUpdateEnabled: false }) + useUpdateStore.setState({ status: 'downloaded', availableVersion: '0.2.0' }) + await renderAboutSettings() + expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked() + expect(useUpdateStore.getState().initialize).not.toHaveBeenCalled() + const check = screen.getByRole('button', { name: '检查更新' }) + const install = screen.getByRole('button', { name: '安装并重启' }) + expect(check).toBeEnabled() + expect(install).toBeEnabled() + fireEvent.click(check) + fireEvent.click(install) + expect(useUpdateStore.getState().checkForUpdates).toHaveBeenCalledOnce() + expect(useUpdateStore.getState().installUpdate).toHaveBeenCalledOnce() + expect(screen.getByText('更新已下载。点击「安装并重启」后应用新版。')).toBeInTheDocument() + await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0') }) it('opens the group QR in a dialog instead of pushing it below the fold', async () => { - render() + await renderAboutSettings() // The version arrives from an async host call; let it settle so the assertion // is not racing a state update. await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0') diff --git a/desktop/src/pages/settings/AboutSettings.tsx b/desktop/src/pages/settings/AboutSettings.tsx index 6dcc88b3..03ae5c00 100644 --- a/desktop/src/pages/settings/AboutSettings.tsx +++ b/desktop/src/pages/settings/AboutSettings.tsx @@ -12,6 +12,7 @@ import { SettingsPageHeader, SettingsRow, SettingsSection, + SettingsSwitchRow, } from '@/components/settings/SettingsSection' import type { UpdateProxyMode } from '../../types/settings' import { MarkdownRenderer } from '../../components/markdown/MarkdownRenderer' @@ -46,6 +47,8 @@ export function AboutSettings() { const [version, setVersion] = useState('') const updateProxy = useSettingsStore((s) => s.updateProxy) const setUpdateProxy = useSettingsStore((s) => s.setUpdateProxy) + const autoUpdateEnabled = useSettingsStore((s) => s.autoUpdateEnabled) + const setAutoUpdateEnabled = useSettingsStore((s) => s.setAutoUpdateEnabled) const updateStatus = useUpdateStore((s) => s.status) const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) @@ -61,6 +64,8 @@ export function AboutSettings() { const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy) const [updateProxySaveError, setUpdateProxySaveError] = useState(null) const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false) + const [isSavingAutoUpdate, setIsSavingAutoUpdate] = useState(false) + const [autoUpdateSaveError, setAutoUpdateSaveError] = useState(null) const [communityOpen, setCommunityOpen] = useState(false) useEffect(() => { @@ -80,8 +85,8 @@ export function AboutSettings() { }, []) useEffect(() => { - void initialize() - }, [initialize]) + if (autoUpdateEnabled) void initialize() + }, [autoUpdateEnabled, initialize]) useEffect(() => { setUpdateProxyDraft(updateProxy) @@ -144,6 +149,18 @@ export function AboutSettings() { } } + const saveAutoUpdateEnabled = async (enabled: boolean) => { + setIsSavingAutoUpdate(true) + setAutoUpdateSaveError(null) + try { + await setAutoUpdateEnabled(enabled) + } catch (error) { + setAutoUpdateSaveError(error instanceof Error ? error.message : String(error)) + } finally { + setIsSavingAutoUpdate(false) + } + } + const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 const downloadedText = formatBytes(downloadedBytes) const updateDescription = (() => { @@ -229,6 +246,18 @@ export function AboutSettings() { )} > + void saveAutoUpdateEnabled(enabled)} + footer={autoUpdateSaveError ? ( +

+ {t('update.autoUpdateSaveFailed', { error: autoUpdateSaveError })} +

+ ) : undefined} + /> diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 2a8be34d..b3b0cfe7 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -237,6 +237,153 @@ describe('settingsStore Auto mode consent', () => { }) }) +describe('settingsStore automatic update preference', () => { + function installSettingsFixture(user: Record = {}, updateUser = vi.fn().mockResolvedValue({ ok: true })) { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(async () => ({ ...user })), + updateUser, + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { get: vi.fn().mockResolvedValue({ settings: { enabled: false } }) }, + })) + return updateUser + } + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('upgrades an old settings fixture without the preference to enabled', async () => { + const original = { unknownFuturePreference: { keep: true }, updateProxy: { mode: 'system' } } + const updateUser = installSettingsFixture(original) + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true) + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true) + expect(updateUser).not.toHaveBeenCalled() + expect(original).toEqual({ unknownFuturePreference: { keep: true }, updateProxy: { mode: 'system' } }) + }) + + it('persists disabled automatic updates and restores them on the next store startup', async () => { + const user: Record = { unknownFuturePreference: 'keep' } + const updateUser = vi.fn(async (patch: Record) => { + Object.assign(user, patch) + return { ok: true } + }) + installSettingsFixture(user, updateUser) + const { useSettingsStore } = await import('./settingsStore') + await useSettingsStore.getState().fetchAll() + await useSettingsStore.getState().setAutoUpdateEnabled(false) + + expect(updateUser).toHaveBeenCalledWith({ autoUpdateEnabled: false }) + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false) + + vi.resetModules() + const restarted = await import('./settingsStore') + await restarted.useSettingsStore.getState().fetchAll() + + expect(restarted.useSettingsStore.getState().autoUpdateEnabled).toBe(false) + expect(user).toEqual({ unknownFuturePreference: 'keep', autoUpdateEnabled: false }) + await restarted.useSettingsStore.getState().setAutoUpdateEnabled(true) + expect(updateUser).toHaveBeenLastCalledWith({ autoUpdateEnabled: true }) + }) + + it.each([true, false])('rolls back to the persisted value %s when saving fails', async previous => { + const failure = new Error('fixture write failed') + const updateUser = vi.fn().mockRejectedValue(failure) + installSettingsFixture({ autoUpdateEnabled: previous }, updateUser) + const { useSettingsStore } = await import('./settingsStore') + await useSettingsStore.getState().fetchAll() + const saving = useSettingsStore.getState().setAutoUpdateEnabled(!previous) + + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(!previous) + await expect(saving).rejects.toBe(failure) + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(previous) + }) + + it('keeps overlapping saves ordered and does not restore a stale failed choice', async () => { + let rejectFirst!: (error: Error) => void + const updateUser = vi.fn() + .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject })) + .mockResolvedValueOnce({ ok: true }) + installSettingsFixture({}, updateUser) + const { useSettingsStore } = await import('./settingsStore') + const first = useSettingsStore.getState().setAutoUpdateEnabled(false) + const firstRejected = expect(first).rejects.toThrow('fixture first save failed') + await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1)) + const second = useSettingsStore.getState().setAutoUpdateEnabled(true) + rejectFirst(new Error('fixture first save failed')) + await firstRejected + await second + + expect(updateUser.mock.calls).toEqual([[{ autoUpdateEnabled: false }], [{ autoUpdateEnabled: true }]]) + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true) + }) + + it.each(['load-before-save', 'load-during-save', 'load-during-save-completes-after-save'] as const)('does not let %s restore automatic updates from a stale settings response', async timing => { + let finishSave!: () => void + let finishRead!: (settings: Record) => void + const user: Record = { autoUpdateEnabled: true } + const updateUser = vi.fn() + .mockImplementationOnce(async (patch: Record) => { + await new Promise(resolve => { finishSave = resolve }) + Object.assign(user, patch) + return { ok: true } + }) + .mockRejectedValueOnce(new Error('fixture later save failed')) + installSettingsFixture(user, updateUser) + const { useSettingsStore } = await import('./settingsStore') + const { settingsApi } = await import('../api/settings') + await useSettingsStore.getState().fetchAll() + vi.mocked(settingsApi.getUser).mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve })) + let loading: Promise + let saving: Promise + if (timing === 'load-before-save') { + loading = useSettingsStore.getState().fetchAll() + saving = useSettingsStore.getState().setAutoUpdateEnabled(false) + await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1)) + finishSave() + await saving + finishRead({ autoUpdateEnabled: true }) + await loading + } else { + saving = useSettingsStore.getState().setAutoUpdateEnabled(false) + await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1)) + loading = useSettingsStore.getState().fetchAll() + if (timing === 'load-during-save-completes-after-save') { + finishSave() + await saving + } + finishRead({ autoUpdateEnabled: true }) + await loading + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false) + if (timing === 'load-during-save') { + finishSave() + await saving + } + } + + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false) + expect(user.autoUpdateEnabled).toBe(false) + await expect(useSettingsStore.getState().setAutoUpdateEnabled(true)).rejects.toThrow('fixture later save failed') + expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false) + }) +}) + describe('settingsStore update proxy persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 34f51ee3..0b6eb582 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -89,6 +89,7 @@ type SettingsStore = { desktopTerminal: DesktopTerminalSettings webSearch: WebSearchSettings updateProxy: UpdateProxySettings + autoUpdateEnabled: boolean network: NetworkSettings /** null = never configured; the UI falls back to DEFAULT_CLEANUP_PERIOD_DAYS. */ cleanupPeriodDays: number | null @@ -126,6 +127,7 @@ type SettingsStore = { setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise setWebSearch: (settings: WebSearchSettings) => Promise setUpdateProxy: (settings: UpdateProxySettings) => Promise + setAutoUpdateEnabled: (enabled: boolean) => Promise setNetwork: (settings: NetworkSettings) => Promise setCleanupPeriodDays: (days: number) => Promise setTraceCaptureEnabled: (enabled: boolean) => Promise @@ -170,6 +172,10 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { mode: 'system', url: '', } +let autoUpdateSaveQueue: Promise = Promise.resolve() +let autoUpdateSaveVersion = 0 +let autoUpdatePendingSaves = 0 +let lastPersistedAutoUpdateEnabled = true // Keep milliseconds within the signed 32-bit timer limit, matching the server. export const NETWORK_TIMEOUT_MAX_SECONDS = Math.floor(2_147_483_647 / 1000) @@ -226,6 +232,7 @@ export const useSettingsStore = create((set, get) => ({ desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, + autoUpdateEnabled: true, network: DEFAULT_NETWORK_SETTINGS, cleanupPeriodDays: null, traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS, @@ -252,6 +259,8 @@ export const useSettingsStore = create((set, get) => ({ }, fetchAll: async () => { + const autoUpdateVersionAtStart = autoUpdateSaveVersion + const autoUpdateSavePendingAtStart = autoUpdatePendingSaves > 0 set({ isLoading: true, error: null }) try { const previousH5Access = get().h5Access @@ -274,6 +283,11 @@ export const useSettingsStore = create((set, get) => ({ ]) const desktopTerminal = normalizeDesktopTerminalSettings(userSettings.desktopTerminal) lastPersistedDesktopTerminal = desktopTerminal + // Read-time upgrade: existing settings keep automatic checks enabled + // until the user explicitly saves the new preference. + const hydrateAutoUpdate = !autoUpdateSavePendingAtStart && autoUpdatePendingSaves === 0 && + autoUpdateVersionAtStart === autoUpdateSaveVersion + if (hydrateAutoUpdate) lastPersistedAutoUpdateEnabled = userSettings.autoUpdateEnabled !== false // Nothing to do for the theme here: uiStore already applied it at // startup, and re-applying would re-persist and re-report it on every // provider switch. @@ -297,6 +311,7 @@ export const useSettingsStore = create((set, get) => ({ desktopTerminal, webSearch: normalizeWebSearchSettings(userSettings.webSearch), updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), + autoUpdateEnabled: hydrateAutoUpdate ? lastPersistedAutoUpdateEnabled : get().autoUpdateEnabled, network: normalizeNetworkSettings(userSettings.network), cleanupPeriodDays: normalizeCleanupPeriodDays(userSettings.cleanupPeriodDays), traceCapture, @@ -572,6 +587,31 @@ export const useSettingsStore = create((set, get) => ({ } }, + setAutoUpdateEnabled: async (enabled) => { + const saveVersion = ++autoUpdateSaveVersion + autoUpdatePendingSaves += 1 + set({ autoUpdateEnabled: enabled }) + const save = autoUpdateSaveQueue + .catch(() => undefined) + .then(async () => { + try { + await settingsApi.updateUser({ autoUpdateEnabled: enabled }) + lastPersistedAutoUpdateEnabled = enabled + } catch (error) { + if (saveVersion === autoUpdateSaveVersion) { + set({ autoUpdateEnabled: lastPersistedAutoUpdateEnabled }) + } + throw error + } + }) + autoUpdateSaveQueue = save + try { + await save + } finally { + autoUpdatePendingSaves -= 1 + } + }, + setNetwork: async (settings) => { const prev = get().network const next = normalizeNetworkSettings(settings) diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index f1f47f09..a541aba6 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { browserHost } from '../lib/desktopHost/browserHost' +import { createElectronHost } from '../lib/desktopHost/electronHost' +import type { DesktopUpdateDownloadEvent } from '../lib/desktopHost/types' +import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from '../../electron/ipc/channels' +import { ElectronUpdaterService, type ElectronUpdaterLike } from '../../electron/services/updater' const check = vi.fn() const relaunch = vi.fn() @@ -310,6 +314,238 @@ describe('updateStore', () => { expect(download).toHaveBeenCalledTimes(1) }) + it('honors the existing Later fixture across repeated startups until explicit installation', async () => { + vi.useFakeTimers() + try { + // Existing releases already persist this string; no new schema is needed. + window.localStorage.setItem('cc-haha-dismissed-update-version', '0.2.0') + const download = vi.fn().mockResolvedValue(undefined) + const install = vi.fn().mockResolvedValue(undefined) + check.mockResolvedValue({ version: '0.2.0', download, install, close: vi.fn() }) + + for (let restart = 0; restart < 3; restart += 1) { + vi.resetModules() + const { useUpdateStore } = await import('./updateStore') + const startup = useUpdateStore.getState().initialize() + await vi.advanceTimersByTimeAsync(5000) + await startup + + expect(useUpdateStore.getState().availableVersion).toBe('0.2.0') + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + expect(download).not.toHaveBeenCalled() + expect(install).not.toHaveBeenCalled() + expect(relaunch).not.toHaveBeenCalled() + } + + const { useUpdateStore } = await import('./updateStore') + await useUpdateStore.getState().installUpdate() + + expect(download).toHaveBeenCalledTimes(1) + expect(install).toHaveBeenCalledTimes(1) + expect(relaunch).toHaveBeenCalledTimes(1) + expect(window.localStorage.getItem('cc-haha-dismissed-update-version')).toBeNull() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('does not start automatic checks after automatic updates have been disabled', async () => { + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ autoUpdateEnabled: false }) + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().initialize() + await useUpdateStore.getState().checkForUpdates({ silent: true }) + + expect(check).not.toHaveBeenCalled() + expect(useUpdateStore.getState().status).toBe('idle') + }) + + it('honors disabling automatic updates during the startup delay', async () => { + vi.useFakeTimers() + try { + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ autoUpdateEnabled: true }) + const { useUpdateStore } = await import('./updateStore') + const startup = useUpdateStore.getState().initialize() + + useSettingsStore.setState({ autoUpdateEnabled: false }) + await vi.advanceTimersByTimeAsync(5000) + await startup + + expect(check).not.toHaveBeenCalled() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('starts a fresh automatic check when re-enabled after a skipped startup check', async () => { + vi.useFakeTimers() + try { + check.mockResolvedValue(null) + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + const { useUpdateStore } = await import('./updateStore') + const firstStartup = useUpdateStore.getState().initialize() + useSettingsStore.setState({ autoUpdateEnabled: false }) + await vi.advanceTimersByTimeAsync(5000) + await firstStartup + expect(check).not.toHaveBeenCalled() + + useSettingsStore.setState({ autoUpdateEnabled: true }) + const nextStartup = useUpdateStore.getState().initialize() + const duplicateStartup = useUpdateStore.getState().initialize() + await vi.advanceTimersByTimeAsync(5000) + await Promise.all([nextStartup, duplicateStartup]) + + expect(check).toHaveBeenCalledTimes(1) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('keeps manual checking and explicit installation available with automatic updates disabled', async () => { + vi.useFakeTimers() + try { + const download = vi.fn().mockResolvedValue(undefined) + const install = vi.fn().mockResolvedValue(undefined) + check.mockResolvedValue({ version: '0.2.0', download, install, close: vi.fn() }) + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ autoUpdateEnabled: false }) + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().checkForUpdates() + + expect(check).toHaveBeenCalledTimes(1) + expect(download).not.toHaveBeenCalled() + expect(useUpdateStore.getState().status).toBe('available') + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + + await useUpdateStore.getState().installUpdate() + + expect(download).toHaveBeenCalledTimes(1) + expect(install).toHaveBeenCalledTimes(1) + expect(relaunch).toHaveBeenCalledTimes(1) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('does not start a background download if automatic updates are disabled while checking', async () => { + const download = vi.fn().mockResolvedValue(undefined) + let resolveCheck!: (update: unknown) => void + check.mockImplementation(() => new Promise(resolve => { resolveCheck = resolve })) + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ autoUpdateEnabled: true }) + const { useUpdateStore } = await import('./updateStore') + const checking = useUpdateStore.getState().checkForUpdates({ silent: true }) + + useSettingsStore.setState({ autoUpdateEnabled: false }) + resolveCheck({ version: '0.2.0', download, close: vi.fn() }) + await checking + + expect(download).not.toHaveBeenCalled() + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + }) + + it('keeps a finishing download quiet when automatic updates have been disabled', async () => { + let finishDownload!: () => void + const download = vi.fn(() => new Promise(resolve => { finishDownload = resolve })) + check.mockResolvedValue({ version: '0.2.0', download, close: vi.fn() }) + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ autoUpdateEnabled: true }) + const { useUpdateStore } = await import('./updateStore') + await useUpdateStore.getState().checkForUpdates({ silent: true }) + const downloading = useUpdateStore.getState().downloadUpdate() + + useSettingsStore.setState({ autoUpdateEnabled: false }) + finishDownload() + await downloading + + expect(useUpdateStore.getState().status).toBe('downloaded') + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + }) + + it.each(['rejected', 'watchdog'] as const)('retries a %s restart through the real Electron host and updater without losing its download', async (failure) => { + vi.useFakeTimers() + try { + const updater: ElectronUpdaterLike = { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(async () => ({ updateInfo: { version: '0.2.0' } })), + downloadUpdate: vi.fn(async () => {}), + quitAndInstall: vi.fn(), + on: () => updater, + off: () => updater, + } + const service = new ElectronUpdaterService(updater) + let downloadHandler: ((event: DesktopUpdateDownloadEvent) => void) | null = null + let firstRelaunch = true + window.desktopHost = createElectronHost({ + async invoke(channel: ElectronIpcChannel) { + let result: unknown + switch (channel) { + case ELECTRON_IPC_CHANNELS.appGetVersion: result = '0.1.0'; break + case ELECTRON_IPC_CHANNELS.updateCheck: result = await service.checkForUpdates(); break + case ELECTRON_IPC_CHANNELS.updateDownload: + await service.downloadUpdate(event => downloadHandler?.(event)) + break + case ELECTRON_IPC_CHANNELS.updateInstall: service.stageDownloadedUpdate(); break + case ELECTRON_IPC_CHANNELS.updateCancelInstall: service.cancelInstall(); break + case ELECTRON_IPC_CHANNELS.updatePrepareInstall: break + case ELECTRON_IPC_CHANNELS.runtimeGetServerUrl: result = 'http://127.0.0.1:3456'; break + case ELECTRON_IPC_CHANNELS.updateRelaunch: + if (firstRelaunch) { + firstRelaunch = false + if (failure === 'rejected') throw new Error('fixture restart failed') + break + } + service.quitAndInstallDownloadedUpdate({}) + break + default: throw new Error(`Unexpected fixture IPC: ${channel}`) + } + return result as T + }, + async subscribe(_channel, handler) { + downloadHandler = handler as (event: DesktopUpdateDownloadEvent) => void + return () => { downloadHandler = null } + }, + }) + vi.resetModules() + const { useUpdateStore } = await import('./updateStore') + await useUpdateStore.getState().checkForUpdates() + await useUpdateStore.getState().downloadUpdate() + await useUpdateStore.getState().installUpdate() + if (failure === 'watchdog') await vi.advanceTimersByTimeAsync(15_000) + + expect(useUpdateStore.getState().status).toBe('downloaded') + expect(useUpdateStore.getState().error).toContain(failure === 'rejected' + ? 'fixture restart failed' + : 'Try installing the update again') + expect(service.hasStagedUpdate()).toBe(false) + expect(service.hasDownloadedUpdate()).toBe(true) + + await useUpdateStore.getState().installUpdate() + + expect(useUpdateStore.getState().status).toBe('restarting') + expect(updater.downloadUpdate).toHaveBeenCalledTimes(1) + expect(updater.quitAndInstall).toHaveBeenCalledTimes(1) + expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + it('prompts again when a newer version is available after dismissing an older one', async () => { const oldDownload = vi.fn(async (onEvent?: (event: unknown) => void) => { onEvent?.({ event: 'Started', data: { contentLength: 100 } }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index 58e767fd..01091dd6 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -65,7 +65,7 @@ function scheduleRelaunchWatchdog(host: DesktopHost) { useUpdateStore.setState((state) => ({ ...state, status: 'downloaded', - error: 'Restart did not start automatically. Restart the app manually to finish installing the update.', + error: 'Restart did not start automatically. Try installing the update again.', shouldPrompt: true, progressPercent: 100, })) @@ -136,7 +136,7 @@ async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | n } function shouldPromptForVersion(version: string | null) { - return !!version && readDismissedUpdateVersion() !== version + return useSettingsStore.getState().autoUpdateEnabled && !!version && readDismissedUpdateVersion() !== version } function getErrorMessage(error: unknown) { @@ -194,7 +194,7 @@ export const useUpdateStore = create((set, get) => ({ shouldPrompt: false, initialize: async () => { - if (!getUpdateHost()) return + if (!getUpdateHost() || !useSettingsStore.getState().autoUpdateEnabled) return if (!startupCheckPromise) { startupCheckPromise = (async () => { await new Promise((resolve) => setTimeout(resolve, 5000)) @@ -210,6 +210,7 @@ export const useUpdateStore = create((set, get) => ({ checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => { const host = getUpdateHost() if (!host) return null + if (silent && !useSettingsStore.getState().autoUpdateEnabled) return null if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate clearRelaunchWatchdog() @@ -281,7 +282,7 @@ export const useUpdateStore = create((set, get) => ({ shouldPrompt: false, })) - if (autoDownload && (shouldOffer || !silent)) { + if (autoDownload && useSettingsStore.getState().autoUpdateEnabled && (shouldOffer || !silent)) { void get().downloadUpdate().catch(() => { // The store records the failure and keeps the manual install path retryable. }) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index ef9c7c9e..b97faca2 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -163,6 +163,7 @@ export type UserSettings = { outputStyle?: string skipWebFetchPreflight?: boolean desktopNotificationsEnabled?: boolean + autoUpdateEnabled?: boolean webSearch?: WebSearchSettings updateProxy?: Partial network?: { diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts index 0db8b948..c9a4f36e 100644 --- a/scripts/quality-gate/package-smoke/index.test.ts +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { @@ -16,7 +16,19 @@ import { // These fixtures contain synthetic Mach-O headers, not runnable executables. // Resource execution has dedicated cases below with an explicit runner. function inspectPackagedArtifacts(rootDir: string, options: Parameters[1]) { - return inspectPackage(rootDir, { hostPlatform: 'linux', ...options }) + return inspectPackage(rootDir, { + hostPlatform: 'linux', + ...(options.platform === 'windows' ? { + commandRunner: (command: string, args: string[]) => { + expect(command).toBe('bun') + expect(args[0]).toBe('run') + expect(args[1]).toBe(join(rootDir, 'desktop', 'scripts', 'assert-windows-icon.ts')) + const branded = readFileSync(args[2], 'utf8') === 'synthetic branded executable' + return { status: branded ? 0 : 1, stderr: branded ? '' : 'The Windows executable icon differs from the application ICO' } + }, + } : {}), + ...options, + }) } function createRepoRoot() { @@ -85,6 +97,15 @@ function thinMachO(arch: 'arm64' | 'x64', minimum = '14.4') { return bytes } +function writeWindowsBranding(rootDir: string, unpackedDir: string) { + // The root-only policy lane mocks the parser process; the desktop parser + // suite separately verifies real synthetic PE resource tables. + const icon = 'synthetic application ICO' + writeFile(rootDir, `${unpackedDir}/Claude Code Haha.exe`, 'synthetic branded executable') + writeFile(rootDir, `${unpackedDir}/resources/app-icon.ico`, icon) + writeFile(rootDir, 'desktop/src-tauri/icons/icon.ico', icon) +} + const tempDirs: string[] = [] afterEach(() => { @@ -171,7 +192,7 @@ describe('final macOS helper cursor resource verification', () => { commandRunner: (command, args, options) => { expect(args).toEqual(['--probe-cursor-resources']) expect(command.startsWith(source.helper)).toBe(false) - expect(command).toContain('Relocated Helper.app/Contents/MacOS/cc-haha-computer-use') + expect(command.replaceAll('\\', '/')).toContain('Relocated Helper.app/Contents/MacOS/cc-haha-computer-use') expect(options?.timeout).toBe(10_000) expect(options?.maxBuffer).toBe(1024 * 1024) temporaryRoot = options!.cwd @@ -638,6 +659,7 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') writeFile(rootDir, 'desktop/build-artifacts/electron/latest.yml', 'path: Claude Code Haha Setup 0.3.1.exe\n') + writeWindowsBranding(rootDir, 'desktop/build-artifacts/electron/win-unpacked') const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows' }) @@ -658,6 +680,7 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') writeFile(rootDir, 'desktop/build-artifacts/windows-x64/latest.yml', 'path: Claude-Code-Haha-0.3.1-x64.exe\n') + writeWindowsBranding(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked') const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', @@ -667,7 +690,7 @@ describe('packaged artifact inspection', () => { }) expect(report.passed).toBe(true) - expect(report.artifactsDir.endsWith('desktop/build-artifacts/windows-x64')).toBe(true) + expect(report.artifactsDir.replaceAll('\\', '/').endsWith('desktop/build-artifacts/windows-x64')).toBe(true) }) test('passes Windows arm64 checks only when arm64 sidecar and node-pty native module are present', async () => { @@ -682,6 +705,7 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-arm64/pty.node') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/latest.yml', 'path: Claude-Code-Haha-0.3.1-arm64.exe\n') + writeWindowsBranding(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked') const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', @@ -729,6 +753,7 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + writeWindowsBranding(rootDir, 'desktop/build-artifacts/electron/win-unpacked') const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'dir' }) @@ -737,6 +762,21 @@ describe('packaged artifact inspection', () => { expect(report.notes.join('\n')).toContain('Windows app-update.yml was not required') }) + test.each(['missing-icon', 'wrong-icon', 'wrong-executable'] as const)('rejects Windows branding with %s', async failure => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + const unpackedDir = 'desktop/build-artifacts/electron/win-unpacked' + writeFile(rootDir, `${unpackedDir}/resources/app.asar`) + writeWindowsBranding(rootDir, unpackedDir) + if (failure === 'missing-icon') rmSync(join(rootDir, unpackedDir, 'resources/app-icon.ico')) + if (failure === 'wrong-icon') writeFile(rootDir, `${unpackedDir}/resources/app-icon.ico`, 'wrong icon') + if (failure === 'wrong-executable') writeFile(rootDir, `${unpackedDir}/Claude Code Haha.exe`, 'unbranded executable') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'dir' }) + expect(report.missingChecks.some(check => check.label === 'Windows application and taskbar icons')).toBe(true) + expect(report.notes.join('\n')).toContain('Windows icon inspection failed:') + }) + test('does not treat the win-unpacked app executable as a Windows release installer', async () => { const rootDir = createRepoRoot() tempDirs.push(rootDir) @@ -798,7 +838,7 @@ describe('packaged artifact inspection', () => { }) expect(report.passed).toBe(true) - expect(report.artifactsDir.endsWith('desktop/build-artifacts/linux-x64')).toBe(true) + expect(report.artifactsDir.replaceAll('\\', '/').endsWith('desktop/build-artifacts/linux-x64')).toBe(true) }) test('accepts Linux architecture-specific update metadata from arm64 builds', async () => { @@ -872,7 +912,7 @@ describe('packaged artifact inspection', () => { }) expect(report.passed).toBe(true) - expect(report.passedChecks.some((check) => check.path.includes('linux-arm64-unpacked/resources/app.asar'))).toBe(true) + expect(report.passedChecks.some((check) => check.path.replaceAll('\\', '/').includes('linux-arm64-unpacked/resources/app.asar'))).toBe(true) expect(report.packagedArtifacts.some((artifact) => artifact.label === 'Linux RPM package')).toBe(true) }) diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts index dbc6ad71..b269ca92 100644 --- a/scripts/quality-gate/package-smoke/index.ts +++ b/scripts/quality-gate/package-smoke/index.ts @@ -573,7 +573,7 @@ function assertCursorResourcesContained(helperApp: string, directory: string) { const target = pending.pop()! const canonical = realpathSync(target) const withinApp = relative(app, canonical) - if (isAbsolute(withinApp) || withinApp === '..' || withinApp.startsWith('../')) { + if (isAbsolute(withinApp) || withinApp === '..' || normalizePath(withinApp).startsWith('../')) { throw new Error(`cursor resource escapes the helper app: ${target}`) } if (visited.has(canonical)) continue @@ -1049,7 +1049,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti } } -function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { +function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport, options: InspectOptions) { const installers = findMatches(report.artifactsDir, (candidate) => { const normalized = normalizePath(candidate) return normalized.endsWith('.exe') && !isInsideWindowsUnpackedDir(normalized) @@ -1079,6 +1079,34 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { if (unpackedDir) { const resourcesDir = join(unpackedDir, 'resources') + const executable = join(unpackedDir, `${report.productName}.exe`) + const shellIcon = join(resourcesDir, 'app-icon.ico') + const iconRecord = { label: 'Windows application and taskbar icons', path: toRelative(rootDir, executable) } + let iconProbeRoot: string | undefined + try { + const expectedIconPath = join(rootDir, 'desktop', 'src-tauri', 'icons', 'icon.ico') + const expectedIcon = readFileSync(expectedIconPath) + if (!readFileSync(shellIcon).equals(expectedIcon)) throw new Error('The taskbar ICO differs from the application ICO') + if (!existsSync(executable)) throw new Error('The Windows app executable is missing') + // Desktop packaging installs the builder's PE parser; the root-only + // policy lane must not import desktop dependencies while checking Linux. + iconProbeRoot = mkdtempSync(join(tmpdir(), 'cc-haha-windows-icon-')) + const result = (options.commandRunner ?? defaultCommandRunner)('bun', [ + 'run', join(rootDir, 'desktop', 'scripts', 'assert-windows-icon.ts'), executable, expectedIconPath, + ], { + cwd: rootDir, + env: createSandboxedTestEnvironment(iconProbeRoot, undefined, process.env), + timeout: 30_000, + maxBuffer: 1024 * 1024, + }) + if (result.status !== 0) throw new Error(firstDiagnosticLine(result.stderr ?? '') ?? 'The Windows executable icon inspection failed') + report.passedChecks.push(iconRecord) + } catch (error) { + report.missingChecks.push(iconRecord) + report.notes.push(`Windows icon inspection failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + if (iconProbeRoot) rmSync(iconProbeRoot, { recursive: true, force: true }) + } const unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked') const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty') const sidecarDir = join(unpackedResourcesDir, 'src-tauri', 'binaries') @@ -1298,7 +1326,7 @@ export async function inspectPackagedArtifacts(rootDir: string, options: Inspect if (options.platform === 'macos') { inspectMacosArtifacts(resolvedRootDir, report, options) } else if (options.platform === 'windows') { - inspectWindowsArtifacts(resolvedRootDir, report) + inspectWindowsArtifacts(resolvedRootDir, report, options) } else { inspectLinuxArtifacts(resolvedRootDir, report) } diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index 2a5268f0..0d507915 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -185,9 +185,9 @@ describe('E2E: Full Flow', () => { // 3. Settings // ============================================= - it('should expose the default team preference in initial settings', async () => { + it('should expose the default team and automatic update preferences in initial settings', async () => { const { data } = await api('GET', '/api/settings/user') - expect(data).toEqual({ agentTeamsEnabled: true }) + expect(data).toEqual({ agentTeamsEnabled: true, autoUpdateEnabled: true }) }) it('should update and read user settings', async () => { diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts index 658db679..4c31b192 100644 --- a/src/server/__tests__/persistence-upgrade.test.ts +++ b/src/server/__tests__/persistence-upgrade.test.ts @@ -56,6 +56,36 @@ describe('persistent storage upgrade migrations', () => { expect(await new SettingsService().getAgentTeamsEnabled()).toBe(false) }) + test('upgrades the legacy automatic update preference on read without rewriting shared user settings', async () => { + const userPath = path.join(tempDir, 'settings.json') + const legacy = { + env: { UNKNOWN_ENV: 'preserved' }, + updateProxy: { mode: 'manual', url: 'http://127.0.0.1:7890' }, + unknownFuturePreference: { keep: true }, + } + const original = JSON.stringify(legacy) + await fs.writeFile(userPath, original) + const service = new SettingsService() + + expect(await service.getAutoUpdateEnabled()).toBe(true) + expect(await fs.readFile(userPath, 'utf-8')).toBe(original) + await service.updateUserSettings({ autoUpdateEnabled: false }) + + expect(await new SettingsService().getAutoUpdateEnabled()).toBe(false) + expect(JSON.parse(await fs.readFile(userPath, 'utf-8'))).toEqual({ ...legacy, autoUpdateEnabled: false }) + await service.updateUserSettings({ autoUpdateEnabled: true }) + expect(await new SettingsService().getAutoUpdateEnabled()).toBe(true) + }) + + test('rejects a non-boolean automatic update choice without changing the settings file', async () => { + const userPath = path.join(tempDir, 'settings.json') + const original = JSON.stringify({ autoUpdateEnabled: false, unknownFuturePreference: 'keep' }) + await fs.writeFile(userPath, original) + + await expect(new SettingsService().updateUserSettings({ autoUpdateEnabled: 'false' })).rejects.toThrow('autoUpdateEnabled must be a boolean') + expect(await fs.readFile(userPath, 'utf-8')).toBe(original) + }) + test('migrates legacy providers index and writes a backup before changing it', async () => { const ccHahaDir = path.join(tempDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 7bf13d01..304e65c0 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -502,7 +502,32 @@ describe('Settings API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body).toEqual({ agentTeamsEnabled: true }) + expect(body).toEqual({ agentTeamsEnabled: true, autoUpdateEnabled: true }) + }) + + it('persists automatic updates through the user settings API and preserves unrelated fields', async () => { + const settingsPath = path.join(tmpDir, 'settings.json') + const original = JSON.stringify({ futureSetting: { keep: true } }) + await fs.writeFile(settingsPath, original) + const initial = makeRequest('GET', '/api/settings/user') + const oldSettings = await handleSettingsApi(initial.req, initial.url, initial.segments) + expect(await oldSettings.json()).toMatchObject({ autoUpdateEnabled: true, futureSetting: { keep: true } }) + expect(await fs.readFile(settingsPath, 'utf-8')).toBe(original) + + for (const enabled of [false, true]) { + const put = makeRequest('PUT', '/api/settings/user', { autoUpdateEnabled: enabled }) + expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(200) + const get = makeRequest('GET', '/api/settings/user') + const response = await handleSettingsApi(get.req, get.url, get.segments) + expect(await response.json()).toMatchObject({ autoUpdateEnabled: enabled, futureSetting: { keep: true } }) + expect(JSON.parse(await fs.readFile(settingsPath, 'utf-8'))).toEqual({ futureSetting: { keep: true }, autoUpdateEnabled: enabled }) + } + }) + + it.each(['false', null, 1])('rejects invalid automatic update preference %j without changing settings', async value => { + const put = makeRequest('PUT', '/api/settings/user', { autoUpdateEnabled: value }) + expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(400) + expect(await new SettingsService().getUserSettings()).toEqual({}) }) it('persists the General team preference across reads and overrides legacy env', async () => { diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts index cb866f7b..66e39c83 100644 --- a/src/server/api/settings.ts +++ b/src/server/api/settings.ts @@ -101,8 +101,10 @@ export async function handleSettingsApi( async function handleUserSettings(req: Request): Promise { if (req.method === 'GET') { + const userSettings = await settingsService.getUserSettings() return Response.json({ - ...await settingsService.getUserSettings(), + ...userSettings, + autoUpdateEnabled: await settingsService.getAutoUpdateEnabled(userSettings), agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(), }) } diff --git a/src/server/services/settingsService.ts b/src/server/services/settingsService.ts index b6255695..0653bc6c 100644 --- a/src/server/services/settingsService.ts +++ b/src/server/services/settingsService.ts @@ -104,6 +104,14 @@ export class SettingsService { return this.readJsonFile(this.getUserSettingsPath()) } + /** Read-time upgrade for settings written before the desktop update toggle. + * Do not rewrite the user-owned file until an explicit preference is saved. + */ + async getAutoUpdateEnabled(settings?: Record): Promise { + const user = settings ?? await this.getUserSettings() + return user.autoUpdateEnabled !== false + } + /** Read-time upgrade for older settings that only stored the team env flag. * Keep the original file intact until the user explicitly saves a choice. */ @@ -189,6 +197,9 @@ export class SettingsService { /** 更新用户级设置(顶层浅合并,并保留桌面终端的未知子字段) */ async updateUserSettings(settings: Record): Promise { + if (Object.hasOwn(settings, 'autoUpdateEnabled') && typeof settings.autoUpdateEnabled !== 'boolean') { + throw ApiError.badRequest('autoUpdateEnabled must be a boolean') + } if (Object.hasOwn(settings, 'agentTeamsEnabled') && typeof settings.agentTeamsEnabled !== 'boolean') { throw ApiError.badRequest('agentTeamsEnabled must be a boolean') }