mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): respect update deferrals and add automatic update control (#1482)
This commit is contained in:
@@ -57,7 +57,7 @@ import {
|
||||
} from './services/appMode'
|
||||
import { installMacOsChromiumKeychainPromptGuard } from './services/keychain'
|
||||
import { installStdioWriteFailureGuards } from './services/stdioGuards'
|
||||
import { applyWindowsAppUserModelId } from './services/appIdentity'
|
||||
import { applyWindowsAppUserModelId, applyWindowsTaskbarIdentity, resolveWindowsWindowIcon } from './services/appIdentity'
|
||||
import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards'
|
||||
import { installPreviewCleanupOnRendererNavigation } from './services/previewLifecycle'
|
||||
import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke'
|
||||
@@ -742,7 +742,7 @@ function registerIpcHandlers() {
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.updatePrepareInstall, async () => { await publicAccessManager?.stop(); getServerRuntime().stopAll() })
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.updateCancelInstall, () => getUpdaterService().cancelInstall())
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.updateRelaunch, () => {
|
||||
if (getUpdaterService().hasDownloadedUpdate()) {
|
||||
if (getUpdaterService().hasStagedUpdate()) {
|
||||
isQuitting = true
|
||||
getUpdaterService().quitAndInstallDownloadedUpdate()
|
||||
return
|
||||
@@ -894,8 +894,10 @@ function registerIpcHandlers() {
|
||||
async function createMainWindow() {
|
||||
const restoredState = readWindowState(app, screen.getAllDisplays())
|
||||
const bounds = windowOptionsFromState(restoredState)
|
||||
const identityPaths = { desktopRoot: appRoot(), resourcesPath: process.resourcesPath }
|
||||
mainWindow = new BrowserWindow({
|
||||
...bounds,
|
||||
icon: resolveWindowsWindowIcon(identityPaths),
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
show: false,
|
||||
@@ -913,6 +915,11 @@ async function createMainWindow() {
|
||||
webviewTag: true,
|
||||
},
|
||||
})
|
||||
applyWindowsTaskbarIdentity(mainWindow, {
|
||||
...identityPaths,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: process.execPath,
|
||||
})
|
||||
// Before any guest can exist: its session denies OS permissions, and its
|
||||
// preferences are replaced with the sandboxed set the policy pins.
|
||||
workspaceBrowserSession()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { applyWindowsAppUserModelId, WINDOWS_APP_USER_MODEL_ID } from './appIdentity'
|
||||
import { applyWindowsAppUserModelId, applyWindowsTaskbarIdentity, resolveWindowsWindowIcon, WINDOWS_APP_USER_MODEL_ID } from './appIdentity'
|
||||
|
||||
describe('applyWindowsAppUserModelId', () => {
|
||||
it('sets the AppUserModelID on Windows so toast notifications are attributed to the app', () => {
|
||||
@@ -26,3 +28,118 @@ describe('applyWindowsAppUserModelId', () => {
|
||||
expect(WINDOWS_APP_USER_MODEL_ID).toBe(pkg.build?.appId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Windows window and pinned taskbar identity', () => {
|
||||
function fixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'electron-app-identity-'))
|
||||
const desktopRoot = path.join(root, 'resources', 'app.asar')
|
||||
const resourcesPath = path.join(root, 'resources')
|
||||
const executablePath = path.join(root, 'Claude Code Haha.exe')
|
||||
const externalIcon = path.join(resourcesPath, 'app-icon.ico')
|
||||
const bundledIcon = path.join(desktopRoot, 'src-tauri', 'icons', 'icon.ico')
|
||||
const write = (file: string) => {
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
writeFileSync(file, 'icon')
|
||||
}
|
||||
return { root, desktopRoot, resourcesPath, executablePath, externalIcon, bundledIcon, write }
|
||||
}
|
||||
|
||||
it('uses the real resource icon for a packaged Windows window and pin', () => {
|
||||
const f = fixture()
|
||||
try {
|
||||
f.write(f.externalIcon)
|
||||
f.write(f.bundledIcon)
|
||||
const window = { setAppDetails: vi.fn() }
|
||||
expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.externalIcon)
|
||||
expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32')).toBe(true)
|
||||
expect(window.setAppDetails).toHaveBeenCalledWith({
|
||||
appId: WINDOWS_APP_USER_MODEL_ID,
|
||||
appIconPath: f.externalIcon,
|
||||
appIconIndex: 0,
|
||||
relaunchCommand: `"${f.executablePath}"`,
|
||||
relaunchDisplayName: 'Claude Code Haha',
|
||||
})
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the bundled window icon while keeping ASAR paths away from the Windows shell', () => {
|
||||
const f = fixture()
|
||||
try {
|
||||
f.write(f.bundledIcon)
|
||||
const window = { setAppDetails: vi.fn() }
|
||||
expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.bundledIcon)
|
||||
applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32')
|
||||
expect(window.setAppDetails.mock.calls[0]?.[0].appIconPath).toBe(f.executablePath)
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves pin relaunch metadata alone for development runs and other platforms', () => {
|
||||
const f = fixture()
|
||||
try {
|
||||
f.write(f.bundledIcon)
|
||||
const window = { setAppDetails: vi.fn() }
|
||||
expect(resolveWindowsWindowIcon(f, 'win32')).toBe(f.bundledIcon)
|
||||
expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: false }, 'win32')).toBe(false)
|
||||
for (const platform of ['darwin', 'linux'] as const) {
|
||||
expect(resolveWindowsWindowIcon(f, platform)).toBeUndefined()
|
||||
expect(applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, platform)).toBe(false)
|
||||
}
|
||||
expect(window.setAppDetails).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fail startup when an icon is absent', () => {
|
||||
const f = fixture()
|
||||
try {
|
||||
const window = { setAppDetails: vi.fn() }
|
||||
expect(resolveWindowsWindowIcon(f, 'win32')).toBeUndefined()
|
||||
expect(() => applyWindowsTaskbarIdentity(window, { ...f, isPackaged: true }, 'win32')).not.toThrow()
|
||||
expect(window.setAppDetails.mock.calls[0]?.[0].appIconPath).toBe(f.executablePath)
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies both identities to the actual main-window construction before it is shown', async () => {
|
||||
const f = fixture()
|
||||
try {
|
||||
f.write(f.externalIcon)
|
||||
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const source = readFileSync(path.join(desktopDir, 'electron', 'main.ts'), 'utf8')
|
||||
const start = source.indexOf('async function createMainWindow()')
|
||||
const end = source.indexOf(' // Before any guest can exist:', start)
|
||||
const window = { setAppDetails: vi.fn() }
|
||||
const BrowserWindow = vi.fn(function (_options: unknown) { return window })
|
||||
const context = {
|
||||
app: { isPackaged: true },
|
||||
process: { resourcesPath: f.resourcesPath, execPath: f.executablePath, platform: 'win32' },
|
||||
appRoot: () => f.desktopRoot,
|
||||
mainWindow: null,
|
||||
screen: { getAllDisplays: () => [] },
|
||||
readWindowState: () => null,
|
||||
windowOptionsFromState: () => ({}),
|
||||
windowChromeOptionsForPlatform: () => ({}),
|
||||
resolveStartupWindowBackground: () => '#ffffff',
|
||||
preloadPath: () => 'preload.cjs',
|
||||
MIN_WINDOW_WIDTH: 400,
|
||||
MIN_WINDOW_HEIGHT: 300,
|
||||
BrowserWindow,
|
||||
resolveWindowsWindowIcon: (paths: Parameters<typeof resolveWindowsWindowIcon>[0]) => resolveWindowsWindowIcon(paths, 'win32'),
|
||||
applyWindowsTaskbarIdentity: (host: Parameters<typeof applyWindowsTaskbarIdentity>[0], paths: Parameters<typeof applyWindowsTaskbarIdentity>[1]) => applyWindowsTaskbarIdentity(host, paths, 'win32'),
|
||||
createMainWindow: undefined as undefined | (() => Promise<unknown>),
|
||||
}
|
||||
await runInNewContext(`${source.slice(start, end)}\n}`, context)
|
||||
await context.createMainWindow!()
|
||||
expect(BrowserWindow.mock.calls[0]?.[0]).toMatchObject({ show: false, icon: f.externalIcon })
|
||||
expect(window.setAppDetails.mock.calls[0]?.[0]).toMatchObject({ appId: WINDOWS_APP_USER_MODEL_ID, appIconPath: f.externalIcon })
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
export type AppUserModelIdHost = {
|
||||
setAppUserModelId(id: string): void
|
||||
}
|
||||
@@ -16,3 +20,40 @@ export function applyWindowsAppUserModelId(
|
||||
app.setAppUserModelId(appUserModelId)
|
||||
return true
|
||||
}
|
||||
|
||||
type WindowsIconPaths = {
|
||||
desktopRoot: string
|
||||
resourcesPath: string
|
||||
}
|
||||
|
||||
// BrowserWindow can read the bundled icon through Electron's ASAR-aware file
|
||||
// APIs, but Explorer cannot. Keep a real ICO beside app.asar for new taskbar pins.
|
||||
export function resolveWindowsWindowIcon(
|
||||
paths: WindowsIconPaths,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string | undefined {
|
||||
if (platform !== 'win32') return undefined
|
||||
return [
|
||||
path.join(paths.resourcesPath, 'app-icon.ico'),
|
||||
path.join(paths.desktopRoot, 'src-tauri', 'icons', 'icon.ico'),
|
||||
].find(candidate => existsSync(candidate))
|
||||
}
|
||||
|
||||
export function applyWindowsTaskbarIdentity(
|
||||
window: Pick<BrowserWindow, 'setAppDetails'>,
|
||||
paths: WindowsIconPaths & { isPackaged: boolean, executablePath: string },
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
// A development launch targets electron.exe and needs the entry script and
|
||||
// dev-server environment. Do not turn its button into a production shortcut.
|
||||
if (platform !== 'win32' || !paths.isPackaged) return false
|
||||
const externalIcon = path.join(paths.resourcesPath, 'app-icon.ico')
|
||||
window.setAppDetails({
|
||||
appId: WINDOWS_APP_USER_MODEL_ID,
|
||||
appIconPath: existsSync(externalIcon) ? externalIcon : paths.executablePath,
|
||||
appIconIndex: 0,
|
||||
relaunchCommand: `"${paths.executablePath}"`,
|
||||
relaunchDisplayName: 'Claude Code Haha',
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ function writeLog(logPath: string | undefined, payload: Record<string, unknown>)
|
||||
|
||||
class UpdateSmokeUpdater implements ElectronUpdaterLike {
|
||||
autoDownload = true
|
||||
autoInstallOnAppQuit = true
|
||||
logger: unknown = null
|
||||
private progressHandler: ProgressHandler | null = null
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -13,6 +14,7 @@ function fakeUpdater(): ElectronUpdaterLike & {
|
||||
let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null
|
||||
const updater = {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
@@ -168,6 +170,34 @@ describe('Electron updater service', () => {
|
||||
await expect(service.checkForUpdates()).rejects.toThrow('feed unavailable')
|
||||
})
|
||||
|
||||
it('does not offer metadata that electron-updater marks unavailable', async () => {
|
||||
const localUpdater = fakeUpdater()
|
||||
// A release can be newer but unavailable due to its minimum OS version
|
||||
// or staged rollout. electron-updater still returns its metadata.
|
||||
localUpdater.checkForUpdates.mockResolvedValue({
|
||||
isUpdateAvailable: false,
|
||||
updateInfo: { version: '9.9.9', body: 'Not eligible for this installation' },
|
||||
})
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
|
||||
await expect(service.checkForUpdates()).resolves.toBeNull()
|
||||
await expect(service.downloadUpdate(() => {})).rejects.toThrow('No Electron update')
|
||||
expect(localUpdater.downloadUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers metadata that electron-updater explicitly marks available', async () => {
|
||||
const localUpdater = fakeUpdater()
|
||||
localUpdater.checkForUpdates.mockResolvedValue({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { version: '1.2.4', body: 'Eligible update' },
|
||||
})
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
|
||||
await expect(service.checkForUpdates()).resolves.toEqual({ version: '1.2.4', body: 'Eligible update' })
|
||||
await service.downloadUpdate(() => {})
|
||||
expect(localUpdater.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stages then installs through quitAndInstall only after an update has downloaded', async () => {
|
||||
const service = new ElectronUpdaterService(updater)
|
||||
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
|
||||
@@ -183,12 +213,98 @@ describe('Electron updater service', () => {
|
||||
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('keeps ordinary quits and cached-download restarts from installing an update', async () => {
|
||||
const install = vi.fn()
|
||||
const cache = { downloaded: false }
|
||||
for (let restart = 0; restart < 3; restart += 1) {
|
||||
const app = new EventEmitter()
|
||||
const localUpdater = fakeUpdater()
|
||||
localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
|
||||
localUpdater.downloadUpdate.mockImplementation(async () => {
|
||||
cache.downloaded = true
|
||||
// BaseUpdater registers this after either a fresh or cached download.
|
||||
app.once('quit', () => {
|
||||
if (localUpdater.autoInstallOnAppQuit && cache.downloaded) install()
|
||||
})
|
||||
})
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
app.emit('quit')
|
||||
}
|
||||
|
||||
expect(cache.downloaded).toBe(true)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires an explicit install request before the downloaded update may restart into installation', async () => {
|
||||
const localUpdater = fakeUpdater()
|
||||
localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
|
||||
localUpdater.downloadUpdate.mockResolvedValue(undefined)
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
|
||||
expect(service.hasStagedUpdate()).toBe(false)
|
||||
expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested')
|
||||
expect(localUpdater.quitAndInstall).not.toHaveBeenCalled()
|
||||
|
||||
service.stageDownloadedUpdate()
|
||||
expect(service.hasStagedUpdate()).toBe(true)
|
||||
service.quitAndInstallDownloadedUpdate({})
|
||||
|
||||
expect(localUpdater.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(localUpdater.quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
expect(localUpdater.autoInstallOnAppQuit).toBe(false)
|
||||
})
|
||||
|
||||
it('disarms canceled installs while keeping the download available for an explicit retry', async () => {
|
||||
const localUpdater = fakeUpdater()
|
||||
localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
|
||||
localUpdater.downloadUpdate.mockResolvedValue(undefined)
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
service.stageDownloadedUpdate()
|
||||
service.cancelInstall()
|
||||
|
||||
expect(service.hasDownloadedUpdate()).toBe(true)
|
||||
expect(service.hasStagedUpdate()).toBe(false)
|
||||
expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested')
|
||||
|
||||
await service.downloadUpdate(() => {})
|
||||
service.stageDownloadedUpdate()
|
||||
service.quitAndInstallDownloadedUpdate({})
|
||||
|
||||
expect(localUpdater.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(localUpdater.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not carry install consent over to a newly checked version', async () => {
|
||||
const localUpdater = fakeUpdater()
|
||||
localUpdater.checkForUpdates
|
||||
.mockResolvedValueOnce({ updateInfo: { version: '1.2.4' } })
|
||||
.mockResolvedValueOnce({ updateInfo: { version: '1.2.5' } })
|
||||
localUpdater.downloadUpdate.mockResolvedValue(undefined)
|
||||
const service = new ElectronUpdaterService(localUpdater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
service.stageDownloadedUpdate()
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
|
||||
expect(service.hasStagedUpdate()).toBe(false)
|
||||
expect(() => service.quitAndInstallDownloadedUpdate({})).toThrow('has not been requested')
|
||||
expect(localUpdater.quitAndInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands the spawned installer an environment without the app-managed portable selection', async () => {
|
||||
const service = new ElectronUpdaterService(updater)
|
||||
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
|
||||
updater.downloadUpdate.mockResolvedValue(undefined)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
service.stageDownloadedUpdate()
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data',
|
||||
@@ -208,6 +324,7 @@ describe('Electron updater service', () => {
|
||||
updater.downloadUpdate.mockResolvedValue(undefined)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
service.stageDownloadedUpdate()
|
||||
|
||||
const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' }
|
||||
service.quitAndInstallDownloadedUpdate(env)
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ElectronUpdateInfo = {
|
||||
}
|
||||
|
||||
export type ElectronUpdateCheckResult = {
|
||||
isUpdateAvailable?: boolean
|
||||
updateInfo?: ElectronUpdateInfo
|
||||
} | null
|
||||
|
||||
@@ -18,6 +19,7 @@ export type ElectronUpdateCheckOptions = {
|
||||
|
||||
export type ElectronUpdaterLike = {
|
||||
autoDownload: boolean
|
||||
autoInstallOnAppQuit: boolean
|
||||
disableDifferentialDownload?: boolean
|
||||
logger?: unknown
|
||||
checkForUpdates(): Promise<ElectronUpdateCheckResult>
|
||||
@@ -93,6 +95,7 @@ export class ElectronUpdaterService {
|
||||
private readonly updateConfigPath?: string
|
||||
private pendingUpdate: ElectronUpdateMetadata | null = null
|
||||
private downloaded = false
|
||||
private installRequested = false
|
||||
private proxyKey: string | null = null
|
||||
|
||||
constructor(
|
||||
@@ -104,6 +107,10 @@ export class ElectronUpdaterService {
|
||||
this.proxyController = proxyController
|
||||
this.updateConfigPath = runtimeOptions.updateConfigPath
|
||||
this.updater.autoDownload = false
|
||||
// Background downloads must stay pending after "Later", normal quits,
|
||||
// and app-mode restarts. On macOS this also keeps the ZIP out of Squirrel
|
||||
// until the user explicitly requests quitAndInstall.
|
||||
this.updater.autoInstallOnAppQuit = false
|
||||
// Differential download issues many small sequential range requests and is
|
||||
// RTT-bound against the GitHub CDN, so it downloads far below line speed.
|
||||
this.updater.disableDifferentialDownload = true
|
||||
@@ -122,6 +129,7 @@ export class ElectronUpdaterService {
|
||||
}
|
||||
|
||||
async checkForUpdates(options?: ElectronUpdateCheckOptions): Promise<ElectronUpdateMetadata | null> {
|
||||
this.installRequested = false
|
||||
let result: ElectronUpdateCheckResult
|
||||
try {
|
||||
await this.applyProxy(options)
|
||||
@@ -134,7 +142,7 @@ export class ElectronUpdaterService {
|
||||
if (!isMissingUpdateMetadataError(error)) throw error
|
||||
result = null
|
||||
}
|
||||
this.pendingUpdate = normalizeUpdateInfo(result?.updateInfo)
|
||||
this.pendingUpdate = result?.isUpdateAvailable === false ? null : normalizeUpdateInfo(result?.updateInfo)
|
||||
this.downloaded = false
|
||||
return this.pendingUpdate
|
||||
}
|
||||
@@ -177,8 +185,9 @@ export class ElectronUpdaterService {
|
||||
}
|
||||
|
||||
cancelInstall() {
|
||||
this.pendingUpdate = null
|
||||
this.downloaded = false
|
||||
// The renderer retains the downloaded update on an install/relaunch
|
||||
// failure. Revoke the install request, but keep the download retryable.
|
||||
this.installRequested = false
|
||||
}
|
||||
|
||||
stageDownloadedUpdate() {
|
||||
@@ -188,14 +197,21 @@ export class ElectronUpdaterService {
|
||||
if (!this.downloaded) {
|
||||
throw new Error('Electron update has not finished downloading')
|
||||
}
|
||||
this.installRequested = true
|
||||
}
|
||||
|
||||
hasDownloadedUpdate(): boolean {
|
||||
return !!this.pendingUpdate && this.downloaded
|
||||
}
|
||||
|
||||
hasStagedUpdate(): boolean {
|
||||
return this.hasDownloadedUpdate() && this.installRequested
|
||||
}
|
||||
|
||||
quitAndInstallDownloadedUpdate(env: NodeJS.ProcessEnv = process.env) {
|
||||
this.stageDownloadedUpdate()
|
||||
if (!this.hasStagedUpdate()) {
|
||||
throw new Error('Electron update installation has not been requested')
|
||||
}
|
||||
// The NSIS installer spawned here inherits this process's environment.
|
||||
// Hand it the same clean environment a manually launched setup gets, so
|
||||
// its legacy-data checks read the persisted app-mode.json instead of a
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { Server } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BaseUpdater } from 'electron-updater/out/BaseUpdater'
|
||||
import { MacUpdater } from 'electron-updater/out/MacUpdater'
|
||||
import type { AppAdapter } from 'electron-updater/out/AppAdapter'
|
||||
import { ElectronUpdaterService, type ElectronUpdaterLike } from './updater'
|
||||
|
||||
function fixtureApp(root: string, events: EventEmitter): AppAdapter {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
name: 'Updater fixture',
|
||||
isPackaged: true,
|
||||
appUpdateConfigPath: join(root, 'app-update.yml'),
|
||||
userDataPath: root,
|
||||
baseCachePath: root,
|
||||
whenReady: async () => {},
|
||||
relaunch: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
onQuit: handler => { events.once('quit', handler) },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the dependency's real quit handler, replacing only download transport
|
||||
// and installer execution. These boundaries must never touch a real install.
|
||||
class ExitUpdaterFixture extends BaseUpdater {
|
||||
install = vi.fn(() => true)
|
||||
|
||||
constructor(app: AppAdapter) {
|
||||
super(undefined, app)
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
const updateInfo = { version: '1.2.4', files: [], releaseDate: '', path: 'fixture.exe', sha512: 'fixture' }
|
||||
return { isUpdateAvailable: true, updateInfo, versionInfo: updateInfo }
|
||||
}
|
||||
|
||||
async downloadUpdate() {
|
||||
this.addQuitHandler()
|
||||
return []
|
||||
}
|
||||
|
||||
protected async doDownloadUpdate() { return [] }
|
||||
protected doInstall() { return true }
|
||||
}
|
||||
|
||||
describe('locked electron-updater lifecycle', () => {
|
||||
it('reproduces the upstream Windows/Linux default install on normal exit', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'updater-exit-default-'))
|
||||
try {
|
||||
const events = new EventEmitter()
|
||||
const updater = new ExitUpdaterFixture(fixtureApp(root, events))
|
||||
updater.logger = null
|
||||
await updater.downloadUpdate()
|
||||
events.emit('quit', 0)
|
||||
|
||||
expect(updater.install).toHaveBeenCalledWith(true, false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the real exit hook disarmed across three updater instances and normal exits', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'updater-exit-manual-'))
|
||||
try {
|
||||
for (let restart = 0; restart < 3; restart += 1) {
|
||||
const events = new EventEmitter()
|
||||
const updater = new ExitUpdaterFixture(fixtureApp(root, events))
|
||||
const service = new ElectronUpdaterService(updater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
events.emit('quit', 0)
|
||||
|
||||
expect(updater.install).not.toHaveBeenCalled()
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the macOS ZIP out of Squirrel until explicit installation', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'updater-mac-manual-'))
|
||||
const archive = join(root, 'update.zip')
|
||||
writeFileSync(archive, 'fixture archive')
|
||||
const nativeEvents = new EventEmitter()
|
||||
let feed: { url: string, headers: Record<string, string> } | null = null
|
||||
let nativeDownload: Promise<void> | null = null
|
||||
const nativeUpdater = Object.assign(nativeEvents, {
|
||||
setFeedURL: vi.fn((next: NonNullable<typeof feed>) => { feed = next }),
|
||||
checkForUpdates: vi.fn(() => {
|
||||
nativeDownload = (async () => {
|
||||
const metadata = await fetch(feed!.url, { headers: feed!.headers })
|
||||
const { url } = await metadata.json() as { url: string }
|
||||
const response = await fetch(url)
|
||||
expect(await response.text()).toBe('fixture archive')
|
||||
nativeEvents.emit('update-downloaded')
|
||||
})()
|
||||
}),
|
||||
quitAndInstall: vi.fn(),
|
||||
})
|
||||
type MacFixture = ElectronUpdaterLike & {
|
||||
server?: Server
|
||||
squirrelDownloadedUpdate: boolean
|
||||
updateDownloaded(fileInfo: unknown, event: unknown): Promise<unknown>
|
||||
closeServerIfExists(): void
|
||||
}
|
||||
// Skip the constructor's require('electron') so this runs on every OS.
|
||||
// Download completion and quitAndInstall still use the locked dependency's
|
||||
// real implementation; only Squirrel itself is replaced with loopback I/O.
|
||||
const updater = Object.assign(Object.create(MacUpdater.prototype), {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
autoRunAppAfterInstall: true,
|
||||
squirrelDownloadedUpdate: false,
|
||||
nativeUpdater,
|
||||
app: fixtureApp(root, new EventEmitter()),
|
||||
dispatchUpdateDownloaded: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => ({ updateInfo: { version: '1.2.4' } })),
|
||||
}) as MacFixture
|
||||
updater.downloadUpdate = vi.fn(() => updater.updateDownloaded({
|
||||
url: new URL('https://fixture.invalid/update.zip'),
|
||||
info: { size: Buffer.byteLength('fixture archive') },
|
||||
}, { downloadedFile: archive, version: '1.2.4' }))
|
||||
nativeEvents.on('update-downloaded', () => { updater.squirrelDownloadedUpdate = true })
|
||||
|
||||
try {
|
||||
const service = new ElectronUpdaterService(updater)
|
||||
await service.checkForUpdates()
|
||||
await service.downloadUpdate(() => {})
|
||||
|
||||
expect(nativeUpdater.setFeedURL).toHaveBeenCalledTimes(1)
|
||||
expect(nativeUpdater.checkForUpdates).not.toHaveBeenCalled()
|
||||
expect(nativeUpdater.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(service.hasStagedUpdate()).toBe(false)
|
||||
|
||||
service.stageDownloadedUpdate()
|
||||
service.quitAndInstallDownloadedUpdate({})
|
||||
await nativeDownload
|
||||
|
||||
expect(nativeUpdater.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
expect(nativeUpdater.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await nativeDownload
|
||||
if (updater.server?.listening) {
|
||||
await new Promise<void>(resolve => updater.server!.close(() => resolve()))
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,10 @@
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "src-tauri/icons/icon.ico"
|
||||
"icon": "src-tauri/icons/icon.ico",
|
||||
"extraResources": [
|
||||
{ "from": "src-tauri/icons/icon.ico", "to": "app-icon.ico" }
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Data, NtExecutable, NtExecutableResource, Resource } from 'resedit'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertWindowsExecutableIcon } from './assert-windows-icon'
|
||||
import { createSandboxedTestEnvironment } from '../../scripts/pr/test-environment'
|
||||
|
||||
const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const applicationIcon = readFileSync(path.join(desktopRoot, 'src-tauri', 'icons', 'icon.ico'))
|
||||
|
||||
function executableWithIcons(icon?: Uint8Array): Buffer {
|
||||
const executable = NtExecutable.createEmpty(false, false)
|
||||
const resources = NtExecutableResource.from(executable)
|
||||
if (icon) {
|
||||
Resource.IconGroupEntry.replaceIconsForResource(
|
||||
resources.entries, 1, 1033, Data.IconFile.from(icon).icons.map(item => item.data),
|
||||
)
|
||||
resources.outputResource(executable)
|
||||
}
|
||||
return Buffer.from(executable.generate())
|
||||
}
|
||||
|
||||
describe('Windows packaged executable icon', () => {
|
||||
it('accepts the application icon embedded into the actual PE resource table', () => {
|
||||
expect(() => assertWindowsExecutableIcon(executableWithIcons(applicationIcon), applicationIcon)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an executable with no embedded application icon', () => {
|
||||
expect(() => assertWindowsExecutableIcon(executableWithIcons(), applicationIcon)).toThrow('no icon group')
|
||||
})
|
||||
|
||||
it('rejects a different icon even when it is a valid PE with a valid ICO resource', () => {
|
||||
const icon = Data.IconFile.from(applicationIcon)
|
||||
icon.icons = icon.icons.slice(0, 1)
|
||||
expect(() => assertWindowsExecutableIcon(executableWithIcons(Buffer.from(icon.generate())), applicationIcon))
|
||||
.toThrow('differs from the application ICO')
|
||||
})
|
||||
|
||||
it('rejects arbitrary executable bytes', () => {
|
||||
expect(() => assertWindowsExecutableIcon(Buffer.from('not a PE executable'), applicationIcon)).toThrow()
|
||||
})
|
||||
|
||||
it.each([true, false])('runs the real CLI boundary with branded=%s and no writes to input artifacts', branded => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'windows-icon-cli-'))
|
||||
try {
|
||||
const executablePath = path.join(root, 'Fixture App.exe')
|
||||
const iconPath = path.join(root, 'expected.ico')
|
||||
const executable = executableWithIcons(branded ? applicationIcon : undefined)
|
||||
writeFileSync(executablePath, executable)
|
||||
writeFileSync(iconPath, applicationIcon)
|
||||
const result = spawnSync('bun', [
|
||||
'run', path.join(desktopRoot, 'scripts', 'assert-windows-icon.ts'), executablePath, iconPath,
|
||||
], {
|
||||
cwd: root,
|
||||
env: createSandboxedTestEnvironment(path.join(root, 'home')),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(result.status).toBe(branded ? 0 : 1)
|
||||
expect(branded ? result.stdout : result.stderr).toContain(branded ? 'embeds the application ICO' : 'contains no icon group')
|
||||
expect(readFileSync(executablePath)).toEqual(executable)
|
||||
expect(readFileSync(iconPath)).toEqual(applicationIcon)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { Data, NtExecutable, NtExecutableResource, Resource } from 'resedit'
|
||||
|
||||
type IconItem = InstanceType<typeof Data.IconItem> | InstanceType<typeof Data.RawIconItem>
|
||||
|
||||
function iconFingerprint(icon: IconItem): string {
|
||||
return createHash('sha256').update(Buffer.from(icon.isRaw() ? icon.bin : icon.generate())).digest('hex')
|
||||
}
|
||||
|
||||
// Reuse electron-builder's PE resource parser. Inspect bytes from the finished
|
||||
// executable, including signed builds, instead of trusting win.icon config.
|
||||
export function assertWindowsExecutableIcon(executable: Uint8Array, expectedIcon: Uint8Array): void {
|
||||
const expected = Data.IconFile.from(expectedIcon).icons.map(item => iconFingerprint(item.data)).sort()
|
||||
if (expected.length === 0) throw new Error('The expected Windows ICO contains no icons')
|
||||
const pe = NtExecutable.from(executable, { ignoreCert: true })
|
||||
const resources = NtExecutableResource.from(pe).entries
|
||||
// Explorer uses icon index 0, the first icon group, when no index is supplied.
|
||||
const group = Resource.IconGroupEntry.fromEntries(resources)[0]
|
||||
if (!group) throw new Error('The Windows executable contains no icon group')
|
||||
const actual = group.getIconItemsFromEntries(resources).map(iconFingerprint).sort()
|
||||
if (group.icons.length !== expected.length || actual.length !== expected.length
|
||||
|| actual.some((hash, index) => hash !== expected[index])) {
|
||||
throw new Error('The Windows executable icon differs from the application ICO')
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [executablePath, expectedIconPath] = process.argv.slice(2)
|
||||
if (!executablePath || !expectedIconPath) throw new Error('Usage: bun run assert-windows-icon.ts <executable> <expected.ico>')
|
||||
assertWindowsExecutableIcon(readFileSync(executablePath), readFileSync(expectedIconPath))
|
||||
console.log('Windows executable embeds the application ICO')
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useUpdateStore } from '../../stores/updateStore'
|
||||
|
||||
describe('UpdateChecker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useSettingsStore.setState({ locale: 'en', autoUpdateEnabled: true })
|
||||
Reflect.deleteProperty(window, '__TAURI__')
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
@@ -44,7 +44,7 @@ describe('UpdateChecker', () => {
|
||||
render(<UpdateChecker />)
|
||||
|
||||
expect(screen.getByText('Update ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('v0.1.5 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument()
|
||||
expect(screen.getByText('v0.1.5 has been downloaded. Click “Install and restart” to apply it.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Release notes' })
|
||||
@@ -52,6 +52,30 @@ describe('UpdateChecker', () => {
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('keeps automatic updates quiet when disabled and starts them when re-enabled', async () => {
|
||||
const initialize = vi.fn().mockResolvedValue(undefined)
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
useUpdateStore.setState({ status: 'downloaded', initialize })
|
||||
render(<UpdateChecker />)
|
||||
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Update ready')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
useSettingsStore.setState({ autoUpdateEnabled: true })
|
||||
})
|
||||
|
||||
expect(initialize).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('Update ready')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Update ready')).not.toBeInTheDocument()
|
||||
expect(initialize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders the update prompt in Electron desktop runtime', () => {
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
@@ -176,7 +200,7 @@ describe('UpdateChecker', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Update ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('v0.2.0 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument()
|
||||
expect(screen.getByText('v0.2.0 has been downloaded. Click “Install and restart” to apply it.')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Install and restart'))
|
||||
})
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useTranslation } from '../../i18n'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||
import { useUpdateStore } from '../../stores/updateStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
export function UpdateChecker() {
|
||||
const t = useTranslation()
|
||||
const autoUpdateEnabled = useSettingsStore((s) => s.autoUpdateEnabled)
|
||||
const status = useUpdateStore((s) => s.status)
|
||||
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
||||
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
||||
@@ -17,10 +19,10 @@ export function UpdateChecker() {
|
||||
const dismissPrompt = useUpdateStore((s) => s.dismissPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [initialize])
|
||||
if (autoUpdateEnabled) void initialize()
|
||||
}, [autoUpdateEnabled, initialize])
|
||||
|
||||
if (!isDesktopRuntime()) return null
|
||||
if (!isDesktopRuntime() || !autoUpdateEnabled) return null
|
||||
|
||||
const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded'
|
||||
|
||||
|
||||
@@ -3573,6 +3573,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
|
||||
// ─── Update Checker ──────────────────────────────────────
|
||||
'update.available': 'v{version} available',
|
||||
'update.autoUpdate': 'Automatic updates',
|
||||
'update.autoUpdateDescription': 'Automatically check for new versions and download updates in the background. Manual checks and installation remain available when turned off.',
|
||||
'update.autoUpdateSaveFailed': 'Could not save automatic update settings: {error}',
|
||||
'update.availableLabel': 'Available',
|
||||
'update.checking': 'Checking for updates...',
|
||||
'update.checkNow': 'Check now',
|
||||
@@ -3580,7 +3583,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'update.currentVersionUnknown': 'Unknown',
|
||||
'update.newVersion': 'New version v{version} available',
|
||||
'update.downloading': 'Downloading...',
|
||||
'update.downloaded': 'Update downloaded. Restart when you are ready to use it.',
|
||||
'update.downloaded': 'Update downloaded. Click “Install and restart” to apply it.',
|
||||
'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
|
||||
'update.installAndRestart': 'Install and restart',
|
||||
'update.installing': 'Installing update...',
|
||||
@@ -3600,7 +3603,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
|
||||
'update.proxyUrlRequired': 'Enter a proxy URL.',
|
||||
'update.releaseNotes': 'Release Notes',
|
||||
'update.readyBody': 'v{version} has been downloaded. Restart when you are ready to use it.',
|
||||
'update.readyBody': 'v{version} has been downloaded. Click “Install and restart” to apply it.',
|
||||
'update.readyTitle': 'Update ready',
|
||||
'update.restarting': 'Restarting to finish update...',
|
||||
'update.upToDate': 'You are up to date on v{version}.',
|
||||
|
||||
@@ -3574,6 +3574,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── Update Checker ──────────────────────────────────────
|
||||
'update.available': 'v{version} が利用可能',
|
||||
'update.autoUpdate': '自動更新',
|
||||
'update.autoUpdateDescription': '新しいバージョンを自動で確認し、バックグラウンドでダウンロードします。オフにしても手動での確認とインストールは利用できます。',
|
||||
'update.autoUpdateSaveFailed': '自動更新の設定を保存できませんでした:{error}',
|
||||
'update.availableLabel': '利用可能',
|
||||
'update.checking': '更新を確認中...',
|
||||
'update.checkNow': '今すぐ確認',
|
||||
@@ -3581,7 +3584,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'update.currentVersionUnknown': '不明',
|
||||
'update.newVersion': '新しいバージョン v{version} が利用可能',
|
||||
'update.downloading': 'ダウンロード中...',
|
||||
'update.downloaded': '更新をダウンロードしました。使用する準備ができたら再起動してください。',
|
||||
'update.downloaded': '更新をダウンロードしました。「インストールして再起動」をクリックして適用してください。',
|
||||
'update.idle': '更新を確認して、インストール済みのバージョンを最新の GitHub Release と比較します。',
|
||||
'update.installAndRestart': 'インストールして再起動',
|
||||
'update.installing': '更新をインストール中...',
|
||||
@@ -3601,7 +3604,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'update.proxyUrlInvalid': 'HTTP または HTTPS のプロキシ URL を入力してください。',
|
||||
'update.proxyUrlRequired': 'プロキシ URL を入力してください。',
|
||||
'update.releaseNotes': 'リリースノート',
|
||||
'update.readyBody': 'v{version} をダウンロードしました。使用する準備ができたら再起動してください。',
|
||||
'update.readyBody': 'v{version} をダウンロードしました。「インストールして再起動」をクリックして適用してください。',
|
||||
'update.readyTitle': '更新の準備完了',
|
||||
'update.restarting': '更新を完了するために再起動中...',
|
||||
'update.upToDate': 'v{version} は最新です。',
|
||||
|
||||
@@ -3576,6 +3576,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── Update Checker ──────────────────────────────────────
|
||||
'update.available': 'v{version} 사용 가능',
|
||||
'update.autoUpdate': '자동 업데이트',
|
||||
'update.autoUpdateDescription': '새 버전을 자동으로 확인하고 백그라운드에서 다운로드합니다. 꺼도 수동 확인과 설치는 계속 사용할 수 있습니다.',
|
||||
'update.autoUpdateSaveFailed': '자동 업데이트 설정을 저장하지 못했습니다: {error}',
|
||||
'update.availableLabel': '사용 가능',
|
||||
'update.checking': '업데이트 확인 중...',
|
||||
'update.checkNow': '지금 확인',
|
||||
@@ -3583,7 +3586,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'update.currentVersionUnknown': '알 수 없음',
|
||||
'update.newVersion': '새 버전 v{version} 사용 가능',
|
||||
'update.downloading': '다운로드 중...',
|
||||
'update.downloaded': '업데이트를 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.',
|
||||
'update.downloaded': '업데이트를 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.',
|
||||
'update.idle': '업데이트를 확인하여 설치된 버전을 최신 GitHub Release와 비교합니다.',
|
||||
'update.installAndRestart': '설치하고 다시 시작',
|
||||
'update.installing': '업데이트 설치 중...',
|
||||
@@ -3603,7 +3606,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'update.proxyUrlInvalid': 'HTTP 또는 HTTPS 프록시 URL을 입력하세요.',
|
||||
'update.proxyUrlRequired': '프록시 URL을 입력하세요.',
|
||||
'update.releaseNotes': '릴리스 노트',
|
||||
'update.readyBody': 'v{version}을(를) 다운로드했습니다. 사용할 준비가 되면 다시 시작하세요.',
|
||||
'update.readyBody': 'v{version}을(를) 다운로드했습니다. 적용하려면 “설치하고 다시 시작”을 클릭하세요.',
|
||||
'update.readyTitle': '업데이트 준비 완료',
|
||||
'update.restarting': '업데이트를 완료하기 위해 다시 시작 중...',
|
||||
'update.upToDate': 'v{version}은(는) 최신입니다.',
|
||||
|
||||
@@ -3573,6 +3573,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── 更新檢查 ──────────────────────────────────────
|
||||
'update.available': 'v{version} 可用',
|
||||
'update.autoUpdate': '自動更新',
|
||||
'update.autoUpdateDescription': '自動檢查新版本並在背景下載;關閉後仍可手動檢查與安裝。',
|
||||
'update.autoUpdateSaveFailed': '自動更新設定儲存失敗:{error}',
|
||||
'update.availableLabel': '可更新版本',
|
||||
'update.checking': '正在檢查更新...',
|
||||
'update.checkNow': '檢查更新',
|
||||
@@ -3580,7 +3583,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'update.currentVersionUnknown': '未知版本',
|
||||
'update.newVersion': '新版本 v{version} 可用',
|
||||
'update.downloading': '下載中...',
|
||||
'update.downloaded': '更新已下載。方便時重啟即可使用新版。',
|
||||
'update.downloaded': '更新已下載。點擊「安裝並重啟」後套用新版。',
|
||||
'update.idle': '點選檢查更新,對比當前安裝版本和 GitHub Releases 的最新版本。',
|
||||
'update.installAndRestart': '安裝並重啟',
|
||||
'update.installing': '正在安裝更新...',
|
||||
@@ -3600,7 +3603,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'update.proxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。',
|
||||
'update.proxyUrlRequired': '請輸入代理地址。',
|
||||
'update.releaseNotes': '更新說明',
|
||||
'update.readyBody': 'v{version} 已下載。方便時重啟即可使用新版。',
|
||||
'update.readyBody': 'v{version} 已下載。點擊「安裝並重啟」後套用新版。',
|
||||
'update.readyTitle': '更新已準備好',
|
||||
'update.restarting': '正在重啟以完成更新...',
|
||||
'update.upToDate': '當前已是最新版本 v{version}。',
|
||||
|
||||
@@ -3572,6 +3572,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── 更新检查 ──────────────────────────────────────
|
||||
'update.available': 'v{version} 可用',
|
||||
'update.autoUpdate': '自动更新',
|
||||
'update.autoUpdateDescription': '自动检查新版本并在后台下载;关闭后仍可手动检查与安装。',
|
||||
'update.autoUpdateSaveFailed': '自动更新设置保存失败:{error}',
|
||||
'update.availableLabel': '可更新版本',
|
||||
'update.checking': '正在检查更新...',
|
||||
'update.checkNow': '检查更新',
|
||||
@@ -3579,7 +3582,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'update.currentVersionUnknown': '未知版本',
|
||||
'update.newVersion': '新版本 v{version} 可用',
|
||||
'update.downloading': '下载中...',
|
||||
'update.downloaded': '更新已下载。方便时重启即可使用新版。',
|
||||
'update.downloaded': '更新已下载。点击「安装并重启」后应用新版。',
|
||||
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
|
||||
'update.installAndRestart': '安装并重启',
|
||||
'update.installing': '正在安装更新...',
|
||||
@@ -3599,7 +3602,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
|
||||
'update.proxyUrlRequired': '请输入代理地址。',
|
||||
'update.releaseNotes': '更新说明',
|
||||
'update.readyBody': 'v{version} 已下载。方便时重启即可使用新版。',
|
||||
'update.readyBody': 'v{version} 已下载。点击「安装并重启」后应用新版。',
|
||||
'update.readyTitle': '更新已准备好',
|
||||
'update.restarting': '正在重启以完成更新...',
|
||||
'update.upToDate': '当前已是最新版本 v{version}。',
|
||||
|
||||
@@ -1,19 +1,107 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useUpdateStore } from '@/stores/updateStore'
|
||||
import { settingsApi } from '@/api/settings'
|
||||
import { AboutSettings } from './AboutSettings'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
const initialSettingsState = useSettingsStore.getState()
|
||||
const initialUpdateState = useUpdateStore.getState()
|
||||
|
||||
async function renderAboutSettings() {
|
||||
render(<AboutSettings />)
|
||||
await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
useSettingsStore.setState({ locale: 'zh', autoUpdateEnabled: true })
|
||||
useUpdateStore.setState({
|
||||
status: 'idle',
|
||||
availableVersion: null,
|
||||
error: null,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
installUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
|
||||
// Establish the persisted fixture as well as the optimistic store value;
|
||||
// failed saves must roll back to a successful save, not an arbitrary setState.
|
||||
await useSettingsStore.getState().setAutoUpdateEnabled(true)
|
||||
vi.mocked(settingsApi.updateUser).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useSettingsStore.setState(initialSettingsState)
|
||||
useUpdateStore.setState(initialUpdateState)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows automatic updates enabled by default with the scope of the setting', async () => {
|
||||
await renderAboutSettings()
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked()
|
||||
expect(screen.getByText('自动检查新版本并在后台下载;关闭后仍可手动检查与安装。')).toBeInTheDocument()
|
||||
await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0')
|
||||
})
|
||||
|
||||
it('saves automatic updates off and back on through the settings API', async () => {
|
||||
await renderAboutSettings()
|
||||
const toggle = screen.getByRole('switch', { name: '自动更新' })
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(toggle).not.toBeChecked())
|
||||
await waitFor(() => expect(toggle).toBeEnabled())
|
||||
expect(settingsApi.updateUser).toHaveBeenCalledWith({ autoUpdateEnabled: false })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(toggle).toBeChecked())
|
||||
await waitFor(() => expect(toggle).toBeEnabled())
|
||||
expect(settingsApi.updateUser).toHaveBeenLastCalledWith({ autoUpdateEnabled: true })
|
||||
})
|
||||
|
||||
it('disables the switch until saving finishes', async () => {
|
||||
let finishSave: (result: { ok: true }) => void = () => {}
|
||||
vi.mocked(settingsApi.updateUser).mockImplementationOnce(() => new Promise(resolve => { finishSave = resolve }))
|
||||
await renderAboutSettings()
|
||||
const toggle = screen.getByRole('switch', { name: '自动更新' })
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toBeDisabled()
|
||||
await waitFor(() => expect(settingsApi.updateUser).toHaveBeenCalledOnce())
|
||||
await act(async () => { finishSave({ ok: true }) })
|
||||
expect(toggle).toBeEnabled()
|
||||
expect(toggle).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('surfaces a failed save and restores the previous automatic update setting', async () => {
|
||||
vi.mocked(settingsApi.updateUser).mockRejectedValueOnce(new Error('fixture save failed'))
|
||||
await renderAboutSettings()
|
||||
const toggle = screen.getByRole('switch', { name: '自动更新' })
|
||||
fireEvent.click(toggle)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('自动更新设置保存失败:fixture save failed')
|
||||
expect(toggle).toBeChecked()
|
||||
expect(toggle).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps manual checking and installing available while automatic updates are off', async () => {
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
useUpdateStore.setState({ status: 'downloaded', availableVersion: '0.2.0' })
|
||||
await renderAboutSettings()
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked()
|
||||
expect(useUpdateStore.getState().initialize).not.toHaveBeenCalled()
|
||||
const check = screen.getByRole('button', { name: '检查更新' })
|
||||
const install = screen.getByRole('button', { name: '安装并重启' })
|
||||
expect(check).toBeEnabled()
|
||||
expect(install).toBeEnabled()
|
||||
fireEvent.click(check)
|
||||
fireEvent.click(install)
|
||||
expect(useUpdateStore.getState().checkForUpdates).toHaveBeenCalledOnce()
|
||||
expect(useUpdateStore.getState().installUpdate).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText('更新已下载。点击「安装并重启」后应用新版。')).toBeInTheDocument()
|
||||
await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0')
|
||||
})
|
||||
|
||||
it('opens the group QR in a dialog instead of pushing it below the fold', async () => {
|
||||
render(<AboutSettings />)
|
||||
await renderAboutSettings()
|
||||
// The version arrives from an async host call; let it settle so the assertion
|
||||
// is not racing a state update.
|
||||
await screen.findByText((_, node) => node?.tagName === 'SPAN' && node.textContent === '版本 0.1.0')
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SettingsPageHeader,
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsSwitchRow,
|
||||
} from '@/components/settings/SettingsSection'
|
||||
import type { UpdateProxyMode } from '../../types/settings'
|
||||
import { MarkdownRenderer } from '../../components/markdown/MarkdownRenderer'
|
||||
@@ -46,6 +47,8 @@ export function AboutSettings() {
|
||||
const [version, setVersion] = useState('')
|
||||
const updateProxy = useSettingsStore((s) => s.updateProxy)
|
||||
const setUpdateProxy = useSettingsStore((s) => s.setUpdateProxy)
|
||||
const autoUpdateEnabled = useSettingsStore((s) => s.autoUpdateEnabled)
|
||||
const setAutoUpdateEnabled = useSettingsStore((s) => s.setAutoUpdateEnabled)
|
||||
const updateStatus = useUpdateStore((s) => s.status)
|
||||
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
||||
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
||||
@@ -61,6 +64,8 @@ export function AboutSettings() {
|
||||
const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy)
|
||||
const [updateProxySaveError, setUpdateProxySaveError] = useState<string | null>(null)
|
||||
const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false)
|
||||
const [isSavingAutoUpdate, setIsSavingAutoUpdate] = useState(false)
|
||||
const [autoUpdateSaveError, setAutoUpdateSaveError] = useState<string | null>(null)
|
||||
const [communityOpen, setCommunityOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +85,8 @@ export function AboutSettings() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [initialize])
|
||||
if (autoUpdateEnabled) void initialize()
|
||||
}, [autoUpdateEnabled, initialize])
|
||||
|
||||
useEffect(() => {
|
||||
setUpdateProxyDraft(updateProxy)
|
||||
@@ -144,6 +149,18 @@ export function AboutSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveAutoUpdateEnabled = async (enabled: boolean) => {
|
||||
setIsSavingAutoUpdate(true)
|
||||
setAutoUpdateSaveError(null)
|
||||
try {
|
||||
await setAutoUpdateEnabled(enabled)
|
||||
} catch (error) {
|
||||
setAutoUpdateSaveError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsSavingAutoUpdate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
|
||||
const downloadedText = formatBytes(downloadedBytes)
|
||||
const updateDescription = (() => {
|
||||
@@ -229,6 +246,18 @@ export function AboutSettings() {
|
||||
)}
|
||||
>
|
||||
<SettingsGroup>
|
||||
<SettingsSwitchRow
|
||||
title={t('update.autoUpdate')}
|
||||
description={t('update.autoUpdateDescription')}
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={isSavingAutoUpdate}
|
||||
onChange={(enabled) => void saveAutoUpdateEnabled(enabled)}
|
||||
footer={autoUpdateSaveError ? (
|
||||
<p role="alert" className="text-xs leading-[1.5] text-[var(--color-error)]">
|
||||
{t('update.autoUpdateSaveFailed', { error: autoUpdateSaveError })}
|
||||
</p>
|
||||
) : undefined}
|
||||
/>
|
||||
<SettingsRow
|
||||
title={(
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -237,6 +237,153 @@ describe('settingsStore Auto mode consent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore automatic update preference', () => {
|
||||
function installSettingsFixture(user: Record<string, unknown> = {}, updateUser = vi.fn().mockResolvedValue({ ok: true })) {
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn(async () => ({ ...user })),
|
||||
updateUser,
|
||||
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
||||
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: { get: vi.fn().mockResolvedValue({ settings: { enabled: false } }) },
|
||||
}))
|
||||
return updateUser
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('upgrades an old settings fixture without the preference to enabled', async () => {
|
||||
const original = { unknownFuturePreference: { keep: true }, updateProxy: { mode: 'system' } }
|
||||
const updateUser = installSettingsFixture(original)
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true)
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true)
|
||||
expect(updateUser).not.toHaveBeenCalled()
|
||||
expect(original).toEqual({ unknownFuturePreference: { keep: true }, updateProxy: { mode: 'system' } })
|
||||
})
|
||||
|
||||
it('persists disabled automatic updates and restores them on the next store startup', async () => {
|
||||
const user: Record<string, unknown> = { unknownFuturePreference: 'keep' }
|
||||
const updateUser = vi.fn(async (patch: Record<string, unknown>) => {
|
||||
Object.assign(user, patch)
|
||||
return { ok: true }
|
||||
})
|
||||
installSettingsFixture(user, updateUser)
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
await useSettingsStore.getState().setAutoUpdateEnabled(false)
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith({ autoUpdateEnabled: false })
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false)
|
||||
|
||||
vi.resetModules()
|
||||
const restarted = await import('./settingsStore')
|
||||
await restarted.useSettingsStore.getState().fetchAll()
|
||||
|
||||
expect(restarted.useSettingsStore.getState().autoUpdateEnabled).toBe(false)
|
||||
expect(user).toEqual({ unknownFuturePreference: 'keep', autoUpdateEnabled: false })
|
||||
await restarted.useSettingsStore.getState().setAutoUpdateEnabled(true)
|
||||
expect(updateUser).toHaveBeenLastCalledWith({ autoUpdateEnabled: true })
|
||||
})
|
||||
|
||||
it.each([true, false])('rolls back to the persisted value %s when saving fails', async previous => {
|
||||
const failure = new Error('fixture write failed')
|
||||
const updateUser = vi.fn().mockRejectedValue(failure)
|
||||
installSettingsFixture({ autoUpdateEnabled: previous }, updateUser)
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
const saving = useSettingsStore.getState().setAutoUpdateEnabled(!previous)
|
||||
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(!previous)
|
||||
await expect(saving).rejects.toBe(failure)
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(previous)
|
||||
})
|
||||
|
||||
it('keeps overlapping saves ordered and does not restore a stale failed choice', async () => {
|
||||
let rejectFirst!: (error: Error) => void
|
||||
const updateUser = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
installSettingsFixture({}, updateUser)
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
const first = useSettingsStore.getState().setAutoUpdateEnabled(false)
|
||||
const firstRejected = expect(first).rejects.toThrow('fixture first save failed')
|
||||
await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1))
|
||||
const second = useSettingsStore.getState().setAutoUpdateEnabled(true)
|
||||
rejectFirst(new Error('fixture first save failed'))
|
||||
await firstRejected
|
||||
await second
|
||||
|
||||
expect(updateUser.mock.calls).toEqual([[{ autoUpdateEnabled: false }], [{ autoUpdateEnabled: true }]])
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['load-before-save', 'load-during-save', 'load-during-save-completes-after-save'] as const)('does not let %s restore automatic updates from a stale settings response', async timing => {
|
||||
let finishSave!: () => void
|
||||
let finishRead!: (settings: Record<string, unknown>) => void
|
||||
const user: Record<string, unknown> = { autoUpdateEnabled: true }
|
||||
const updateUser = vi.fn()
|
||||
.mockImplementationOnce(async (patch: Record<string, unknown>) => {
|
||||
await new Promise<void>(resolve => { finishSave = resolve })
|
||||
Object.assign(user, patch)
|
||||
return { ok: true }
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('fixture later save failed'))
|
||||
installSettingsFixture(user, updateUser)
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
const { settingsApi } = await import('../api/settings')
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
vi.mocked(settingsApi.getUser).mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve }))
|
||||
let loading: Promise<void>
|
||||
let saving: Promise<void>
|
||||
if (timing === 'load-before-save') {
|
||||
loading = useSettingsStore.getState().fetchAll()
|
||||
saving = useSettingsStore.getState().setAutoUpdateEnabled(false)
|
||||
await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1))
|
||||
finishSave()
|
||||
await saving
|
||||
finishRead({ autoUpdateEnabled: true })
|
||||
await loading
|
||||
} else {
|
||||
saving = useSettingsStore.getState().setAutoUpdateEnabled(false)
|
||||
await vi.waitFor(() => expect(updateUser).toHaveBeenCalledTimes(1))
|
||||
loading = useSettingsStore.getState().fetchAll()
|
||||
if (timing === 'load-during-save-completes-after-save') {
|
||||
finishSave()
|
||||
await saving
|
||||
}
|
||||
finishRead({ autoUpdateEnabled: true })
|
||||
await loading
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false)
|
||||
if (timing === 'load-during-save') {
|
||||
finishSave()
|
||||
await saving
|
||||
}
|
||||
}
|
||||
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false)
|
||||
expect(user.autoUpdateEnabled).toBe(false)
|
||||
await expect(useSettingsStore.getState().setAutoUpdateEnabled(true)).rejects.toThrow('fixture later save failed')
|
||||
expect(useSettingsStore.getState().autoUpdateEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore update proxy persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@@ -89,6 +89,7 @@ type SettingsStore = {
|
||||
desktopTerminal: DesktopTerminalSettings
|
||||
webSearch: WebSearchSettings
|
||||
updateProxy: UpdateProxySettings
|
||||
autoUpdateEnabled: boolean
|
||||
network: NetworkSettings
|
||||
/** null = never configured; the UI falls back to DEFAULT_CLEANUP_PERIOD_DAYS. */
|
||||
cleanupPeriodDays: number | null
|
||||
@@ -126,6 +127,7 @@ type SettingsStore = {
|
||||
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
|
||||
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
|
||||
setAutoUpdateEnabled: (enabled: boolean) => Promise<void>
|
||||
setNetwork: (settings: NetworkSettings) => Promise<void>
|
||||
setCleanupPeriodDays: (days: number) => Promise<void>
|
||||
setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
|
||||
@@ -170,6 +172,10 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
|
||||
mode: 'system',
|
||||
url: '',
|
||||
}
|
||||
let autoUpdateSaveQueue: Promise<void> = Promise.resolve()
|
||||
let autoUpdateSaveVersion = 0
|
||||
let autoUpdatePendingSaves = 0
|
||||
let lastPersistedAutoUpdateEnabled = true
|
||||
|
||||
// Keep milliseconds within the signed 32-bit timer limit, matching the server.
|
||||
export const NETWORK_TIMEOUT_MAX_SECONDS = Math.floor(2_147_483_647 / 1000)
|
||||
@@ -226,6 +232,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
|
||||
autoUpdateEnabled: true,
|
||||
network: DEFAULT_NETWORK_SETTINGS,
|
||||
cleanupPeriodDays: null,
|
||||
traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS,
|
||||
@@ -252,6 +259,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchAll: async () => {
|
||||
const autoUpdateVersionAtStart = autoUpdateSaveVersion
|
||||
const autoUpdateSavePendingAtStart = autoUpdatePendingSaves > 0
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const previousH5Access = get().h5Access
|
||||
@@ -274,6 +283,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
])
|
||||
const desktopTerminal = normalizeDesktopTerminalSettings(userSettings.desktopTerminal)
|
||||
lastPersistedDesktopTerminal = desktopTerminal
|
||||
// Read-time upgrade: existing settings keep automatic checks enabled
|
||||
// until the user explicitly saves the new preference.
|
||||
const hydrateAutoUpdate = !autoUpdateSavePendingAtStart && autoUpdatePendingSaves === 0 &&
|
||||
autoUpdateVersionAtStart === autoUpdateSaveVersion
|
||||
if (hydrateAutoUpdate) lastPersistedAutoUpdateEnabled = userSettings.autoUpdateEnabled !== false
|
||||
// Nothing to do for the theme here: uiStore already applied it at
|
||||
// startup, and re-applying would re-persist and re-report it on every
|
||||
// provider switch.
|
||||
@@ -297,6 +311,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
desktopTerminal,
|
||||
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
|
||||
autoUpdateEnabled: hydrateAutoUpdate ? lastPersistedAutoUpdateEnabled : get().autoUpdateEnabled,
|
||||
network: normalizeNetworkSettings(userSettings.network),
|
||||
cleanupPeriodDays: normalizeCleanupPeriodDays(userSettings.cleanupPeriodDays),
|
||||
traceCapture,
|
||||
@@ -572,6 +587,31 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setAutoUpdateEnabled: async (enabled) => {
|
||||
const saveVersion = ++autoUpdateSaveVersion
|
||||
autoUpdatePendingSaves += 1
|
||||
set({ autoUpdateEnabled: enabled })
|
||||
const save = autoUpdateSaveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
try {
|
||||
await settingsApi.updateUser({ autoUpdateEnabled: enabled })
|
||||
lastPersistedAutoUpdateEnabled = enabled
|
||||
} catch (error) {
|
||||
if (saveVersion === autoUpdateSaveVersion) {
|
||||
set({ autoUpdateEnabled: lastPersistedAutoUpdateEnabled })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
autoUpdateSaveQueue = save
|
||||
try {
|
||||
await save
|
||||
} finally {
|
||||
autoUpdatePendingSaves -= 1
|
||||
}
|
||||
},
|
||||
|
||||
setNetwork: async (settings) => {
|
||||
const prev = get().network
|
||||
const next = normalizeNetworkSettings(settings)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserHost } from '../lib/desktopHost/browserHost'
|
||||
import { createElectronHost } from '../lib/desktopHost/electronHost'
|
||||
import type { DesktopUpdateDownloadEvent } from '../lib/desktopHost/types'
|
||||
import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from '../../electron/ipc/channels'
|
||||
import { ElectronUpdaterService, type ElectronUpdaterLike } from '../../electron/services/updater'
|
||||
|
||||
const check = vi.fn()
|
||||
const relaunch = vi.fn()
|
||||
@@ -310,6 +314,238 @@ describe('updateStore', () => {
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('honors the existing Later fixture across repeated startups until explicit installation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Existing releases already persist this string; no new schema is needed.
|
||||
window.localStorage.setItem('cc-haha-dismissed-update-version', '0.2.0')
|
||||
const download = vi.fn().mockResolvedValue(undefined)
|
||||
const install = vi.fn().mockResolvedValue(undefined)
|
||||
check.mockResolvedValue({ version: '0.2.0', download, install, close: vi.fn() })
|
||||
|
||||
for (let restart = 0; restart < 3; restart += 1) {
|
||||
vi.resetModules()
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
const startup = useUpdateStore.getState().initialize()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await startup
|
||||
|
||||
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
|
||||
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
|
||||
expect(download).not.toHaveBeenCalled()
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
expect(relaunch).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
await useUpdateStore.getState().installUpdate()
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
expect(relaunch).toHaveBeenCalledTimes(1)
|
||||
expect(window.localStorage.getItem('cc-haha-dismissed-update-version')).toBeNull()
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not start automatic checks after automatic updates have been disabled', async () => {
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
|
||||
await useUpdateStore.getState().initialize()
|
||||
await useUpdateStore.getState().checkForUpdates({ silent: true })
|
||||
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
expect(useUpdateStore.getState().status).toBe('idle')
|
||||
})
|
||||
|
||||
it('honors disabling automatic updates during the startup delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({ autoUpdateEnabled: true })
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
const startup = useUpdateStore.getState().initialize()
|
||||
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await startup
|
||||
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts a fresh automatic check when re-enabled after a skipped startup check', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
check.mockResolvedValue(null)
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
const firstStartup = useUpdateStore.getState().initialize()
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await firstStartup
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
|
||||
useSettingsStore.setState({ autoUpdateEnabled: true })
|
||||
const nextStartup = useUpdateStore.getState().initialize()
|
||||
const duplicateStartup = useUpdateStore.getState().initialize()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await Promise.all([nextStartup, duplicateStartup])
|
||||
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps manual checking and explicit installation available with automatic updates disabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const download = vi.fn().mockResolvedValue(undefined)
|
||||
const install = vi.fn().mockResolvedValue(undefined)
|
||||
check.mockResolvedValue({ version: '0.2.0', download, install, close: vi.fn() })
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
|
||||
await useUpdateStore.getState().checkForUpdates()
|
||||
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
expect(download).not.toHaveBeenCalled()
|
||||
expect(useUpdateStore.getState().status).toBe('available')
|
||||
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
|
||||
|
||||
await useUpdateStore.getState().installUpdate()
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
expect(relaunch).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not start a background download if automatic updates are disabled while checking', async () => {
|
||||
const download = vi.fn().mockResolvedValue(undefined)
|
||||
let resolveCheck!: (update: unknown) => void
|
||||
check.mockImplementation(() => new Promise(resolve => { resolveCheck = resolve }))
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({ autoUpdateEnabled: true })
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
const checking = useUpdateStore.getState().checkForUpdates({ silent: true })
|
||||
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
resolveCheck({ version: '0.2.0', download, close: vi.fn() })
|
||||
await checking
|
||||
|
||||
expect(download).not.toHaveBeenCalled()
|
||||
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a finishing download quiet when automatic updates have been disabled', async () => {
|
||||
let finishDownload!: () => void
|
||||
const download = vi.fn(() => new Promise<void>(resolve => { finishDownload = resolve }))
|
||||
check.mockResolvedValue({ version: '0.2.0', download, close: vi.fn() })
|
||||
vi.resetModules()
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({ autoUpdateEnabled: true })
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
await useUpdateStore.getState().checkForUpdates({ silent: true })
|
||||
const downloading = useUpdateStore.getState().downloadUpdate()
|
||||
|
||||
useSettingsStore.setState({ autoUpdateEnabled: false })
|
||||
finishDownload()
|
||||
await downloading
|
||||
|
||||
expect(useUpdateStore.getState().status).toBe('downloaded')
|
||||
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['rejected', 'watchdog'] as const)('retries a %s restart through the real Electron host and updater without losing its download', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const updater: ElectronUpdaterLike = {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
checkForUpdates: vi.fn(async () => ({ updateInfo: { version: '0.2.0' } })),
|
||||
downloadUpdate: vi.fn(async () => {}),
|
||||
quitAndInstall: vi.fn(),
|
||||
on: () => updater,
|
||||
off: () => updater,
|
||||
}
|
||||
const service = new ElectronUpdaterService(updater)
|
||||
let downloadHandler: ((event: DesktopUpdateDownloadEvent) => void) | null = null
|
||||
let firstRelaunch = true
|
||||
window.desktopHost = createElectronHost({
|
||||
async invoke<T>(channel: ElectronIpcChannel) {
|
||||
let result: unknown
|
||||
switch (channel) {
|
||||
case ELECTRON_IPC_CHANNELS.appGetVersion: result = '0.1.0'; break
|
||||
case ELECTRON_IPC_CHANNELS.updateCheck: result = await service.checkForUpdates(); break
|
||||
case ELECTRON_IPC_CHANNELS.updateDownload:
|
||||
await service.downloadUpdate(event => downloadHandler?.(event))
|
||||
break
|
||||
case ELECTRON_IPC_CHANNELS.updateInstall: service.stageDownloadedUpdate(); break
|
||||
case ELECTRON_IPC_CHANNELS.updateCancelInstall: service.cancelInstall(); break
|
||||
case ELECTRON_IPC_CHANNELS.updatePrepareInstall: break
|
||||
case ELECTRON_IPC_CHANNELS.runtimeGetServerUrl: result = 'http://127.0.0.1:3456'; break
|
||||
case ELECTRON_IPC_CHANNELS.updateRelaunch:
|
||||
if (firstRelaunch) {
|
||||
firstRelaunch = false
|
||||
if (failure === 'rejected') throw new Error('fixture restart failed')
|
||||
break
|
||||
}
|
||||
service.quitAndInstallDownloadedUpdate({})
|
||||
break
|
||||
default: throw new Error(`Unexpected fixture IPC: ${channel}`)
|
||||
}
|
||||
return result as T
|
||||
},
|
||||
async subscribe(_channel, handler) {
|
||||
downloadHandler = handler as (event: DesktopUpdateDownloadEvent) => void
|
||||
return () => { downloadHandler = null }
|
||||
},
|
||||
})
|
||||
vi.resetModules()
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
await useUpdateStore.getState().checkForUpdates()
|
||||
await useUpdateStore.getState().downloadUpdate()
|
||||
await useUpdateStore.getState().installUpdate()
|
||||
if (failure === 'watchdog') await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(useUpdateStore.getState().status).toBe('downloaded')
|
||||
expect(useUpdateStore.getState().error).toContain(failure === 'rejected'
|
||||
? 'fixture restart failed'
|
||||
: 'Try installing the update again')
|
||||
expect(service.hasStagedUpdate()).toBe(false)
|
||||
expect(service.hasDownloadedUpdate()).toBe(true)
|
||||
|
||||
await useUpdateStore.getState().installUpdate()
|
||||
|
||||
expect(useUpdateStore.getState().status).toBe('restarting')
|
||||
expect(updater.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(updater.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('prompts again when a newer version is available after dismissing an older one', async () => {
|
||||
const oldDownload = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
||||
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
|
||||
|
||||
@@ -65,7 +65,7 @@ function scheduleRelaunchWatchdog(host: DesktopHost) {
|
||||
useUpdateStore.setState((state) => ({
|
||||
...state,
|
||||
status: 'downloaded',
|
||||
error: 'Restart did not start automatically. Restart the app manually to finish installing the update.',
|
||||
error: 'Restart did not start automatically. Try installing the update again.',
|
||||
shouldPrompt: true,
|
||||
progressPercent: 100,
|
||||
}))
|
||||
@@ -136,7 +136,7 @@ async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | n
|
||||
}
|
||||
|
||||
function shouldPromptForVersion(version: string | null) {
|
||||
return !!version && readDismissedUpdateVersion() !== version
|
||||
return useSettingsStore.getState().autoUpdateEnabled && !!version && readDismissedUpdateVersion() !== version
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
@@ -194,7 +194,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
||||
shouldPrompt: false,
|
||||
|
||||
initialize: async () => {
|
||||
if (!getUpdateHost()) return
|
||||
if (!getUpdateHost() || !useSettingsStore.getState().autoUpdateEnabled) return
|
||||
if (!startupCheckPromise) {
|
||||
startupCheckPromise = (async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
@@ -210,6 +210,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
||||
checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => {
|
||||
const host = getUpdateHost()
|
||||
if (!host) return null
|
||||
if (silent && !useSettingsStore.getState().autoUpdateEnabled) return null
|
||||
if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate
|
||||
clearRelaunchWatchdog()
|
||||
|
||||
@@ -281,7 +282,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
||||
shouldPrompt: false,
|
||||
}))
|
||||
|
||||
if (autoDownload && (shouldOffer || !silent)) {
|
||||
if (autoDownload && useSettingsStore.getState().autoUpdateEnabled && (shouldOffer || !silent)) {
|
||||
void get().downloadUpdate().catch(() => {
|
||||
// The store records the failure and keeps the manual install path retryable.
|
||||
})
|
||||
|
||||
@@ -163,6 +163,7 @@ export type UserSettings = {
|
||||
outputStyle?: string
|
||||
skipWebFetchPreflight?: boolean
|
||||
desktopNotificationsEnabled?: boolean
|
||||
autoUpdateEnabled?: boolean
|
||||
webSearch?: WebSearchSettings
|
||||
updateProxy?: Partial<UpdateProxySettings>
|
||||
network?: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import {
|
||||
@@ -16,7 +16,19 @@ import {
|
||||
// These fixtures contain synthetic Mach-O headers, not runnable executables.
|
||||
// Resource execution has dedicated cases below with an explicit runner.
|
||||
function inspectPackagedArtifacts(rootDir: string, options: Parameters<typeof inspectPackage>[1]) {
|
||||
return inspectPackage(rootDir, { hostPlatform: 'linux', ...options })
|
||||
return inspectPackage(rootDir, {
|
||||
hostPlatform: 'linux',
|
||||
...(options.platform === 'windows' ? {
|
||||
commandRunner: (command: string, args: string[]) => {
|
||||
expect(command).toBe('bun')
|
||||
expect(args[0]).toBe('run')
|
||||
expect(args[1]).toBe(join(rootDir, 'desktop', 'scripts', 'assert-windows-icon.ts'))
|
||||
const branded = readFileSync(args[2], 'utf8') === 'synthetic branded executable'
|
||||
return { status: branded ? 0 : 1, stderr: branded ? '' : 'The Windows executable icon differs from the application ICO' }
|
||||
},
|
||||
} : {}),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
function createRepoRoot() {
|
||||
@@ -85,6 +97,15 @@ function thinMachO(arch: 'arm64' | 'x64', minimum = '14.4') {
|
||||
return bytes
|
||||
}
|
||||
|
||||
function writeWindowsBranding(rootDir: string, unpackedDir: string) {
|
||||
// The root-only policy lane mocks the parser process; the desktop parser
|
||||
// suite separately verifies real synthetic PE resource tables.
|
||||
const icon = 'synthetic application ICO'
|
||||
writeFile(rootDir, `${unpackedDir}/Claude Code Haha.exe`, 'synthetic branded executable')
|
||||
writeFile(rootDir, `${unpackedDir}/resources/app-icon.ico`, icon)
|
||||
writeFile(rootDir, 'desktop/src-tauri/icons/icon.ico', icon)
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
@@ -171,7 +192,7 @@ describe('final macOS helper cursor resource verification', () => {
|
||||
commandRunner: (command, args, options) => {
|
||||
expect(args).toEqual(['--probe-cursor-resources'])
|
||||
expect(command.startsWith(source.helper)).toBe(false)
|
||||
expect(command).toContain('Relocated Helper.app/Contents/MacOS/cc-haha-computer-use')
|
||||
expect(command.replaceAll('\\', '/')).toContain('Relocated Helper.app/Contents/MacOS/cc-haha-computer-use')
|
||||
expect(options?.timeout).toBe(10_000)
|
||||
expect(options?.maxBuffer).toBe(1024 * 1024)
|
||||
temporaryRoot = options!.cwd
|
||||
@@ -638,6 +659,7 @@ describe('packaged artifact inspection', () => {
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/latest.yml', 'path: Claude Code Haha Setup 0.3.1.exe\n')
|
||||
writeWindowsBranding(rootDir, 'desktop/build-artifacts/electron/win-unpacked')
|
||||
|
||||
const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows' })
|
||||
|
||||
@@ -658,6 +680,7 @@ describe('packaged artifact inspection', () => {
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-x64/latest.yml', 'path: Claude-Code-Haha-0.3.1-x64.exe\n')
|
||||
writeWindowsBranding(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked')
|
||||
|
||||
const report = await inspectPackagedArtifacts(rootDir, {
|
||||
platform: 'windows',
|
||||
@@ -667,7 +690,7 @@ describe('packaged artifact inspection', () => {
|
||||
})
|
||||
|
||||
expect(report.passed).toBe(true)
|
||||
expect(report.artifactsDir.endsWith('desktop/build-artifacts/windows-x64')).toBe(true)
|
||||
expect(report.artifactsDir.replaceAll('\\', '/').endsWith('desktop/build-artifacts/windows-x64')).toBe(true)
|
||||
})
|
||||
|
||||
test('passes Windows arm64 checks only when arm64 sidecar and node-pty native module are present', async () => {
|
||||
@@ -682,6 +705,7 @@ describe('packaged artifact inspection', () => {
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-arm64/pty.node')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/latest.yml', 'path: Claude-Code-Haha-0.3.1-arm64.exe\n')
|
||||
writeWindowsBranding(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked')
|
||||
|
||||
const report = await inspectPackagedArtifacts(rootDir, {
|
||||
platform: 'windows',
|
||||
@@ -729,6 +753,7 @@ describe('packaged artifact inspection', () => {
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json')
|
||||
writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node')
|
||||
writeWindowsBranding(rootDir, 'desktop/build-artifacts/electron/win-unpacked')
|
||||
|
||||
const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'dir' })
|
||||
|
||||
@@ -737,6 +762,21 @@ describe('packaged artifact inspection', () => {
|
||||
expect(report.notes.join('\n')).toContain('Windows app-update.yml was not required')
|
||||
})
|
||||
|
||||
test.each(['missing-icon', 'wrong-icon', 'wrong-executable'] as const)('rejects Windows branding with %s', async failure => {
|
||||
const rootDir = createRepoRoot()
|
||||
tempDirs.push(rootDir)
|
||||
const unpackedDir = 'desktop/build-artifacts/electron/win-unpacked'
|
||||
writeFile(rootDir, `${unpackedDir}/resources/app.asar`)
|
||||
writeWindowsBranding(rootDir, unpackedDir)
|
||||
if (failure === 'missing-icon') rmSync(join(rootDir, unpackedDir, 'resources/app-icon.ico'))
|
||||
if (failure === 'wrong-icon') writeFile(rootDir, `${unpackedDir}/resources/app-icon.ico`, 'wrong icon')
|
||||
if (failure === 'wrong-executable') writeFile(rootDir, `${unpackedDir}/Claude Code Haha.exe`, 'unbranded executable')
|
||||
|
||||
const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'dir' })
|
||||
expect(report.missingChecks.some(check => check.label === 'Windows application and taskbar icons')).toBe(true)
|
||||
expect(report.notes.join('\n')).toContain('Windows icon inspection failed:')
|
||||
})
|
||||
|
||||
test('does not treat the win-unpacked app executable as a Windows release installer', async () => {
|
||||
const rootDir = createRepoRoot()
|
||||
tempDirs.push(rootDir)
|
||||
@@ -798,7 +838,7 @@ describe('packaged artifact inspection', () => {
|
||||
})
|
||||
|
||||
expect(report.passed).toBe(true)
|
||||
expect(report.artifactsDir.endsWith('desktop/build-artifacts/linux-x64')).toBe(true)
|
||||
expect(report.artifactsDir.replaceAll('\\', '/').endsWith('desktop/build-artifacts/linux-x64')).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts Linux architecture-specific update metadata from arm64 builds', async () => {
|
||||
@@ -872,7 +912,7 @@ describe('packaged artifact inspection', () => {
|
||||
})
|
||||
|
||||
expect(report.passed).toBe(true)
|
||||
expect(report.passedChecks.some((check) => check.path.includes('linux-arm64-unpacked/resources/app.asar'))).toBe(true)
|
||||
expect(report.passedChecks.some((check) => check.path.replaceAll('\\', '/').includes('linux-arm64-unpacked/resources/app.asar'))).toBe(true)
|
||||
expect(report.packagedArtifacts.some((artifact) => artifact.label === 'Linux RPM package')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -573,7 +573,7 @@ function assertCursorResourcesContained(helperApp: string, directory: string) {
|
||||
const target = pending.pop()!
|
||||
const canonical = realpathSync(target)
|
||||
const withinApp = relative(app, canonical)
|
||||
if (isAbsolute(withinApp) || withinApp === '..' || withinApp.startsWith('../')) {
|
||||
if (isAbsolute(withinApp) || withinApp === '..' || normalizePath(withinApp).startsWith('../')) {
|
||||
throw new Error(`cursor resource escapes the helper app: ${target}`)
|
||||
}
|
||||
if (visited.has(canonical)) continue
|
||||
@@ -1049,7 +1049,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti
|
||||
}
|
||||
}
|
||||
|
||||
function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) {
|
||||
function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport, options: InspectOptions) {
|
||||
const installers = findMatches(report.artifactsDir, (candidate) => {
|
||||
const normalized = normalizePath(candidate)
|
||||
return normalized.endsWith('.exe') && !isInsideWindowsUnpackedDir(normalized)
|
||||
@@ -1079,6 +1079,34 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) {
|
||||
|
||||
if (unpackedDir) {
|
||||
const resourcesDir = join(unpackedDir, 'resources')
|
||||
const executable = join(unpackedDir, `${report.productName}.exe`)
|
||||
const shellIcon = join(resourcesDir, 'app-icon.ico')
|
||||
const iconRecord = { label: 'Windows application and taskbar icons', path: toRelative(rootDir, executable) }
|
||||
let iconProbeRoot: string | undefined
|
||||
try {
|
||||
const expectedIconPath = join(rootDir, 'desktop', 'src-tauri', 'icons', 'icon.ico')
|
||||
const expectedIcon = readFileSync(expectedIconPath)
|
||||
if (!readFileSync(shellIcon).equals(expectedIcon)) throw new Error('The taskbar ICO differs from the application ICO')
|
||||
if (!existsSync(executable)) throw new Error('The Windows app executable is missing')
|
||||
// Desktop packaging installs the builder's PE parser; the root-only
|
||||
// policy lane must not import desktop dependencies while checking Linux.
|
||||
iconProbeRoot = mkdtempSync(join(tmpdir(), 'cc-haha-windows-icon-'))
|
||||
const result = (options.commandRunner ?? defaultCommandRunner)('bun', [
|
||||
'run', join(rootDir, 'desktop', 'scripts', 'assert-windows-icon.ts'), executable, expectedIconPath,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: createSandboxedTestEnvironment(iconProbeRoot, undefined, process.env),
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
if (result.status !== 0) throw new Error(firstDiagnosticLine(result.stderr ?? '') ?? 'The Windows executable icon inspection failed')
|
||||
report.passedChecks.push(iconRecord)
|
||||
} catch (error) {
|
||||
report.missingChecks.push(iconRecord)
|
||||
report.notes.push(`Windows icon inspection failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
if (iconProbeRoot) rmSync(iconProbeRoot, { recursive: true, force: true })
|
||||
}
|
||||
const unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked')
|
||||
const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty')
|
||||
const sidecarDir = join(unpackedResourcesDir, 'src-tauri', 'binaries')
|
||||
@@ -1298,7 +1326,7 @@ export async function inspectPackagedArtifacts(rootDir: string, options: Inspect
|
||||
if (options.platform === 'macos') {
|
||||
inspectMacosArtifacts(resolvedRootDir, report, options)
|
||||
} else if (options.platform === 'windows') {
|
||||
inspectWindowsArtifacts(resolvedRootDir, report)
|
||||
inspectWindowsArtifacts(resolvedRootDir, report, options)
|
||||
} else {
|
||||
inspectLinuxArtifacts(resolvedRootDir, report)
|
||||
}
|
||||
|
||||
@@ -185,9 +185,9 @@ describe('E2E: Full Flow', () => {
|
||||
// 3. Settings
|
||||
// =============================================
|
||||
|
||||
it('should expose the default team preference in initial settings', async () => {
|
||||
it('should expose the default team and automatic update preferences in initial settings', async () => {
|
||||
const { data } = await api('GET', '/api/settings/user')
|
||||
expect(data).toEqual({ agentTeamsEnabled: true })
|
||||
expect(data).toEqual({ agentTeamsEnabled: true, autoUpdateEnabled: true })
|
||||
})
|
||||
|
||||
it('should update and read user settings', async () => {
|
||||
|
||||
@@ -56,6 +56,36 @@ describe('persistent storage upgrade migrations', () => {
|
||||
expect(await new SettingsService().getAgentTeamsEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
test('upgrades the legacy automatic update preference on read without rewriting shared user settings', async () => {
|
||||
const userPath = path.join(tempDir, 'settings.json')
|
||||
const legacy = {
|
||||
env: { UNKNOWN_ENV: 'preserved' },
|
||||
updateProxy: { mode: 'manual', url: 'http://127.0.0.1:7890' },
|
||||
unknownFuturePreference: { keep: true },
|
||||
}
|
||||
const original = JSON.stringify(legacy)
|
||||
await fs.writeFile(userPath, original)
|
||||
const service = new SettingsService()
|
||||
|
||||
expect(await service.getAutoUpdateEnabled()).toBe(true)
|
||||
expect(await fs.readFile(userPath, 'utf-8')).toBe(original)
|
||||
await service.updateUserSettings({ autoUpdateEnabled: false })
|
||||
|
||||
expect(await new SettingsService().getAutoUpdateEnabled()).toBe(false)
|
||||
expect(JSON.parse(await fs.readFile(userPath, 'utf-8'))).toEqual({ ...legacy, autoUpdateEnabled: false })
|
||||
await service.updateUserSettings({ autoUpdateEnabled: true })
|
||||
expect(await new SettingsService().getAutoUpdateEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a non-boolean automatic update choice without changing the settings file', async () => {
|
||||
const userPath = path.join(tempDir, 'settings.json')
|
||||
const original = JSON.stringify({ autoUpdateEnabled: false, unknownFuturePreference: 'keep' })
|
||||
await fs.writeFile(userPath, original)
|
||||
|
||||
await expect(new SettingsService().updateUserSettings({ autoUpdateEnabled: 'false' })).rejects.toThrow('autoUpdateEnabled must be a boolean')
|
||||
expect(await fs.readFile(userPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
test('migrates legacy providers index and writes a backup before changing it', async () => {
|
||||
const ccHahaDir = path.join(tempDir, 'cc-haha')
|
||||
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||
|
||||
@@ -502,7 +502,32 @@ describe('Settings API', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({ agentTeamsEnabled: true })
|
||||
expect(body).toEqual({ agentTeamsEnabled: true, autoUpdateEnabled: true })
|
||||
})
|
||||
|
||||
it('persists automatic updates through the user settings API and preserves unrelated fields', async () => {
|
||||
const settingsPath = path.join(tmpDir, 'settings.json')
|
||||
const original = JSON.stringify({ futureSetting: { keep: true } })
|
||||
await fs.writeFile(settingsPath, original)
|
||||
const initial = makeRequest('GET', '/api/settings/user')
|
||||
const oldSettings = await handleSettingsApi(initial.req, initial.url, initial.segments)
|
||||
expect(await oldSettings.json()).toMatchObject({ autoUpdateEnabled: true, futureSetting: { keep: true } })
|
||||
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(original)
|
||||
|
||||
for (const enabled of [false, true]) {
|
||||
const put = makeRequest('PUT', '/api/settings/user', { autoUpdateEnabled: enabled })
|
||||
expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(200)
|
||||
const get = makeRequest('GET', '/api/settings/user')
|
||||
const response = await handleSettingsApi(get.req, get.url, get.segments)
|
||||
expect(await response.json()).toMatchObject({ autoUpdateEnabled: enabled, futureSetting: { keep: true } })
|
||||
expect(JSON.parse(await fs.readFile(settingsPath, 'utf-8'))).toEqual({ futureSetting: { keep: true }, autoUpdateEnabled: enabled })
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['false', null, 1])('rejects invalid automatic update preference %j without changing settings', async value => {
|
||||
const put = makeRequest('PUT', '/api/settings/user', { autoUpdateEnabled: value })
|
||||
expect((await handleSettingsApi(put.req, put.url, put.segments)).status).toBe(400)
|
||||
expect(await new SettingsService().getUserSettings()).toEqual({})
|
||||
})
|
||||
|
||||
it('persists the General team preference across reads and overrides legacy env', async () => {
|
||||
|
||||
@@ -101,8 +101,10 @@ export async function handleSettingsApi(
|
||||
|
||||
async function handleUserSettings(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
const userSettings = await settingsService.getUserSettings()
|
||||
return Response.json({
|
||||
...await settingsService.getUserSettings(),
|
||||
...userSettings,
|
||||
autoUpdateEnabled: await settingsService.getAutoUpdateEnabled(userSettings),
|
||||
agentTeamsEnabled: await settingsService.getAgentTeamsEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +104,14 @@ export class SettingsService {
|
||||
return this.readJsonFile(this.getUserSettingsPath())
|
||||
}
|
||||
|
||||
/** Read-time upgrade for settings written before the desktop update toggle.
|
||||
* Do not rewrite the user-owned file until an explicit preference is saved.
|
||||
*/
|
||||
async getAutoUpdateEnabled(settings?: Record<string, unknown>): Promise<boolean> {
|
||||
const user = settings ?? await this.getUserSettings()
|
||||
return user.autoUpdateEnabled !== false
|
||||
}
|
||||
|
||||
/** Read-time upgrade for older settings that only stored the team env flag.
|
||||
* Keep the original file intact until the user explicitly saves a choice.
|
||||
*/
|
||||
@@ -189,6 +197,9 @@ export class SettingsService {
|
||||
|
||||
/** 更新用户级设置(顶层浅合并,并保留桌面终端的未知子字段) */
|
||||
async updateUserSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
if (Object.hasOwn(settings, 'autoUpdateEnabled') && typeof settings.autoUpdateEnabled !== 'boolean') {
|
||||
throw ApiError.badRequest('autoUpdateEnabled must be a boolean')
|
||||
}
|
||||
if (Object.hasOwn(settings, 'agentTeamsEnabled') && typeof settings.agentTeamsEnabled !== 'boolean') {
|
||||
throw ApiError.badRequest('agentTeamsEnabled must be a boolean')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user