fix(desktop): respect update deferrals and add automatic update control (#1482)

This commit is contained in:
程序员阿江-Relakkes
2026-10-09 20:53:03 +08:00
committed by GitHub
parent 103b730d0d
commit 0d295d0ecc
31 changed files with 1326 additions and 49 deletions
+9 -2
View File
@@ -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()
+119 -2
View File
@@ -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<typeof resolveWindowsWindowIcon>[0]) => resolveWindowsWindowIcon(paths, 'win32'),
applyWindowsTaskbarIdentity: (host: Parameters<typeof applyWindowsTaskbarIdentity>[0], paths: Parameters<typeof applyWindowsTaskbarIdentity>[1]) => applyWindowsTaskbarIdentity(host, paths, 'win32'),
createMainWindow: undefined as undefined | (() => Promise<unknown>),
}
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 })
}
})
})
+41
View File
@@ -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<BrowserWindow, 'setAppDetails'>,
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
}
+1
View File
@@ -44,6 +44,7 @@ function writeLog(logPath: string | undefined, payload: Record<string, unknown>)
class UpdateSmokeUpdater implements ElectronUpdaterLike {
autoDownload = true
autoInstallOnAppQuit = true
logger: unknown = null
private progressHandler: ProgressHandler | null = null
+117
View File
@@ -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)
+20 -4
View File
@@ -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<ElectronUpdateCheckResult>
@@ -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<ElectronUpdateMetadata | null> {
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
@@ -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<string, string> } | null = null
let nativeDownload: Promise<void> | null = null
const nativeUpdater = Object.assign(nativeEvents, {
setFeedURL: vi.fn((next: NonNullable<typeof feed>) => { 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<unknown>
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<void>(resolve => updater.server!.close(() => resolve()))
}
rmSync(root, { recursive: true, force: true })
}
})
})
+4 -1
View File
@@ -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,
@@ -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 })
}
})
})
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { Data, NtExecutable, NtExecutableResource, Resource } from 'resedit'
type IconItem = InstanceType<typeof Data.IconItem> | InstanceType<typeof Data.RawIconItem>
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 <executable> <expected.ico>')
assertWindowsExecutableIcon(readFileSync(executablePath), readFileSync(expectedIconPath))
console.log('Windows executable embeds the application ICO')
}
@@ -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(<UpdateChecker />)
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(<UpdateChecker />)
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'))
})
@@ -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'
+5 -2
View File
@@ -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}.',
+5 -2
View File
@@ -3574,6 +3574,9 @@ export const jp: Record<TranslationKey, string> = {
// ─── 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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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} は最新です。',
+5 -2
View File
@@ -3576,6 +3576,9 @@ export const kr: Record<TranslationKey, string> = {
// ─── 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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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}은(는) 최신입니다.',
+5 -2
View File
@@ -3573,6 +3573,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── 更新檢查 ──────────────────────────────────────
'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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'update.proxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。',
'update.proxyUrlRequired': '請輸入代理地址。',
'update.releaseNotes': '更新說明',
'update.readyBody': 'v{version} 已下載。方便時重啟即可使用新版。',
'update.readyBody': 'v{version} 已下載。點擊「安裝並重啟」後套用新版。',
'update.readyTitle': '更新已準備好',
'update.restarting': '正在重啟以完成更新...',
'update.upToDate': '當前已是最新版本 v{version}。',
+5 -2
View File
@@ -3572,6 +3572,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── 更新检查 ──────────────────────────────────────
'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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
'update.proxyUrlRequired': '请输入代理地址。',
'update.releaseNotes': '更新说明',
'update.readyBody': 'v{version} 已下载。方便时重启即可使用新版。',
'update.readyBody': 'v{version} 已下载。点击「安装并重启」后应用新版。',
'update.readyTitle': '更新已准备好',
'update.restarting': '正在重启以完成更新...',
'update.upToDate': '当前已是最新版本 v{version}。',
@@ -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(<AboutSettings />)
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(<AboutSettings />)
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')
+31 -2
View File
@@ -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<string | null>(null)
const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false)
const [isSavingAutoUpdate, setIsSavingAutoUpdate] = useState(false)
const [autoUpdateSaveError, setAutoUpdateSaveError] = useState<string | null>(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() {
)}
>
<SettingsGroup>
<SettingsSwitchRow
title={t('update.autoUpdate')}
description={t('update.autoUpdateDescription')}
checked={autoUpdateEnabled}
disabled={isSavingAutoUpdate}
onChange={(enabled) => void saveAutoUpdateEnabled(enabled)}
footer={autoUpdateSaveError ? (
<p role="alert" className="text-xs leading-[1.5] text-[var(--color-error)]">
{t('update.autoUpdateSaveFailed', { error: autoUpdateSaveError })}
</p>
) : undefined}
/>
<SettingsRow
title={(
<span className="inline-flex flex-wrap items-center gap-2">
+147
View File
@@ -237,6 +237,153 @@ describe('settingsStore Auto mode consent', () => {
})
})
describe('settingsStore automatic update preference', () => {
function installSettingsFixture(user: Record<string, unknown> = {}, 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<string, unknown> = { unknownFuturePreference: 'keep' }
const updateUser = vi.fn(async (patch: Record<string, unknown>) => {
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<string, unknown>) => void
const user: Record<string, unknown> = { autoUpdateEnabled: true }
const updateUser = vi.fn()
.mockImplementationOnce(async (patch: Record<string, unknown>) => {
await new Promise<void>(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<void>
let saving: Promise<void>
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()
+40
View File
@@ -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<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
setAutoUpdateEnabled: (enabled: boolean) => Promise<void>
setNetwork: (settings: NetworkSettings) => Promise<void>
setCleanupPeriodDays: (days: number) => Promise<void>
setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
@@ -170,6 +172,10 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
mode: 'system',
url: '',
}
let autoUpdateSaveQueue: Promise<void> = 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<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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)
+236
View File
@@ -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<void>(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<T>(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 } })
+5 -4
View File
@@ -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<UpdateStore>((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<UpdateStore>((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<UpdateStore>((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.
})
+1
View File
@@ -163,6 +163,7 @@ export type UserSettings = {
outputStyle?: string
skipWebFetchPreflight?: boolean
desktopNotificationsEnabled?: boolean
autoUpdateEnabled?: boolean
webSearch?: WebSearchSettings
updateProxy?: Partial<UpdateProxySettings>
network?: {
@@ -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<typeof inspectPackage>[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)
})
+31 -3
View File
@@ -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)
}
+2 -2
View File
@@ -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 () => {
@@ -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 })
+26 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -101,8 +101,10 @@ export async function handleSettingsApi(
async function handleUserSettings(req: Request): Promise<Response> {
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(),
})
}
+11
View File
@@ -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<string, unknown>): Promise<boolean> {
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<string, unknown>): Promise<void> {
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')
}