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' } from './services/appMode'
import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain'
import { installStdioWriteFailureGuards } from './services/stdioGuards' import { installStdioWriteFailureGuards } from './services/stdioGuards'
import { applyWindowsAppUserModelId } from './services/appIdentity' import { applyWindowsAppUserModelId, applyWindowsTaskbarIdentity, resolveWindowsWindowIcon } from './services/appIdentity'
import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards' import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards'
import { installPreviewCleanupOnRendererNavigation } from './services/previewLifecycle' import { installPreviewCleanupOnRendererNavigation } from './services/previewLifecycle'
import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke' 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.updatePrepareInstall, async () => { await publicAccessManager?.stop(); getServerRuntime().stopAll() })
registerHandler(ELECTRON_IPC_CHANNELS.updateCancelInstall, () => getUpdaterService().cancelInstall()) registerHandler(ELECTRON_IPC_CHANNELS.updateCancelInstall, () => getUpdaterService().cancelInstall())
registerHandler(ELECTRON_IPC_CHANNELS.updateRelaunch, () => { registerHandler(ELECTRON_IPC_CHANNELS.updateRelaunch, () => {
if (getUpdaterService().hasDownloadedUpdate()) { if (getUpdaterService().hasStagedUpdate()) {
isQuitting = true isQuitting = true
getUpdaterService().quitAndInstallDownloadedUpdate() getUpdaterService().quitAndInstallDownloadedUpdate()
return return
@@ -894,8 +894,10 @@ function registerIpcHandlers() {
async function createMainWindow() { async function createMainWindow() {
const restoredState = readWindowState(app, screen.getAllDisplays()) const restoredState = readWindowState(app, screen.getAllDisplays())
const bounds = windowOptionsFromState(restoredState) const bounds = windowOptionsFromState(restoredState)
const identityPaths = { desktopRoot: appRoot(), resourcesPath: process.resourcesPath }
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
...bounds, ...bounds,
icon: resolveWindowsWindowIcon(identityPaths),
minWidth: MIN_WINDOW_WIDTH, minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT, minHeight: MIN_WINDOW_HEIGHT,
show: false, show: false,
@@ -913,6 +915,11 @@ async function createMainWindow() {
webviewTag: true, webviewTag: true,
}, },
}) })
applyWindowsTaskbarIdentity(mainWindow, {
...identityPaths,
isPackaged: app.isPackaged,
executablePath: process.execPath,
})
// Before any guest can exist: its session denies OS permissions, and its // Before any guest can exist: its session denies OS permissions, and its
// preferences are replaced with the sandboxed set the policy pins. // preferences are replaced with the sandboxed set the policy pins.
workspaceBrowserSession() 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 path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest' 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', () => { describe('applyWindowsAppUserModelId', () => {
it('sets the AppUserModelID on Windows so toast notifications are attributed to the app', () => { 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) 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 = { export type AppUserModelIdHost = {
setAppUserModelId(id: string): void setAppUserModelId(id: string): void
} }
@@ -16,3 +20,40 @@ export function applyWindowsAppUserModelId(
app.setAppUserModelId(appUserModelId) app.setAppUserModelId(appUserModelId)
return true 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 { class UpdateSmokeUpdater implements ElectronUpdaterLike {
autoDownload = true autoDownload = true
autoInstallOnAppQuit = true
logger: unknown = null logger: unknown = null
private progressHandler: ProgressHandler | null = null private progressHandler: ProgressHandler | null = null
+117
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater' import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater'
import { mkdtempSync, rmSync } from 'node:fs' import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
@@ -13,6 +14,7 @@ function fakeUpdater(): ElectronUpdaterLike & {
let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null
const updater = { const updater = {
autoDownload: true, autoDownload: true,
autoInstallOnAppQuit: true,
checkForUpdates: vi.fn(), checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(), downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(), quitAndInstall: vi.fn(),
@@ -168,6 +170,34 @@ describe('Electron updater service', () => {
await expect(service.checkForUpdates()).rejects.toThrow('feed unavailable') 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 () => { it('stages then installs through quitAndInstall only after an update has downloaded', async () => {
const service = new ElectronUpdaterService(updater) const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
@@ -183,12 +213,98 @@ describe('Electron updater service', () => {
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) 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 () => { it('hands the spawned installer an environment without the app-managed portable selection', async () => {
const service = new ElectronUpdaterService(updater) const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
updater.downloadUpdate.mockResolvedValue(undefined) updater.downloadUpdate.mockResolvedValue(undefined)
await service.checkForUpdates() await service.checkForUpdates()
await service.downloadUpdate(() => {}) await service.downloadUpdate(() => {})
service.stageDownloadedUpdate()
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data', CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data',
@@ -208,6 +324,7 @@ describe('Electron updater service', () => {
updater.downloadUpdate.mockResolvedValue(undefined) updater.downloadUpdate.mockResolvedValue(undefined)
await service.checkForUpdates() await service.checkForUpdates()
await service.downloadUpdate(() => {}) await service.downloadUpdate(() => {})
service.stageDownloadedUpdate()
const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' } const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' }
service.quitAndInstallDownloadedUpdate(env) service.quitAndInstallDownloadedUpdate(env)
+20 -4
View File
@@ -9,6 +9,7 @@ export type ElectronUpdateInfo = {
} }
export type ElectronUpdateCheckResult = { export type ElectronUpdateCheckResult = {
isUpdateAvailable?: boolean
updateInfo?: ElectronUpdateInfo updateInfo?: ElectronUpdateInfo
} | null } | null
@@ -18,6 +19,7 @@ export type ElectronUpdateCheckOptions = {
export type ElectronUpdaterLike = { export type ElectronUpdaterLike = {
autoDownload: boolean autoDownload: boolean
autoInstallOnAppQuit: boolean
disableDifferentialDownload?: boolean disableDifferentialDownload?: boolean
logger?: unknown logger?: unknown
checkForUpdates(): Promise<ElectronUpdateCheckResult> checkForUpdates(): Promise<ElectronUpdateCheckResult>
@@ -93,6 +95,7 @@ export class ElectronUpdaterService {
private readonly updateConfigPath?: string private readonly updateConfigPath?: string
private pendingUpdate: ElectronUpdateMetadata | null = null private pendingUpdate: ElectronUpdateMetadata | null = null
private downloaded = false private downloaded = false
private installRequested = false
private proxyKey: string | null = null private proxyKey: string | null = null
constructor( constructor(
@@ -104,6 +107,10 @@ export class ElectronUpdaterService {
this.proxyController = proxyController this.proxyController = proxyController
this.updateConfigPath = runtimeOptions.updateConfigPath this.updateConfigPath = runtimeOptions.updateConfigPath
this.updater.autoDownload = false 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 // Differential download issues many small sequential range requests and is
// RTT-bound against the GitHub CDN, so it downloads far below line speed. // RTT-bound against the GitHub CDN, so it downloads far below line speed.
this.updater.disableDifferentialDownload = true this.updater.disableDifferentialDownload = true
@@ -122,6 +129,7 @@ export class ElectronUpdaterService {
} }
async checkForUpdates(options?: ElectronUpdateCheckOptions): Promise<ElectronUpdateMetadata | null> { async checkForUpdates(options?: ElectronUpdateCheckOptions): Promise<ElectronUpdateMetadata | null> {
this.installRequested = false
let result: ElectronUpdateCheckResult let result: ElectronUpdateCheckResult
try { try {
await this.applyProxy(options) await this.applyProxy(options)
@@ -134,7 +142,7 @@ export class ElectronUpdaterService {
if (!isMissingUpdateMetadataError(error)) throw error if (!isMissingUpdateMetadataError(error)) throw error
result = null result = null
} }
this.pendingUpdate = normalizeUpdateInfo(result?.updateInfo) this.pendingUpdate = result?.isUpdateAvailable === false ? null : normalizeUpdateInfo(result?.updateInfo)
this.downloaded = false this.downloaded = false
return this.pendingUpdate return this.pendingUpdate
} }
@@ -177,8 +185,9 @@ export class ElectronUpdaterService {
} }
cancelInstall() { cancelInstall() {
this.pendingUpdate = null // The renderer retains the downloaded update on an install/relaunch
this.downloaded = false // failure. Revoke the install request, but keep the download retryable.
this.installRequested = false
} }
stageDownloadedUpdate() { stageDownloadedUpdate() {
@@ -188,14 +197,21 @@ export class ElectronUpdaterService {
if (!this.downloaded) { if (!this.downloaded) {
throw new Error('Electron update has not finished downloading') throw new Error('Electron update has not finished downloading')
} }
this.installRequested = true
} }
hasDownloadedUpdate(): boolean { hasDownloadedUpdate(): boolean {
return !!this.pendingUpdate && this.downloaded return !!this.pendingUpdate && this.downloaded
} }
hasStagedUpdate(): boolean {
return this.hasDownloadedUpdate() && this.installRequested
}
quitAndInstallDownloadedUpdate(env: NodeJS.ProcessEnv = process.env) { 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. // The NSIS installer spawned here inherits this process's environment.
// Hand it the same clean environment a manually launched setup gets, so // 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 // 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": { "win": {
"target": "nsis", "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": { "nsis": {
"oneClick": false, "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', () => { describe('UpdateChecker', () => {
beforeEach(() => { beforeEach(() => {
useSettingsStore.setState({ locale: 'en' }) useSettingsStore.setState({ locale: 'en', autoUpdateEnabled: true })
Reflect.deleteProperty(window, '__TAURI__') Reflect.deleteProperty(window, '__TAURI__')
window.desktopHost = { window.desktopHost = {
...browserHost, ...browserHost,
@@ -44,7 +44,7 @@ describe('UpdateChecker', () => {
render(<UpdateChecker />) render(<UpdateChecker />)
expect(screen.getByText('Update ready')).toBeInTheDocument() 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() expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Release notes' }) const link = screen.getByRole('link', { name: 'Release notes' })
@@ -52,6 +52,30 @@ describe('UpdateChecker', () => {
expect(link).toHaveAttribute('target', '_blank') 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', () => { it('renders the update prompt in Electron desktop runtime', () => {
window.desktopHost = { window.desktopHost = {
...browserHost, ...browserHost,
@@ -176,7 +200,7 @@ describe('UpdateChecker', () => {
}) })
expect(await screen.findByText('Update ready')).toBeInTheDocument() 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 () => { await act(async () => {
fireEvent.click(screen.getByText('Install and restart')) fireEvent.click(screen.getByText('Install and restart'))
}) })
@@ -4,9 +4,11 @@ import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { isDesktopRuntime } from '../../lib/desktopRuntime' import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { useUpdateStore } from '../../stores/updateStore' import { useUpdateStore } from '../../stores/updateStore'
import { useSettingsStore } from '../../stores/settingsStore'
export function UpdateChecker() { export function UpdateChecker() {
const t = useTranslation() const t = useTranslation()
const autoUpdateEnabled = useSettingsStore((s) => s.autoUpdateEnabled)
const status = useUpdateStore((s) => s.status) const status = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion) const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes) const releaseNotes = useUpdateStore((s) => s.releaseNotes)
@@ -17,10 +19,10 @@ export function UpdateChecker() {
const dismissPrompt = useUpdateStore((s) => s.dismissPrompt) const dismissPrompt = useUpdateStore((s) => s.dismissPrompt)
useEffect(() => { useEffect(() => {
void initialize() if (autoUpdateEnabled) void initialize()
}, [initialize]) }, [autoUpdateEnabled, initialize])
if (!isDesktopRuntime()) return null if (!isDesktopRuntime() || !autoUpdateEnabled) return null
const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded' 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 Checker ──────────────────────────────────────
'update.available': 'v{version} available', '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.availableLabel': 'Available',
'update.checking': 'Checking for updates...', 'update.checking': 'Checking for updates...',
'update.checkNow': 'Check now', '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.currentVersionUnknown': 'Unknown',
'update.newVersion': 'New version v{version} available', 'update.newVersion': 'New version v{version} available',
'update.downloading': 'Downloading...', '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.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
'update.installAndRestart': 'Install and restart', 'update.installAndRestart': 'Install and restart',
'update.installing': 'Installing update...', '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.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
'update.proxyUrlRequired': 'Enter a proxy URL.', 'update.proxyUrlRequired': 'Enter a proxy URL.',
'update.releaseNotes': 'Release Notes', '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.readyTitle': 'Update ready',
'update.restarting': 'Restarting to finish update...', 'update.restarting': 'Restarting to finish update...',
'update.upToDate': 'You are up to date on v{version}.', '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 Checker ──────────────────────────────────────
'update.available': 'v{version} が利用可能', 'update.available': 'v{version} が利用可能',
'update.autoUpdate': '自動更新',
'update.autoUpdateDescription': '新しいバージョンを自動で確認し、バックグラウンドでダウンロードします。オフにしても手動での確認とインストールは利用できます。',
'update.autoUpdateSaveFailed': '自動更新の設定を保存できませんでした:{error}',
'update.availableLabel': '利用可能', 'update.availableLabel': '利用可能',
'update.checking': '更新を確認中...', 'update.checking': '更新を確認中...',
'update.checkNow': '今すぐ確認', 'update.checkNow': '今すぐ確認',
@@ -3581,7 +3584,7 @@ export const jp: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '不明', 'update.currentVersionUnknown': '不明',
'update.newVersion': '新しいバージョン v{version} が利用可能', 'update.newVersion': '新しいバージョン v{version} が利用可能',
'update.downloading': 'ダウンロード中...', 'update.downloading': 'ダウンロード中...',
'update.downloaded': '更新をダウンロードしました。使用する準備ができたら再起動してください。', 'update.downloaded': '更新をダウンロードしました。「インストールして再起動」をクリックして適用してください。',
'update.idle': '更新を確認して、インストール済みのバージョンを最新の GitHub Release と比較します。', 'update.idle': '更新を確認して、インストール済みのバージョンを最新の GitHub Release と比較します。',
'update.installAndRestart': 'インストールして再起動', 'update.installAndRestart': 'インストールして再起動',
'update.installing': '更新をインストール中...', 'update.installing': '更新をインストール中...',
@@ -3601,7 +3604,7 @@ export const jp: Record<TranslationKey, string> = {
'update.proxyUrlInvalid': 'HTTP または HTTPS のプロキシ URL を入力してください。', 'update.proxyUrlInvalid': 'HTTP または HTTPS のプロキシ URL を入力してください。',
'update.proxyUrlRequired': 'プロキシ URL を入力してください。', 'update.proxyUrlRequired': 'プロキシ URL を入力してください。',
'update.releaseNotes': 'リリースノート', 'update.releaseNotes': 'リリースノート',
'update.readyBody': 'v{version} をダウンロードしました。使用する準備ができたら再起動してください。', 'update.readyBody': 'v{version} をダウンロードしました。「インストールして再起動」をクリックして適用してください。',
'update.readyTitle': '更新の準備完了', 'update.readyTitle': '更新の準備完了',
'update.restarting': '更新を完了するために再起動中...', 'update.restarting': '更新を完了するために再起動中...',
'update.upToDate': 'v{version} は最新です。', 'update.upToDate': 'v{version} は最新です。',
+5 -2
View File
@@ -3576,6 +3576,9 @@ export const kr: Record<TranslationKey, string> = {
// ─── Update Checker ────────────────────────────────────── // ─── Update Checker ──────────────────────────────────────
'update.available': 'v{version} 사용 가능', 'update.available': 'v{version} 사용 가능',
'update.autoUpdate': '자동 업데이트',
'update.autoUpdateDescription': '새 버전을 자동으로 확인하고 백그라운드에서 다운로드합니다. 꺼도 수동 확인과 설치는 계속 사용할 수 있습니다.',
'update.autoUpdateSaveFailed': '자동 업데이트 설정을 저장하지 못했습니다: {error}',
'update.availableLabel': '사용 가능', 'update.availableLabel': '사용 가능',
'update.checking': '업데이트 확인 중...', 'update.checking': '업데이트 확인 중...',
'update.checkNow': '지금 확인', 'update.checkNow': '지금 확인',
@@ -3583,7 +3586,7 @@ export const kr: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '알 수 없음', 'update.currentVersionUnknown': '알 수 없음',
'update.newVersion': '새 버전 v{version} 사용 가능', 'update.newVersion': '새 버전 v{version} 사용 가능',
'update.downloading': '다운로드 중...', 'update.downloading': '다운로드 중...',
'update.downloaded': '업데이트를 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.', 'update.downloaded': '업데이트를 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.',
'update.idle': '업데이트를 확인하여 설치된 버전을 최신 GitHub Release와 비교합니다.', 'update.idle': '업데이트를 확인하여 설치된 버전을 최신 GitHub Release와 비교합니다.',
'update.installAndRestart': '설치하고 다시 시작', 'update.installAndRestart': '설치하고 다시 시작',
'update.installing': '업데이트 설치 중...', 'update.installing': '업데이트 설치 중...',
@@ -3603,7 +3606,7 @@ export const kr: Record<TranslationKey, string> = {
'update.proxyUrlInvalid': 'HTTP 또는 HTTPS 프록시 URL을 입력하세요.', 'update.proxyUrlInvalid': 'HTTP 또는 HTTPS 프록시 URL을 입력하세요.',
'update.proxyUrlRequired': '프록시 URL을 입력하세요.', 'update.proxyUrlRequired': '프록시 URL을 입력하세요.',
'update.releaseNotes': '릴리스 노트', 'update.releaseNotes': '릴리스 노트',
'update.readyBody': 'v{version}을(를) 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.', 'update.readyBody': 'v{version}을(를) 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.',
'update.readyTitle': '업데이트 준비 완료', 'update.readyTitle': '업데이트 준비 완료',
'update.restarting': '업데이트를 완료하기 위해 다시 시작 중...', 'update.restarting': '업데이트를 완료하기 위해 다시 시작 중...',
'update.upToDate': 'v{version}은(는) 최신입니다.', 'update.upToDate': 'v{version}은(는) 최신입니다.',
+5 -2
View File
@@ -3573,6 +3573,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── 更新檢查 ────────────────────────────────────── // ─── 更新檢查 ──────────────────────────────────────
'update.available': 'v{version} 可用', 'update.available': 'v{version} 可用',
'update.autoUpdate': '自動更新',
'update.autoUpdateDescription': '自動檢查新版本並在背景下載;關閉後仍可手動檢查與安裝。',
'update.autoUpdateSaveFailed': '自動更新設定儲存失敗:{error}',
'update.availableLabel': '可更新版本', 'update.availableLabel': '可更新版本',
'update.checking': '正在檢查更新...', 'update.checking': '正在檢查更新...',
'update.checkNow': '檢查更新', 'update.checkNow': '檢查更新',
@@ -3580,7 +3583,7 @@ export const zh: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '未知版本', 'update.currentVersionUnknown': '未知版本',
'update.newVersion': '新版本 v{version} 可用', 'update.newVersion': '新版本 v{version} 可用',
'update.downloading': '下載中...', 'update.downloading': '下載中...',
'update.downloaded': '更新已下載。方便時重啟即可使用新版。', 'update.downloaded': '更新已下載。點擊「安裝並重啟」後套用新版。',
'update.idle': '點選檢查更新,對比當前安裝版本和 GitHub Releases 的最新版本。', 'update.idle': '點選檢查更新,對比當前安裝版本和 GitHub Releases 的最新版本。',
'update.installAndRestart': '安裝並重啟', 'update.installAndRestart': '安裝並重啟',
'update.installing': '正在安裝更新...', 'update.installing': '正在安裝更新...',
@@ -3600,7 +3603,7 @@ export const zh: Record<TranslationKey, string> = {
'update.proxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。', 'update.proxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。',
'update.proxyUrlRequired': '請輸入代理地址。', 'update.proxyUrlRequired': '請輸入代理地址。',
'update.releaseNotes': '更新說明', 'update.releaseNotes': '更新說明',
'update.readyBody': 'v{version} 已下載。方便時重啟即可使用新版。', 'update.readyBody': 'v{version} 已下載。點擊「安裝並重啟」後套用新版。',
'update.readyTitle': '更新已準備好', 'update.readyTitle': '更新已準備好',
'update.restarting': '正在重啟以完成更新...', 'update.restarting': '正在重啟以完成更新...',
'update.upToDate': '當前已是最新版本 v{version}。', 'update.upToDate': '當前已是最新版本 v{version}。',
+5 -2
View File
@@ -3572,6 +3572,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── 更新检查 ────────────────────────────────────── // ─── 更新检查 ──────────────────────────────────────
'update.available': 'v{version} 可用', 'update.available': 'v{version} 可用',
'update.autoUpdate': '自动更新',
'update.autoUpdateDescription': '自动检查新版本并在后台下载;关闭后仍可手动检查与安装。',
'update.autoUpdateSaveFailed': '自动更新设置保存失败:{error}',
'update.availableLabel': '可更新版本', 'update.availableLabel': '可更新版本',
'update.checking': '正在检查更新...', 'update.checking': '正在检查更新...',
'update.checkNow': '检查更新', 'update.checkNow': '检查更新',
@@ -3579,7 +3582,7 @@ export const zh: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '未知版本', 'update.currentVersionUnknown': '未知版本',
'update.newVersion': '新版本 v{version} 可用', 'update.newVersion': '新版本 v{version} 可用',
'update.downloading': '下载中...', 'update.downloading': '下载中...',
'update.downloaded': '更新已下载。方便时重启即可使用新版。', 'update.downloaded': '更新已下载。点击「安装并重启」后应用新版。',
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。', 'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
'update.installAndRestart': '安装并重启', 'update.installAndRestart': '安装并重启',
'update.installing': '正在安装更新...', 'update.installing': '正在安装更新...',
@@ -3599,7 +3602,7 @@ export const zh: Record<TranslationKey, string> = {
'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。', 'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
'update.proxyUrlRequired': '请输入代理地址。', 'update.proxyUrlRequired': '请输入代理地址。',
'update.releaseNotes': '更新说明', 'update.releaseNotes': '更新说明',
'update.readyBody': 'v{version} 已下载。方便时重启即可使用新版。', 'update.readyBody': 'v{version} 已下载。点击「安装并重启」后应用新版。',
'update.readyTitle': '更新已准备好', 'update.readyTitle': '更新已准备好',
'update.restarting': '正在重启以完成更新...', 'update.restarting': '正在重启以完成更新...',
'update.upToDate': '当前已是最新版本 v{version}。', '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 '@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 { useSettingsStore } from '@/stores/settingsStore'
import { useUpdateStore } from '@/stores/updateStore'
import { settingsApi } from '@/api/settings'
import { AboutSettings } from './AboutSettings' import { AboutSettings } from './AboutSettings'
beforeEach(() => { const initialSettingsState = useSettingsStore.getState()
useSettingsStore.setState({ locale: 'zh' }) 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(() => { afterEach(() => {
cleanup() 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 () => { 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 // The version arrives from an async host call; let it settle so the assertion
// is not racing a state update. // is not racing a state update.
await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0') await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0')
+31 -2
View File
@@ -12,6 +12,7 @@ import {
SettingsPageHeader, SettingsPageHeader,
SettingsRow, SettingsRow,
SettingsSection, SettingsSection,
SettingsSwitchRow,
} from '@/components/settings/SettingsSection' } from '@/components/settings/SettingsSection'
import type { UpdateProxyMode } from '../../types/settings' import type { UpdateProxyMode } from '../../types/settings'
import { MarkdownRenderer } from '../../components/markdown/MarkdownRenderer' import { MarkdownRenderer } from '../../components/markdown/MarkdownRenderer'
@@ -46,6 +47,8 @@ export function AboutSettings() {
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
const updateProxy = useSettingsStore((s) => s.updateProxy) const updateProxy = useSettingsStore((s) => s.updateProxy)
const setUpdateProxy = useSettingsStore((s) => s.setUpdateProxy) 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 updateStatus = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion) const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes) const releaseNotes = useUpdateStore((s) => s.releaseNotes)
@@ -61,6 +64,8 @@ export function AboutSettings() {
const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy) const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy)
const [updateProxySaveError, setUpdateProxySaveError] = useState<string | null>(null) const [updateProxySaveError, setUpdateProxySaveError] = useState<string | null>(null)
const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false) const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false)
const [isSavingAutoUpdate, setIsSavingAutoUpdate] = useState(false)
const [autoUpdateSaveError, setAutoUpdateSaveError] = useState<string | null>(null)
const [communityOpen, setCommunityOpen] = useState(false) const [communityOpen, setCommunityOpen] = useState(false)
useEffect(() => { useEffect(() => {
@@ -80,8 +85,8 @@ export function AboutSettings() {
}, []) }, [])
useEffect(() => { useEffect(() => {
void initialize() if (autoUpdateEnabled) void initialize()
}, [initialize]) }, [autoUpdateEnabled, initialize])
useEffect(() => { useEffect(() => {
setUpdateProxyDraft(updateProxy) 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 hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes) const downloadedText = formatBytes(downloadedBytes)
const updateDescription = (() => { const updateDescription = (() => {
@@ -229,6 +246,18 @@ export function AboutSettings() {
)} )}
> >
<SettingsGroup> <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 <SettingsRow
title={( title={(
<span className="inline-flex flex-wrap items-center gap-2"> <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', () => { describe('settingsStore update proxy persistence', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()
+40
View File
@@ -89,6 +89,7 @@ type SettingsStore = {
desktopTerminal: DesktopTerminalSettings desktopTerminal: DesktopTerminalSettings
webSearch: WebSearchSettings webSearch: WebSearchSettings
updateProxy: UpdateProxySettings updateProxy: UpdateProxySettings
autoUpdateEnabled: boolean
network: NetworkSettings network: NetworkSettings
/** null = never configured; the UI falls back to DEFAULT_CLEANUP_PERIOD_DAYS. */ /** null = never configured; the UI falls back to DEFAULT_CLEANUP_PERIOD_DAYS. */
cleanupPeriodDays: number | null cleanupPeriodDays: number | null
@@ -126,6 +127,7 @@ type SettingsStore = {
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void> setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void> setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void> setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
setAutoUpdateEnabled: (enabled: boolean) => Promise<void>
setNetwork: (settings: NetworkSettings) => Promise<void> setNetwork: (settings: NetworkSettings) => Promise<void>
setCleanupPeriodDays: (days: number) => Promise<void> setCleanupPeriodDays: (days: number) => Promise<void>
setTraceCaptureEnabled: (enabled: boolean) => Promise<void> setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
@@ -170,6 +172,10 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
mode: 'system', mode: 'system',
url: '', 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. // 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) 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, desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
autoUpdateEnabled: true,
network: DEFAULT_NETWORK_SETTINGS, network: DEFAULT_NETWORK_SETTINGS,
cleanupPeriodDays: null, cleanupPeriodDays: null,
traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS, traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS,
@@ -252,6 +259,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}, },
fetchAll: async () => { fetchAll: async () => {
const autoUpdateVersionAtStart = autoUpdateSaveVersion
const autoUpdateSavePendingAtStart = autoUpdatePendingSaves > 0
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const previousH5Access = get().h5Access const previousH5Access = get().h5Access
@@ -274,6 +283,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
]) ])
const desktopTerminal = normalizeDesktopTerminalSettings(userSettings.desktopTerminal) const desktopTerminal = normalizeDesktopTerminalSettings(userSettings.desktopTerminal)
lastPersistedDesktopTerminal = 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 // 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 // startup, and re-applying would re-persist and re-report it on every
// provider switch. // provider switch.
@@ -297,6 +311,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
desktopTerminal, desktopTerminal,
webSearch: normalizeWebSearchSettings(userSettings.webSearch), webSearch: normalizeWebSearchSettings(userSettings.webSearch),
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
autoUpdateEnabled: hydrateAutoUpdate ? lastPersistedAutoUpdateEnabled : get().autoUpdateEnabled,
network: normalizeNetworkSettings(userSettings.network), network: normalizeNetworkSettings(userSettings.network),
cleanupPeriodDays: normalizeCleanupPeriodDays(userSettings.cleanupPeriodDays), cleanupPeriodDays: normalizeCleanupPeriodDays(userSettings.cleanupPeriodDays),
traceCapture, 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) => { setNetwork: async (settings) => {
const prev = get().network const prev = get().network
const next = normalizeNetworkSettings(settings) const next = normalizeNetworkSettings(settings)
+236
View File
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { browserHost } from '../lib/desktopHost/browserHost' 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 check = vi.fn()
const relaunch = vi.fn() const relaunch = vi.fn()
@@ -310,6 +314,238 @@ describe('updateStore', () => {
expect(download).toHaveBeenCalledTimes(1) 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 () => { it('prompts again when a newer version is available after dismissing an older one', async () => {
const oldDownload = vi.fn(async (onEvent?: (event: unknown) => void) => { const oldDownload = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } }) onEvent?.({ event: 'Started', data: { contentLength: 100 } })
+5 -4
View File
@@ -65,7 +65,7 @@ function scheduleRelaunchWatchdog(host: DesktopHost) {
useUpdateStore.setState((state) => ({ useUpdateStore.setState((state) => ({
...state, ...state,
status: 'downloaded', 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, shouldPrompt: true,
progressPercent: 100, progressPercent: 100,
})) }))
@@ -136,7 +136,7 @@ async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | n
} }
function shouldPromptForVersion(version: string | null) { function shouldPromptForVersion(version: string | null) {
return !!version && readDismissedUpdateVersion() !== version return useSettingsStore.getState().autoUpdateEnabled && !!version && readDismissedUpdateVersion() !== version
} }
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
@@ -194,7 +194,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
shouldPrompt: false, shouldPrompt: false,
initialize: async () => { initialize: async () => {
if (!getUpdateHost()) return if (!getUpdateHost() || !useSettingsStore.getState().autoUpdateEnabled) return
if (!startupCheckPromise) { if (!startupCheckPromise) {
startupCheckPromise = (async () => { startupCheckPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 5000)) await new Promise((resolve) => setTimeout(resolve, 5000))
@@ -210,6 +210,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => { checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => {
const host = getUpdateHost() const host = getUpdateHost()
if (!host) return null if (!host) return null
if (silent && !useSettingsStore.getState().autoUpdateEnabled) return null
if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate
clearRelaunchWatchdog() clearRelaunchWatchdog()
@@ -281,7 +282,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
shouldPrompt: false, shouldPrompt: false,
})) }))
if (autoDownload && (shouldOffer || !silent)) { if (autoDownload && useSettingsStore.getState().autoUpdateEnabled && (shouldOffer || !silent)) {
void get().downloadUpdate().catch(() => { void get().downloadUpdate().catch(() => {
// The store records the failure and keeps the manual install path retryable. // The store records the failure and keeps the manual install path retryable.
}) })
+1
View File
@@ -163,6 +163,7 @@ export type UserSettings = {
outputStyle?: string outputStyle?: string
skipWebFetchPreflight?: boolean skipWebFetchPreflight?: boolean
desktopNotificationsEnabled?: boolean desktopNotificationsEnabled?: boolean
autoUpdateEnabled?: boolean
webSearch?: WebSearchSettings webSearch?: WebSearchSettings
updateProxy?: Partial<UpdateProxySettings> updateProxy?: Partial<UpdateProxySettings>
network?: { network?: {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test' 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 { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path' import { basename, dirname, join } from 'node:path'
import { import {
@@ -16,7 +16,19 @@ import {
// These fixtures contain synthetic Mach-O headers, not runnable executables. // These fixtures contain synthetic Mach-O headers, not runnable executables.
// Resource execution has dedicated cases below with an explicit runner. // Resource execution has dedicated cases below with an explicit runner.
function inspectPackagedArtifacts(rootDir: string, options: Parameters<typeof inspectPackage>[1]) { 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() { function createRepoRoot() {
@@ -85,6 +97,15 @@ function thinMachO(arch: 'arm64' | 'x64', minimum = '14.4') {
return bytes 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[] = [] const tempDirs: string[] = []
afterEach(() => { afterEach(() => {
@@ -171,7 +192,7 @@ describe('final macOS helper cursor resource verification', () => {
commandRunner: (command, args, options) => { commandRunner: (command, args, options) => {
expect(args).toEqual(['--probe-cursor-resources']) expect(args).toEqual(['--probe-cursor-resources'])
expect(command.startsWith(source.helper)).toBe(false) 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?.timeout).toBe(10_000)
expect(options?.maxBuffer).toBe(1024 * 1024) expect(options?.maxBuffer).toBe(1024 * 1024)
temporaryRoot = options!.cwd 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/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/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') 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' }) 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/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/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') 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, { const report = await inspectPackagedArtifacts(rootDir, {
platform: 'windows', platform: 'windows',
@@ -667,7 +690,7 @@ describe('packaged artifact inspection', () => {
}) })
expect(report.passed).toBe(true) 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 () => { 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/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/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') 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, { const report = await inspectPackagedArtifacts(rootDir, {
platform: 'windows', 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/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/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/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' }) 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') 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 () => { test('does not treat the win-unpacked app executable as a Windows release installer', async () => {
const rootDir = createRepoRoot() const rootDir = createRepoRoot()
tempDirs.push(rootDir) tempDirs.push(rootDir)
@@ -798,7 +838,7 @@ describe('packaged artifact inspection', () => {
}) })
expect(report.passed).toBe(true) 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 () => { 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.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) 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 target = pending.pop()!
const canonical = realpathSync(target) const canonical = realpathSync(target)
const withinApp = relative(app, canonical) 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}`) throw new Error(`cursor resource escapes the helper app: ${target}`)
} }
if (visited.has(canonical)) continue 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 installers = findMatches(report.artifactsDir, (candidate) => {
const normalized = normalizePath(candidate) const normalized = normalizePath(candidate)
return normalized.endsWith('.exe') && !isInsideWindowsUnpackedDir(normalized) return normalized.endsWith('.exe') && !isInsideWindowsUnpackedDir(normalized)
@@ -1079,6 +1079,34 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) {
if (unpackedDir) { if (unpackedDir) {
const resourcesDir = join(unpackedDir, 'resources') 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 unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked')
const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty') const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty')
const sidecarDir = join(unpackedResourcesDir, 'src-tauri', 'binaries') const sidecarDir = join(unpackedResourcesDir, 'src-tauri', 'binaries')
@@ -1298,7 +1326,7 @@ export async function inspectPackagedArtifacts(rootDir: string, options: Inspect
if (options.platform === 'macos') { if (options.platform === 'macos') {
inspectMacosArtifacts(resolvedRootDir, report, options) inspectMacosArtifacts(resolvedRootDir, report, options)
} else if (options.platform === 'windows') { } else if (options.platform === 'windows') {
inspectWindowsArtifacts(resolvedRootDir, report) inspectWindowsArtifacts(resolvedRootDir, report, options)
} else { } else {
inspectLinuxArtifacts(resolvedRootDir, report) inspectLinuxArtifacts(resolvedRootDir, report)
} }
+2 -2
View File
@@ -185,9 +185,9 @@ describe('E2E: Full Flow', () => {
// 3. Settings // 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') 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 () => { it('should update and read user settings', async () => {
@@ -56,6 +56,36 @@ describe('persistent storage upgrade migrations', () => {
expect(await new SettingsService().getAgentTeamsEnabled()).toBe(false) 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 () => { test('migrates legacy providers index and writes a backup before changing it', async () => {
const ccHahaDir = path.join(tempDir, 'cc-haha') const ccHahaDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true }) await fs.mkdir(ccHahaDir, { recursive: true })
+26 -1
View File
@@ -502,7 +502,32 @@ describe('Settings API', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = await res.json() 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 () => { 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> { async function handleUserSettings(req: Request): Promise<Response> {
if (req.method === 'GET') { if (req.method === 'GET') {
const userSettings = await settingsService.getUserSettings()
return Response.json({ return Response.json({
...await settingsService.getUserSettings(), ...userSettings,
autoUpdateEnabled: await settingsService.getAutoUpdateEnabled(userSettings),
agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(), agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(),
}) })
} }
+11
View File
@@ -104,6 +104,14 @@ export class SettingsService {
return this.readJsonFile(this.getUserSettingsPath()) 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. /** 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. * 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> { 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') { if (Object.hasOwn(settings, 'agentTeamsEnabled') && typeof settings.agentTeamsEnabled !== 'boolean') {
throw ApiError.badRequest('agentTeamsEnabled must be a boolean') throw ApiError.badRequest('agentTeamsEnabled must be a boolean')
} }