From 0cf76b9fad457da91d0959ffdcaaf42b92948f70 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 28 Oct 2026 18:56:20 +0800 Subject: [PATCH 01/25] feat(desktop): switch update channels from settings --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 6 +- apps/desktop/README.zh.md | 6 +- apps/desktop/package.json | 1 + apps/desktop/scripts/test-update-channels.mjs | 22 ++++ apps/desktop/src/ipc.ts | 10 +- apps/desktop/src/locale.ts | 8 ++ apps/desktop/src/main.ts | 69 +++++++++-- apps/desktop/src/mandatory-update-policy.ts | 2 +- apps/desktop/src/preload-app.ts | 10 ++ apps/desktop/src/update-coordinator.ts | 116 +++++++++++++++--- apps/desktop/src/update-discard.ts | 30 +++++ apps/desktop/src/update-presentation.ts | 2 + apps/desktop/src/update-settings.ts | 99 +++++++++++++++ apps/desktop/tests/README.i18n.yaml | 4 +- apps/desktop/tests/README.md | 2 + apps/desktop/tests/README.zh.md | 2 + .../tests/fixtures/update-channels.mjs | 93 ++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 70 +++++++++-- apps/desktop/tests/preload-app.spec.ts | 19 +++ apps/desktop/tests/update-coordinator.spec.ts | 111 +++++++++++++++++ apps/desktop/tests/update-settings.spec.ts | 50 ++++++++ apps/desktop/tsconfig.host.json | 3 + apps/web/tests/desktop-channel.e2e.ts | 67 ++++++++++ .../desktop-channel/save-error.expected.md | 4 + .../desktop-channel/stable.expected.md | 3 + apps/web/tsconfig.json | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../src/client/DesktopChannelRow.module.css | 66 ++++++++++ .../src/client/DesktopChannelRow.tsx | 48 ++++++++ .../src/client/DesktopUpdateIndicator.tsx | 10 +- .../src/client/desktop-channel-source.ts | 47 +++++++ .../src/client/desktop-update-source.ts | 2 +- .../ui-settings-general/src/client/index.ts | 12 ++ .../ui-settings-general/src/client/locales.ts | 64 ++++++++++ .../client/ui-settings-general/src/types.ts | 30 +++++ .../tests/apply.client.spec.ts | 21 ++++ .../tests/components.client.spec.tsx | 46 +++++++ .../tests/desktop-channel.client.spec.ts | 91 ++++++++++++++ .../tests/desktop-update.client.spec.tsx | 16 +++ .../src/client/slot-catalog.ts | 1 + 43 files changed, 1223 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/scripts/test-update-channels.mjs create mode 100644 apps/desktop/src/update-discard.ts create mode 100644 apps/desktop/src/update-settings.ts create mode 100644 apps/desktop/tests/fixtures/update-channels.mjs create mode 100644 apps/desktop/tests/update-settings.spec.ts create mode 100644 apps/web/tests/desktop-channel.e2e.ts create mode 100644 apps/web/tests/expected/desktop-channel/save-error.expected.md create mode 100644 apps/web/tests/expected/desktop-channel/stable.expected.md create mode 100644 packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css create mode 100644 packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx create mode 100644 packages/client/ui-settings-general/src/client/desktop-channel-source.ts create mode 100644 packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e7fced6b8e..bd9da9f598 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 2e57457c44c85b5f - zh: 5b8510a3dc4c6c9d + en: addf84365e71ca32 + zh: 9804df876e463374 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f961a72a92..b4affa7427 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -302,7 +302,11 @@ On first launch, including after an upgrade, Desktop creates this document if it } ``` -Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). +Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). + +In Desktop **Settings → General → Update channel**, **Stable** selects `latest` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. + +A failed UI save keeps the current channel and update target; invalid startup JSON or channel suspends ordinary updates while the application remains usable. The update indicator exposes a configuration error independently of download progress; open it for the file path and recovery guidance. Fix the file and restart, or select a valid channel in Settings; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a7500d5d28..aa0db81531 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -302,7 +302,11 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett } ``` -已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 +启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 + +在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `latest`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 + +UI 保存失败时保留当前渠道和更新目标;启动时 JSON 或渠道无效会暂停普通更新,主应用仍可使用。更新入口独立于下载进度显示配置错误,点击可查看文件路径和恢复方法。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 081a90ead3..0433ba6799 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "bundle": "tsdown --config-loader native", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", + "test:updates:channels": "pnpm run build && node scripts/test-update-channels.mjs", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "render:tray-icon": "tsx scripts/render-tray-icon.ts", diff --git a/apps/desktop/scripts/test-update-channels.mjs b/apps/desktop/scripts/test-update-channels.mjs new file mode 100644 index 0000000000..3c1b271c44 --- /dev/null +++ b/apps/desktop/scripts/test-update-channels.mjs @@ -0,0 +1,22 @@ +/** Qualify built channel transitions with Electron and the pinned updater, without installing. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import electron from 'electron' + +const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) +const environment = { ...process.env, DSH_CHANNEL_TEST_ROOT: root } +delete environment.ELECTRON_RUN_AS_NODE +const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/update-channels.mjs', import.meta.url))], { + env: environment, stdio: 'inherit', windowsHide: true, +}) +const deadline = setTimeout(() => { child.kill() }, 30_000) +try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => signal ? reject(new Error(`Channel qualification ended on ${signal}`)) : resolve(code)) + }) + if (code !== 0) throw new Error(`Channel qualification failed: ${code}`) +} finally { clearTimeout(deadline); await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e62a84f0a1..ef06aed148 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,5 +1,7 @@ /** Typed preload operations exposed only by the Electron shell. */ +export type { DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import type { DesktopChannelBridge, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' @@ -29,6 +31,9 @@ export const DESKTOP_IPC = { deviceInfo: 'dsh-desktop:device-info', localeBootstrap: 'dsh-desktop:locale-bootstrap', localeChanged: 'dsh-desktop:locale-changed', + updatesChannelGet: 'dsh-desktop:updates-channel-get', + updatesChannelSet: 'dsh-desktop:updates-channel-set', + updatesChannelChanged: 'dsh-desktop:updates-channel-changed', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', updatesPresentation: 'dsh-desktop:updates-presentation', @@ -48,13 +53,14 @@ export interface DesktopUpdateState { /** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */ readonly technicalDetails?: string readonly percent?: number - readonly failedOperation?: 'check' | 'download' | 'install' + readonly failedOperation?: 'check' | 'download' | 'install' | 'configuration' /** Main-owned preparation cause; UI wording is selected by the active locale. */ readonly preparationFailure?: DesktopUpdatePreparationFailureKind } /** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */ export type DesktopUpdateFailureKind = + | 'configuration' | 'check' | 'check-network' | 'download' @@ -72,6 +78,7 @@ export interface DesktopUpdatePresentation { readonly version?: string readonly percent?: number readonly failure?: DesktopUpdateFailureKind + readonly configurationError?: DesktopChannelState['error'] } /** Product documents cannot supply update versions, package URLs, or installation authorization. */ @@ -87,6 +94,7 @@ export interface DshDesktopProductApi { */ deviceInfo(): Promise readonly updates: { + readonly channels: DesktopChannelBridge status(): Promise open(): Promise subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 74a1f19941..2bc29737ae 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -105,6 +105,7 @@ export const en = { checkUpdatesMenu: 'Check for Updates…', reloadPageMenu: 'Reload Page', restartAppHostMenu: 'Restart App and Host', + updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', @@ -284,6 +285,7 @@ export const zh = { checkUpdatesMenu: '检查更新…', reloadPageMenu: '刷新页面', restartAppHostMenu: '重启应用与 Host', + updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', @@ -357,6 +359,7 @@ export const zh = { /** Japanese copy, checked against the English keys. */ export const ja = { + updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', cliCommandMenu: 'dsh コマンドを管理…', cliCommandTitle: 'dsh コマンドを管理', cliCommandLocation: 'デスクトップコマンド: {path}', @@ -534,6 +537,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', cliCommandMenu: 'Administrar el comando dsh…', cliCommandTitle: 'Administrar el comando dsh', cliCommandLocation: 'Comando de escritorio: {path}', @@ -711,6 +715,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', cliCommandMenu: 'Gérer la commande dsh…', cliCommandTitle: 'Gérer la commande dsh', cliCommandLocation: 'Commande de bureau : {path}', @@ -888,6 +893,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', cliCommandMenu: 'dsh-Befehl verwalten…', cliCommandTitle: 'dsh-Befehl verwalten', cliCommandLocation: 'Desktop-Befehl: {path}', @@ -1065,6 +1071,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', cliCommandMenu: 'Управление командой dsh…', cliCommandTitle: 'Управление командой dsh', cliCommandLocation: 'Команда Desktop: {path}', @@ -1242,6 +1249,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', cliCommandMenu: 'Gerenciar comando dsh…', cliCommandTitle: 'Gerenciar comando dsh', cliCommandLocation: 'Comando da área de trabalho: {path}', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4770fe83bf..29b535c088 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,9 @@ -import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' -import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ +import type { DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import { DesktopUpdateSettings, DesktopUpdateSettingsError, isDesktopUpdateChannel } from './update-settings.ts' +import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -421,6 +423,9 @@ async function main(): Promise { let updateStoppedHost = false let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } + let channelState: DesktopChannelState | undefined + const updatePresentation = (state: DesktopUpdateState) => ({ ...presentDesktopUpdate(state), + ...(channelState?.error === undefined ? {} : { configurationError: channelState.error }) }) const systemLanguages = app.getPreferredSystemLanguages() let locale = windowsLanguage === undefined ? resolveDesktopStartupLocale(null, systemLanguages) : resolveDesktopLocale(windowsLanguage) @@ -611,9 +616,9 @@ async function main(): Promise { updateState = state mandatoryUI?.sync() for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state)) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(state)) } - if (state.phase === 'error' && state.failedOperation !== 'check') { + if (state.phase === 'error' && state.failedOperation !== 'check' && state.failedOperation !== 'configuration') { if (state.failedOperation === 'install' && fileDragStopped && !quitting) { fileDragCleanups.add(installFileDrag()) fileDragStopped = false @@ -675,7 +680,13 @@ async function main(): Promise { } const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) - const updateChannel = desktopUpdateChannel(manifest) + const packagedChannel = desktopUpdateChannel(manifest) + const channelSettings = new DesktopUpdateSettings(app.getPath('userData'), packagedChannel) + const initialChannel = await channelSettings.load() + channelState = { channel: initialChannel.kind === 'ready' ? initialChannel.channel : null, + packagedChannel, changing: false, + ...(initialChannel.kind === 'error' ? { error: initialChannel.error.kind } : {}) } + const updateChannel = initialChannel.kind === 'ready' ? initialChannel.channel : packagedChannel const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -736,12 +747,22 @@ async function main(): Promise { updateChannel, ) + if (initialChannel.kind === 'error') updates.suspendConfiguration(initialChannel.error.path) + const publishChannel = (state: DesktopChannelState): DesktopChannelState => { + channelState = state + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.updatesChannelChanged, state) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(updates.state)) + } + return state + } + const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) - const downloadUpdate = async (version: string): Promise => { + const downloadUpdate = async (version: string, revision = updates.revision): Promise => { void track('desktop_upgrade_click', {}) updateJournal?.action('download-requested') - const state = await updates.download(version) + const state = await updates.download(version, revision) if (state.phase !== 'ready' || quitting) return state // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it. // A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory @@ -749,7 +770,7 @@ async function main(): Promise { if (!isMandatory()) await windowShown() // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited. if (quitting) return state - return updates.install(version) + return updates.install(version, revision) } const windowShown = (): Promise => new Promise((resolve) => { const window = currentDialogWindow() @@ -865,9 +886,28 @@ async function main(): Promise { windowsLanguage = locale.id refreshApplicationMenu() }) + ipcMain.handle(DESKTOP_IPC.updatesChannelGet, (event) => { + assertProductSender(event) + return channelState + }) + let channelRequests = 0 + ipcMain.handle(DESKTOP_IPC.updatesChannelSet, async (event, channel: unknown) => { + assertProductSender(event) + if (!isDesktopUpdateChannel(channel)) throw new Error('desktop update: unsupported channel') + if (channelState === undefined) throw new Error('desktop update: settings are not initialized') + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + await updates.switchChannel(channel, () => channelSettings.save(channel)) + return publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) + } catch (error) { + return publishChannel({ ...channelState, changing: --channelRequests > 0, + error: error instanceof DesktopUpdateSettingsError ? error.kind : 'apply' }) + } + }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) - return presentDesktopUpdate(updates.state) + return updatePresentation(updates.state) }) ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => { assertProductSender(event) @@ -896,6 +936,10 @@ async function main(): Promise { let promptOperation: Promise | undefined let policyAuthenticationQueued = false const openUpdatePrompt = (manual = false): Promise => { + if (channelState?.error !== undefined) { + return ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, + message: locale.messages.updateConfigurationFailed, detail: channelSettings.path }).then(() => {}) + } if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() } @@ -913,6 +957,7 @@ async function main(): Promise { let progress: Promise | undefined try { let state = updates.state + const revision = updates.revision if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { controller = new AbortController() ordinaryDialogs.add(controller) @@ -935,7 +980,7 @@ async function main(): Promise { if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { if (state.version !== undefined) { failedOperation = 'install' - await showUpdateFailure(await updates.install(state.version)) + await showUpdateFailure(await updates.install(state.version, revision)) } return } @@ -950,7 +995,7 @@ async function main(): Promise { if (!isMandatory() && state.version !== undefined) { controller?.abort() failedOperation = 'download' - await showUpdateFailure(await downloadUpdate(state.version)) + await showUpdateFailure(await downloadUpdate(state.version, revision)) } } finally { controller?.abort() @@ -1404,7 +1449,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', - channel: updateChannel, + get channel() { return updates.selectedChannel }, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 92168a7ea3..b51d58b914 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -137,7 +137,7 @@ export class DesktopMandatoryUpdatePolicy { /** * @param config - Resolved deployment settings. - * @param identity - Installed software identity, fixed for this application build. + * @param identity - Installed software identity; a channel getter may follow an explicit user selection. * @param publish - Receives policy changes without controlling downloads or existing tasks. * @param request - Anonymous Fetch or the dedicated test-authentication Session transport. * @param client - UI build version, language, and UTC offset, sampled for every check. diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c9937a301e..0547e081fd 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,6 @@ /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' @@ -59,6 +60,15 @@ function createProductApi(): DshDesktopProductApi { }, }, updates: { + channels: { + get: () => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelGet) as Promise, + set: channel => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelSet, channel) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopChannelState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesChannelChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesChannelChanged, handle) } + }, + }, status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, subscribe(listener) { diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 3b83cea08f..98bc1d9569 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -3,33 +3,40 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' -import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater' +import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo, type UpdateCheckResult } from 'electron-updater' import { gt, valid } from 'semver' import type { DesktopUpdateState } from './ipc.ts' import { DesktopUpdateHttpExecutor } from './update-http-executor.ts' import { DesktopUpdatePreparationError } from './update-error.ts' +import type { DesktopUpdateChannel } from './update-channel.ts' +import { discardDesktopUpdate } from './update-discard.ts' const { autoUpdater } = electronUpdater /** Owns one updater target until its download and installation settle. */ export class DesktopUpdateCoordinator { private current: DesktopUpdateState = { phase: 'idle' } - private candidate: string | undefined - private downloaded = false + private release: { kind: 'none' } | { kind: 'checked' | 'downloaded' | 'handed-off'; version: string } = { kind: 'none' } private disposed = false + private generation = 0 + private switching = 0 + private downloadGeneration = 0 + private switchTail: Promise = Promise.resolve() + private cancellation: UpdateCheckResult['cancellationToken'] + private configurationError: string | undefined private checkOperation: Promise | undefined private downloadOperation: Promise | undefined private installOperation: Promise | undefined private readonly onProgress = (progress: ProgressInfo): void => { - if (this.downloadOperation === undefined || this.downloaded) return + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || this.downloaded) return const percent = Math.min(100, Math.max(0, progress.percent)) this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent }) } private readonly onDownloaded = (info: UpdateInfo): void => { - if (this.downloadOperation === undefined || info.version !== this.candidate) return - this.downloaded = true + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || info.version !== this.candidate) return + this.release = { kind: 'downloaded', version: info.version } } private readonly onError = (error: Error): void => { @@ -46,6 +53,7 @@ export class DesktopUpdateCoordinator { * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. * @param channel - Installed package channel; omitted test/local callers retain nightly. + * @param discard - Clears settled library targets and platform resources before another channel is used. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -55,7 +63,8 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, - private readonly channel: 'nightly' | 'latest' = 'nightly', + private channel: DesktopUpdateChannel = 'nightly', + private readonly discard: (updater: AppUpdater) => Promise = discardDesktopUpdate, ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -69,9 +78,9 @@ export class DesktopUpdateCoordinator { this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false this.updater.channel = channel - this.updater.allowPrerelease = channel === 'nightly' // Selecting a channel can enable downgrade in electron-updater. this.updater.allowDowngrade = false + this.updater.allowPrerelease = channel === 'nightly' this.updater.on('download-progress', this.onProgress) this.updater.on('update-downloaded', this.onDownloaded) this.updater.on('error', this.onError) @@ -80,6 +89,62 @@ export class DesktopUpdateCoordinator { /** Latest observable state; complete download identity remains main-process-owned. */ get state(): DesktopUpdateState { return this.current } + /** Selected runtime channel, including while a new feed is being checked. */ + get selectedChannel(): DesktopUpdateChannel { return this.channel } + + /** Revision captured by confirmations so channel changes invalidate old user actions. */ + get revision(): number { return this.generation } + + /** + * Suspend ordinary updates when startup settings cannot select a trusted channel. + * @param detail - File path and safe diagnostic, without document contents. + */ + suspendConfiguration(detail: string): void { + this.configurationError = detail + this.setState({ phase: 'error', failedOperation: 'configuration', message: detail }) + } + + /** + * Persist a UI choice, retire the old target, and check the selected feed. + * Accepted choices serialize; installation cannot start while any choice is pending. + * Persistence failure leaves the previous channel and target intact. Cleanup failure + * suspends updates until another explicit switch successfully retires the target. + * @param channel - Validated UI choice. + * @param persist - Atomic settings save; rejects before a choice is applied on failure. + * @returns After the selection is applied and its check settles; check failures remain observable. + */ + switchChannel(channel: DesktopUpdateChannel, persist: () => Promise): Promise { + this.assertLive() + if (this.installOperation !== undefined || this.current.phase === 'installing' || this.release.kind === 'handed-off') { + return Promise.reject(new Error('desktop update: installation is in progress')) + } + this.switching++ + const operation = this.switchTail.then(async () => { + this.assertLive() + await persist() + this.generation++ + this.cancellation?.cancel() + await Promise.allSettled([this.checkOperation, this.downloadOperation]) + this.assertLive() + this.release = { kind: 'none' } + this.cancellation = undefined + try { await this.discard(this.updater) } catch (error) { + this.suspendConfiguration('desktop update: could not retire the previous update') + throw error + } + this.channel = channel + this.updater.channel = channel + this.updater.allowDowngrade = false + this.updater.allowPrerelease = channel === 'nightly' + this.configurationError = undefined + this.setState({ phase: 'checking' }) + const result = await this.doCheck() + if (result.phase === 'error') this.setState(result) + }).finally(() => { this.switching-- }) + this.switchTail = operation.catch(() => { /* The caller reports this selection's failure; later choices may retry. */ }) + return operation + } + /** * Check metadata without downloading, joining any current check. * @param manual - Whether a failed check must remain visible in the status indicator. @@ -87,7 +152,8 @@ export class DesktopUpdateCoordinator { */ async check(manual = false): Promise { this.assertLive() - if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current + if (this.configurationError !== undefined || this.switching > 0 + || this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current this.checkOperation ??= Promise.resolve().then(() => this.doCheck()) .finally(() => { this.checkOperation = undefined }) @@ -98,24 +164,31 @@ export class DesktopUpdateCoordinator { /** * @param version - Version shown in the user's download confirmation. + * @param revision - Revision captured by the download confirmation; omitted uses the current target. * @returns Download readiness or failure, without authorizing installation. */ - async download(version: string): Promise { + async download(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation || this.configurationError !== undefined || this.switching > 0) return this.current if (this.downloaded || this.installOperation !== undefined) return this.current + const generation = this.generation + this.downloadGeneration = generation this.downloadOperation ??= Promise.resolve().then(async () => { await this.checkOperation + if (generation !== this.generation) return this.current this.assertLive() if (this.candidate === undefined) throw new Error('desktop update: no checked update is available') if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale') this.setState({ phase: 'downloading', version, percent: 0 }) try { - await this.updater.downloadUpdate() + await this.updater.downloadUpdate(this.cancellation) + if (generation !== this.generation || this.disposed) return this.current if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness') this.downloadResult?.(true) return this.setState({ phase: 'ready', version }) } catch (error) { - this.downloaded = false + if (generation !== this.generation || this.disposed) return this.current + this.release = { kind: 'checked', version } this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed') return this.setState(this.failure(error, 'download')) } @@ -126,16 +199,20 @@ export class DesktopUpdateCoordinator { /** * Install a prepared target after a separate user confirmation. * @param version - Exact version displayed in the confirmation, never a renderer-selected URL. + * @param revision - Revision captured when the confirmation began; omitted uses the current target. * @returns Installation handoff or a recoverable preparation error. */ - async install(version: string): Promise { + async install(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation) return this.current + if (this.configurationError !== undefined || this.switching > 0) throw new Error('desktop update: channel selection is not ready') if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready') this.installOperation ??= Promise.resolve().then(async () => { this.setState({ phase: 'installing', version }) try { if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version }) this.assertLive() + this.release = { kind: 'handed-off', version } this.updater.quitAndInstall(true, true) return this.current } catch (error) { @@ -152,10 +229,14 @@ export class DesktopUpdateCoordinator { this.updater.off('download-progress', this.onProgress) this.updater.off('update-downloaded', this.onDownloaded) // Pending updater promises can still emit EventEmitter errors during shutdown. - void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation]) + void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation, this.switchTail]) .then(() => { this.updater.off('error', this.onError) }) } + private get candidate(): string | undefined { return this.release.kind === 'none' ? undefined : this.release.version } + + private get downloaded(): boolean { return this.release.kind === 'downloaded' || this.release.kind === 'handed-off' } + private assertLive(): void { if (this.disposed) throw new Error('desktop update: coordinator is disposed') } @@ -182,17 +263,22 @@ export class DesktopUpdateCoordinator { } private async doCheck(): Promise { + const generation = this.generation try { this.assertLive() if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source') const result = await this.updater.checkForUpdates() + if (generation !== this.generation || this.disposed) return this.current if (result === null) throw new Error('desktop update: no check result was returned') + this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') - this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined + this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) + ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { + if (generation !== this.generation || this.disposed) return this.current return this.failure(error, 'check') } } diff --git a/apps/desktop/src/update-discard.ts b/apps/desktop/src/update-discard.ts new file mode 100644 index 0000000000..5dffab76c5 --- /dev/null +++ b/apps/desktop/src/update-discard.ts @@ -0,0 +1,30 @@ +/** Retires electron-updater 6.x targets only after owned checks and downloads settle. */ +import type { Server } from 'node:http' +import type { AppUpdater } from 'electron-updater' + +/** + * Revoke the library's pending target and await macOS's local feed server closure. + * This adapter depends on private electron-updater 6.x fields, covered by real-library tests. + * autoInstallOnAppQuit must be false and quitAndInstall must not have been called. + * @param updater - Settled updater owned by the Desktop coordinator. + * @returns After no old target or local feed server remains usable for installation. + */ +export async function discardDesktopUpdate(updater: AppUpdater): Promise { + // The dependency omits these private fields from its public integration declarations. + const internal = updater as object as { + updateInfoAndProvider: unknown + downloadedUpdateHelper?: { clear(): Promise } | null + server?: Server | null + squirrelDownloadedUpdate?: boolean + } + if (internal.squirrelDownloadedUpdate === true) throw new Error('desktop update: native installation already staged') + const server = internal.server + if (server?.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } + internal.server = null + internal.updateInfoAndProvider = null + await internal.downloadedUpdateHelper?.clear() +} diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 670b6eb4b6..84ad155c5e 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -10,6 +10,7 @@ const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INT * @returns Stable presentation kind without raw diagnostics. */ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind { + if (state.failedOperation === 'configuration') return 'configuration' if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure const operation = state.failedOperation ?? 'install' return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation @@ -24,6 +25,7 @@ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailu export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string { const kind = desktopUpdateFailureKind(state) const summaries: Readonly> = { + configuration: messages.updateConfigurationFailed, check: messages.updateCheckFailed, 'check-network': messages.updateCheckNetworkFailed, download: messages.updateDownloadFailed, diff --git a/apps/desktop/src/update-settings.ts b/apps/desktop/src/update-settings.ts new file mode 100644 index 0000000000..79061e8ce6 --- /dev/null +++ b/apps/desktop/src/update-settings.ts @@ -0,0 +1,99 @@ +/** Startup-only file reads and explicit UI writes for shell-owned update channels. */ +import { readFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import type { DesktopUpdateChannel } from './update-channel.ts' + +/** A settings operation failed without applying a new runtime channel. */ +export class DesktopUpdateSettingsError extends Error { + /** + * @param kind - Stable cause for localized configuration guidance. + * @param path - User-editable document, never its contents. + * @param options - Original filesystem or parsing failure, when available. + */ + constructor(readonly kind: 'read' | 'invalid' | 'write', readonly path: string, options?: ErrorOptions) { + super(`desktop update settings: ${kind}: ${path}`, options) + } +} + +/** Startup result; invalid settings never silently select the packaged channel. */ +export type DesktopUpdateSettingsResult = + | { kind: 'ready'; channel: DesktopUpdateChannel } + | { kind: 'error'; error: DesktopUpdateSettingsError } + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Validate a channel from disk or IPC. + * @param value - Untrusted channel value. + * @returns Whether the value names a supported feed. + */ +export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdateChannel { + return value === 'nightly' || value === 'latest' +} + +/** Reads no files after startup except during an explicit, serialized UI save. */ +export class DesktopUpdateSettings { + readonly path: string + + /** + * @param userData - Electron userData, independent of Host and DSH_HOME. + * @param packagedChannel - Default selected by the installer build input. + */ + constructor(userData: string, private readonly packagedChannel: DesktopUpdateChannel) { + this.path = join(userData, 'desktop', 'settings.json') + } + + /** @returns The startup channel, or a failure that must suspend ordinary updates. */ + async load(): Promise { + try { + const document = await this.document() + const value = object(document.updates) ? document.updates.channel : undefined + if (value !== undefined && !isDesktopUpdateChannel(value)) throw new DesktopUpdateSettingsError('invalid', this.path) + return { kind: 'ready', channel: value ?? this.packagedChannel } + } catch (error) { + if (!(error instanceof DesktopUpdateSettingsError)) throw error + return { kind: 'error', error } + } + } + + /** + * Save one explicit UI selection while retaining other fields from the latest document. + * Cooperating writers serialize; external editors must not write concurrently with a save. + * An invalid existing document is preserved and rejected, never replaced with defaults. + * @param channel - Validated UI selection. + * @returns After the atomic write commits; failures leave the runtime selection unchanged. + */ + async save(channel: DesktopUpdateChannel): Promise { + try { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + await withFileLock(this.path, async () => { + const document = await this.document() + const updates = object(document.updates) ? document.updates : {} + const next = { ...document, updates: { ...updates, channel } } + await writeFileAtomic(this.path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }) + }) + } catch (error) { + if (error instanceof DesktopUpdateSettingsError) throw error + throw new DesktopUpdateSettingsError('write', this.path, { cause: error }) + } + } + + private async document(): Promise> { + let raw: string + try { raw = await readFile(this.path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw new DesktopUpdateSettingsError('read', this.path, { cause: error }) + } + let value: unknown + try { value = JSON.parse(raw) } catch (error) { + throw new DesktopUpdateSettingsError('invalid', this.path, { cause: error }) + } + if (!object(value) || (value.updates !== undefined && !object(value.updates))) { + throw new DesktopUpdateSettingsError('invalid', this.path) + } + return value + } +} diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 97e9812489..521e995aca 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6f4de4503748f3e2 zh: 648370375f16e463 /desktop-local-verification/manual-walkthrough: - en: c4b4ee8763d8464a - zh: ee2159bc08966592 + en: 0915730d4bf5bebd + zh: 15ce92581c0c8302 /desktop-local-verification/evidence: en: dcb2473264c825d4 zh: cebb2832efa15974 diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index cda4533699..68e3b5a480 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -65,6 +65,8 @@ With Host, client, and Desktop artifacts built, run `node --import tsx apps/desk +After building Desktop, `node apps/desktop/scripts/test-update-channels.mjs` checks the pinned Electron updater against loopback feeds and inert cached files. It verifies channel filenames, downgrade prohibition, rollout eligibility and download-reference retirement; on macOS it also closes the local handoff server. No installer, Host or remote publication runs. This qualification does not replace signed installed-app testing on Windows and macOS. + ## Evidence The local command builds Desktop and executes Electron 44 with the actual HTTP updater, policy client, sandboxed preload, and mandatory renderer. It records each scenario and preserves its report under `.desktop-build/qualification/local-updater-*`. The installer call, external browser, and clipboard are observation substitutes; downloaded bytes are not an executable installer. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index 6cca69e8ac..0ebb9f0d66 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -65,6 +65,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录 +构建 Desktop 后,`node apps/desktop/scripts/test-update-channels.mjs` 使用本地回环清单和无执行能力的缓存文件检查固定版本的 Electron updater。它验证渠道文件名、禁止降级、灰度命中及下载引用撤销,在 macOS 上还关闭本地交接服务。不运行安装器、Host 或远程发布。此验证不能替代 Windows 和 macOS 的签名安装应用测试。 + ## 验证证据 本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。 diff --git a/apps/desktop/tests/fixtures/update-channels.mjs b/apps/desktop/tests/fixtures/update-channels.mjs new file mode 100644 index 0000000000..dc0c6a3c3a --- /dev/null +++ b/apps/desktop/tests/fixtures/update-channels.mjs @@ -0,0 +1,93 @@ +/** Real updater channel/cache qualification with inert files; no installer is executed. */ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { mkdir, writeFile, access } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' +import updaterModule from 'electron-updater' +import helperModule from 'electron-updater/out/DownloadedUpdateHelper.js' +import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js' +import { discardDesktopUpdate } from '../../lib/types/update-discard.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' + +async function main() { + await app.whenReady() + const root = process.env.DSH_CHANNEL_TEST_ROOT + assert.ok(root) + const requests = [] + let percentage + const server = createServer((request, response) => { + requests.push(request.url.split('?')[0]) + response.end(JSON.stringify({ version: '2.0.0', stagingPercentage: percentage, + files: [{ url: 'inert.exe', sha512: 'unused', size: 5 }] })) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const origin = `http://127.0.0.1:${server.address().port}/` + const forbidden = () => { throw new Error('Installer execution is forbidden') } + let coordinator + try { + const config = join(root, 'app-update.yml') + await writeFile(config, 'updaterCacheDirName: cache\n') + const adapter = { version: '1.0.0', name: 'channel-test', isPackaged: true, + appUpdateConfigPath: config, userDataPath: root, baseCachePath: root, + whenReady: async () => {}, quit: forbidden, relaunch: forbidden, onQuit: forbidden } + const updater = new updaterModule.NsisUpdater(undefined, adapter) + updater.logger = null + updater.httpExecutor = new DesktopUpdateHttpExecutor(5000) + updater.setFeedURL({ provider: 'generic', url: origin, channel: 'nightly' }) + updater.quitAndInstall = forbidden + coordinator = new DesktopUpdateCoordinator(state => state, async () => false, + updater, () => true, () => '1.0.0') + assert.equal((await coordinator.check(true)).phase, 'available') + assert.equal(requests.at(-1), `/nightly${process.platform === 'darwin' ? '-mac' : ''}.yml`) + await coordinator.switchChannel('latest', async () => {}) + assert.equal(requests.at(-1), `/latest${process.platform === 'darwin' ? '-mac' : ''}.yml`) + assert.equal(updater.allowDowngrade, false) + percentage = 0 + await coordinator.switchChannel('nightly', async () => {}) + assert.equal(coordinator.state.phase, 'idle') + percentage = 100 + await coordinator.switchChannel('nightly', async () => {}) + assert.equal(coordinator.state.phase, 'available') + assert.equal(await updater.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 }), + await updater.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 })) + + const platforms = [updater] + if (process.platform === 'darwin') { + const mac = new updaterModule.MacUpdater(undefined, adapter) + mac.logger = null + mac.autoInstallOnAppQuit = false + platforms.push(mac) + } + for (const target of platforms) { + const helper = new helperModule.DownloadedUpdateHelper(join(root, target.constructor.name)) + await mkdir(helper.cacheDirForPendingUpdate, { recursive: true }) + const file = join(helper.cacheDirForPendingUpdate, 'inert-update') + await writeFile(file, 'inert') + await helper.setDownloadedFile(file, null, { version: '2.0.0' }, + { url: new URL('inert.exe', origin), info: { url: 'inert.exe', sha512: 'unused' } }, 'inert-update', true) + target.downloadedUpdateHelper = helper + const proxy = createServer() + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + target.server = proxy + await discardDesktopUpdate(target) + assert.equal(proxy.listening, false) + assert.equal(helper.file, null) + assert.equal(helper.packageFile, null) + assert.equal(target.updateInfoAndProvider, null) + await assert.rejects(access(file)) + target.removeAllListeners() + } + console.log('PASS: real provider channel switching, rollout eligibility, NSIS cache retirement and available macOS proxy retirement') + } finally { + coordinator?.dispose() + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + + } + +} +void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 20325642a2..981570a2b9 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -69,8 +69,8 @@ const harness = await vi.hoisted(async () => { }) const loginShell = vi.fn((base: NodeJS.ProcessEnv, _config: unknown, _options: { signal?: AbortSignal }) => readLoginShell(base)) const updateCheck = vi.fn(async (_manual?: boolean): Promise => updateState) - const updateDownload = vi.fn(async (_version: string): Promise => updateState) - const updateInstall = vi.fn(async (_version: string): Promise => updateState) + const updateDownload = vi.fn(async (_version: string, _revision?: number): Promise => updateState) + const updateInstall = vi.fn(async (_version: string, _revision?: number): Promise => updateState) const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>() const menuBuilder = vi.fn<(template: MenuItemConstructorOptions[]) => { popup: typeof popup }>(() => ({ popup })) const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() }) @@ -366,10 +366,24 @@ vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class { dispose() {} } })) vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class { - constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise) { + selectedChannel: 'nightly' | 'latest' = 'nightly' + revision = 0 + constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise, + _updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'latest') { + this.selectedChannel = channel harness.prepareUpdate = beforeRestart harness.publishUpdate = publish } + async switchChannel(channel: 'nightly' | 'latest', persist: () => Promise) { + await persist() + this.selectedChannel = channel + this.revision++ + harness.updateState = { phase: 'idle' } + } + suspendConfiguration(detail: string) { + harness.updateState = { phase: 'error', failedOperation: 'configuration', message: detail } + harness.publishUpdate(harness.updateState) + } get state() { return harness.updateState } readonly check = harness.updateCheck readonly download = harness.updateDownload @@ -486,6 +500,40 @@ describe('desktop main startup', () => { }) }) + it('persists a channel through product IPC and ignores external edits during the process', async () => { + configureTestAuthPopup(true) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'nightly', changing: false }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toEqual({ channel: 'latest', packagedChannel: 'nightly', changing: false }) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'latest' } }) + writeFileSync(path, JSON.stringify({ updates: { channel: 'nightly' } })) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'latest' }) + expect(harness.windows[0]!.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, + { channel: 'latest', packagedChannel: 'nightly', changing: false }) + await expect(invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'alpha')).rejects.toThrow('unsupported channel') + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'latest' }) + }) + + it('reports a corrupt document without replacing it or pretending a UI save succeeded', async () => { + await readyForUpdate() + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toMatchObject({ channel: 'nightly', changing: false, error: 'invalid' }) + expect(readFileSync(path, 'utf8')).toBe('{broken') + expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'invalid' }) + }) + + it('keeps startup channel errors visible and recovers after a valid UI selection', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } })) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: null, error: 'invalid' }) + expect(harness.hosts).toHaveLength(1) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toEqual({ channel: 'latest', packagedChannel: 'nightly', changing: false }) + }) + it('starts the Host with authentication popups disabled when local settings are invalid', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') @@ -778,7 +826,7 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(0) harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' } await invoke(DESKTOP_IPC.updatesOpen, 'app') - expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0) expect(testAuth.login).not.toHaveBeenCalled() }) @@ -837,7 +885,7 @@ describe('desktop main startup', () => { it('accepts product IPC only from the current application top frame in the owned main window', async () => { await readyForUpdate() const sender = harness.windows[0]!.webContents - for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.deviceInfo]) { + for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.updatesChannelGet, DESKTOP_IPC.deviceInfo]) { const handler = harness.handlers.get(channel)! expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow() for (const event of [ @@ -1521,12 +1569,12 @@ describe('desktop main startup', () => { window.visible = false const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise await vi.advanceTimersByTimeAsync(0) - expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1') + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1', 0) expect(harness.updateInstall).not.toHaveBeenCalled() window.visible = true window.emit('show') await opened - expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1') + expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0) }) it('waits for Platform view storage cleanup before an ordinary quit completes', async () => { @@ -1753,7 +1801,7 @@ describe('desktop main startup', () => { await invoke(DESKTOP_IPC.updatesOpen, 'app') const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) expect(messages).not.toContain(en.mandatoryUnavailable) - expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) }) it('does not wait for a pending policy request before proceeding with an ordinary update', async () => { @@ -1767,7 +1815,7 @@ describe('desktop main startup', () => { await invoke(DESKTOP_IPC.updatesOpen, 'app') }) try { - await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') }) + await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0) }) } finally { policy.resolve(Response.json({ code: 500 }, { status: 503 })) await operation @@ -1854,11 +1902,11 @@ describe('desktop main startup', () => { await started.promise const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app') expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() - expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) expect(harness.updateInstall).not.toHaveBeenCalled() downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' }) await Promise.all([action, repeated]) - expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0) await harness.updateCheck() expect(harness.updateInstall).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index af9a9f226a..b05c6dd4aa 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -279,3 +279,22 @@ const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['file const imageProtocolBack = (value: DesktopFileDrag): import('../src/ipc.ts').DshDesktopProductApi['fileDrag'] => value void imageProtocol void imageProtocolBack + + +it('exposes channel choices and scoped subscriptions without exposing updater objects', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/index.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await api.updates.channels.get() + await api.updates.channels.set('latest') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([[DESKTOP_IPC.updatesChannelGet], [DESKTOP_IPC.updatesChannelSet, 'latest']]) + const listener = vi.fn() + const dispose = api.updates.channels.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesChannelChanged)?.[1] as + (event: unknown, state: unknown) => void + const state = { channel: 'latest', packagedChannel: 'nightly', changing: false } + handler({}, state) + expect(listener).toHaveBeenCalledWith(state) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, handler) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 3aef0bc60f..75b243e65d 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -257,3 +257,114 @@ it('keeps a stable-version nightly installation on nightly and rejects prereleas expect((await latest.coordinator.check()).phase).toBe('error') expect(latest.downloadUpdate).not.toHaveBeenCalled() }) + + +it('retires a downloaded target and rejects its old confirmation after switching', async () => { + const f = fixture() + await f.coordinator.check() + const revision = f.coordinator.revision + await f.coordinator.download('1.1.0-rc.2') + const save = vi.fn(async () => {}) + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + await f.coordinator.switchChannel('latest', save) + expect(save).toHaveBeenCalledOnce() + expect(f.coordinator.selectedChannel).toBe('latest') + expect(f.updater.allowDowngrade).toBe(false) + expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' }) + await f.coordinator.install('1.1.0-rc.2', revision) + expect(f.quitAndInstall).not.toHaveBeenCalled() +}) + +it('leaves the current target intact if saving the choice fails', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await expect(f.coordinator.switchChannel('latest', () => Promise.reject(new Error('read only')))).rejects.toThrow('read only') + expect(f.coordinator.selectedChannel).toBe('nightly') + expect(f.coordinator.state.phase).toBe('ready') + await f.coordinator.install('1.1.0-rc.2') + expect(f.quitAndInstall).toHaveBeenCalledOnce() +}) + +it('discards a check result that belongs to the previous channel', async () => { + const f = fixture() + const started = Promise.withResolvers() + const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>() + f.checkForUpdates.mockImplementationOnce(() => { started.resolve(undefined); return checked.promise }) + const checking = f.coordinator.check() + await started.promise + const saved = Promise.withResolvers() + const switching = f.coordinator.switchChannel('latest', async () => { saved.resolve(undefined) }) + await saved.promise + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '9.0.0-alpha.1' } }) + await Promise.all([checking, switching]) + expect(f.states.some(state => state.version === '9.0.0-alpha.1')).toBe(false) + expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' }) +}) + +it('does not check or install while startup channel configuration is invalid', async () => { + const f = fixture() + f.coordinator.suspendConfiguration('/settings.json') + await f.coordinator.check(true) + await f.coordinator.download('1.1.0-rc.2') + expect(f.checkForUpdates).not.toHaveBeenCalled() + expect(f.downloadUpdate).not.toHaveBeenCalled() + expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) + await f.coordinator.switchChannel('nightly', async () => {}) + expect(f.coordinator.state.phase).toBe('available') +}) + + +it('waits for a cancelled download before checking the new channel and ignores late progress', async () => { + const f = fixture() + const entered = Promise.withResolvers() + const finished = Promise.withResolvers() + f.downloadUpdate.mockImplementationOnce(async () => { entered.resolve(undefined); return finished.promise }) + await f.coordinator.check() + const downloading = f.coordinator.download('1.1.0-rc.2') + await entered.promise + const persisted = Promise.withResolvers() + const switching = f.coordinator.switchChannel('latest', async () => { persisted.resolve(undefined) }) + await persisted.promise + await Promise.resolve() + const count = f.states.length + f.events.emit('download-progress', { percent: 99 }) + f.events.emit('update-downloaded', { version: '1.1.0-rc.2' }) + expect(f.states).toHaveLength(count) + expect(f.checkForUpdates).toHaveBeenCalledTimes(1) + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + finished.resolve([]) + await Promise.all([downloading, switching]) + expect(f.coordinator.state).toEqual({ phase: 'available', version: '1.2.0' }) + expect(f.downloadResult).not.toHaveBeenCalled() + expect(f.updater.allowDowngrade).toBe(false) +}) + +it('rejects a channel switch during installation preparation without persisting it', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + const preparation = Promise.withResolvers() + f.beforeRestart.mockReturnValueOnce(preparation.promise) + const installing = f.coordinator.install('1.1.0-rc.2') + const save = vi.fn(async () => {}) + await expect(f.coordinator.switchChannel('latest', save)).rejects.toThrow('installation is in progress') + expect(save).not.toHaveBeenCalled() + preparation.resolve(false) + await installing + expect(f.coordinator.state.phase).toBe('ready') +}) + + +it('does not accept a channel switch after installer handoff even if a later error is reported', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await f.coordinator.install('1.1.0-rc.2') + f.events.emit('error', new Error('native updater failed after handoff')) + expect(f.coordinator.state.phase).toBe('error') + const save = vi.fn(async () => {}) + await expect(f.coordinator.switchChannel('latest', save)).rejects.toThrow('installation is in progress') + expect(save).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/update-settings.spec.ts b/apps/desktop/tests/update-settings.spec.ts new file mode 100644 index 0000000000..adec819960 --- /dev/null +++ b/apps/desktop/tests/update-settings.spec.ts @@ -0,0 +1,50 @@ +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { DesktopUpdateSettings, DesktopUpdateSettingsError } from '../src/update-settings.ts' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, 'desktop')) + return new DesktopUpdateSettings(root, 'latest') +} + +it('uses the packaged channel for missing settings without recreating the document', async () => { + const settings = await fixture() + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'latest' }) + await expect(readFile(settings.path)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('preserves other fields during explicit saves and only rereads external edits on load', async () => { + const settings = await fixture() + await writeFile(settings.path, JSON.stringify({ updates: { allowTestAuthPopupWindow: true }, other: 4 })) + const startup = await settings.load() + await settings.save('nightly') + expect(startup).toEqual({ kind: 'ready', channel: 'latest' }) + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'nightly' }, other: 4 }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await writeFile(settings.path, '{}') + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'latest' }) +}) + +it.each(['{', 'null', '{"updates":[]}', '{"updates":{"channel":"alpha"}}', '{"updates":{"channel":null}}'])('suspends updates for invalid channel settings: %s', async (raw) => { + const settings = await fixture() + await writeFile(settings.path, raw) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { kind: 'invalid', path: settings.path } }) + expect(await readFile(settings.path, 'utf8')).toBe(raw) +}) + +it('refuses to replace malformed user JSON during a UI save', async () => { + const settings = await fixture() + await writeFile(settings.path, '{') + await expect(settings.save('nightly')).rejects.toBeInstanceOf(DesktopUpdateSettingsError) + expect(await readFile(settings.path, 'utf8')).toBe('{') +}) + +it('reports unreadable configuration instead of silently choosing a default', async () => { + const settings = await fixture() + await mkdir(settings.path) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { kind: 'read' } }) +}) diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 44992641d5..0748666cfc 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -40,6 +40,9 @@ }, { "path": "../../packages/util/file-drag" + }, + { + "path": "../../packages/client/ui-settings-general/tsconfig.host.json" } ], "exclude": [ diff --git a/apps/web/tests/desktop-channel.e2e.ts b/apps/web/tests/desktop-channel.e2e.ts new file mode 100644 index 0000000000..f7040e028a --- /dev/null +++ b/apps/web/tests/desktop-channel.e2e.ts @@ -0,0 +1,67 @@ +/** Built settings UI uses the shell file API; external edits wait for a new shell startup. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { DesktopUpdateSettings } from '../../desktop/src/update-settings.ts' +import type { DesktopChannelState } from '../../desktop/src/ipc.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { openSettings } from './support.ts' + +it('persists a Desktop channel from Settings and keeps external edits startup-only', async () => { + const scaffold = await launchWebScaffold({}) + try { + const browser = await chromium.launch() + try { + const settings = new DesktopUpdateSettings(join(scaffold.harnessHome, 'shell'), 'nightly') + await settings.save('nightly') + let state: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } + const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'en-US' }) + const tripwire = watchConsole(page) + await page.exposeFunction('readChannel', () => state) + await page.exposeFunction('setChannel', async (channel: 'nightly' | 'latest') => { + try { + await settings.save(channel) + state = { ...state, channel, error: undefined } + } catch (_error) { state = { ...state, error: 'invalid' } } + return state + }) + await page.addInitScript(() => { + const bridge = globalThis as typeof globalThis & { + readChannel(): Promise + setChannel(channel: 'nightly' | 'latest'): Promise + } + Object.assign(globalThis, { dshDesktop: { protocolVersion: 1, updates: { + status: async () => ({ phase: 'idle' }), open: async () => {}, subscribe: () => () => {}, + channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'latest') => bridge.setChannel(channel), + subscribe: () => () => {} }, + } } }) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'en') + const row = page.getByRole('group', { name: 'Update channel', exact: true }) + await row.getByText('Development', { exact: true }).waitFor() + await row.getByRole('button', { name: 'Update channel', exact: true }).click() + await page.getByRole('menuitem', { name: 'Stable', exact: true }).click() + await row.getByText('Stable', { exact: true }).waitFor() + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toMatchObject({ updates: { channel: 'latest' } }) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/stable.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd), webSnapshotMode()) + await writeFile(settings.path, JSON.stringify({ updates: { channel: 'nightly' } })) + expect(await row.getByText('Stable', { exact: true }).count()).toBe(1) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await writeFile(settings.path, '{broken') + await row.getByRole('button', { name: 'Update channel', exact: true }).click() + await page.getByRole('menuitem', { name: 'Development', exact: true }).click() + await row.getByRole('alert').waitFor() + await row.getByText('Stable', { exact: true }).waitFor() + expect(await row.getByText('Stable', { exact: true }).count()).toBe(1) + expect(await readFile(settings.path, 'utf8')).toBe('{broken') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/save-error.expected.md', import.meta.url)), + await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd), webSnapshotMode()) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) diff --git a/apps/web/tests/expected/desktop-channel/save-error.expected.md b/apps/web/tests/expected/desktop-channel/save-error.expected.md new file mode 100644 index 0000000000..fec048ed66 --- /dev/null +++ b/apps/web/tests/expected/desktop-channel/save-error.expected.md @@ -0,0 +1,4 @@ +- group "Update channel": + - text: Update channel Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades. + - alert: Could not apply update settings. Open the desktop update indicator for details. + - button "Update channel": Stable diff --git a/apps/web/tests/expected/desktop-channel/stable.expected.md b/apps/web/tests/expected/desktop-channel/stable.expected.md new file mode 100644 index 0000000000..0f9a68b006 --- /dev/null +++ b/apps/web/tests/expected/desktop-channel/stable.expected.md @@ -0,0 +1,3 @@ +- group "Update channel": + - text: Update channel Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades. + - button "Update channel": Stable diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c653301218..ce35d2a462 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -125,6 +125,7 @@ "tests/desktop-locale.e2e.ts", "tests/settings-import.e2e.ts", "tests/desktop-updates.e2e.ts", + "tests/desktop-channel.e2e.ts", "tests/developer-tools-settings.e2e.ts", "tests/models-settings.e2e.ts", "tests/deepseek-messages-settings.e2e.ts", diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 3be104f993..15f112134d 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: ba55fe92bf49dc5a - zh: 9a0e1345496a0ae6 + en: 9a8b0990bcc25116 + zh: 134a7760b703c991 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 9c8c35ae0e672b14 zh: 503c8e9d24a93d88 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 3a7c6ffdee..4ede7ddcc5 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -63,6 +63,8 @@ Access: the settings shell and the `settings.open` shortcut are registered only ----- +Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Its loading, saved-choice and failure states follow the Electron subscription; selecting a channel calls the shell directly. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). + ## Understand the implementation diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index bc53c128df..161db17cd9 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -63,6 +63,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ----- +当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。加载、已保存选择和失败状态跟随 Electron 订阅,选择渠道直接调用桌面主进程。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 + ## 理解实现 diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css b/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css new file mode 100644 index 0000000000..4e8af8c381 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.module.css @@ -0,0 +1,66 @@ +/* Desktop update channel row: label plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +/* Narrow settings panel: the 48px gutter separates the label from its control + across a wide row and only squeezes the label here. */ +@media (max-width: 767px) { + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + padding-right: 16px; + } +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.description { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +@media (hover: hover) { + .selector:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx new file mode 100644 index 0000000000..ee8733fc2b --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -0,0 +1,48 @@ +/** Desktop channel selection; persistence and updater transitions remain in Electron. */ +import { useState } from 'react' +import { Menu, IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { DesktopChannelSource } from './desktop-channel-source.ts' +import type { DesktopUpdateChannel } from '../types.ts' +import css from './DesktopChannelRow.module.css' + +/** Framework-bound shell state and explicit selection action. */ +export interface DesktopChannelRowInjected { + hooks: { channel: DesktopChannelSource['store'] } + select(channel: DesktopUpdateChannel): Promise +} + +/** + * Render the Desktop-only channel control without depending on a Host settings service. + * @param props - Shell observation, selection action and localized copy. + * @returns The General settings row. + */ +export function DesktopChannelRow({ useChannel, select, t }: + PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> & InjectFace) { + const { state, failed } = useChannel(value => value) + const [open, setOpen] = useState(false) + const [saving, setSaving] = useState(false) + const busy = saving || state?.changing === true + return
+
+
{t('desktop.channel.title')}
+
{t('desktop.channel.description')}
+ {(failed || state?.error !== undefined) &&
{t('desktop.channel.error')}
} +
+ { setOpen(false) }} portal align="end" + selectedId={state?.channel ?? ''} + items={[{ id: 'latest', label: t('desktop.channel.latest') }, { id: 'nightly', label: t('desktop.channel.nightly') }]} + onSelect={(id) => { + setOpen(false) + setSaving(true) + void select(id as DesktopUpdateChannel).finally(() => { setSaving(false) }) + }} + anchor={} /> +
+} diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 728fee6537..e149fe71a8 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -12,6 +12,7 @@ const BUSY_PHASES: ReadonlySet = new Set([ ]) function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { label: string; detail: string } { + if (state.configurationError !== undefined) return { label: t('desktop.channel.unavailable'), detail: t('desktop.update.configurationFailed') } const labels: Readonly> = { idle: '', checking: t('desktop.update.checking'), @@ -28,6 +29,7 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l } if (state.phase === 'error') { const failures: Readonly> = { + configuration: t('desktop.update.configurationFailed'), check: t('desktop.update.checkFailed'), 'check-network': t('desktop.update.checkNetworkFailed'), download: t('desktop.update.downloadFailed'), @@ -62,12 +64,12 @@ export function DesktopUpdateIndicator({ wide, hidden, t, view, onOpen }: { onOpen: () => void }) { const { presentation: state, failed, opening } = view - if (!wide || hidden || (!failed && (state === undefined || state.phase === 'idle'))) return null + if (!wide || hidden || (!failed && (state === undefined || (state.phase === 'idle' && state.configurationError === undefined)))) return null const retryLabel = t('desktop.update.retry') const copy = state === undefined ? { label: retryLabel, detail: retryLabel } : updateCopy(state, t) const label = failed ? retryLabel : copy.label - const error = failed || state?.phase === 'error' - const busy = opening || (state !== undefined && BUSY_PHASES.has(state.phase)) + const error = failed || state?.phase === 'error' || state?.configurationError !== undefined + const busy = opening || (state !== undefined && state.configurationError === undefined && BUSY_PHASES.has(state.phase)) return } /> } + +/** Shell-owned operation feedback that survives closing Settings. */ +export interface DesktopChannelToastInjected { + hooks: { channel: DesktopChannelSource['store'] } + dismiss(sequence: number): void +} + +/** + * Render the latest channel save result outside Settings. + * @param props - Shared operation state, dismissal and localized copy. + * @returns A transient toast, or nothing. + */ +export function DesktopChannelToast({ useChannel, dismiss, t }: + PropsRuntime<'shell.overlay'> & PropsLocale<'settings'> & InjectFace) { + const notice = useChannel(value => value.notice) + if (notice === undefined) return null + return { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : {}} /> +} diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index e149fe71a8..48dfaa809d 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -12,7 +12,7 @@ const BUSY_PHASES: ReadonlySet = new Set([ ]) function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { label: string; detail: string } { - if (state.configurationError !== undefined) return { label: t('desktop.channel.unavailable'), detail: t('desktop.update.configurationFailed') } + if (state.configurationError !== undefined) return { label: t('desktop.channel.unavailable'), detail: t(`desktop.update.configuration.${state.configurationError}`) } const labels: Readonly> = { idle: '', checking: t('desktop.update.checking'), diff --git a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts index 08b28284da..7d92b785ab 100644 --- a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts +++ b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts @@ -1,13 +1,23 @@ -/** One shell-owned channel subscription shared by Settings views. */ +/** One shell-owned channel subscription shared by Settings views and their toast. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { DesktopChannelBridge, DesktopChannelState, DesktopUpdateChannel } from '../types.ts' +import type { DesktopChannelBridge, DesktopChannelState, DesktopUpdateChannel, DesktopChannelChangeResult } from '../types.ts' + +type Notice = { sequence: number; outcome: 'applied' | Extract['reason'] } +/** Actual shell state, optimistic selection and transient operation feedback. */ +export interface DesktopChannelSnapshot { + state?: DesktopChannelState + failed: boolean + pending?: DesktopUpdateChannel + notice?: Notice +} /** Client observation; no Host service or configuration file is read here. */ export class DesktopChannelSource { - /** Stable settings observation shared by mounted General views. */ - readonly store = createSnapshotStore<{ state?: DesktopChannelState; failed: boolean }>({ failed: false }) + /** Stable observation shared by mounted General views and the shell toast. */ + readonly store = createSnapshotStore({ failed: false }) private live = true private revision = 0 + private sequence = 0 private readonly unsubscribe: () => void /** @param bridge - Isolated shell API, present only in Desktop. */ @@ -15,29 +25,51 @@ export class DesktopChannelSource { this.unsubscribe = bridge.subscribe((state) => { if (!this.live) return this.revision++ - this.store.set({ state, failed: false }) + this.store.set({ ...this.store.getSnapshot(), state, failed: false }) }) void bridge.get().then((state) => { - if (this.live && this.revision === 0) this.store.set({ state, failed: false }) + if (this.live && this.revision === 0) this.store.set({ ...this.store.getSnapshot(), state, failed: false }) }, () => { - if (this.live && this.revision === 0) this.store.set({ failed: true }) + if (this.live && this.revision === 0) this.store.set({ ...this.store.getSnapshot(), failed: true }) }) } /** - * Ask the shell to persist and apply a UI choice; failures remain visible. + * Show a pending choice until the shell reports its actual selection. * @param channel - Selected feed. * @returns After the shell reports the result. */ async select(channel: DesktopUpdateChannel): Promise { - if (!this.isLive()) return + if (!this.isLive() || this.store.getSnapshot().pending !== undefined) return const revision = this.revision + const { notice: _notice, ...previous } = this.store.getSnapshot() + this.store.set({ ...previous, pending: channel }) + let result: DesktopChannelChangeResult | undefined try { - const state = await this.bridge.set(channel) - if (this.live && revision === this.revision) this.store.set({ state, failed: false }) + result = await this.bridge.set(channel) } catch (_error) { - if (this.live && revision === this.revision) this.store.set({ ...this.store.getSnapshot(), failed: true }) + // An IPC failure has no reliable user-facing detail or confirmed new selection. } + if (!this.isLive()) return + const accept = result !== undefined && revision === this.revision + if (accept) this.revision++ + const { pending: _pending, ...current } = this.store.getSnapshot() + this.store.set({ + ...current, + ...(accept && result !== undefined ? { state: result.state, failed: false } : {}), + notice: { sequence: ++this.sequence, outcome: result?.kind === 'applied' ? 'applied' : result?.reason ?? 'save' }, + }) + } + + /** + * Retire only the toast whose animation completed. + * @param sequence - Notice identity captured by the rendered toast. + */ + dismissNotice(sequence: number): void { + const current = this.store.getSnapshot() + if (current.notice?.sequence !== sequence) return + const { notice: _notice, ...next } = current + this.store.set(next) } private isLive(): boolean { return this.live } diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index a0e9b73cc9..e3dd01d407 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -36,7 +36,7 @@ import { SettingsRoot } from './SettingsRoot.tsx' import { DesktopUpdateBadge } from './DesktopUpdateIndicator.tsx' import type { DesktopUpdateBridge } from '../types.ts' import { DesktopChannelSource } from './desktop-channel-source.ts' -import { DesktopChannelRow } from './DesktopChannelRow.tsx' +import { DesktopChannelRow, DesktopChannelToast } from './DesktopChannelRow.tsx' import { DesktopUpdateSource } from './desktop-update-source.ts' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' @@ -114,6 +114,10 @@ export function apply(ctx: ClientContext): void { name: 'settings.general.item', id: 'desktop-update-channel', order: 95, locale: NS, inject: () => ({ hooks: { channel: channel.store }, select: (value: 'nightly' | 'latest') => channel.select(value) }), }, DesktopChannelRow)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'desktop-channel-toast', locale: NS, + inject: () => ({ hooks: { channel: channel.store }, dismiss: (sequence: number) => { channel.dismissNotice(sequence) } }), + }, DesktopChannelToast)) } ctx.slots.inject('sidebar.toggle.badge', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index edb4fc76fc..a32f40867e 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,10 +6,18 @@ export const zh = { 'desktop.channel.description': '正式版仅接收稳定版本;开发版包含预发布版本,可能不稳定。切换不会降级。', 'desktop.channel.latest': '正式版', 'desktop.channel.nightly': '开发版', - 'desktop.channel.changing': '正在切换…', 'desktop.channel.unavailable': '配置不可用', 'desktop.channel.error': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.update.configurationFailed': '未能应用更新配置。请点击桌面更新入口查看原因。', + 'desktop.channel.result.applied': '更新渠道已切换', + 'desktop.channel.result.save': '保存失败', + 'desktop.channel.result.format': '保存失败,配置文件格式有误', + 'desktop.channel.result.permission': '保存失败,没有写入权限', + 'desktop.channel.result.apply': '保存失败', + 'desktop.update.configuration.read': '更新已暂停:无法读取配置文件', + 'desktop.update.configuration.format': '更新已暂停:配置文件格式有误', + 'desktop.update.configuration.channel': '更新已暂停:channel 只能是 nightly 或 latest', + 'desktop.update.configuration.cleanup': '更新已暂停:无法清理旧更新,请重启应用', 'trigger': '设置', 'shortcut.open': '打开设置', 'desktop.update.available': '新版本', @@ -63,10 +71,18 @@ export const en = { 'desktop.channel.description': 'Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades.', 'desktop.channel.latest': 'Stable', 'desktop.channel.nightly': 'Development', - 'desktop.channel.changing': 'Switching…', 'desktop.channel.unavailable': 'Configuration unavailable', 'desktop.channel.error': 'Could not apply update settings. Open the desktop update indicator for details.', 'desktop.update.configurationFailed': 'Could not apply update settings. Open the desktop update indicator for details.', + 'desktop.channel.result.applied': 'Update channel changed', + 'desktop.channel.result.save': 'Could not save', + 'desktop.channel.result.format': 'Could not save: invalid settings file', + 'desktop.channel.result.permission': 'Could not save: permission denied', + 'desktop.channel.result.apply': 'Could not save', + 'desktop.update.configuration.read': 'Updates paused: cannot read settings file', + 'desktop.update.configuration.format': 'Updates paused: invalid settings file', + 'desktop.update.configuration.channel': 'Updates paused: channel must be nightly or latest', + 'desktop.update.configuration.cleanup': 'Updates paused: cannot discard previous update; restart the app', 'trigger': 'Settings', 'shortcut.open': 'Open settings', 'desktop.update.available': 'Update', @@ -117,10 +133,18 @@ export const ja = { 'desktop.channel.description': '安定版は正式リリースのみ、開発版は不安定なプレビュー版も受信します。切り替えてもダウングレードしません。', 'desktop.channel.latest': '安定版', 'desktop.channel.nightly': '開発版', - 'desktop.channel.changing': '切り替え中…', 'desktop.channel.unavailable': '設定を利用できません', 'desktop.channel.error': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', 'desktop.update.configurationFailed': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', + 'desktop.channel.result.applied': '更新チャンネルを変更しました', + 'desktop.channel.result.save': '保存できませんでした', + 'desktop.channel.result.format': '保存できませんでした:設定ファイルの形式が不正です', + 'desktop.channel.result.permission': '保存できませんでした:書き込み権限がありません', + 'desktop.channel.result.apply': '保存できませんでした', + 'desktop.update.configuration.read': '更新を停止中:設定ファイルを読み取れません', + 'desktop.update.configuration.format': '更新を停止中:設定ファイルの形式が不正です', + 'desktop.update.configuration.channel': '更新を停止中:channel は nightly または latest にしてください', + 'desktop.update.configuration.cleanup': '更新を停止中:以前の更新を削除できません。アプリを再起動してください', 'trigger': '設定', 'shortcut.open': '設定を開く', 'desktop.update.available': '更新', @@ -171,10 +195,18 @@ export const es = { 'desktop.channel.description': 'Estable recibe solo versiones estables. Desarrollo incluye versiones preliminares que pueden ser inestables. Cambiar no instala versiones anteriores.', 'desktop.channel.latest': 'Estable', 'desktop.channel.nightly': 'Desarrollo', - 'desktop.channel.changing': 'Cambiando…', 'desktop.channel.unavailable': 'Configuración no disponible', 'desktop.channel.error': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', 'desktop.update.configurationFailed': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', + 'desktop.channel.result.applied': 'Canal de actualización cambiado', + 'desktop.channel.result.save': 'No se pudo guardar', + 'desktop.channel.result.format': 'No se pudo guardar: archivo de configuración no válido', + 'desktop.channel.result.permission': 'No se pudo guardar: permiso denegado', + 'desktop.channel.result.apply': 'No se pudo guardar', + 'desktop.update.configuration.read': 'Actualizaciones pausadas: no se puede leer la configuración', + 'desktop.update.configuration.format': 'Actualizaciones pausadas: archivo de configuración no válido', + 'desktop.update.configuration.channel': 'Actualizaciones pausadas: channel debe ser nightly o latest', + 'desktop.update.configuration.cleanup': 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', 'trigger': 'Configuración', 'shortcut.open': 'Abrir configuración', 'desktop.update.available': 'Actualizar', @@ -225,10 +257,18 @@ export const fr = { 'desktop.channel.description': 'Stable reçoit uniquement les versions stables. Développement inclut des préversions potentiellement instables. Le changement ne rétrograde pas la version.', 'desktop.channel.latest': 'Stable', 'desktop.channel.nightly': 'Développement', - 'desktop.channel.changing': 'Changement…', 'desktop.channel.unavailable': 'Configuration indisponible', 'desktop.channel.error': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', 'desktop.update.configurationFailed': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', + 'desktop.channel.result.applied': 'Canal de mise à jour modifié', + 'desktop.channel.result.save': 'Échec de l’enregistrement', + 'desktop.channel.result.format': 'Échec de l’enregistrement : fichier de paramètres invalide', + 'desktop.channel.result.permission': 'Échec de l’enregistrement : permission refusée', + 'desktop.channel.result.apply': 'Échec de l’enregistrement', + 'desktop.update.configuration.read': 'Mises à jour suspendues : lecture des paramètres impossible', + 'desktop.update.configuration.format': 'Mises à jour suspendues : fichier de paramètres invalide', + 'desktop.update.configuration.channel': 'Mises à jour suspendues : channel doit être nightly ou latest', + 'desktop.update.configuration.cleanup': 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', 'trigger': 'Paramètres', 'shortcut.open': 'Ouvrir les paramètres', 'desktop.update.available': 'Mise à jour', @@ -279,10 +319,18 @@ export const de = { 'desktop.channel.description': 'Stabil erhält nur stabile Versionen. Entwicklung enthält möglicherweise instabile Vorabversionen. Ein Wechsel führt kein Downgrade durch.', 'desktop.channel.latest': 'Stabil', 'desktop.channel.nightly': 'Entwicklung', - 'desktop.channel.changing': 'Wird gewechselt…', 'desktop.channel.unavailable': 'Konfiguration nicht verfügbar', 'desktop.channel.error': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', 'desktop.update.configurationFailed': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', + 'desktop.channel.result.applied': 'Update-Kanal geändert', + 'desktop.channel.result.save': 'Speichern fehlgeschlagen', + 'desktop.channel.result.format': 'Speichern fehlgeschlagen: ungültige Einstellungsdatei', + 'desktop.channel.result.permission': 'Speichern fehlgeschlagen: keine Schreibberechtigung', + 'desktop.channel.result.apply': 'Speichern fehlgeschlagen', + 'desktop.update.configuration.read': 'Updates pausiert: Einstellungsdatei nicht lesbar', + 'desktop.update.configuration.format': 'Updates pausiert: ungültige Einstellungsdatei', + 'desktop.update.configuration.channel': 'Updates pausiert: channel muss nightly oder latest sein', + 'desktop.update.configuration.cleanup': 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', 'trigger': 'Einstellungen', 'shortcut.open': 'Einstellungen öffnen', 'desktop.update.available': 'Aktualisieren', @@ -333,10 +381,18 @@ export const ru = { 'desktop.channel.description': 'Стабильный канал получает только стабильные версии. Канал разработки включает предварительные версии. Переключение не понижает версию.', 'desktop.channel.latest': 'Стабильный', 'desktop.channel.nightly': 'Разработка', - 'desktop.channel.changing': 'Переключение…', 'desktop.channel.unavailable': 'Настройки недоступны', 'desktop.channel.error': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', 'desktop.update.configurationFailed': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', + 'desktop.channel.result.applied': 'Канал обновлений изменён', + 'desktop.channel.result.save': 'Не удалось сохранить', + 'desktop.channel.result.format': 'Не удалось сохранить: неверный формат файла настроек', + 'desktop.channel.result.permission': 'Не удалось сохранить: нет прав на запись', + 'desktop.channel.result.apply': 'Не удалось сохранить', + 'desktop.update.configuration.read': 'Обновления приостановлены: не удалось прочитать настройки', + 'desktop.update.configuration.format': 'Обновления приостановлены: неверный формат файла настроек', + 'desktop.update.configuration.channel': 'Обновления приостановлены: channel должен быть nightly или latest', + 'desktop.update.configuration.cleanup': 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', 'trigger': 'Настройки', 'shortcut.open': 'Открыть настройки', 'desktop.update.available': 'Обновить', @@ -387,10 +443,18 @@ export const pt = { 'desktop.channel.description': 'Estável recebe apenas versões estáveis. Desenvolvimento inclui versões preliminares potencialmente instáveis. A mudança não reduz a versão.', 'desktop.channel.latest': 'Estável', 'desktop.channel.nightly': 'Desenvolvimento', - 'desktop.channel.changing': 'Alterando…', 'desktop.channel.unavailable': 'Configuração indisponível', 'desktop.channel.error': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', 'desktop.update.configurationFailed': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', + 'desktop.channel.result.applied': 'Canal de atualização alterado', + 'desktop.channel.result.save': 'Não foi possível salvar', + 'desktop.channel.result.format': 'Não foi possível salvar: arquivo de configurações inválido', + 'desktop.channel.result.permission': 'Não foi possível salvar: permissão negada', + 'desktop.channel.result.apply': 'Não foi possível salvar', + 'desktop.update.configuration.read': 'Atualizações pausadas: não foi possível ler as configurações', + 'desktop.update.configuration.format': 'Atualizações pausadas: arquivo de configurações inválido', + 'desktop.update.configuration.channel': 'Atualizações pausadas: channel deve ser nightly ou latest', + 'desktop.update.configuration.cleanup': 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', 'trigger': 'Configurações', 'shortcut.open': 'Abrir configurações', 'desktop.update.available': 'Atualizar', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index c3769f0649..f6fae57043 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -46,9 +46,14 @@ export interface DesktopChannelState { readonly channel: DesktopUpdateChannel | null readonly packagedChannel: DesktopUpdateChannel readonly changing: boolean - readonly error?: 'read' | 'invalid' | 'write' | 'apply' + readonly error?: 'read' | 'format' | 'channel' | 'cleanup' } +/** Outcome of one UI action; failures are not global updater state. */ +export type DesktopChannelChangeResult = + | { readonly kind: 'applied'; readonly state: DesktopChannelState } + | { readonly kind: 'failed'; readonly state: DesktopChannelState; readonly reason: 'save' | 'format' | 'permission' | 'apply' } + /** UI choices persist in the shell configuration, independently of Host settings. */ export interface DesktopChannelBridge { /** @returns Current shell selection and application status, without reading disk again. */ @@ -57,7 +62,7 @@ export interface DesktopChannelBridge { * @param channel - Explicit user choice to persist and apply. * @returns The resulting selection or failure; it does not authorize installation. */ - set(channel: DesktopUpdateChannel): Promise + set(channel: DesktopUpdateChannel): Promise /** * @param listener - Receives changes made from any Desktop window. * @returns Removes this window's subscription. diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 430ae3d7ac..4f35ce2c56 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -17,7 +17,7 @@ import { inject } from '../src/client/index.ts' import type { DeveloperToolsRowInjected } from '../src/client/DeveloperToolsRow.tsx' import { HostReleasePrompt, type HostReleasePromptInjected } from '../src/client/HostReleasePrompt.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import type { DesktopChannelRowInjected } from '../src/client/DesktopChannelRow.tsx' +import type { DesktopChannelRowInjected, DesktopChannelToastInjected } from '../src/client/DesktopChannelRow.tsx' import type { DesktopChannelBridge } from '../src/types.ts' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' @@ -213,7 +213,7 @@ describe('ui-settings-general apply', () => { it('removes the Desktop channel row and shell subscription when its plugin unloads', async ({ mock, start }) => { const unsubscribe = vi.fn() const state = { channel: 'nightly', packagedChannel: 'nightly', changing: false } as const - const save = vi.fn(async () => state) + const save = vi.fn(async () => ({ kind: 'applied' as const, state })) const channels: DesktopChannelBridge = { get: async () => state, set: save, subscribe: () => unsubscribe } vi.stubGlobal('dshDesktop', { protocolVersion: 1, updates: { channels, @@ -221,12 +221,21 @@ describe('ui-settings-general apply', () => { onTestFinished(() => { vi.unstubAllGlobals() }) const { c } = await client(mock, start) const row = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'desktop-update-channel')! + expect(c.ctx.slots.entries('shell.overlay').some(entry => entry.options.id === 'desktop-channel-toast')).toBe(true) const select = row.inject!().select as DesktopChannelRowInjected['select'] await select('latest') expect(save).toHaveBeenCalledWith('latest') + const toast = c.ctx.slots.entries('shell.overlay').find(entry => entry.options.id === 'desktop-channel-toast')! + const injected = toast.inject!() + const hooks = injected.hooks as DesktopChannelToastInjected['hooks'] + const dismiss = injected.dismiss as DesktopChannelToastInjected['dismiss'] + expect(hooks.channel.getSnapshot().notice?.outcome).toBe('applied') + dismiss(1) + expect(hooks.channel.getSnapshot().notice).toBeUndefined() await c.unload(SELF) await c.flush() expect(unsubscribe).toHaveBeenCalledOnce() + expect(c.ctx.slots.entries('shell.overlay').some(entry => entry.options.id === 'desktop-channel-toast')).toBe(false) }) it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index d6206263f3..0d85c5e8fa 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -2,15 +2,16 @@ import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' -import { DesktopChannelRow } from '../src/client/DesktopChannelRow.tsx' -import type { DesktopChannelState } from '../src/types.ts' +import { DesktopChannelRow, DesktopChannelToast } from '../src/client/DesktopChannelRow.tsx' +import { DesktopChannelSource } from '../src/client/desktop-channel-source.ts' +import type { DesktopChannelChangeResult, DesktopChannelState } from '../src/types.ts' import { DeveloperToolsRow } from '../src/client/DeveloperToolsRow.tsx' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -72,7 +73,7 @@ describe('Desktop collapsed update badge', () => { state = { failed: false, opening: false, presentation: { phase: 'error', failure: 'install' } } view.rerender() expect(screen.getByRole('img', { name: en['desktop.update.retry'] })).toBeTruthy() - state = { failed: false, opening: false, presentation: { phase: 'idle', configurationError: 'invalid' } } + state = { failed: false, opening: false, presentation: { phase: 'idle', configurationError: 'format' } } view.rerender() expect(screen.getByRole('img', { name: en['desktop.channel.unavailable'] })).toBeTruthy() state = { ...state, presentation: { phase: 'idle' } } @@ -323,7 +324,7 @@ describe('Host release change confirmation', () => { it('shows channel load errors and waits for an explicit selection to settle', async () => { - let state: { state?: DesktopChannelState; failed: boolean } = { failed: false } + let state: { state?: DesktopChannelState; failed: boolean; pending?: 'latest' } = { failed: false } const pending = Promise.withResolvers() const select = vi.fn(() => pending.promise) const props = { ...kit, t, select, useChannel: bindSnapshotSelector(createSnapshotStore(state)) } @@ -333,15 +334,17 @@ it('shows channel load errors and waits for an explicit selection to settle', as state = { failed: true } view.rerender() expect(screen.getByRole('alert')).toBeTruthy() - state = { failed: false, state: { channel: null, packagedChannel: 'nightly', changing: false, error: 'invalid' } } + state = { failed: false, state: { channel: null, packagedChannel: 'nightly', changing: false, error: 'format' } } view.rerender() expect(control.textContent).toContain('Configuration unavailable') fireEvent.click(control) fireEvent.click(screen.getByRole('menuitem', { name: 'Stable' })) expect(select).toHaveBeenCalledWith('latest') - expect(control.textContent).toContain('Switching') + state = { ...state, pending: 'latest' } + view.rerender() + expect(control.textContent).toBe('Stable') + expect(control.getAttribute('aria-busy')).toBe('true') pending.resolve(undefined) - await waitFor(() => { expect(control.hasAttribute('disabled')).toBe(false) }) state = { failed: false, state: { channel: 'latest', packagedChannel: 'nightly', changing: false } } view.rerender() expect(control.textContent).toBe('Stable') @@ -358,3 +361,40 @@ it('shows channel load errors and waits for an explicit selection to settle', as view.rerender() await waitFor(() => { expect(control.textContent).toBe('Development') }) }) + +it('keeps the failure toast after Settings unmounts and rolls back the remounted selector', async () => { + const state: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } + const reply = Promise.withResolvers() + const source = new DesktopChannelSource({ get: async () => state, set: () => reply.promise, subscribe: () => () => {} }) + const useChannel = bindSnapshotSelector(source.store) + const select = (channel: 'nightly' | 'latest') => source.select(channel) + const row = render() + const toast = render( { source.dismissNotice(sequence) }} />) + try { + await waitFor(() => { expect(screen.getByRole('button', { name: 'Update channel' }).textContent).toBe('Development') }) + fireEvent.click(screen.getByRole('button', { name: 'Update channel' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Stable' })) + expect(screen.getByRole('button', { name: 'Update channel' }).textContent).toBe('Stable') + expect(screen.getByRole('button', { name: 'Update channel' }).getAttribute('aria-busy')).toBe('true') + row.unmount() + reply.resolve({ kind: 'failed', state, reason: 'permission' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Could not save: permission denied') }) + render() + expect(screen.getByRole('button', { name: 'Update channel' }).textContent).toBe('Development') + } finally { source.dispose(); toast.unmount() } +}) + +it('dismisses a successful channel toast after its display lifetime', async () => { + vi.useFakeTimers() + const dismiss = vi.fn() + const store = createSnapshotStore({ + failed: false, notice: { sequence: 7, outcome: 'applied' }, + }) + const view = render() + try { + expect(screen.getByRole('alert').textContent).toBe('Update channel changed') + await act(async () => { await vi.advanceTimersByTimeAsync(4000) }) + expect(dismiss).toHaveBeenCalledWith(7) + } finally { view.unmount(); vi.useRealTimers() } +}) diff --git a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts index 387b87bbf4..f7c794140f 100644 --- a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts +++ b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts @@ -1,13 +1,13 @@ import { expect, it, vi } from 'vitest' import { DesktopChannelSource } from '../src/client/desktop-channel-source.ts' -import type { DesktopChannelState } from '../src/types.ts' +import type { DesktopChannelState, DesktopChannelChangeResult } from '../src/types.ts' const nightly: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } const latest: DesktopChannelState = { ...nightly, channel: 'latest' } function fixture() { const get = Promise.withResolvers() - const set = Promise.withResolvers() + const set = Promise.withResolvers() let listener: (state: DesktopChannelState) => void = () => {} const unsubscribe = vi.fn() const select = vi.fn(() => set.promise) @@ -16,76 +16,107 @@ function fixture() { return { source, get, set, select, unsubscribe, emit: (state: DesktopChannelState) => { listener(state) } } } -it('keeps pushed multi-window state ahead of stale initial and selection replies', async () => { +it('keeps pushed multi-window state ahead of stale replies while settling its own operation', async () => { const f = fixture() f.emit(nightly) f.get.resolve(latest) await f.get.promise expect(f.source.store.getSnapshot().state).toEqual(nightly) const pending = f.source.select('latest') + expect(f.source.store.getSnapshot().pending).toBe('latest') + await f.source.select('nightly') + expect(f.select).toHaveBeenCalledOnce() f.emit(latest) f.emit(nightly) - f.set.resolve(latest) + f.set.resolve({ kind: 'applied', state: latest }) await pending - expect(f.source.store.getSnapshot().state).toEqual(nightly) + expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false, notice: { sequence: 1, outcome: 'applied' } }) f.source.dispose() expect(f.unsubscribe).toHaveBeenCalledOnce() }) -it('keeps save failures visible and permits an explicit retry', async () => { +it('rolls back a rejected save and retains a toast across shell broadcasts', async () => { const f = fixture() f.get.resolve(nightly) await f.get.promise const pending = f.source.select('latest') - f.set.reject(new Error('IPC closed')) + f.emit({ ...nightly, changing: true }) + f.emit(nightly) + f.set.resolve({ kind: 'failed', state: nightly, reason: 'format' }) await pending - expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: true }) + expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false, notice: { sequence: 1, outcome: 'format' } }) f.emit(latest) - expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false }) + expect(f.source.store.getSnapshot().notice?.outcome).toBe('format') + await f.source.select('nightly') + f.source.dismissNotice(1) + expect(f.source.store.getSnapshot().notice?.sequence).toBe(2) + f.source.dismissNotice(2) + expect(f.source.store.getSnapshot().notice).toBeUndefined() f.source.dispose() }) -it('ignores events and replies after disposal', async () => { +it('reports generic save failure after transport rejection without flagging global state', async () => { + const f = fixture() + f.get.resolve(nightly) + await f.get.promise + const pending = f.source.select('latest') + f.emit(latest) + f.set.reject(new Error('IPC closed')) + await pending + expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false, notice: { sequence: 1, outcome: 'save' } }) + f.source.dispose() +}) + +it('ignores events, successful replies and new requests after disposal', async () => { const f = fixture() const pending = f.source.select('latest') f.source.dispose() + const before = f.source.store.getSnapshot() f.emit(latest) f.get.resolve(nightly) - f.set.resolve(latest) + f.set.resolve({ kind: 'applied', state: latest }) await pending await f.source.select('nightly') expect(f.select).toHaveBeenCalledOnce() - expect(f.source.store.getSnapshot()).toEqual({ failed: false }) + expect(f.source.store.getSnapshot()).toBe(before) }) - it('reports initial IPC failure and accepts a later explicit choice response', async () => { const f = fixture() f.get.reject(new Error('unavailable')) await Promise.resolve() expect(f.source.store.getSnapshot()).toEqual({ failed: true }) const pending = f.source.select('latest') - f.set.resolve(latest) + f.set.resolve({ kind: 'applied', state: latest }) await pending - expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false }) + expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false, notice: { sequence: 1, outcome: 'applied' } }) f.source.dispose() }) -it('ignores rejected reads after an event or disposal, and rejected writes after a newer event', async () => { +it('ignores rejected reads after an event and all rejected responses after disposal', async () => { const f = fixture() f.emit(nightly) f.get.reject(new Error('stale read')) - const pending = f.source.select('latest') - f.emit(latest) - f.set.reject(new Error('stale write')) - await pending - expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false }) + await Promise.resolve() + expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false }) f.source.dispose() const closed = fixture() const writing = closed.source.select('latest') closed.source.dispose() + const before = closed.source.store.getSnapshot() closed.get.reject(new Error('late read')) closed.set.reject(new Error('late write')) await writing - expect(closed.source.store.getSnapshot()).toEqual({ failed: false }) + expect(closed.source.store.getSnapshot()).toBe(before) +}) + +it('does not let a late initial read overwrite an accepted selection', async () => { + const f = fixture() + const pending = f.source.select('latest') + f.set.resolve({ kind: 'applied', state: latest }) + await pending + f.get.resolve(nightly) + await f.get.promise + expect(f.source.store.getSnapshot().state).toEqual(latest) + f.source.dispose() }) diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx index 821be459e4..a1291e5fb6 100644 --- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -202,12 +202,12 @@ it('localizes fullscreen timeout guidance and preserves the retry action', async it('keeps a configuration error actionable while download progress continues', async () => { const f = fixture() - await f.emit({ phase: 'idle', configurationError: 'invalid' }) + await f.emit({ phase: 'idle', configurationError: 'format' }) const error = screen.getByRole('button', { name: '配置不可用' }) fireEvent.click(error) await act(async () => {}) expect(f.open).toHaveBeenCalledOnce() - await f.emit({ phase: 'downloading', percent: 58, configurationError: 'write' }) + await f.emit({ phase: 'downloading', percent: 58, configurationError: 'cleanup' }) fireEvent.click(screen.getByRole('button', { name: '配置不可用' })) await act(async () => {}) expect(f.open).toHaveBeenCalledTimes(2) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 3eaef1f661..988e661e35 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3724,6 +3724,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'', 'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'', 'client-ui-settings-account AccountPlatformHost id \'account.platform-page\'', + 'client-ui-settings-general DesktopChannelToast id \'desktop-channel-toast\'', 'client-ui-settings-general HostReleasePrompt id \'host-release-change\'', 'client-ui-settings-session-log UploadToast id \'session-log-upload-toast\'', 'client-ui-shortcuts ShortcutReference id \'shortcuts\'', From 8e30aa7bdc4d6c45bb5952c7263383d2f4737f16 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:25:33 +0800 Subject: [PATCH 11/25] fix(desktop): simplify update channel description --- .../ui-settings-general/src/client/locales.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index a32f40867e..d2bc78f2bd 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -3,7 +3,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'desktop.channel.title': '更新渠道', - 'desktop.channel.description': '正式版仅接收稳定版本;开发版包含预发布版本,可能不稳定。切换不会降级。', + 'desktop.channel.description': '正式版适合日常使用;开发版可提前体验新功能,但可能不稳定。', 'desktop.channel.latest': '正式版', 'desktop.channel.nightly': '开发版', 'desktop.channel.unavailable': '配置不可用', @@ -68,7 +68,7 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'desktop.channel.title': 'Update channel', - 'desktop.channel.description': 'Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades.', + 'desktop.channel.description': 'Stable is suitable for everyday use. Development offers early access to new features but may be unstable.', 'desktop.channel.latest': 'Stable', 'desktop.channel.nightly': 'Development', 'desktop.channel.unavailable': 'Configuration unavailable', @@ -130,7 +130,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { 'desktop.channel.title': '更新チャンネル', - 'desktop.channel.description': '安定版は正式リリースのみ、開発版は不安定なプレビュー版も受信します。切り替えてもダウングレードしません。', + 'desktop.channel.description': '安定版は日常的な使用に適しています。開発版では新機能をいち早く試せますが、不安定な場合があります。', 'desktop.channel.latest': '安定版', 'desktop.channel.nightly': '開発版', 'desktop.channel.unavailable': '設定を利用できません', @@ -192,7 +192,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { 'desktop.channel.title': 'Canal de actualizaciones', - 'desktop.channel.description': 'Estable recibe solo versiones estables. Desarrollo incluye versiones preliminares que pueden ser inestables. Cambiar no instala versiones anteriores.', + 'desktop.channel.description': 'Estable es adecuado para el uso diario. Desarrollo permite probar nuevas funciones antes, pero puede ser inestable.', 'desktop.channel.latest': 'Estable', 'desktop.channel.nightly': 'Desarrollo', 'desktop.channel.unavailable': 'Configuración no disponible', @@ -254,7 +254,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { 'desktop.channel.title': 'Canal de mise à jour', - 'desktop.channel.description': 'Stable reçoit uniquement les versions stables. Développement inclut des préversions potentiellement instables. Le changement ne rétrograde pas la version.', + 'desktop.channel.description': 'Stable convient à un usage quotidien. Développement permet de découvrir les nouveautés en avance, mais peut être instable.', 'desktop.channel.latest': 'Stable', 'desktop.channel.nightly': 'Développement', 'desktop.channel.unavailable': 'Configuration indisponible', @@ -316,7 +316,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { 'desktop.channel.title': 'Update-Kanal', - 'desktop.channel.description': 'Stabil erhält nur stabile Versionen. Entwicklung enthält möglicherweise instabile Vorabversionen. Ein Wechsel führt kein Downgrade durch.', + 'desktop.channel.description': 'Stabil eignet sich für den Alltag. Entwicklung bietet frühzeitigen Zugang zu neuen Funktionen, kann aber instabil sein.', 'desktop.channel.latest': 'Stabil', 'desktop.channel.nightly': 'Entwicklung', 'desktop.channel.unavailable': 'Konfiguration nicht verfügbar', @@ -378,7 +378,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { 'desktop.channel.title': 'Канал обновлений', - 'desktop.channel.description': 'Стабильный канал получает только стабильные версии. Канал разработки включает предварительные версии. Переключение не понижает версию.', + 'desktop.channel.description': 'Стабильный канал подходит для повседневного использования. Канал разработки позволяет раньше попробовать новые функции, но может быть нестабильным.', 'desktop.channel.latest': 'Стабильный', 'desktop.channel.nightly': 'Разработка', 'desktop.channel.unavailable': 'Настройки недоступны', @@ -440,7 +440,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { 'desktop.channel.title': 'Canal de atualização', - 'desktop.channel.description': 'Estável recebe apenas versões estáveis. Desenvolvimento inclui versões preliminares potencialmente instáveis. A mudança não reduz a versão.', + 'desktop.channel.description': 'Estável é adequado para o uso diário. Desenvolvimento oferece acesso antecipado a novos recursos, mas pode ser instável.', 'desktop.channel.latest': 'Estável', 'desktop.channel.nightly': 'Desenvolvimento', 'desktop.channel.unavailable': 'Configuração indisponível', From 1c9a7e6a71f99afdd2f4f0c5ad766362d94847c3 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:26:09 +0800 Subject: [PATCH 12/25] fix(desktop): preserve updates on repeated channel selection --- apps/desktop/src/main.ts | 4 +-- apps/desktop/src/update-coordinator.ts | 8 +++-- apps/desktop/tests/main-startup.spec.ts | 1 + apps/desktop/tests/update-coordinator.spec.ts | 29 +++++++++++++++++++ .../ui-settings-general/README.i18n.yaml | 4 +-- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/desktop-channel-source.ts | 6 +++- .../client/ui-settings-general/src/types.ts | 2 +- .../tests/desktop-channel.client.spec.ts | 22 ++++++++++++++ 10 files changed, 69 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f25ef33daa..cf331062ce 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -902,8 +902,8 @@ async function main(): Promise { channelRequests++ publishChannel({ ...channelState, changing: true }) try { - await updates.switchChannel(channel, () => channelSettings.save(channel)) - return { kind: 'applied', state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } + const kind = await updates.switchChannel(channel, () => channelSettings.save(channel)) + return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } } catch (error) { console.error('desktop update: channel change failed', error) const state = publishChannel({ ...channelState, changing: --channelRequests > 0, diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 18940a2f5d..0beab5ae25 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -113,9 +113,9 @@ export class DesktopUpdateCoordinator { * suspends updates until another explicit switch successfully retires the target. * @param channel - Validated UI choice. * @param persist - Atomic settings save; rejects before a choice is applied on failure. - * @returns After the selection is applied and its check settles; check failures remain observable. + * @returns Whether the selection changed; healthy repeated choices preserve the current operation. */ - switchChannel(channel: DesktopUpdateChannel, persist: () => Promise): Promise { + switchChannel(channel: DesktopUpdateChannel, persist: () => Promise): Promise<'applied' | 'unchanged'> { this.assertLive() if (this.installOperation !== undefined || this.current.phase === 'installing' || this.release.kind === 'handed-off') { return Promise.reject(new Error('desktop update: installation is in progress')) @@ -123,6 +123,7 @@ export class DesktopUpdateCoordinator { this.switching++ const operation = this.switchTail.then(async () => { this.assertLive() + if (channel === this.channel && this.configurationError === undefined) return 'unchanged' as const await persist() this.generation++ this.cancellation?.cancel() @@ -140,8 +141,9 @@ export class DesktopUpdateCoordinator { this.setState({ phase: 'checking' }) const result = await this.doCheck() if (result.phase === 'error') this.setState(result) + return 'applied' as const }).finally(() => { this.switching-- }) - this.switchTail = operation.catch(() => { /* The caller reports this selection's failure; later choices may retry. */ }) + this.switchTail = operation.then(() => {}).catch(() => { /* The caller reports this selection's failure; later choices may retry. */ }) return operation } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 10e80edbf2..8c8fa73d3b 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -382,6 +382,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class this.configurationFailure = undefined this.revision++ harness.updateState = { phase: 'idle' } + return 'applied' as const } suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup') { this.configurationFailure = reason diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 7256fccce8..3f194e4fd9 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -388,3 +388,32 @@ it('suspends updates if retiring a downloaded target fails and recovers on a lat await f.coordinator.switchChannel('latest', async () => {}) expect(f.coordinator.configurationFailure).toBeUndefined() }) + + +it.each(['idle', 'downloading', 'ready'] as const)('preserves %s when the current channel is selected again', async (phase) => { + const f = fixture() + const save = vi.fn(async () => {}) + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + let downloading: Promise | undefined + if (phase !== 'idle') { + await f.coordinator.check() + if (phase === 'downloading') f.downloadUpdate.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + downloading = f.coordinator.download('1.1.0-rc.2') + if (phase === 'ready') await downloading + else await started.promise + } + const before = f.coordinator.state + const revision = f.coordinator.revision + const checks = f.checkForUpdates.mock.calls.length + try { + expect(await f.coordinator.switchChannel('nightly', save)).toBe('unchanged') + expect(save).not.toHaveBeenCalled() + expect(f.coordinator.state).toBe(before) + expect(f.coordinator.revision).toBe(revision) + expect(f.checkForUpdates).toHaveBeenCalledTimes(checks) + } finally { + pending.resolve(['verified-package']) + await downloading + } +}) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 684fc37113..70e89130c9 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: 6ff1dbfd2da5a8a1 - zh: 5cd9e94aea081dc6 + en: 4d85d6dffd8d9e21 + zh: 653e583277563758 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 9c8c35ae0e672b14 zh: 503c8e9d24a93d88 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 1944a3ac31..12a8c064a8 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -63,7 +63,7 @@ Access: the settings shell and the `settings.open` shortcut are registered only ----- -Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Selecting a channel calls Electron directly and temporarily displays that choice with a spinner. The reply settles the selection against actual shell state; failure rolls it back. A shell-owned toast reports success or a short save failure, including after Settings closes. Persistent updater suspensions remain in the update indicator; a failed save does not create one. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). +Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Selecting the current healthy channel leaves downloads and prepared updates intact without a save or notification. Selecting another channel calls Electron directly and temporarily displays that choice with a spinner. The reply settles the selection against actual shell state; failure rolls it back. A shell-owned toast reports success or a short save failure, including after Settings closes. Persistent updater suspensions remain in the update indicator; a failed save does not create one. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). ## Understand the implementation diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 40540ad393..f894efbe66 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -63,7 +63,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ----- -当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。选择渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 +当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。重复选择当前正常渠道不会保存、提示或影响下载与待安装更新。选择其他渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 ## 理解实现 diff --git a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts index 7d92b785ab..43344ad29b 100644 --- a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts +++ b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts @@ -41,6 +41,8 @@ export class DesktopChannelSource { */ async select(channel: DesktopUpdateChannel): Promise { if (!this.isLive() || this.store.getSnapshot().pending !== undefined) return + const state = this.store.getSnapshot().state + if (state?.channel === channel && state.error === undefined && !state.changing) return const revision = this.revision const { notice: _notice, ...previous } = this.store.getSnapshot() this.store.set({ ...previous, pending: channel }) @@ -57,7 +59,9 @@ export class DesktopChannelSource { this.store.set({ ...current, ...(accept && result !== undefined ? { state: result.state, failed: false } : {}), - notice: { sequence: ++this.sequence, outcome: result?.kind === 'applied' ? 'applied' : result?.reason ?? 'save' }, + ...(result?.kind === 'unchanged' ? {} : { + notice: { sequence: ++this.sequence, outcome: result?.kind === 'applied' ? 'applied' as const : result?.kind === 'failed' ? result.reason : 'save' as const }, + }), }) } diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index f6fae57043..f533e4478e 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -51,7 +51,7 @@ export interface DesktopChannelState { /** Outcome of one UI action; failures are not global updater state. */ export type DesktopChannelChangeResult = - | { readonly kind: 'applied'; readonly state: DesktopChannelState } + | { readonly kind: 'applied' | 'unchanged'; readonly state: DesktopChannelState } | { readonly kind: 'failed'; readonly state: DesktopChannelState; readonly reason: 'save' | 'format' | 'permission' | 'apply' } /** UI choices persist in the shell configuration, independently of Host settings. */ diff --git a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts index f7c794140f..ba40f44887 100644 --- a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts +++ b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts @@ -120,3 +120,25 @@ it('does not let a late initial read overwrite an accepted selection', async () expect(f.source.store.getSnapshot().state).toEqual(latest) f.source.dispose() }) + + +it('does not submit or toast for the current healthy selection', async () => { + const f = fixture() + f.get.resolve(nightly) + await f.get.promise + await f.source.select('nightly') + expect(f.select).not.toHaveBeenCalled() + expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false }) + f.source.dispose() +}) + +it('allows same-channel recovery and suppresses feedback for a shell no-op', async () => { + const f = fixture() + f.emit({ ...nightly, error: 'cleanup' }) + const pending = f.source.select('nightly') + expect(f.select).toHaveBeenCalledWith('nightly') + f.set.resolve({ kind: 'unchanged', state: nightly }) + await pending + expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false }) + f.source.dispose() +}) From d2bd3f3893d38ca120fd63b7fa33a7908a031574 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:27:21 +0800 Subject: [PATCH 13/25] feat(desktop): rename stable update channel consistently --- .github/workflows/macos-intel-package.yml | 4 +- .github/workflows/macos-package.yml | 4 +- .github/workflows/windows-package.yml | 4 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- apps/desktop/publishing/README.i18n.yaml | 4 +- apps/desktop/publishing/README.md | 2 +- apps/desktop/publishing/README.zh.md | 2 +- apps/desktop/publishing/ci/README.i18n.yaml | 8 ++-- apps/desktop/publishing/ci/README.md | 4 +- apps/desktop/publishing/ci/README.zh.md | 4 +- .../desktop-auto-update-environment.d.mts | 6 +-- .../desktop-auto-update-environment.mjs | 12 +++--- apps/desktop/scripts/desktop-ci-version.mjs | 2 +- apps/desktop/scripts/desktop-publish-route.ts | 4 +- apps/desktop/scripts/desktop-release-store.ts | 2 +- apps/desktop/scripts/desktop-upload-plan.ts | 2 +- .../scripts/electron-builder-config.mjs | 2 +- .../scripts/macos-app-update-config.d.mts | 4 +- .../scripts/macos-app-update-config.mjs | 14 +++---- apps/desktop/scripts/publish-desktop-ci.ts | 2 +- apps/desktop/src/locale.ts | 16 +++---- apps/desktop/src/mandatory-update-policy.ts | 2 +- apps/desktop/src/settings-file.ts | 2 +- apps/desktop/src/update-channel.ts | 2 +- apps/desktop/src/update-coordinator.ts | 2 +- .../src/updates/electron-updater/channel.ts | 2 +- .../desktop-auto-update-environment.spec.ts | 11 +++++ .../tests/desktop-publication-summary.spec.ts | 10 ++--- apps/desktop/tests/desktop-rollback.spec.ts | 14 +++---- .../desktop/tests/desktop-upload-plan.spec.ts | 14 +++---- .../tests/expected/publication-production.md | 10 ++--- .../tests/macos-app-update-config.spec.ts | 8 ++-- apps/desktop/tests/main-startup.spec.ts | 26 ++++++------ apps/desktop/tests/preload-app.spec.ts | 6 +-- apps/desktop/tests/settings-file.spec.ts | 30 ++++++------- apps/desktop/tests/update-channel.spec.ts | 3 +- .../tests/update-configuration.spec.ts | 2 +- apps/desktop/tests/update-coordinator.spec.ts | 36 ++++++++-------- apps/desktop/tests/update-settings.spec.ts | 10 ++--- .../electron-updater/qualification.mjs | 6 +-- apps/web/tests/desktop-channel.e2e.ts | 8 ++-- .../desktop-stable-channel/guide.i18n.yaml | 16 +++++++ .../desktop-stable-channel/guide.md | 18 ++++++++ .../desktop-stable-channel/guide.zh.md | 18 ++++++++ .../src/client/DesktopChannelRow.tsx | 4 +- .../ui-settings-general/src/client/index.ts | 2 +- .../ui-settings-general/src/client/locales.ts | 32 +++++++------- .../client/ui-settings-general/src/types.ts | 2 +- .../tests/apply.client.spec.ts | 4 +- .../tests/components.client.spec.tsx | 10 ++--- .../tests/desktop-channel.client.spec.ts | 42 +++++++++---------- 56 files changed, 267 insertions(+), 203 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index cc835b8933..653f0a8101 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 073f1e9541..e28dc09a6b 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 29229605de..76a4b68f10 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index bc9dc4f216..68bc787000 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 2f2056adf22c62ef - zh: def6d09fbbf35a2b + en: de5a03c9b2cf7796 + zh: 37bbc84a931feb3a /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a9099a6b92..60c5ad612c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -304,7 +304,7 @@ On first launch, including after an upgrade, Desktop creates this document if it Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). -In Desktop **Settings → General → Update channel**, **Stable** selects `latest` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. +In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and, for settings errors, the file path. Fix the file and restart, or select a valid channel in Settings; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f73e9f9576..f1fc864e08 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -304,7 +304,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 -在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `latest`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 +在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或旧更新清理失败会暂停普通更新,主应用仍可使用。更新入口独立于下载进度保留暂停原因,点击可查看恢复方法;配置错误另显示文件路径。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index c39bb71bc3..c9e8ef139e 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 3f38ab26ee195b39 - zh: f00eddafa8f3b9ca + en: 8d09a324cde88ae1 + zh: 308d4902bd01f046 /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 28aa9ff9c7..96328662e0 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 2e87933826..84581366d2 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 99b4b44aef..ae50a03293 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -21,8 +21,8 @@ en: eed7c4ed0d4e2758 zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: - en: 3c3e3a09573c4386 - zh: c851ae6a94f2284a + en: 81b0df7268e80a87 + zh: 35a3dc184f9a4442 /desktop-publication/fixed-installer-downloads: en: ce8296f8f1ee881e zh: 749ab63bd6ed4248 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index b9bb98af2b..2e5ead449f 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -61,7 +61,7 @@ pnpm run upload:mac:arm64 The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Each package publishes only its selected channel: `nightly.yml` / `nightly-mac.yml` or `stable.yml` / `stable-mac.yml`. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 0f64fb31ee..86594f9ddf 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -61,7 +61,7 @@ pnpm run upload:mac:arm64 内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个包仅发布自身选定渠道的清单:`nightly.yml` / `nightly-mac.yml` 或 `stable.yml` / `stable-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 14ef478ed7..e01a4e7c0c 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -24,11 +24,11 @@ en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: f7c12d343891e547 - zh: da596ee1366c2046 + en: ee8d79ac75482502 + zh: 1d986a76860ed262 /desktop-ci-publication/recovery-and-records: - en: b62a7484929f9941 - zh: c9dd06c77098e6ef + en: 4ff6f0673d057ca8 + zh: 7171d060dd86db9a /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 5009bf87e7..4c224d2a7b 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -70,7 +70,7 @@ Environment approval gates this workflow, not arbitrary code running under the s For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. @@ -80,7 +80,7 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records -After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. +After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 151032eed9..ae61bde4e9 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -70,7 +70,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 @@ -80,7 +80,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 +解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 4d42b3dd1e..98a6309d14 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -17,7 +17,7 @@ export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -68,7 +68,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, - channel?: 'nightly' | 'latest', + channel?: 'nightly' | 'stable', ): string /** @@ -104,7 +104,7 @@ export function resolveDesktopUploadConfig( * @param env Packaging settings. * @returns Validated channel. */ -export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'stable' /** * Read a required nonempty deployment setting. diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 835beadd6d..33cff177a2 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -8,11 +8,11 @@ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' /** * Resolve the channel embedded in the installer; omitted local settings retain nightly. * @param {NodeJS.ProcessEnv} env Packaging or upload settings. - * @returns {'nightly' | 'latest'} Validated channel. + * @returns {'nightly' | 'stable'} Validated channel. */ export function resolveDesktopUpdateChannel(env) { const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop auto-update: invalid update channel') return channel } @@ -90,7 +90,7 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. - * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. + * @param {'nightly' | 'stable'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { @@ -148,7 +148,7 @@ function httpsOrigin(value, name) { * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { @@ -177,7 +177,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'stable' ? '/stable' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -187,7 +187,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 8fef629287..3822b8329f 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -24,7 +24,7 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } - if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version') try { const resolved = validateDesktopBuildVersion(candidate, productVersion) return resolved diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 9b6fedfa42..5c8d7a9ef3 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -12,7 +12,7 @@ const runSchema = z.object({ const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string(), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), }).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), @@ -25,7 +25,7 @@ export interface DesktopPublishRoute { readonly attempt: string readonly commit: string readonly sourceRef: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly runnerName: string readonly runnerLabel: string readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64' diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index c080272e1d..bb3345aa74 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -14,7 +14,7 @@ const sourceSchema = z.strictObject({ commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 1461444fc6..1299dfdae8 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -219,7 +219,7 @@ export async function createDesktopUploadPlan( throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version') const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index cf0c19e895..399d73a616 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -102,7 +102,7 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) - if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') + if (update?.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index ce18266c48..926762b9ec 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,14 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string - readonly channel?: 'nightly' | 'latest' + readonly channel?: 'nightly' | 'stable' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index d6b4132c71..bdb400bcde 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -24,24 +24,24 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'stable' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { - throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') + if (provider.provider !== 'generic' || !['nightly', 'stable'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or stable channel') } return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'stable', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f9348ec2eb..19dcea3f58 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -17,7 +17,7 @@ import { publishDesktopTag } from './desktop-publish-tag.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), - sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), + sourceRef: z.string(), channel: z.enum(['nightly', 'stable']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 2b9d89464a..eeb97f40eb 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -109,7 +109,7 @@ export const en = { updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', updateConfigurationRead: 'Updates paused: cannot read settings file. Fix the settings file and restart, or select the channel again in Settings.', updateConfigurationFormat: 'Updates paused: invalid settings file. Fix the settings file and restart, or select the channel again in Settings.', - updateConfigurationChannel: 'Updates paused: channel must be nightly or latest. Fix the settings file and restart, or select the channel again in Settings.', + updateConfigurationChannel: 'Updates paused: channel must be nightly or stable. Fix the settings file and restart, or select the channel again in Settings.', updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', @@ -294,7 +294,7 @@ export const zh = { updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', updateConfigurationRead: '更新已暂停:无法读取配置文件。请修正配置文件后重启,或在设置中重新选择渠道', updateConfigurationFormat: '更新已暂停:配置文件格式有误。请修正配置文件后重启,或在设置中重新选择渠道', - updateConfigurationChannel: '更新已暂停:channel 只能是 nightly 或 latest。请修正配置文件后重启,或在设置中重新选择渠道', + updateConfigurationChannel: '更新已暂停:channel 只能是 nightly 或 stable。请修正配置文件后重启,或在设置中重新选择渠道', updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', @@ -477,7 +477,7 @@ export const ja = { updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', updateConfigurationRead: '更新を停止中:設定ファイルを読み取れません. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', updateConfigurationFormat: '更新を停止中:設定ファイルの形式が不正です. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', - updateConfigurationChannel: '更新を停止中:channel は nightly または latest にしてください. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', + updateConfigurationChannel: '更新を停止中:channel は nightly または stable にしてください. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', @@ -660,7 +660,7 @@ export const es = { updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', updateConfigurationRead: 'Actualizaciones pausadas: no se puede leer la configuración. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', updateConfigurationFormat: 'Actualizaciones pausadas: archivo de configuración no válido. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', - updateConfigurationChannel: 'Actualizaciones pausadas: channel debe ser nightly o latest. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', + updateConfigurationChannel: 'Actualizaciones pausadas: channel debe ser nightly o stable. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', @@ -843,7 +843,7 @@ export const fr = { updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', updateConfigurationRead: 'Mises à jour suspendues : lecture des paramètres impossible. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', updateConfigurationFormat: 'Mises à jour suspendues : fichier de paramètres invalide. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', - updateConfigurationChannel: 'Mises à jour suspendues : channel doit être nightly ou latest. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', + updateConfigurationChannel: 'Mises à jour suspendues : channel doit être nightly ou stable. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', @@ -1026,7 +1026,7 @@ export const de = { updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', updateConfigurationRead: 'Updates pausiert: Einstellungsdatei nicht lesbar. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', updateConfigurationFormat: 'Updates pausiert: ungültige Einstellungsdatei. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', - updateConfigurationChannel: 'Updates pausiert: channel muss nightly oder latest sein. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', + updateConfigurationChannel: 'Updates pausiert: channel muss nightly oder stable sein. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', @@ -1209,7 +1209,7 @@ export const ru = { updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', updateConfigurationRead: 'Обновления приостановлены: не удалось прочитать настройки. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', updateConfigurationFormat: 'Обновления приостановлены: неверный формат файла настроек. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', - updateConfigurationChannel: 'Обновления приостановлены: channel должен быть nightly или latest. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', + updateConfigurationChannel: 'Обновления приостановлены: channel должен быть nightly или stable. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', @@ -1392,7 +1392,7 @@ export const pt = { updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', updateConfigurationRead: 'Atualizações pausadas: não foi possível ler as configurações. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', updateConfigurationFormat: 'Atualizações pausadas: arquivo de configurações inválido. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', - updateConfigurationChannel: 'Atualizações pausadas: channel deve ser nightly ou latest. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', + updateConfigurationChannel: 'Atualizações pausadas: channel deve ser nightly ou stable. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index b51d58b914..e90125feef 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,7 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ diff --git a/apps/desktop/src/settings-file.ts b/apps/desktop/src/settings-file.ts index 996854a3b0..17eb8e7b9d 100644 --- a/apps/desktop/src/settings-file.ts +++ b/apps/desktop/src/settings-file.ts @@ -6,7 +6,7 @@ import { z } from 'zod' const preferencesSchema = z.object({ allowTestAuthPopupWindow: z.boolean(), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), }) const documentSchema = z.looseObject({ updates: z.looseObject({}).optional() }) diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts index 450dedcd95..85f5e10da1 100644 --- a/apps/desktop/src/update-channel.ts +++ b/apps/desktop/src/update-channel.ts @@ -13,6 +13,6 @@ export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' const channel = manifest.dshDesktopUpdateChannel - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop update: invalid packaged channel') return channel } diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 0beab5ae25..1f388306d5 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -275,7 +275,7 @@ export class DesktopUpdateCoordinator { this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') - if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') + if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease') this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts index 498770159e..169d0cf01b 100644 --- a/apps/desktop/src/updates/electron-updater/channel.ts +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -6,7 +6,7 @@ import type { AppUpdater } from 'electron-updater' * @param updater - Updater whose prior operations have settled. * @param channel - Selected distribution channel. */ -export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'latest'): void { +export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'stable'): void { updater.channel = channel // The library channel setter enables downgrades. updater.allowDowngrade = false diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 9fbe629c89..4e2e7f9c70 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -119,3 +119,14 @@ describe('desktop auto-update environment', () => { expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) }) }) + + +it.each([['darwin', 'arm64', 'mac-arm64', 'stable-mac.yml'], ['darwin', 'x64', 'mac-x64', 'stable-mac.yml'], ['win32', 'x64', 'win-x64', 'stable.yml']] as const) +('routes stable metadata and binaries separately for %s %s', (platform, arch, target, filename) => { + const config = resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DSH_DESKTOP_UPDATE_CHANNEL: 'stable' }, platform, arch) + expect(config.channel).toBe('stable') + expect(config.keyPrefix).toBe(`dsh-desk/feeds/${target}`) + expect(config.binaryKeyPrefix).toBe(`dsh-desk/bin/${target}/stable`) + expect(desktopUpdateMetadataFilename('1.0.0', platform, config.channel)).toBe(filename) + expect(() => resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_UPDATE_CHANNEL: 'latest' }, platform, arch)).toThrow(/channel/u) +}) diff --git a/apps/desktop/tests/desktop-publication-summary.spec.ts b/apps/desktop/tests/desktop-publication-summary.spec.ts index 5267d29034..bfb5beb483 100644 --- a/apps/desktop/tests/desktop-publication-summary.spec.ts +++ b/apps/desktop/tests/desktop-publication-summary.spec.ts @@ -6,16 +6,16 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' const source: DesktopRetainedSource = { repository: 'owner/repo', runId: '20', attempt: '3', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), - sourceRef: 'dsh-v1.0.0', channel: 'latest', runnerName: 'windows-publisher', target: 'win-x64', + sourceRef: 'dsh-v1.0.0', channel: 'stable', runnerName: 'windows-publisher', target: 'win-x64', preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'signed', } const plan: DesktopUploadPlan = { environment: 'production', target: 'win-x64', version: '1.0.0', bucket: 'production-1234567890', publicUrl: 'https://download.example.com/dsh-desk/feeds/win-x64/', secretIdEnvName: 'SECRET_ID', secretKeyEnvName: 'SECRET_KEY', artifacts: [ - { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe', + { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe', contents: 'installer bytes', contentType: 'application/octet-stream', channelMetadata: false }, - { path: '/not-read/latest.yml', filename: 'latest.yml', key: 'dsh-desk/feeds/win-x64/latest.yml', + { path: '/not-read/stable.yml', filename: 'stable.yml', key: 'dsh-desk/feeds/win-x64/stable.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true }, ], } @@ -23,8 +23,8 @@ const plan: DesktopUploadPlan = { it('shows production destinations and tag after approval with upload-byte fingerprints', async () => { const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64)) await expect(summary).toMatchFileSnapshot('./expected/publication-production.md') - expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe') - expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/latest.yml') + expect(summary).toContain('/stable/deepseek-harness-1.0.0-win-x64.exe') + expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/stable.yml') expect(summary).toContain(createHash('sha256').update('version: 1.0.0\n').digest('hex')) expect(summary).not.toContain('测试 release ID |') expect(summary).not.toContain('SECRET_KEY') diff --git a/apps/desktop/tests/desktop-rollback.spec.ts b/apps/desktop/tests/desktop-rollback.spec.ts index 2bf2151626..f7912ea09b 100644 --- a/apps/desktop/tests/desktop-rollback.spec.ts +++ b/apps/desktop/tests/desktop-rollback.spec.ts @@ -55,17 +55,17 @@ it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects % expect(f.writes).toEqual([]) }) -it('restores nightly when latest already matches and resumes a partially restored pair', async () => { - for (const latestAlreadyMatches of [true, false]) { +it('restores nightly when stable already matches and resumes a partially restored pair', async () => { + for (const stableAlreadyMatches of [true, false]) { const f = fixture() const nightly = f.plan.artifacts[1]! - const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] } - f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') - if (!latestAlreadyMatches) { + const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/stable.yml', filename: 'stable.yml' }] } + f.objects.set('feed/stable.yml', stableAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') + if (!stableAlreadyMatches) { const put = f.operations.transport.put.bind(f.operations.transport) const interrupted = { ...f.operations, transport: { ...f.operations.transport, async put(...args: Parameters) { - if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption') + if (args[0].key === 'feed/stable.yml') throw new Error('fixture interruption') return put(...args) }, } } @@ -73,7 +73,7 @@ it('restores nightly when latest already matches and resumes a partially restore } await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents) - expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents) + expect(f.objects.get('feed/stable.yml')).toBe(nightly.contents) expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1) } }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index def8cb19bf..d213484167 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -38,7 +38,7 @@ async function fixture( target: DesktopPackageTargetName, version = '1.2.3', environment: 'test' | 'production' = 'test', - channel: 'nightly' | 'latest' = 'nightly', + channel: 'nightly' | 'stable' = 'nightly', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) temporaryDirectories.push(root) @@ -175,14 +175,14 @@ describe('desktop upload plan', () => { .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) }) - it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => { + it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => { const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) - const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest')) - expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml']) + const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable')) + expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml']) expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) - expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) - const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest') - await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u) + expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable') + await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u) }) it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { diff --git a/apps/desktop/tests/expected/publication-production.md b/apps/desktop/tests/expected/publication-production.md index 9041d1b3d9..a3b93ccb87 100644 --- a/apps/desktop/tests/expected/publication-production.md +++ b/apps/desktop/tests/expected/publication-production.md @@ -7,10 +7,10 @@ | --- | --- | | 发布环境 | production | | 版本号 | 1.0.0 | -| 升级通道 | latest | +| 升级通道 | stable | | 平台 | win-x64 | -| 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | -| 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/latest.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | +| 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | +| 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | | 打包来源 | 查看打包运行 #20 · 第 3 次尝试
源码:dsh-v1.0.0 @ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
打包机器:windows-publisher | | 签名与公证 | EXE 签名校验通过 | | Git tag | desktop-v1.0.0 | @@ -33,8 +33,8 @@ COS 存储桶:production-1234567890 | 文件 | 大小 | SHA-256 | | --- | --- | --- | -| deepseek-harness-1.0.0-win-x64.exe | 15 字节 | e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c | -| latest.yml | 15 字节 | 8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1 | +| deepseek-harness-1.0.0-win-x64.exe | 15 字节 | e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c | +| stable.yml | 15 字节 | 8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1 | diff --git a/apps/desktop/tests/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts index bba859fa7c..23e255e48a 100644 --- a/apps/desktop/tests/macos-app-update-config.spec.ts +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -66,10 +66,10 @@ describe('macOS packaged updater configuration', () => { }) -it('seals latest into the macOS updater file and rejects a different expected channel', async () => { +it('seals stable into the macOS updater file and rejects a different expected channel', async () => { const paths = await fixture() - const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }]) - await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater') - await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined() + const stable = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'stable' }]) + await writeMacOSAppUpdateConfig(paths.resourcesDir, stable, 'fixture-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, stable)).resolves.toBeUndefined() await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 8c8fa73d3b..df594bed39 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -367,16 +367,16 @@ vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class { dispose() {} } })) vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class { - selectedChannel: 'nightly' | 'latest' = 'nightly' + selectedChannel: 'nightly' | 'stable' = 'nightly' revision = 0 configurationFailure: 'startup' | 'cleanup' | undefined constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise, - _updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'latest') { + _updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'stable') { this.selectedChannel = channel harness.prepareUpdate = beforeRestart harness.publishUpdate = publish } - async switchChannel(channel: 'nightly' | 'latest', persist: () => Promise) { + async switchChannel(channel: 'nightly' | 'stable', persist: () => Promise) { await persist() this.selectedChannel = channel this.configurationFailure = undefined @@ -509,22 +509,22 @@ describe('desktop main startup', () => { configureTestAuthPopup(true) await readyForUpdate() expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'nightly', changing: false }) - expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toEqual({ kind: 'applied', state: { channel: 'latest', packagedChannel: 'nightly', changing: false } }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } }) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') - expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'latest' } }) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'stable' } }) writeFileSync(path, JSON.stringify({ updates: { channel: 'nightly' } })) - expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'latest' }) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) expect(harness.windows[0]!.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, - { channel: 'latest', packagedChannel: 'nightly', changing: false }) + { channel: 'stable', packagedChannel: 'nightly', changing: false }) await expect(invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'alpha')).rejects.toThrow('unsupported channel') - expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'latest' }) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) }) it('reports a corrupt document without replacing it or pretending a UI save succeeded', async () => { await readyForUpdate() const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') writeFileSync(path, '{broken') - expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toMatchObject({ kind: 'failed', reason: 'format', state: { channel: 'nightly', changing: false } }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'failed', reason: 'format', state: { channel: 'nightly', changing: false } }) expect(readFileSync(path, 'utf8')).toBe('{broken') expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).not.toHaveProperty('configurationError') }) @@ -533,7 +533,7 @@ describe('desktop main startup', () => { await readyForUpdate() harness.updateState = { phase: 'ready', version: '1.0.1' } writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{broken') - await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest') + await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable') await invoke(DESKTOP_IPC.updatesOpen, 'app') expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0) }) @@ -545,7 +545,7 @@ describe('desktop main startup', () => { await readyForUpdate() expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: null, error: 'channel' }) expect(harness.hosts).toHaveLength(1) - expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toEqual({ kind: 'applied', state: { channel: 'latest', packagedChannel: 'nightly', changing: false } }) + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } }) }) @@ -557,7 +557,7 @@ describe('desktop main startup', () => { this.suspendConfiguration('cache retirement failed', 'cleanup') throw new Error('cache retirement failed') }) - expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toMatchObject({ + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'failed', reason: 'apply', state: { channel: 'nightly', error: 'cleanup' }, }) expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'cleanup' }) @@ -577,7 +577,7 @@ describe('desktop main startup', () => { message: en.updateConfigurationChannel, detail: path, }) writeFileSync(path, '{broken') - expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'latest')).toMatchObject({ + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'failed', reason: 'format', state: { channel: null, error: 'channel' }, }) expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' }) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 0580ec7881..fa3d2386f0 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -299,13 +299,13 @@ it('exposes channel choices and scoped subscriptions without exposing updater ob await import('../src/preload-app.ts') const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi await api.updates.channels.get() - await api.updates.channels.set('latest') - expect(electron.ipcRenderer.invoke.mock.calls).toEqual([[DESKTOP_IPC.updatesChannelGet], [DESKTOP_IPC.updatesChannelSet, 'latest']]) + await api.updates.channels.set('stable') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([[DESKTOP_IPC.updatesChannelGet], [DESKTOP_IPC.updatesChannelSet, 'stable']]) const listener = vi.fn() const dispose = api.updates.channels.subscribe(listener) const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesChannelChanged)?.[1] as (event: unknown, state: unknown) => void - const state = { channel: 'latest', packagedChannel: 'nightly', changing: false } + const state = { channel: 'stable', packagedChannel: 'nightly', changing: false } handler({}, state) expect(listener).toHaveBeenCalledWith(state) dispose() diff --git a/apps/desktop/tests/settings-file.spec.ts b/apps/desktop/tests/settings-file.spec.ts index 7d5009f380..de0de14c71 100644 --- a/apps/desktop/tests/settings-file.spec.ts +++ b/apps/desktop/tests/settings-file.spec.ts @@ -41,7 +41,7 @@ it.each([ const result = await file.initialize() expect(result).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid', reason }, path: file.path } }) expect(JSON.stringify(result)).not.toContain('secret-value') - await expect(file.saveUpdates({ channel: 'latest' })).rejects.toMatchObject({ problem: { kind: 'invalid', reason } }) + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { kind: 'invalid', reason } }) expect(await readFile(file.path, 'utf8')).toBe(raw) }) @@ -52,7 +52,7 @@ it('keeps an invalid channel independent from the popup preference and repairs o allowTestAuthPopupWindow: { kind: 'valid', value: true }, } }) expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(true) - expect(await new DesktopUpdateSettings(root, 'latest').load()).toMatchObject({ kind: 'error' }) + expect(await new DesktopUpdateSettings(root, 'stable').load()).toMatchObject({ kind: 'error' }) await file.saveUpdates({ channel: 'nightly' }) expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ other: [1], updates: { channel: 'nightly', allowTestAuthPopupWindow: true, future: { x: 2 }, @@ -63,12 +63,12 @@ it('keeps a valid channel usable when the popup preference is invalid', async () const { root, file } = await fixture('{"updates":{"channel":"nightly","allowTestAuthPopupWindow":"bad"}}') vi.spyOn(console, 'warn').mockImplementation(() => {}) expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) - expect(await new DesktopUpdateSettings(root, 'latest').load()).toEqual({ kind: 'ready', channel: 'nightly' }) - await file.saveUpdates({ channel: 'latest' }) - expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'latest', allowTestAuthPopupWindow: 'bad' } }) + expect(await new DesktopUpdateSettings(root, 'stable').load()).toEqual({ kind: 'ready', channel: 'nightly' }) + await file.saveUpdates({ channel: 'stable' }) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable', allowTestAuthPopupWindow: 'bad' } }) await file.saveUpdates({ allowTestAuthPopupWindow: true }) expect(await file.read()).toMatchObject({ kind: 'ready', fields: { - allowTestAuthPopupWindow: { kind: 'valid', value: true }, channel: { kind: 'valid', value: 'latest' }, + allowTestAuthPopupWindow: { kind: 'valid', value: true }, channel: { kind: 'valid', value: 'stable' }, } }) }) @@ -83,8 +83,8 @@ it('serializes initializers and patches from separate storage instances without const { root, file } = await fixture() const other = new DesktopSettingsFile(root) await Promise.all([file.initialize(), other.initialize()]) - await Promise.all([file.saveUpdates({ channel: 'latest' }), other.saveUpdates({ allowTestAuthPopupWindow: true })]) - expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'latest', allowTestAuthPopupWindow: true } }) + await Promise.all([file.saveUpdates({ channel: 'stable' }), other.saveUpdates({ allowTestAuthPopupWindow: true })]) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable', allowTestAuthPopupWindow: true } }) await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) }) @@ -103,7 +103,7 @@ it('preserves a file created by an external writer during exclusive initializati it('reports preparation failure as unchanged and does not create a file', async () => { const { file } = await fixture() vi.spyOn(fs, 'mkdir').mockRejectedValue(Object.assign(new Error('denied'), { code: 'EACCES' })) - await expect(file.saveUpdates({ channel: 'latest' })).rejects.toMatchObject({ problem: { + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { kind: 'write', operation: 'save', effect: 'unchanged', } }) await expect(readFile(file.path)).rejects.toMatchObject({ code: 'ENOENT' }) @@ -112,7 +112,7 @@ it('reports preparation failure as unchanged and does not create a file', async it('reports uncertain write completion without reporting a successful save', async () => { const { file } = await fixture('{}') vi.spyOn(atomic, 'writeFileAtomic').mockRejectedValue(new Error('write failed')) - await expect(file.saveUpdates({ channel: 'latest' })).rejects.toMatchObject({ problem: { + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { kind: 'write', operation: 'save', effect: 'unknown', } }) expect(await readFile(file.path, 'utf8')).toBe('{}') @@ -126,13 +126,13 @@ it('reports a committed write when lock cleanup fails, without claiming rollback if (path === `${file.path}.lock`) throw new Error('lock cleanup failed') return original(path, options) }) - await expect(file.saveUpdates({ channel: 'latest' })).rejects.toMatchObject({ problem: { + await expect(file.saveUpdates({ channel: 'stable' })).rejects.toMatchObject({ problem: { kind: 'write', operation: 'save', effect: 'written', } }) - expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'latest' } }) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable' } }) }) -it.each(['nightly', 'latest'])('accepts the same channel at the IPC and file boundaries: %s', async (channel) => { +it.each(['nightly', 'stable'])('accepts the same channel at the IPC and file boundaries: %s', async (channel) => { const { file } = await fixture(JSON.stringify({ updates: { channel } })) expect(isDesktopUpdateChannel(channel)).toBe(true) expect(await file.read()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'valid', value: channel } } }) @@ -160,13 +160,13 @@ it('reports a partial initialization write as uncertain and preserves it for dia }) it('rereads fields after acquiring the writer lock instead of saving a stale snapshot', async () => { - const { file } = await fixture('{"updates":{"channel":"latest"}}') + const { file } = await fixture('{"updates":{"channel":"stable"}}') const started = Promise.withResolvers() const release = Promise.withResolvers() const holder = atomic.withFileLock(file.path, async () => { started.resolve(undefined) await release.promise - await writeFile(file.path, '{"updates":{"channel":"latest","future":3},"other":4}') + await writeFile(file.path, '{"updates":{"channel":"stable","future":3},"other":4}') }) await started.promise const attempting = Promise.withResolvers() diff --git a/apps/desktop/tests/update-channel.spec.ts b/apps/desktop/tests/update-channel.spec.ts index 8ea7bb98a4..191cd8e5d9 100644 --- a/apps/desktop/tests/update-channel.spec.ts +++ b/apps/desktop/tests/update-channel.spec.ts @@ -3,8 +3,9 @@ import { desktopUpdateChannel } from '../src/update-channel.ts' it('reads the installed channel independently from the current version', () => { expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly') - expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest') + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'stable' })).toBe('stable') expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly') + expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'latest' })).toThrow(/channel/u) expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u) expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u) }) diff --git a/apps/desktop/tests/update-configuration.spec.ts b/apps/desktop/tests/update-configuration.spec.ts index 6da4e057a5..571fb7c1bc 100644 --- a/apps/desktop/tests/update-configuration.spec.ts +++ b/apps/desktop/tests/update-configuration.spec.ts @@ -30,7 +30,7 @@ it('only exposes a permission hint for a known filesystem permission failure', ( it('provides distinct localized reasons and recovery without raw errors', () => { expect(configurationMessage('read', en)).toContain('cannot read settings') expect(configurationMessage('format', en)).toContain('invalid settings') - expect(configurationMessage('channel', zh)).toContain('nightly 或 latest') + expect(configurationMessage('channel', zh)).toContain('nightly 或 stable') expect(configurationMessage('cleanup', zh)).toContain('请重启应用') expect(configurationMessage('cleanup', en)).not.toContain('settings file') }) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 3f194e4fd9..275be855c9 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -39,7 +39,7 @@ describe('desktop release metadata', () => { nodeVersion: '24.17.0', pnpmVersion: '11.7.0', } - expect(() => parseDesktopRelease({ ...base, version: 'latest' })).toThrow(/invalid desktop release metadata/u) + expect(() => parseDesktopRelease({ ...base, version: 'stable' })).toThrow(/invalid desktop release metadata/u) expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u) }) }) @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') { +function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alpha.1') { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -244,18 +244,18 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), }) -it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => { +it('keeps a stable-version nightly installation on nightly and rejects prereleases on stable', async () => { const nightly = fixture('nightly', '1.0.0') expect(nightly.updater.channel).toBe('nightly') expect(nightly.updater.allowPrerelease).toBe(true) expect((await nightly.coordinator.check()).phase).toBe('available') expect(nightly.updater.channel).toBe('nightly') - const latest = fixture('latest', '1.0.0') - expect(latest.updater.channel).toBe('latest') - expect(latest.updater.allowPrerelease).toBe(false) - expect(latest.updater.allowDowngrade).toBe(false) - expect((await latest.coordinator.check()).phase).toBe('error') - expect(latest.downloadUpdate).not.toHaveBeenCalled() + const stable = fixture('stable', '1.0.0') + expect(stable.updater.channel).toBe('stable') + expect(stable.updater.allowPrerelease).toBe(false) + expect(stable.updater.allowDowngrade).toBe(false) + expect((await stable.coordinator.check()).phase).toBe('error') + expect(stable.downloadUpdate).not.toHaveBeenCalled() }) @@ -266,9 +266,9 @@ it('retires a downloaded target and rejects its old confirmation after switching await f.coordinator.download('1.1.0-rc.2') const save = vi.fn(async () => {}) f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) - await f.coordinator.switchChannel('latest', save) + await f.coordinator.switchChannel('stable', save) expect(save).toHaveBeenCalledOnce() - expect(f.coordinator.selectedChannel).toBe('latest') + expect(f.coordinator.selectedChannel).toBe('stable') expect(f.updater.allowDowngrade).toBe(false) expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' }) await f.coordinator.install('1.1.0-rc.2', revision) @@ -279,7 +279,7 @@ it('leaves the current target intact if saving the choice fails', async () => { const f = fixture() await f.coordinator.check() await f.coordinator.download('1.1.0-rc.2') - await expect(f.coordinator.switchChannel('latest', () => Promise.reject(new Error('read only')))).rejects.toThrow('read only') + await expect(f.coordinator.switchChannel('stable', () => Promise.reject(new Error('read only')))).rejects.toThrow('read only') expect(f.coordinator.selectedChannel).toBe('nightly') expect(f.coordinator.state.phase).toBe('ready') await f.coordinator.install('1.1.0-rc.2') @@ -294,7 +294,7 @@ it('discards a check result that belongs to the previous channel', async () => { const checking = f.coordinator.check() await started.promise const saved = Promise.withResolvers() - const switching = f.coordinator.switchChannel('latest', async () => { saved.resolve(undefined) }) + const switching = f.coordinator.switchChannel('stable', async () => { saved.resolve(undefined) }) await saved.promise f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '9.0.0-alpha.1' } }) @@ -327,7 +327,7 @@ it('waits for a cancelled download before checking the new channel and ignores l const downloading = f.coordinator.download('1.1.0-rc.2') await entered.promise const persisted = Promise.withResolvers() - const switching = f.coordinator.switchChannel('latest', async () => { persisted.resolve(undefined) }) + const switching = f.coordinator.switchChannel('stable', async () => { persisted.resolve(undefined) }) await persisted.promise await Promise.resolve() const count = f.states.length @@ -351,7 +351,7 @@ it('rejects a channel switch during installation preparation without persisting f.beforeRestart.mockReturnValueOnce(preparation.promise) const installing = f.coordinator.install('1.1.0-rc.2') const save = vi.fn(async () => {}) - await expect(f.coordinator.switchChannel('latest', save)).rejects.toThrow('installation is in progress') + await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress') expect(save).not.toHaveBeenCalled() preparation.resolve(false) await installing @@ -367,7 +367,7 @@ it('does not accept a channel switch after installer handoff even if a later err f.events.emit('error', new Error('native updater failed after handoff')) expect(f.coordinator.state.phase).toBe('error') const save = vi.fn(async () => {}) - await expect(f.coordinator.switchChannel('latest', save)).rejects.toThrow('installation is in progress') + await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress') expect(save).not.toHaveBeenCalled() }) @@ -377,7 +377,7 @@ it('suspends updates if retiring a downloaded target fails and recovers on a lat await f.coordinator.download('1.1.0-rc.2') const clear = vi.fn().mockRejectedValueOnce(new Error('locked cache')).mockResolvedValueOnce(undefined) Object.assign(f.updater, { downloadedUpdateHelper: { clear } }) - await expect(f.coordinator.switchChannel('latest', async () => {})).rejects.toThrow('locked cache') + await expect(f.coordinator.switchChannel('stable', async () => {})).rejects.toThrow('locked cache') expect(f.coordinator.configurationFailure).toBe('cleanup') expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) const checks = f.checkForUpdates.mock.calls.length @@ -385,7 +385,7 @@ it('suspends updates if retiring a downloaded target fails and recovers on a lat await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('channel selection is not ready') expect(f.checkForUpdates).toHaveBeenCalledTimes(checks) expect(f.quitAndInstall).not.toHaveBeenCalled() - await f.coordinator.switchChannel('latest', async () => {}) + await f.coordinator.switchChannel('stable', async () => {}) expect(f.coordinator.configurationFailure).toBeUndefined() }) diff --git a/apps/desktop/tests/update-settings.spec.ts b/apps/desktop/tests/update-settings.spec.ts index 54433f5348..010554db4d 100644 --- a/apps/desktop/tests/update-settings.spec.ts +++ b/apps/desktop/tests/update-settings.spec.ts @@ -10,12 +10,12 @@ async function fixture() { const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) onTestFinished(() => rm(root, { recursive: true, force: true })) await mkdir(join(root, 'desktop')) - return new DesktopUpdateSettings(root, 'latest') + return new DesktopUpdateSettings(root, 'stable') } it('uses the packaged channel for missing settings without recreating the document', async () => { const settings = await fixture() - expect(await settings.load()).toEqual({ kind: 'ready', channel: 'latest' }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) await expect(readFile(settings.path)).rejects.toMatchObject({ code: 'ENOENT' }) }) @@ -24,11 +24,11 @@ it('preserves other fields during explicit saves and only rereads external edits await writeFile(settings.path, JSON.stringify({ updates: { allowTestAuthPopupWindow: true }, other: 4 })) const startup = await settings.load() await settings.save('nightly') - expect(startup).toEqual({ kind: 'ready', channel: 'latest' }) + expect(startup).toEqual({ kind: 'ready', channel: 'stable' }) expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'nightly' }, other: 4 }) expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) await writeFile(settings.path, '{}') - expect(await settings.load()).toEqual({ kind: 'ready', channel: 'latest' }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) }) it.each(['{', 'null', '{"updates":[]}', '{"updates":{"channel":"alpha"}}', '{"updates":{"channel":null}}'])('suspends updates for invalid channel settings: %s', async (raw) => { @@ -55,7 +55,7 @@ it('updates the document initialized by shell settings without losing the popup const root = await mkdtemp(join(tmpdir(), 'desktop-shared-settings-')) onTestFinished(() => rm(root, { recursive: true, force: true })) expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) - const settings = new DesktopUpdateSettings(root, 'latest') + const settings = new DesktopUpdateSettings(root, 'stable') await settings.save('nightly') expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: false, channel: 'nightly' }, diff --git a/apps/desktop/tests/updates/electron-updater/qualification.mjs b/apps/desktop/tests/updates/electron-updater/qualification.mjs index 0714402476..6a73cdda23 100644 --- a/apps/desktop/tests/updates/electron-updater/qualification.mjs +++ b/apps/desktop/tests/updates/electron-updater/qualification.mjs @@ -56,9 +56,9 @@ async function main() { selectUpdateChannel(target, 'nightly') assert.ok((await target.checkForUpdates()).updateInfo) assert.equal(requests.at(-1), `/nightly${process.platform === 'darwin' ? '-mac' : ''}.yml`) - selectUpdateChannel(target, 'latest') + selectUpdateChannel(target, 'stable') await target.checkForUpdates() - assert.equal(requests.at(-1), `/latest${process.platform === 'darwin' ? '-mac' : ''}.yml`) + assert.equal(requests.at(-1), `/stable${process.platform === 'darwin' ? '-mac' : ''}.yml`) assert.equal(target.allowDowngrade, false) assert.equal(target.allowPrerelease, false) version = '0.9.0' @@ -85,7 +85,7 @@ async function main() { const proxy = target.server if (process.platform === 'darwin') assert.equal(proxy.listening, true) await discardDesktopUpdate(target) - selectUpdateChannel(target, 'latest') + selectUpdateChannel(target, 'stable') if (proxy) assert.equal(proxy.listening, false) assert.equal(helper.file, null) assert.equal(helper.packageFile, null) diff --git a/apps/web/tests/desktop-channel.e2e.ts b/apps/web/tests/desktop-channel.e2e.ts index 9ad351077b..cc0bf9c830 100644 --- a/apps/web/tests/desktop-channel.e2e.ts +++ b/apps/web/tests/desktop-channel.e2e.ts @@ -20,7 +20,7 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'en-US' }) const tripwire = watchConsole(page) await page.exposeFunction('readChannel', () => state) - await page.exposeFunction('setChannel', async (channel: 'nightly' | 'latest'): Promise => { + await page.exposeFunction('setChannel', async (channel: 'nightly' | 'stable'): Promise => { try { await settings.save(channel) state = { ...state, channel, error: undefined } @@ -30,11 +30,11 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on await page.addInitScript(() => { const bridge = globalThis as typeof globalThis & { readChannel(): Promise - setChannel(channel: 'nightly' | 'latest'): Promise + setChannel(channel: 'nightly' | 'stable'): Promise } Object.assign(globalThis, { dshDesktop: { protocolVersion: 1, updates: { status: async () => ({ phase: 'idle' }), open: async () => {}, subscribe: () => () => {}, - channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'latest') => bridge.setChannel(channel), + channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'stable') => bridge.setChannel(channel), subscribe: () => () => {} }, } } }) }) @@ -46,7 +46,7 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on await page.getByRole('menuitem', { name: 'Stable', exact: true }).click() await row.getByText('Stable', { exact: true }).waitFor() await page.getByText('Update channel changed', { exact: true }).waitFor() - expect(JSON.parse(await readFile(settings.path, 'utf8'))).toMatchObject({ updates: { channel: 'latest' } }) + expect(JSON.parse(await readFile(settings.path, 'utf8'))).toMatchObject({ updates: { channel: 'stable' } }) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/stable.expected.md', import.meta.url)), await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd), webSnapshotMode()) await writeFile(settings.path, JSON.stringify({ updates: { channel: 'nightly' } })) diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml new file mode 100644 index 0000000000..ce81afec05 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md +/: + en: 754ae2b680f57c78 + zh: b3586f3db403f9e3 +/desktop-stable-channel-name: + en: 085fffc14a9bb6be + zh: 51c269f7b97b6e31 +/desktop-stable-channel-name/change: + en: bca4377ec9032e3b + zh: 6da9f3be04f1cd39 +/desktop-stable-channel-name/migration: + en: 8442b24f420f6be2 + zh: b2517178eccbf8d5 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md new file mode 100644 index 0000000000..9261aa2960 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The Desktop stable update channel is named stable instead of latest." +--- + +# Desktop stable channel name + +English | [中文](guide.zh.md) + +## Change + +Desktop packages, update feeds, and channel configuration use `stable` instead of `latest`. The `nightly` channel is unchanged. The old channel value is rejected; there is no alias. Fixed website downloads named `dsh-latest-*` are unchanged. + +## Migration + +1. Select `stable` in packaging workflows or set `DSH_DESKTOP_UPDATE_CHANNEL=stable` when building a stable-channel package. Build a stable version, without a prerelease suffix. +2. If a local Desktop settings file contains `updates.channel: "latest"`, change the value to `"stable"` and restart Desktop. Omitting the field uses the package default. +3. Publish with the updated tooling: it writes `stable.yml` on Windows and `stable-mac.yml` on macOS within each architecture's feed directory, with stable-channel binaries under `stable/`. Verify the selected channel and requested feed in the test deployment before production publication. Old packages still requesting `latest` feeds do not migrate automatically. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md new file mode 100644 index 0000000000..441693811b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "桌面正式更新渠道由 latest 更名为 stable。" +--- + +# 桌面正式渠道名称 + +[English](guide.md) | 中文 + +## 变更 + +桌面安装包、更新清单及渠道配置使用 `stable`,不再使用 `latest`。`nightly` 渠道不变。旧渠道值会被拒绝,不提供别名。网站名为 `dsh-latest-*` 的固定下载文件保持不变。 + +## 迁移 + +1. 在打包工作流中选择 `stable`,或在构建正式渠道包时设置 `DSH_DESKTOP_UPDATE_CHANNEL=stable`。使用不含预发布后缀的稳定版本号。 +2. 如果本地桌面配置文件包含 `updates.channel: "latest"`,将该值改为 `"stable"` 并重启桌面应用。省略该字段时使用安装包默认值。 +3. 使用更新后的工具发布:在各架构的清单目录内,Windows 写入 `stable.yml`,macOS 写入 `stable-mac.yml`;正式渠道二进制位于 `stable/` 下。在正式发布前,通过测试部署核对选定渠道和请求的清单。仍请求 `latest` 清单的旧包不会自动迁移。 diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx index 23fc133edd..8b5c07e549 100644 --- a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -31,7 +31,7 @@ export function DesktopChannelRow({ useChannel, select, t }: { setOpen(false) }} portal align="end" selectedId={channel ?? ''} - items={[{ id: 'latest', label: t('desktop.channel.latest') }, { id: 'nightly', label: t('desktop.channel.nightly') }]} + items={[{ id: 'stable', label: t('desktop.channel.stable') }, { id: 'nightly', label: t('desktop.channel.nightly') }]} onSelect={(id) => { setOpen(false) void select(id as DesktopUpdateChannel) @@ -40,7 +40,7 @@ export function DesktopChannelRow({ useChannel, select, t }: aria-busy={busy} aria-label={t('desktop.channel.title')} aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(!open) }}> {channel === undefined || channel === null - ? t('desktop.channel.unavailable') : t(channel === 'latest' ? 'desktop.channel.latest' : 'desktop.channel.nightly')} + ? t('desktop.channel.unavailable') : t(channel === 'stable' ? 'desktop.channel.stable' : 'desktop.channel.nightly')} {busy ? : } } /> diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index e3dd01d407..1dbb241570 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -112,7 +112,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => () => { channel.dispose() }, 'ui-settings-general: desktop channel') ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'desktop-update-channel', order: 95, locale: NS, - inject: () => ({ hooks: { channel: channel.store }, select: (value: 'nightly' | 'latest') => channel.select(value) }), + inject: () => ({ hooks: { channel: channel.store }, select: (value: 'nightly' | 'stable') => channel.select(value) }), }, DesktopChannelRow)) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'desktop-channel-toast', locale: NS, diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d2bc78f2bd..7b1d8231ee 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -4,7 +4,7 @@ export const zh = { 'desktop.channel.title': '更新渠道', 'desktop.channel.description': '正式版适合日常使用;开发版可提前体验新功能,但可能不稳定。', - 'desktop.channel.latest': '正式版', + 'desktop.channel.stable': '正式版', 'desktop.channel.nightly': '开发版', 'desktop.channel.unavailable': '配置不可用', 'desktop.channel.error': '未能应用更新配置。请点击桌面更新入口查看原因。', @@ -16,7 +16,7 @@ export const zh = { 'desktop.channel.result.apply': '保存失败', 'desktop.update.configuration.read': '更新已暂停:无法读取配置文件', 'desktop.update.configuration.format': '更新已暂停:配置文件格式有误', - 'desktop.update.configuration.channel': '更新已暂停:channel 只能是 nightly 或 latest', + 'desktop.update.configuration.channel': '更新已暂停:channel 只能是 nightly 或 stable', 'desktop.update.configuration.cleanup': '更新已暂停:无法清理旧更新,请重启应用', 'trigger': '设置', 'shortcut.open': '打开设置', @@ -69,7 +69,7 @@ export type SettingsKey = keyof typeof zh export const en = { 'desktop.channel.title': 'Update channel', 'desktop.channel.description': 'Stable is suitable for everyday use. Development offers early access to new features but may be unstable.', - 'desktop.channel.latest': 'Stable', + 'desktop.channel.stable': 'Stable', 'desktop.channel.nightly': 'Development', 'desktop.channel.unavailable': 'Configuration unavailable', 'desktop.channel.error': 'Could not apply update settings. Open the desktop update indicator for details.', @@ -81,7 +81,7 @@ export const en = { 'desktop.channel.result.apply': 'Could not save', 'desktop.update.configuration.read': 'Updates paused: cannot read settings file', 'desktop.update.configuration.format': 'Updates paused: invalid settings file', - 'desktop.update.configuration.channel': 'Updates paused: channel must be nightly or latest', + 'desktop.update.configuration.channel': 'Updates paused: channel must be nightly or stable', 'desktop.update.configuration.cleanup': 'Updates paused: cannot discard previous update; restart the app', 'trigger': 'Settings', 'shortcut.open': 'Open settings', @@ -131,7 +131,7 @@ export const en = { export const ja = { 'desktop.channel.title': '更新チャンネル', 'desktop.channel.description': '安定版は日常的な使用に適しています。開発版では新機能をいち早く試せますが、不安定な場合があります。', - 'desktop.channel.latest': '安定版', + 'desktop.channel.stable': '安定版', 'desktop.channel.nightly': '開発版', 'desktop.channel.unavailable': '設定を利用できません', 'desktop.channel.error': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', @@ -143,7 +143,7 @@ export const ja = { 'desktop.channel.result.apply': '保存できませんでした', 'desktop.update.configuration.read': '更新を停止中:設定ファイルを読み取れません', 'desktop.update.configuration.format': '更新を停止中:設定ファイルの形式が不正です', - 'desktop.update.configuration.channel': '更新を停止中:channel は nightly または latest にしてください', + 'desktop.update.configuration.channel': '更新を停止中:channel は nightly または stable にしてください', 'desktop.update.configuration.cleanup': '更新を停止中:以前の更新を削除できません。アプリを再起動してください', 'trigger': '設定', 'shortcut.open': '設定を開く', @@ -193,7 +193,7 @@ export const ja = { export const es = { 'desktop.channel.title': 'Canal de actualizaciones', 'desktop.channel.description': 'Estable es adecuado para el uso diario. Desarrollo permite probar nuevas funciones antes, pero puede ser inestable.', - 'desktop.channel.latest': 'Estable', + 'desktop.channel.stable': 'Estable', 'desktop.channel.nightly': 'Desarrollo', 'desktop.channel.unavailable': 'Configuración no disponible', 'desktop.channel.error': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', @@ -205,7 +205,7 @@ export const es = { 'desktop.channel.result.apply': 'No se pudo guardar', 'desktop.update.configuration.read': 'Actualizaciones pausadas: no se puede leer la configuración', 'desktop.update.configuration.format': 'Actualizaciones pausadas: archivo de configuración no válido', - 'desktop.update.configuration.channel': 'Actualizaciones pausadas: channel debe ser nightly o latest', + 'desktop.update.configuration.channel': 'Actualizaciones pausadas: channel debe ser nightly o stable', 'desktop.update.configuration.cleanup': 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', 'trigger': 'Configuración', 'shortcut.open': 'Abrir configuración', @@ -255,7 +255,7 @@ export const es = { export const fr = { 'desktop.channel.title': 'Canal de mise à jour', 'desktop.channel.description': 'Stable convient à un usage quotidien. Développement permet de découvrir les nouveautés en avance, mais peut être instable.', - 'desktop.channel.latest': 'Stable', + 'desktop.channel.stable': 'Stable', 'desktop.channel.nightly': 'Développement', 'desktop.channel.unavailable': 'Configuration indisponible', 'desktop.channel.error': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', @@ -267,7 +267,7 @@ export const fr = { 'desktop.channel.result.apply': 'Échec de l’enregistrement', 'desktop.update.configuration.read': 'Mises à jour suspendues : lecture des paramètres impossible', 'desktop.update.configuration.format': 'Mises à jour suspendues : fichier de paramètres invalide', - 'desktop.update.configuration.channel': 'Mises à jour suspendues : channel doit être nightly ou latest', + 'desktop.update.configuration.channel': 'Mises à jour suspendues : channel doit être nightly ou stable', 'desktop.update.configuration.cleanup': 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', 'trigger': 'Paramètres', 'shortcut.open': 'Ouvrir les paramètres', @@ -317,7 +317,7 @@ export const fr = { export const de = { 'desktop.channel.title': 'Update-Kanal', 'desktop.channel.description': 'Stabil eignet sich für den Alltag. Entwicklung bietet frühzeitigen Zugang zu neuen Funktionen, kann aber instabil sein.', - 'desktop.channel.latest': 'Stabil', + 'desktop.channel.stable': 'Stabil', 'desktop.channel.nightly': 'Entwicklung', 'desktop.channel.unavailable': 'Konfiguration nicht verfügbar', 'desktop.channel.error': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', @@ -329,7 +329,7 @@ export const de = { 'desktop.channel.result.apply': 'Speichern fehlgeschlagen', 'desktop.update.configuration.read': 'Updates pausiert: Einstellungsdatei nicht lesbar', 'desktop.update.configuration.format': 'Updates pausiert: ungültige Einstellungsdatei', - 'desktop.update.configuration.channel': 'Updates pausiert: channel muss nightly oder latest sein', + 'desktop.update.configuration.channel': 'Updates pausiert: channel muss nightly oder stable sein', 'desktop.update.configuration.cleanup': 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', 'trigger': 'Einstellungen', 'shortcut.open': 'Einstellungen öffnen', @@ -379,7 +379,7 @@ export const de = { export const ru = { 'desktop.channel.title': 'Канал обновлений', 'desktop.channel.description': 'Стабильный канал подходит для повседневного использования. Канал разработки позволяет раньше попробовать новые функции, но может быть нестабильным.', - 'desktop.channel.latest': 'Стабильный', + 'desktop.channel.stable': 'Стабильный', 'desktop.channel.nightly': 'Разработка', 'desktop.channel.unavailable': 'Настройки недоступны', 'desktop.channel.error': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', @@ -391,7 +391,7 @@ export const ru = { 'desktop.channel.result.apply': 'Не удалось сохранить', 'desktop.update.configuration.read': 'Обновления приостановлены: не удалось прочитать настройки', 'desktop.update.configuration.format': 'Обновления приостановлены: неверный формат файла настроек', - 'desktop.update.configuration.channel': 'Обновления приостановлены: channel должен быть nightly или latest', + 'desktop.update.configuration.channel': 'Обновления приостановлены: channel должен быть nightly или stable', 'desktop.update.configuration.cleanup': 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', 'trigger': 'Настройки', 'shortcut.open': 'Открыть настройки', @@ -441,7 +441,7 @@ export const ru = { export const pt = { 'desktop.channel.title': 'Canal de atualização', 'desktop.channel.description': 'Estável é adequado para o uso diário. Desenvolvimento oferece acesso antecipado a novos recursos, mas pode ser instável.', - 'desktop.channel.latest': 'Estável', + 'desktop.channel.stable': 'Estável', 'desktop.channel.nightly': 'Desenvolvimento', 'desktop.channel.unavailable': 'Configuração indisponível', 'desktop.channel.error': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', @@ -453,7 +453,7 @@ export const pt = { 'desktop.channel.result.apply': 'Não foi possível salvar', 'desktop.update.configuration.read': 'Atualizações pausadas: não foi possível ler as configurações', 'desktop.update.configuration.format': 'Atualizações pausadas: arquivo de configurações inválido', - 'desktop.update.configuration.channel': 'Atualizações pausadas: channel deve ser nightly ou latest', + 'desktop.update.configuration.channel': 'Atualizações pausadas: channel deve ser nightly ou stable', 'desktop.update.configuration.cleanup': 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', 'trigger': 'Configurações', 'shortcut.open': 'Abrir configurações', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index f533e4478e..eb69b58020 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -39,7 +39,7 @@ export interface DesktopUpdateView { } /** Shell-owned channel choices exposed through the isolated preload. */ -export type DesktopUpdateChannel = 'nightly' | 'latest' +export type DesktopUpdateChannel = 'nightly' | 'stable' /** Current runtime selection and application status; failure never implies a successful switch. */ export interface DesktopChannelState { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 4f35ce2c56..f262157136 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -223,8 +223,8 @@ describe('ui-settings-general apply', () => { const row = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'desktop-update-channel')! expect(c.ctx.slots.entries('shell.overlay').some(entry => entry.options.id === 'desktop-channel-toast')).toBe(true) const select = row.inject!().select as DesktopChannelRowInjected['select'] - await select('latest') - expect(save).toHaveBeenCalledWith('latest') + await select('stable') + expect(save).toHaveBeenCalledWith('stable') const toast = c.ctx.slots.entries('shell.overlay').find(entry => entry.options.id === 'desktop-channel-toast')! const injected = toast.inject!() const hooks = injected.hooks as DesktopChannelToastInjected['hooks'] diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 0d85c5e8fa..e0d85bbf84 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -324,7 +324,7 @@ describe('Host release change confirmation', () => { it('shows channel load errors and waits for an explicit selection to settle', async () => { - let state: { state?: DesktopChannelState; failed: boolean; pending?: 'latest' } = { failed: false } + let state: { state?: DesktopChannelState; failed: boolean; pending?: 'stable' } = { failed: false } const pending = Promise.withResolvers() const select = vi.fn(() => pending.promise) const props = { ...kit, t, select, useChannel: bindSnapshotSelector(createSnapshotStore(state)) } @@ -339,13 +339,13 @@ it('shows channel load errors and waits for an explicit selection to settle', as expect(control.textContent).toContain('Configuration unavailable') fireEvent.click(control) fireEvent.click(screen.getByRole('menuitem', { name: 'Stable' })) - expect(select).toHaveBeenCalledWith('latest') - state = { ...state, pending: 'latest' } + expect(select).toHaveBeenCalledWith('stable') + state = { ...state, pending: 'stable' } view.rerender() expect(control.textContent).toBe('Stable') expect(control.getAttribute('aria-busy')).toBe('true') pending.resolve(undefined) - state = { failed: false, state: { channel: 'latest', packagedChannel: 'nightly', changing: false } } + state = { failed: false, state: { channel: 'stable', packagedChannel: 'nightly', changing: false } } view.rerender() expect(control.textContent).toBe('Stable') fireEvent.click(control) @@ -367,7 +367,7 @@ it('keeps the failure toast after Settings unmounts and rolls back the remounted const reply = Promise.withResolvers() const source = new DesktopChannelSource({ get: async () => state, set: () => reply.promise, subscribe: () => () => {} }) const useChannel = bindSnapshotSelector(source.store) - const select = (channel: 'nightly' | 'latest') => source.select(channel) + const select = (channel: 'nightly' | 'stable') => source.select(channel) const row = render() const toast = render( { source.dismissNotice(sequence) }} />) diff --git a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts index ba40f44887..7a51b3a215 100644 --- a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts +++ b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts @@ -3,7 +3,7 @@ import { DesktopChannelSource } from '../src/client/desktop-channel-source.ts' import type { DesktopChannelState, DesktopChannelChangeResult } from '../src/types.ts' const nightly: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } -const latest: DesktopChannelState = { ...nightly, channel: 'latest' } +const stable: DesktopChannelState = { ...nightly, channel: 'stable' } function fixture() { const get = Promise.withResolvers() @@ -19,16 +19,16 @@ function fixture() { it('keeps pushed multi-window state ahead of stale replies while settling its own operation', async () => { const f = fixture() f.emit(nightly) - f.get.resolve(latest) + f.get.resolve(stable) await f.get.promise expect(f.source.store.getSnapshot().state).toEqual(nightly) - const pending = f.source.select('latest') - expect(f.source.store.getSnapshot().pending).toBe('latest') + const pending = f.source.select('stable') + expect(f.source.store.getSnapshot().pending).toBe('stable') await f.source.select('nightly') expect(f.select).toHaveBeenCalledOnce() - f.emit(latest) + f.emit(stable) f.emit(nightly) - f.set.resolve({ kind: 'applied', state: latest }) + f.set.resolve({ kind: 'applied', state: stable }) await pending expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false, notice: { sequence: 1, outcome: 'applied' } }) f.source.dispose() @@ -39,13 +39,13 @@ it('rolls back a rejected save and retains a toast across shell broadcasts', asy const f = fixture() f.get.resolve(nightly) await f.get.promise - const pending = f.source.select('latest') + const pending = f.source.select('stable') f.emit({ ...nightly, changing: true }) f.emit(nightly) f.set.resolve({ kind: 'failed', state: nightly, reason: 'format' }) await pending expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false, notice: { sequence: 1, outcome: 'format' } }) - f.emit(latest) + f.emit(stable) expect(f.source.store.getSnapshot().notice?.outcome).toBe('format') await f.source.select('nightly') f.source.dismissNotice(1) @@ -59,22 +59,22 @@ it('reports generic save failure after transport rejection without flagging glob const f = fixture() f.get.resolve(nightly) await f.get.promise - const pending = f.source.select('latest') - f.emit(latest) + const pending = f.source.select('stable') + f.emit(stable) f.set.reject(new Error('IPC closed')) await pending - expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false, notice: { sequence: 1, outcome: 'save' } }) + expect(f.source.store.getSnapshot()).toEqual({ state: stable, failed: false, notice: { sequence: 1, outcome: 'save' } }) f.source.dispose() }) it('ignores events, successful replies and new requests after disposal', async () => { const f = fixture() - const pending = f.source.select('latest') + const pending = f.source.select('stable') f.source.dispose() const before = f.source.store.getSnapshot() - f.emit(latest) + f.emit(stable) f.get.resolve(nightly) - f.set.resolve({ kind: 'applied', state: latest }) + f.set.resolve({ kind: 'applied', state: stable }) await pending await f.source.select('nightly') expect(f.select).toHaveBeenCalledOnce() @@ -86,10 +86,10 @@ it('reports initial IPC failure and accepts a later explicit choice response', a f.get.reject(new Error('unavailable')) await Promise.resolve() expect(f.source.store.getSnapshot()).toEqual({ failed: true }) - const pending = f.source.select('latest') - f.set.resolve({ kind: 'applied', state: latest }) + const pending = f.source.select('stable') + f.set.resolve({ kind: 'applied', state: stable }) await pending - expect(f.source.store.getSnapshot()).toEqual({ state: latest, failed: false, notice: { sequence: 1, outcome: 'applied' } }) + expect(f.source.store.getSnapshot()).toEqual({ state: stable, failed: false, notice: { sequence: 1, outcome: 'applied' } }) f.source.dispose() }) @@ -101,7 +101,7 @@ it('ignores rejected reads after an event and all rejected responses after dispo expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false }) f.source.dispose() const closed = fixture() - const writing = closed.source.select('latest') + const writing = closed.source.select('stable') closed.source.dispose() const before = closed.source.store.getSnapshot() closed.get.reject(new Error('late read')) @@ -112,12 +112,12 @@ it('ignores rejected reads after an event and all rejected responses after dispo it('does not let a late initial read overwrite an accepted selection', async () => { const f = fixture() - const pending = f.source.select('latest') - f.set.resolve({ kind: 'applied', state: latest }) + const pending = f.source.select('stable') + f.set.resolve({ kind: 'applied', state: stable }) await pending f.get.resolve(nightly) await f.get.promise - expect(f.source.store.getSnapshot().state).toEqual(latest) + expect(f.source.store.getSnapshot().state).toEqual(stable) f.source.dispose() }) From f578528f1f832ecac74a5593a15b724c7e023c70 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:28:37 +0800 Subject: [PATCH 14/25] fix(desktop): guide recovery from invalid settings files --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/locale.ts | 40 +++++++++++-------- apps/desktop/src/main.ts | 7 +++- apps/desktop/tests/main-startup.spec.ts | 19 ++++++++- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/DesktopChannelRow.tsx | 5 ++- .../src/client/desktop-channel-source.ts | 14 +++++-- .../ui-settings-general/src/client/index.ts | 3 +- .../ui-settings-general/src/client/locales.ts | 16 ++++++++ .../tests/components.client.spec.tsx | 25 +++++++++++- .../tests/desktop-channel.client.spec.ts | 13 ++++++ 15 files changed, 125 insertions(+), 33 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 68bc787000..933f52f7d1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: de5a03c9b2cf7796 - zh: 37bbc84a931feb3a + en: 34608297cbb2b6fd + zh: 152dc590a69c2feb /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 60c5ad612c..697a2b0649 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -306,7 +306,7 @@ Startup loading never overwrites existing documents; an omitted `updates` object In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. -During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and, for settings errors, the file path. Fix the file and restart, or select a valid channel in Settings; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. +During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file and restart, or select a valid channel in Settings; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f1fc864e08..433251ee70 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -306,7 +306,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 -UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或旧更新清理失败会暂停普通更新,主应用仍可使用。更新入口独立于下载进度保留暂停原因,点击可查看恢复方法;配置错误另显示文件路径。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或旧更新清理失败会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index eeb97f40eb..f7c5361a2d 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -107,10 +107,11 @@ export const en = { toggleDevToolsMenu: 'Developer tools', restartAppHostMenu: 'Restart App and Host', updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', - updateConfigurationRead: 'Updates paused: cannot read settings file. Fix the settings file and restart, or select the channel again in Settings.', - updateConfigurationFormat: 'Updates paused: invalid settings file. Fix the settings file and restart, or select the channel again in Settings.', + updateConfigurationRead: 'Updates paused: cannot read settings file. Fix the settings file and restart.', + updateConfigurationFormat: 'Updates paused: invalid settings file. Fix the settings file and restart.', updateConfigurationChannel: 'Updates paused: channel must be nightly or stable. Fix the settings file and restart, or select the channel again in Settings.', updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', + updateShowSettingsFile: 'Show settings file', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', @@ -292,10 +293,11 @@ export const zh = { toggleDevToolsMenu: '开发者工具', restartAppHostMenu: '重启应用与 Host', updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', - updateConfigurationRead: '更新已暂停:无法读取配置文件。请修正配置文件后重启,或在设置中重新选择渠道', - updateConfigurationFormat: '更新已暂停:配置文件格式有误。请修正配置文件后重启,或在设置中重新选择渠道', + updateConfigurationRead: '更新已暂停:无法读取配置文件。请修正配置文件后重启', + updateConfigurationFormat: '更新已暂停:配置文件格式有误。请修正配置文件后重启', updateConfigurationChannel: '更新已暂停:channel 只能是 nightly 或 stable。请修正配置文件后重启,或在设置中重新选择渠道', updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', + updateShowSettingsFile: '显示配置文件', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', @@ -475,10 +477,11 @@ export const ja = { toggleDevToolsMenu: '開発者ツール', restartAppHostMenu: 'アプリとホストを再起動', updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', - updateConfigurationRead: '更新を停止中:設定ファイルを読み取れません. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', - updateConfigurationFormat: '更新を停止中:設定ファイルの形式が不正です. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', + updateConfigurationRead: '更新を停止中:設定ファイルを読み取れません. 設定ファイルを修正して再起動してください。', + updateConfigurationFormat: '更新を停止中:設定ファイルの形式が不正です. 設定ファイルを修正して再起動してください。', updateConfigurationChannel: '更新を停止中:channel は nightly または stable にしてください. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', + updateShowSettingsFile: '設定ファイルを表示', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。', @@ -658,10 +661,11 @@ export const es = { toggleDevToolsMenu: 'Herramientas de desarrollo', restartAppHostMenu: 'Reiniciar la aplicación y el host', updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', - updateConfigurationRead: 'Actualizaciones pausadas: no se puede leer la configuración. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', - updateConfigurationFormat: 'Actualizaciones pausadas: archivo de configuración no válido. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', + updateConfigurationRead: 'Actualizaciones pausadas: no se puede leer la configuración. Corrige el archivo y reinicia.', + updateConfigurationFormat: 'Actualizaciones pausadas: archivo de configuración no válido. Corrige el archivo y reinicia.', updateConfigurationChannel: 'Actualizaciones pausadas: channel debe ser nightly o stable. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', + updateShowSettingsFile: 'Mostrar archivo de ajustes', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', updateDownloadFailed: 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.', @@ -841,10 +845,11 @@ export const fr = { toggleDevToolsMenu: 'Outils de développement', restartAppHostMenu: "Redémarrez l'application et l'hôte", updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', - updateConfigurationRead: 'Mises à jour suspendues : lecture des paramètres impossible. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', - updateConfigurationFormat: 'Mises à jour suspendues : fichier de paramètres invalide. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', + updateConfigurationRead: 'Mises à jour suspendues : lecture des paramètres impossible. Corrigez le fichier et redémarrez.', + updateConfigurationFormat: 'Mises à jour suspendues : fichier de paramètres invalide. Corrigez le fichier et redémarrez.', updateConfigurationChannel: 'Mises à jour suspendues : channel doit être nightly ou stable. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', + updateShowSettingsFile: 'Afficher le fichier de paramètres', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', updateDownloadFailed: 'Impossible de télécharger la mise à jour. Veuillez réessayer.', @@ -1024,10 +1029,11 @@ export const de = { toggleDevToolsMenu: 'Entwicklertools', restartAppHostMenu: 'Anwendung und Host neu starten', updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', - updateConfigurationRead: 'Updates pausiert: Einstellungsdatei nicht lesbar. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', - updateConfigurationFormat: 'Updates pausiert: ungültige Einstellungsdatei. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', + updateConfigurationRead: 'Updates pausiert: Einstellungsdatei nicht lesbar. Korrigiere die Datei und starte neu.', + updateConfigurationFormat: 'Updates pausiert: ungültige Einstellungsdatei. Korrigiere die Datei und starte neu.', updateConfigurationChannel: 'Updates pausiert: channel muss nightly oder stable sein. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', + updateShowSettingsFile: 'Einstellungsdatei anzeigen', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', @@ -1207,10 +1213,11 @@ export const ru = { toggleDevToolsMenu: 'Инструменты разработчика', restartAppHostMenu: 'Перезапустите приложение и хост.', updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', - updateConfigurationRead: 'Обновления приостановлены: не удалось прочитать настройки. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', - updateConfigurationFormat: 'Обновления приостановлены: неверный формат файла настроек. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', + updateConfigurationRead: 'Обновления приостановлены: не удалось прочитать настройки. Исправьте файл и перезапустите приложение', + updateConfigurationFormat: 'Обновления приостановлены: неверный формат файла настроек. Исправьте файл и перезапустите приложение', updateConfigurationChannel: 'Обновления приостановлены: channel должен быть nightly или stable. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', + updateShowSettingsFile: 'Показать файл настроек', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.', @@ -1390,10 +1397,11 @@ export const pt = { toggleDevToolsMenu: 'Ferramentas do desenvolvedor', restartAppHostMenu: 'Reinicie o aplicativo e o host', updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', - updateConfigurationRead: 'Atualizações pausadas: não foi possível ler as configurações. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', - updateConfigurationFormat: 'Atualizações pausadas: arquivo de configurações inválido. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', + updateConfigurationRead: 'Atualizações pausadas: não foi possível ler as configurações. Corrija o arquivo e reinicie.', + updateConfigurationFormat: 'Atualizações pausadas: arquivo de configurações inválido. Corrija o arquivo e reinicie.', updateConfigurationChannel: 'Atualizações pausadas: channel deve ser nightly ou stable. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', + updateShowSettingsFile: 'Mostrar arquivo de configurações', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', updateDownloadFailed: 'Não foi possível baixar a atualização. Por favor, tente novamente.', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cf331062ce..0c538dd17f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -943,9 +943,14 @@ async function main(): Promise { let policyAuthenticationQueued = false const openUpdatePrompt = (manual = false): Promise => { if (channelState?.error !== undefined) { + const settingsError = channelState.error !== 'cleanup' return ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, message: configurationMessage(channelState.error, locale.messages), - ...(channelState.error === 'cleanup' ? {} : { detail: channelSettings.path }) }).then(() => {}) + ...(settingsError ? { detail: channelSettings.path, + buttons: [locale.messages.updateShowSettingsFile, locale.messages.updateClose], cancelId: 1 } : {}), + }).then((result) => { + if (settingsError && result.response === 0) shell.showItemInFolder(channelSettings.path) + }) } if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index df594bed39..001682a9e6 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -304,7 +304,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, WebContentsView: harness.FakeView, dialog: harness.dialog, - shell: { openExternal: harness.openExternal }, + shell: { openExternal: harness.openExternal, showItemInFolder: vi.fn() }, nativeTheme: harness.nativeTheme, net: { fetch: vi.fn() }, ipcMain: { @@ -567,6 +567,23 @@ describe('desktop main startup', () => { expect(options.detail).toBeUndefined() }) + it('offers the actual settings file for malformed JSON without suggesting channel reselection', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({ + message: en.updateConfigurationFormat, detail: path, + buttons: [en.updateShowSettingsFile, en.updateClose], cancelId: 1, + }) + expect(en.updateConfigurationFormat).not.toContain('select') + const { shell } = await import('electron') + expect(shell.showItemInFolder).toHaveBeenCalledWith(path) + expect(readFileSync(path, 'utf8')).toBe('{broken') + }) + it('shows the invalid startup field and file path until a valid channel is applied', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 70e89130c9..6bb9528588 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: 4d85d6dffd8d9e21 - zh: 653e583277563758 + en: 7600861d064dbc99 + zh: a52ba446016e3a21 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 9c8c35ae0e672b14 zh: 503c8e9d24a93d88 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 12a8c064a8..a074a42e30 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -63,7 +63,7 @@ Access: the settings shell and the `settings.open` shortcut are registered only ----- -Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Selecting the current healthy channel leaves downloads and prepared updates intact without a save or notification. Selecting another channel calls Electron directly and temporarily displays that choice with a spinner. The reply settles the selection against actual shell state; failure rolls it back. A shell-owned toast reports success or a short save failure, including after Settings closes. Persistent updater suspensions remain in the update indicator; a failed save does not create one. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). +Desktop also contributes an **Update channel** row to General when the preload exposes the channel API. Selecting the current healthy channel leaves downloads and prepared updates intact without a save or notification. Selecting another channel calls Electron directly and temporarily displays that choice with a spinner. The reply settles the selection against actual shell state; failure rolls it back. A shell-owned toast reports success or a short save failure, including after Settings closes. Startup settings errors use a ten-second toast with a details action; dismissing it does not clear the error. Persistent updater suspensions remain in the update indicator; a failed save does not create one. Ordinary browsers omit this row. File ownership and restart rules are documented in [local desktop settings](../../../apps/desktop/README.md#local-desktop-settings). ## Understand the implementation diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index f894efbe66..3535082b67 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -63,7 +63,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 ----- -当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。重复选择当前正常渠道不会保存、提示或影响下载与待安装更新。选择其他渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 +当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。重复选择当前正常渠道不会保存、提示或影响下载与待安装更新。选择其他渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。启动配置错误使用可查看详情的十秒 toast;提示消失不会清除错误。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。 ## 理解实现 diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx index 8b5c07e549..0d4bae94fa 100644 --- a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -50,6 +50,7 @@ export function DesktopChannelRow({ useChannel, select, t }: export interface DesktopChannelToastInjected { hooks: { channel: DesktopChannelSource['store'] } dismiss(sequence: number): void + openConfiguration(): void } /** @@ -57,10 +58,12 @@ export interface DesktopChannelToastInjected { * @param props - Shared operation state, dismissal and localized copy. * @returns A transient toast, or nothing. */ -export function DesktopChannelToast({ useChannel, dismiss, t }: +export function DesktopChannelToast({ useChannel, dismiss, openConfiguration, t }: PropsRuntime<'shell.overlay'> & PropsLocale<'settings'> & InjectFace) { const notice = useChannel(value => value.notice) if (notice === undefined) return null return { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : {}} /> } diff --git a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts index 43344ad29b..bd7f4e5970 100644 --- a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts +++ b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { DesktopChannelBridge, DesktopChannelState, DesktopUpdateChannel, DesktopChannelChangeResult } from '../types.ts' -type Notice = { sequence: number; outcome: 'applied' | Extract['reason'] } +type Notice = { sequence: number; outcome: 'configuration' | 'applied' | Extract['reason'] } /** Actual shell state, optimistic selection and transient operation feedback. */ export interface DesktopChannelSnapshot { state?: DesktopChannelState @@ -25,15 +25,23 @@ export class DesktopChannelSource { this.unsubscribe = bridge.subscribe((state) => { if (!this.live) return this.revision++ - this.store.set({ ...this.store.getSnapshot(), state, failed: false }) + this.receive(state) }) void bridge.get().then((state) => { - if (this.live && this.revision === 0) this.store.set({ ...this.store.getSnapshot(), state, failed: false }) + if (this.live && this.revision === 0) this.receive(state) }, () => { if (this.live && this.revision === 0) this.store.set({ ...this.store.getSnapshot(), failed: true }) }) } + private receive(state: DesktopChannelState): void { + const previous = this.store.getSnapshot() + const notify = state.error !== undefined && state.error !== 'cleanup' && state.error !== previous.state?.error + this.store.set({ ...previous, state, failed: false, + ...(notify ? { notice: { sequence: ++this.sequence, outcome: 'configuration' as const } } : {}), + }) + } + /** * Show a pending choice until the shell reports its actual selection. * @param channel - Selected feed. diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 1dbb241570..7953b90016 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -116,7 +116,8 @@ export function apply(ctx: ClientContext): void { }, DesktopChannelRow)) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'desktop-channel-toast', locale: NS, - inject: () => ({ hooks: { channel: channel.store }, dismiss: (sequence: number) => { channel.dismissNotice(sequence) } }), + inject: () => ({ hooks: { channel: channel.store }, + dismiss: (sequence: number) => { channel.dismissNotice(sequence) }, openConfiguration: () => { desktopUpdate.open() } }), }, DesktopChannelToast)) } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 7b1d8231ee..c76eb90130 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -10,6 +10,8 @@ export const zh = { 'desktop.channel.error': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.update.configurationFailed': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.channel.result.applied': '更新渠道已切换', + 'desktop.channel.result.configuration': '设置文件异常,更新已暂停', + 'desktop.channel.details': '查看详情', 'desktop.channel.result.save': '保存失败', 'desktop.channel.result.format': '保存失败,配置文件格式有误', 'desktop.channel.result.permission': '保存失败,没有写入权限', @@ -75,6 +77,8 @@ export const en = { 'desktop.channel.error': 'Could not apply update settings. Open the desktop update indicator for details.', 'desktop.update.configurationFailed': 'Could not apply update settings. Open the desktop update indicator for details.', 'desktop.channel.result.applied': 'Update channel changed', + 'desktop.channel.result.configuration': 'Settings file error; updates are paused', + 'desktop.channel.details': 'View details', 'desktop.channel.result.save': 'Could not save', 'desktop.channel.result.format': 'Could not save: invalid settings file', 'desktop.channel.result.permission': 'Could not save: permission denied', @@ -137,6 +141,8 @@ export const ja = { 'desktop.channel.error': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', 'desktop.update.configurationFailed': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', 'desktop.channel.result.applied': '更新チャンネルを変更しました', + 'desktop.channel.result.configuration': '設定ファイルに問題があるため、更新を停止しています', + 'desktop.channel.details': '詳細を表示', 'desktop.channel.result.save': '保存できませんでした', 'desktop.channel.result.format': '保存できませんでした:設定ファイルの形式が不正です', 'desktop.channel.result.permission': '保存できませんでした:書き込み権限がありません', @@ -199,6 +205,8 @@ export const es = { 'desktop.channel.error': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', 'desktop.update.configurationFailed': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', 'desktop.channel.result.applied': 'Canal de actualización cambiado', + 'desktop.channel.result.configuration': 'Error en el archivo de ajustes; actualizaciones en pausa', + 'desktop.channel.details': 'Ver detalles', 'desktop.channel.result.save': 'No se pudo guardar', 'desktop.channel.result.format': 'No se pudo guardar: archivo de configuración no válido', 'desktop.channel.result.permission': 'No se pudo guardar: permiso denegado', @@ -261,6 +269,8 @@ export const fr = { 'desktop.channel.error': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', 'desktop.update.configurationFailed': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', 'desktop.channel.result.applied': 'Canal de mise à jour modifié', + 'desktop.channel.result.configuration': 'Erreur du fichier de paramètres ; mises à jour suspendues', + 'desktop.channel.details': 'Voir les détails', 'desktop.channel.result.save': 'Échec de l’enregistrement', 'desktop.channel.result.format': 'Échec de l’enregistrement : fichier de paramètres invalide', 'desktop.channel.result.permission': 'Échec de l’enregistrement : permission refusée', @@ -323,6 +333,8 @@ export const de = { 'desktop.channel.error': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', 'desktop.update.configurationFailed': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', 'desktop.channel.result.applied': 'Update-Kanal geändert', + 'desktop.channel.result.configuration': 'Fehler in der Einstellungsdatei; Updates sind angehalten', + 'desktop.channel.details': 'Details anzeigen', 'desktop.channel.result.save': 'Speichern fehlgeschlagen', 'desktop.channel.result.format': 'Speichern fehlgeschlagen: ungültige Einstellungsdatei', 'desktop.channel.result.permission': 'Speichern fehlgeschlagen: keine Schreibberechtigung', @@ -385,6 +397,8 @@ export const ru = { 'desktop.channel.error': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', 'desktop.update.configurationFailed': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', 'desktop.channel.result.applied': 'Канал обновлений изменён', + 'desktop.channel.result.configuration': 'Ошибка файла настроек; обновления приостановлены', + 'desktop.channel.details': 'Подробнее', 'desktop.channel.result.save': 'Не удалось сохранить', 'desktop.channel.result.format': 'Не удалось сохранить: неверный формат файла настроек', 'desktop.channel.result.permission': 'Не удалось сохранить: нет прав на запись', @@ -447,6 +461,8 @@ export const pt = { 'desktop.channel.error': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', 'desktop.update.configurationFailed': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', 'desktop.channel.result.applied': 'Canal de atualização alterado', + 'desktop.channel.result.configuration': 'Erro no arquivo de configurações; atualizações pausadas', + 'desktop.channel.details': 'Ver detalhes', 'desktop.channel.result.save': 'Não foi possível salvar', 'desktop.channel.result.format': 'Não foi possível salvar: arquivo de configurações inválido', 'desktop.channel.result.permission': 'Não foi possível salvar: permissão negada', diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index e0d85bbf84..7a49679085 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -369,7 +369,7 @@ it('keeps the failure toast after Settings unmounts and rolls back the remounted const useChannel = bindSnapshotSelector(source.store) const select = (channel: 'nightly' | 'stable') => source.select(channel) const row = render() - const toast = render( {}} {...kit} t={t} useChannel={useChannel} dismiss={(sequence) => { source.dismissNotice(sequence) }} />) try { await waitFor(() => { expect(screen.getByRole('button', { name: 'Update channel' }).textContent).toBe('Development') }) @@ -391,10 +391,31 @@ it('dismisses a successful channel toast after its display lifetime', async () = const store = createSnapshotStore({ failed: false, notice: { sequence: 7, outcome: 'applied' }, }) - const view = render() + const view = render( {}} {...kit} t={t} + useChannel={bindSnapshotSelector(store)} dismiss={dismiss} />) try { expect(screen.getByRole('alert').textContent).toBe('Update channel changed') await act(async () => { await vi.advanceTimersByTimeAsync(4000) }) expect(dismiss).toHaveBeenCalledWith(7) } finally { view.unmount(); vi.useRealTimers() } }) + + +it('keeps the settings-file notification for ten seconds with a details action', async () => { + vi.useFakeTimers() + const dismiss = vi.fn() + const openConfiguration = vi.fn() + const store = createSnapshotStore({ + failed: false, notice: { sequence: 8, outcome: 'configuration' }, + }) + const view = render() + try { + fireEvent.click(screen.getByRole('button', { name: 'View details' })) + expect(openConfiguration).toHaveBeenCalledOnce() + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(dismiss).not.toHaveBeenCalled() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(dismiss).toHaveBeenCalledWith(8) + } finally { view.unmount(); vi.useRealTimers() } +}) diff --git a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts index 7a51b3a215..3cd8d1530a 100644 --- a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts +++ b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts @@ -142,3 +142,16 @@ it('allows same-channel recovery and suppresses feedback for a shell no-op', asy expect(f.source.store.getSnapshot()).toEqual({ state: nightly, failed: false }) f.source.dispose() }) + + +it('announces startup file errors once and keeps their state after the toast closes', async () => { + const f = fixture() + const state: DesktopChannelState = { ...nightly, channel: null, error: 'format' } + f.get.resolve(state) + await f.get.promise + expect(f.source.store.getSnapshot().notice).toEqual({ sequence: 1, outcome: 'configuration' }) + f.source.dismissNotice(1) + f.emit(state) + expect(f.source.store.getSnapshot()).toEqual({ state, failed: false }) + f.source.dispose() +}) From 598006d608732998c8c43a8c5af103ee0dbf6ce6 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:30:14 +0800 Subject: [PATCH 15/25] fix(desktop): log obsolete update cleanup failures without blocking --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 4 +- apps/desktop/src/update-cleanup-log.ts | 29 +++++++++ .../src/updates/electron-updater/discard.ts | 38 +++++++++++- apps/desktop/tests/update-coordinator.spec.ts | 11 ++-- apps/desktop/tests/update-discard.spec.ts | 62 +++++++++++++++++++ .../updates/electron-updater/README.i18n.yaml | 4 +- .../tests/updates/electron-updater/README.md | 2 +- .../updates/electron-updater/README.zh.md | 2 +- .../electron-updater/qualification.mjs | 29 ++++++++- 11 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/update-cleanup-log.ts create mode 100644 apps/desktop/tests/update-discard.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 933f52f7d1..489201d149 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 34608297cbb2b6fd - zh: 152dc590a69c2feb + en: cf2cd1ad4585a981 + zh: 993a930e4ab9c5c3 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 697a2b0649..15ed933280 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -310,6 +310,8 @@ During a UI switch, the selector shows the pending choice with a loading indicat The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. +Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. + Updater adapter compatibility and platform version records are documented in [Electron updater compatibility](tests/updates/electron-updater/README.md). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 433251ee70..7044f8b33c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -306,10 +306,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 -UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或旧更新清理失败会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 +撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。 + 更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。 diff --git a/apps/desktop/src/update-cleanup-log.ts b/apps/desktop/src/update-cleanup-log.ts new file mode 100644 index 0000000000..6171849873 --- /dev/null +++ b/apps/desktop/src/update-cleanup-log.ts @@ -0,0 +1,29 @@ +/** Durable, best-effort diagnostics for obsolete update files that could not be removed. */ +import { appendFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Filesystem operation on a retired target; not evidence that a future download will fail. */ +export interface UpdateCleanupFailure { + readonly operation: 'list' | 'remove' + readonly path: string + readonly error: unknown +} + +/** + * Flush one cleanup warning to disk; diagnostic failure falls back to stderr without reviving a target. + * @param directory - Shell-owned log directory, outside the update cache. + * @param failure - Failed cleanup operation; no update URL or settings contents. + */ +export function recordUpdateCleanupFailure(directory: string, failure: UpdateCleanupFailure): void { + const error = failure.error + const entry = { time: new Date().toISOString(), event: 'update-cache-cleanup-failed', + operation: failure.operation, path: failure.path, + code: error instanceof Error && 'code' in error ? String(error.code) : null, + reason: error instanceof Error ? error.message : String(error) } + try { + mkdirSync(directory, { recursive: true }) + appendFileSync(join(directory, 'update-cleanup.jsonl'), `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) + } catch (logError) { + console.error('desktop update: could not persist cleanup warning', entry, logError) + } +} diff --git a/apps/desktop/src/updates/electron-updater/discard.ts b/apps/desktop/src/updates/electron-updater/discard.ts index 5dffab76c5..f066141c42 100644 --- a/apps/desktop/src/updates/electron-updater/discard.ts +++ b/apps/desktop/src/updates/electron-updater/discard.ts @@ -1,5 +1,9 @@ /** Retires electron-updater 6.x targets only after owned checks and downloads settle. */ import type { Server } from 'node:http' +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' +import { recordUpdateCleanupFailure } from '../../update-cleanup-log.ts' import type { AppUpdater } from 'electron-updater' /** @@ -7,13 +11,20 @@ import type { AppUpdater } from 'electron-updater' * This adapter depends on private electron-updater 6.x fields, covered by real-library tests. * autoInstallOnAppQuit must be false and quitAndInstall must not have been called. * @param updater - Settled updater owned by the Desktop coordinator. - * @returns After no old target or local feed server remains usable for installation. + * @returns After old in-memory targets and the local feed server are retired; residual files only produce durable warnings. */ export async function discardDesktopUpdate(updater: AppUpdater): Promise { // The dependency omits these private fields from its public integration declarations. const internal = updater as object as { updateInfoAndProvider: unknown - downloadedUpdateHelper?: { clear(): Promise } | null + downloadedUpdateHelper?: { + _file: string | null + _packageFile: string | null + versionInfo: unknown + fileInfo: unknown + _downloadedFileInfo: unknown + cacheDirForPendingUpdate: string + } | null server?: Server | null squirrelDownloadedUpdate?: boolean } @@ -26,5 +37,26 @@ export async function discardDesktopUpdate(updater: AppUpdater): Promise { } internal.server = null internal.updateInfoAndProvider = null - await internal.downloadedUpdateHelper?.clear() + const helper = internal.downloadedUpdateHelper + if (helper == null) return + // Equivalent to the pinned helper's memory reset; its clear() silently swallows filesystem failures. + helper._file = null + helper._packageFile = null + helper.versionInfo = null + helper.fileInfo = null + helper._downloadedFileInfo = null + const directory = helper.cacheDirForPendingUpdate + let names: string[] + try { names = await readdir(directory) } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'list', path: directory, error }) + } + return + } + for (const name of names) { + const path = join(directory, name) + try { await rm(path, { recursive: true, force: true }) } catch (error) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'remove', path, error }) + } + } } diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 275be855c9..40f32b551f 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alpha.1') { +function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alpha.1', discard?: (updater: AppUpdater) => Promise) { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -66,7 +66,7 @@ function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alp const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( (state) => { states.push(state); return state }, - beforeRestart, updater, () => true, () => version, downloadResult, channel, + beforeRestart, updater, () => true, () => version, downloadResult, channel, discard, ) coordinators.push(coordinator) return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult } @@ -372,12 +372,11 @@ it('does not accept a channel switch after installer handoff even if a later err }) it('suspends updates if retiring a downloaded target fails and recovers on a later switch', async () => { - const f = fixture() + const discard = vi.fn().mockRejectedValueOnce(new Error('native target busy')).mockResolvedValueOnce(undefined) + const f = fixture('nightly', '1.1.0-alpha.1', discard) await f.coordinator.check() await f.coordinator.download('1.1.0-rc.2') - const clear = vi.fn().mockRejectedValueOnce(new Error('locked cache')).mockResolvedValueOnce(undefined) - Object.assign(f.updater, { downloadedUpdateHelper: { clear } }) - await expect(f.coordinator.switchChannel('stable', async () => {})).rejects.toThrow('locked cache') + await expect(f.coordinator.switchChannel('stable', async () => {})).rejects.toThrow('native target busy') expect(f.coordinator.configurationFailure).toBe('cleanup') expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) const checks = f.checkForUpdates.mock.calls.length diff --git a/apps/desktop/tests/update-discard.spec.ts b/apps/desktop/tests/update-discard.spec.ts new file mode 100644 index 0000000000..e8ae7f83e7 --- /dev/null +++ b/apps/desktop/tests/update-discard.spec.ts @@ -0,0 +1,62 @@ +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type { AppUpdater } from 'electron-updater' +import { discardDesktopUpdate } from '../src/updates/electron-updater/discard.ts' +import { recordUpdateCleanupFailure } from '../src/update-cleanup-log.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rm: vi.fn(actual.rm) } +}) + +const environment = vi.hoisted(() => ({ logs: '' })) +vi.mock('electron', () => ({ app: { getPath: () => environment.logs } })) +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }) +}) + +it('retires the target despite a removal failure and persists its exact filesystem warning', async () => { + const root = await mkdtemp(join(tmpdir(), 'update-discard-')) + roots.push(root) + const pending = join(root, 'pending') + environment.logs = join(root, 'logs') + await mkdir(pending) + const file = join(pending, 'old.zip') + await writeFile(file, 'obsolete') + const helper = { _file: file, _packageFile: file, versionInfo: {}, fileInfo: {}, + _downloadedFileInfo: {}, cacheDirForPendingUpdate: pending } + const internal = { updateInfoAndProvider: {}, downloadedUpdateHelper: helper } + const failure = Object.assign(new Error('file is locked'), { code: 'EPERM' }) + vi.mocked(fs.rm).mockRejectedValueOnce(failure) + await discardDesktopUpdate(internal as object as AppUpdater) + expect(internal.updateInfoAndProvider).toBeNull() + expect(helper).toMatchObject({ _file: null, _packageFile: null, versionInfo: null, fileInfo: null, _downloadedFileInfo: null }) + expect(await readFile(file, 'utf8')).toBe('obsolete') + const lines = (await readFile(join(environment.logs, 'update-cleanup.jsonl'), 'utf8')).trim().split('\n') + expect(lines).toHaveLength(1) + expect(JSON.parse(lines[0]!)).toMatchObject({ event: 'update-cache-cleanup-failed', operation: 'remove', path: file, + code: 'EPERM', reason: 'file is locked' }) + expect(Number.isNaN(Date.parse(JSON.parse(lines[0]!).time))).toBe(false) +}) + +it('does not retire a target that the native installer already owns', async () => { + const internal = { squirrelDownloadedUpdate: true, updateInfoAndProvider: {} } + await expect(discardDesktopUpdate(internal as object as AppUpdater)).rejects.toThrow('native installation already staged') + expect(internal.updateInfoAndProvider).not.toBeNull() +}) + +it('reports log-write failure separately without restoring a retired target', async () => { + const root = await mkdtemp(join(tmpdir(), 'update-log-')) + roots.push(root) + const file = join(root, 'not-a-directory') + await writeFile(file, 'sentinel') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + recordUpdateCleanupFailure(file, { operation: 'list', path: '/retired/cache', error: new Error('unreadable') }) + expect(error).toHaveBeenCalledWith('desktop update: could not persist cleanup warning', + expect.objectContaining({ operation: 'list', path: '/retired/cache' }), expect.any(Error)) +}) diff --git a/apps/desktop/tests/updates/electron-updater/README.i18n.yaml b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml index 8db227671f..211325c041 100644 --- a/apps/desktop/tests/updates/electron-updater/README.i18n.yaml +++ b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml @@ -6,5 +6,5 @@ en: ff2d052de11d825a zh: aa36e8c136562d5a /electron-updater-compatibility: - en: 9109ea98e3a01e73 - zh: 7d54c86f24b5567e + en: 328e0a77d9abcc03 + zh: aeb6ba53a6497d64 diff --git a/apps/desktop/tests/updates/electron-updater/README.md b/apps/desktop/tests/updates/electron-updater/README.md index b16f81f8ae..8ea87e9734 100644 --- a/apps/desktop/tests/updates/electron-updater/README.md +++ b/apps/desktop/tests/updates/electron-updater/README.md @@ -20,4 +20,4 @@ pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record Workspace dependencies must already be built (`pnpm run build:artifacts` on a fresh checkout). The final command rebuilds Desktop, runs Electron without opening a window, and records only the current platform after successful exit. Commit the JSON change with the dependency or adapter change. Omit `--record` to test without changing records. The lightweight check compares versions only; unchanged versions do not certify modified adapter code. -The fixture uses a private temporary directory and loopback HTTP feed with inert payloads. It checks channel filenames, downgrade rejection, staging eligibility, downloaded-cache retirement and macOS proxy closure. It never executes an installer or contacts a production feed. Dependency installation can require external network access. A 30-second watchdog bounds Electron startup, tests and exit; timeout or failure never updates records. Signed installation and application restart remain separate qualification work. +The fixture uses a private temporary directory and loopback HTTP feed with inert payloads. It checks channel filenames, downgrade rejection, staging eligibility, downloaded-cache retirement, rejection of obsolete cache by a new updater instance, and macOS proxy closure. It never executes an installer or contacts a production feed. Dependency installation can require external network access. A 30-second watchdog bounds Electron startup, tests and exit; timeout or failure never updates records. Signed installation and application restart remain separate qualification work. diff --git a/apps/desktop/tests/updates/electron-updater/README.zh.md b/apps/desktop/tests/updates/electron-updater/README.zh.md index bed6a1c198..1af4292c33 100644 --- a/apps/desktop/tests/updates/electron-updater/README.zh.md +++ b/apps/desktop/tests/updates/electron-updater/README.zh.md @@ -20,4 +20,4 @@ pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record 需要先构建 workspace 依赖(新检出目录运行 `pnpm run build:artifacts`)。最后一条命令重新构建 Desktop,启动不打开窗口的 Electron,并且只在成功退出后记录当前平台。将 JSON 改动与依赖或适配层改动一起提交。不带 `--record` 可只测试、不改记录。轻量检查只比较版本;版本未变不代表修改后的适配层已经通过验证。 -fixture 使用独立临时目录、回环 HTTP 服务和惰性载荷,检查渠道清单文件名、降级拒绝、灰度资格、下载缓存撤销及 macOS 代理关闭。它不执行安装器,也不访问生产更新源。安装依赖可能需要外网。30 秒看门狗覆盖 Electron 启动、测试和退出;超时或失败不会更新记录。签名安装和应用重启另行验证。 +fixture 使用独立临时目录、回环 HTTP 服务和惰性载荷,检查渠道清单文件名、降级拒绝、灰度资格、下载缓存撤销、新 updater 实例拒绝旧缓存,以及 macOS 代理关闭。它不执行安装器,也不访问生产更新源。安装依赖可能需要外网。30 秒看门狗覆盖 Electron 启动、测试和退出;超时或失败不会更新记录。签名安装和应用重启另行验证。 diff --git a/apps/desktop/tests/updates/electron-updater/qualification.mjs b/apps/desktop/tests/updates/electron-updater/qualification.mjs index 6a73cdda23..c8243d5480 100644 --- a/apps/desktop/tests/updates/electron-updater/qualification.mjs +++ b/apps/desktop/tests/updates/electron-updater/qualification.mjs @@ -18,8 +18,8 @@ async function main() { const requests = [] let percentage let version = '2.0.0' - const payload = Buffer.from('inert channel-switch qualification payload') - const sha512 = createHash('sha512').update(payload).digest('base64') + let payload = Buffer.from('inert channel-switch qualification payload') + let sha512 = createHash('sha512').update(payload).digest('base64') const server = createServer((request, response) => { const path = request.url.split('?')[0] requests.push(path) @@ -82,6 +82,9 @@ async function main() { const file = helper.file assert.ok(file) assert.deepEqual(await readFile(file), payload) + const pending = helper.cacheDirForPendingUpdate + const cachedInfo = await readFile(join(pending, 'update-info.json')) + const oldPayload = Buffer.from(payload) const proxy = target.server if (process.platform === 'darwin') assert.equal(proxy.listening, true) await discardDesktopUpdate(target) @@ -92,6 +95,28 @@ async function main() { assert.equal(target.updateInfoAndProvider, null) await assert.rejects(access(file), { code: 'ENOENT' }) await assert.rejects(target.downloadUpdate()) + // A later process can encounter files that a previous process could not remove. + await writeFile(file, oldPayload) + await writeFile(join(pending, 'update-info.json'), cachedInfo) + version = '2.1.0' + payload = Buffer.from('different release after obsolete cache survived') + sha512 = createHash('sha512').update(payload).digest('base64') + const recovered = new Target(undefined, adapter) + ownedUpdater = recovered + recovered.logger = null + recovered.autoDownload = false + recovered.autoInstallOnAppQuit = false + recovered.disableDifferentialDownload = true + recovered.httpExecutor = new DesktopUpdateHttpExecutor(5000) + recovered.setFeedURL({ provider: 'generic', url: origin }) + recovered.quitAndInstall = forbidden + recovered.on('error', () => {}) + selectUpdateChannel(recovered, 'stable') + await recovered.checkForUpdates() + await recovered.downloadUpdate() + assert.deepEqual(await readFile(recovered.downloadedUpdateHelper.file), payload) + assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0') + console.log('PASS: obsolete cache rejected by a new updater instance; current payload verified') console.log('PASS: real provider channel switching, rollout eligibility, real downloads, native platform cache and proxy retirement') } finally { try { From c0795c003a98d4a8aa14b6b23b04d14b785e59eb Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:32:06 +0800 Subject: [PATCH 16/25] fix(desktop): explain current download filesystem failures --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/ipc.ts | 5 ++++ apps/desktop/src/locale.ts | 24 +++++++++++++++++++ apps/desktop/src/update-coordinator.ts | 3 ++- apps/desktop/src/update-error.ts | 19 ++++++++++++++- apps/desktop/src/update-presentation.ts | 4 ++++ apps/desktop/tests/update-coordinator.spec.ts | 23 ++++++++++++++++++ .../desktop/tests/update-presentation.spec.ts | 15 ++++++++++++ .../src/client/DesktopUpdateIndicator.tsx | 3 +++ .../ui-settings-general/src/client/locales.ts | 24 +++++++++++++++++++ .../client/ui-settings-general/src/types.ts | 3 +++ 13 files changed, 125 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 489201d149..ad1dc899fa 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: cf2cd1ad4585a981 - zh: 993a930e4ab9c5c3 + en: 56fe9d6449691818 + zh: 3abfb88538c1d90e /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 15ed933280..0b6d8a1451 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -310,7 +310,7 @@ During a UI switch, the selector shows the pending choice with a loading indicat The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. -Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. +Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. Structured filesystem errors distinguish insufficient space, denied writes and busy files; the error dialog retains the current error code and supplied file paths in its details. Errors without structured evidence keep the generic download-failure guidance. Updater adapter compatibility and platform version records are documented in [Electron updater compatibility](tests/updates/electron-updater/README.md). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 7044f8b33c..cac66aa39e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -310,7 +310,7 @@ UI 切换期间,选择器显示待应用选项和加载指示;失败时恢 共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 -撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。 +撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。有结构化文件系统错误时,区分空间不足、无法写入和文件占用;错误弹窗详情保留当次错误码及已提供的文件路径。没有结构化依据时保留通用下载失败提示。 更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。 diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 2021bc3bc5..83ebe7415a 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -59,6 +59,8 @@ export interface DesktopUpdateState { readonly failedOperation?: 'check' | 'download' | 'install' | 'configuration' /** Main-owned preparation cause; UI wording is selected by the active locale. */ readonly preparationFailure?: DesktopUpdatePreparationFailureKind + /** Current filesystem failure, independent of previous cache cleanup attempts. */ + readonly downloadFailure?: 'space' | 'permission' | 'busy' } /** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */ @@ -68,6 +70,9 @@ export type DesktopUpdateFailureKind = | 'check-network' | 'download' | 'download-network' + | 'download-space' + | 'download-permission' + | 'download-busy' | 'install' | 'install-network' | 'stop-failed' diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index f7c5361a2d..3991e4bc40 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -115,6 +115,9 @@ export const en = { updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', + updateDownloadSpaceFailed: 'Not enough disk space to download the update. Free up space, then retry.', + updateDownloadPermissionFailed: 'Cannot write the update files. Check the folder permissions and retry.', + updateDownloadBusyFailed: 'An update file is in use. Close the program using it, then retry.', updateInstallFailed: 'Could not install the update. Please try again later.', updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.', updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.', @@ -301,6 +304,9 @@ export const zh = { updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', + updateDownloadSpaceFailed: '磁盘空间不足,无法下载更新。请释放空间后重试。', + updateDownloadPermissionFailed: '无法写入更新文件,请检查目录权限后重试。', + updateDownloadBusyFailed: '更新文件正被占用,请关闭占用它的程序后重试。', updateInstallFailed: '安装更新失败,请稍后重试。', updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。', updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。', @@ -485,6 +491,9 @@ export const ja = { updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。', + updateDownloadSpaceFailed: '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。', + updateDownloadPermissionFailed: '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。', + updateDownloadBusyFailed: '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。', updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。', updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。', updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。', @@ -669,6 +678,9 @@ export const es = { updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', updateDownloadFailed: 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.', + updateDownloadSpaceFailed: 'No hay espacio suficiente para descargar la actualización. Libera espacio y vuelve a intentarlo.', + updateDownloadPermissionFailed: 'No se pueden escribir los archivos de actualización. Revisa los permisos de la carpeta y vuelve a intentarlo.', + updateDownloadBusyFailed: 'Un archivo de actualización está en uso. Cierra el programa que lo usa y vuelve a intentarlo.', updateInstallFailed: 'No se pudo instalar la actualización. Inténtelo de nuevo más tarde.', updateCheckNetworkFailed: 'No se pudo buscar actualizaciones. Comprueba tu conexión y vuelve a intentarlo.', updateDownloadNetworkFailed: 'No se pudo descargar la actualización. Comprueba tu conexión y vuelve a intentarlo.', @@ -853,6 +865,9 @@ export const fr = { updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', updateDownloadFailed: 'Impossible de télécharger la mise à jour. Veuillez réessayer.', + updateDownloadSpaceFailed: 'Espace disque insuffisant pour télécharger la mise à jour. Libérez de l’espace, puis réessayez.', + updateDownloadPermissionFailed: 'Impossible d’écrire les fichiers de mise à jour. Vérifiez les autorisations du dossier, puis réessayez.', + updateDownloadBusyFailed: 'Un fichier de mise à jour est utilisé. Fermez le programme qui l’utilise, puis réessayez.', updateInstallFailed: "Impossible d'installer la mise à jour. Veuillez réessayer plus tard.", updateCheckNetworkFailed: 'Impossible de vérifier les mises à jour. Vérifiez votre connexion et réessayez.', updateDownloadNetworkFailed: 'Impossible de télécharger la mise à jour. Vérifiez votre connexion et réessayez.', @@ -1037,6 +1052,9 @@ export const de = { updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', + updateDownloadSpaceFailed: 'Nicht genügend Speicherplatz für das Update. Geben Sie Speicherplatz frei und versuchen Sie es erneut.', + updateDownloadPermissionFailed: 'Update-Dateien können nicht geschrieben werden. Prüfen Sie die Ordnerberechtigungen und versuchen Sie es erneut.', + updateDownloadBusyFailed: 'Eine Update-Datei wird verwendet. Schließen Sie das verwendende Programm und versuchen Sie es erneut.', updateInstallFailed: 'Das Update konnte nicht installiert werden. Bitte versuchen Sie es später noch einmal.', updateCheckNetworkFailed: 'Es konnte nicht nach Updates gesucht werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', @@ -1221,6 +1239,9 @@ export const ru = { updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.', + updateDownloadSpaceFailed: 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.', + updateDownloadPermissionFailed: 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.', + updateDownloadBusyFailed: 'Файл обновления занят. Закройте использующую его программу и повторите попытку.', updateInstallFailed: 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.', updateCheckNetworkFailed: 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.', updateDownloadNetworkFailed: 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.', @@ -1405,6 +1426,9 @@ export const pt = { updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', updateDownloadFailed: 'Não foi possível baixar a atualização. Por favor, tente novamente.', + updateDownloadSpaceFailed: 'Não há espaço suficiente para baixar a atualização. Libere espaço e tente novamente.', + updateDownloadPermissionFailed: 'Não foi possível gravar os arquivos de atualização. Verifique as permissões da pasta e tente novamente.', + updateDownloadBusyFailed: 'Um arquivo de atualização está em uso. Feche o programa que o utiliza e tente novamente.', updateInstallFailed: 'Não foi possível instalar a atualização. Por favor, tente novamente mais tarde.', updateCheckNetworkFailed: 'Não foi possível verificar se há atualizações. Verifique sua conexão e tente novamente.', updateDownloadNetworkFailed: 'Não foi possível baixar a atualização. Verifique sua conexão e tente novamente.', diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 1f388306d5..29cf2d88f3 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -7,7 +7,7 @@ import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo, t import { gt, valid } from 'semver' import type { DesktopUpdateState } from './ipc.ts' import { DesktopUpdateHttpExecutor } from './updates/electron-updater/http-executor.ts' -import { DesktopUpdatePreparationError } from './update-error.ts' +import { DesktopUpdatePreparationError, downloadFailureDetails } from './update-error.ts' import type { DesktopUpdateChannel } from './update-channel.ts' import { selectUpdateChannel } from './updates/electron-updater/channel.ts' import { discardDesktopUpdate } from './updates/electron-updater/discard.ts' @@ -254,6 +254,7 @@ export class DesktopUpdateCoordinator { private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState { return { phase: 'error', ...this.target(), failedOperation, message: error instanceof Error ? error.message : String(error), + ...(failedOperation === 'download' ? downloadFailureDetails(error) : {}), ...(error instanceof DesktopUpdatePreparationError ? { preparationFailure: error.kind, ...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }), diff --git a/apps/desktop/src/update-error.ts b/apps/desktop/src/update-error.ts index adac72738b..a2a9f23c5e 100644 --- a/apps/desktop/src/update-error.ts +++ b/apps/desktop/src/update-error.ts @@ -1,5 +1,5 @@ /** Localized update preparation failures with separately displayed diagnostics. */ -import type { DesktopUpdatePreparationFailureKind } from './ipc.ts' +import type { DesktopUpdatePreparationFailureKind, DesktopUpdateState } from './ipc.ts' /** Only explicitly safe main-process facts may be exposed as technical details. */ export class DesktopUpdatePreparationError extends Error { @@ -10,3 +10,20 @@ export class DesktopUpdatePreparationError extends Error { */ constructor(readonly kind: DesktopUpdatePreparationFailureKind, message: string, readonly technicalDetails?: string) { super(message) } } + +/** + * Preserve current download diagnostics; never infer a cause from an older cleanup failure. + * @param error - Rejected updater download or filesystem error. + * @returns Structured filesystem cause when supplied by the failing operation. + */ +export function downloadFailureDetails(error: unknown): Pick { + if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return {} + const code = error.code + const kind = code === 'ENOSPC' ? 'space' : ['EACCES', 'EPERM', 'EROFS'].includes(code) ? 'permission' : code === 'EBUSY' ? 'busy' : undefined + const path = 'path' in error && typeof error.path === 'string' ? error.path : undefined + const destination = 'dest' in error && typeof error.dest === 'string' ? error.dest : undefined + return { + ...(kind === undefined ? {} : { downloadFailure: kind }), + technicalDetails: [code, error.message, path, destination].filter(value => value !== undefined).join('\n'), + } +} diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 84ad155c5e..4fa061f33a 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -12,6 +12,7 @@ const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INT function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind { if (state.failedOperation === 'configuration') return 'configuration' if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure + if (state.failedOperation === 'download' && state.downloadFailure !== undefined) return `download-${state.downloadFailure}` const operation = state.failedOperation ?? 'install' return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation } @@ -29,6 +30,9 @@ export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: D check: messages.updateCheckFailed, 'check-network': messages.updateCheckNetworkFailed, download: messages.updateDownloadFailed, + 'download-space': messages.updateDownloadSpaceFailed, + 'download-permission': messages.updateDownloadPermissionFailed, + 'download-busy': messages.updateDownloadBusyFailed, 'download-network': messages.updateDownloadNetworkFailed, install: messages.updateInstallFailed, 'install-network': messages.updateInstallNetworkFailed, diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 40f32b551f..75521799f4 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -416,3 +416,26 @@ it.each(['idle', 'downloading', 'ready'] as const)('preserves %s when the curren await downloading } }) + +it.each([ + ['ENOSPC', 'space'], ['EACCES', 'permission'], ['EPERM', 'permission'], ['EROFS', 'permission'], ['EBUSY', 'busy'], +] as const)('reports the current %s download failure without attributing it to cleanup', async (code, kind) => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(Object.assign(new Error('write failed'), { + code, path: '/cache/new.zip', dest: '/cache/pending/new.zip', + })) + const state = await f.coordinator.download('1.1.0-rc.2') + expect(state).toMatchObject({ phase: 'error', failedOperation: 'download', downloadFailure: kind, + technicalDetails: `${code}\nwrite failed\n/cache/new.zip\n/cache/pending/new.zip` }) + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('not ready') + expect((await f.coordinator.download('1.1.0-rc.2')).phase).toBe('ready') + expect(f.coordinator.state.downloadFailure).toBeUndefined() +}) + +it('does not diagnose a filesystem cause from error wording alone', async () => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(new Error('ENOSPC or EACCES in a remote response')) + expect((await f.coordinator.download('1.1.0-rc.2')).downloadFailure).toBeUndefined() +}) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 9c53a9ebec..a9ad2501d3 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -44,3 +44,18 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => { technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateInstallFailed) } }) + +it('offers actionable filesystem recovery without exposing paths in the indicator', () => { + for (const messages of [en, zh]) { + for (const [downloadFailure, expected] of [ + ['space', messages.updateDownloadSpaceFailed], + ['permission', messages.updateDownloadPermissionFailed], + ['busy', messages.updateDownloadBusyFailed], + ] as const) { + const state: DesktopUpdateState = { phase: 'error', failedOperation: 'download', downloadFailure, + technicalDetails: '/private/cache/new.zip', message: 'write failed' } + expect(desktopUpdateErrorSummary(state, messages)).toBe(expected) + expect(presentDesktopUpdate(state)).toEqual({ phase: 'error', failure: `download-${downloadFailure}` }) + } + } +}) diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 48dfaa809d..ae22d5c6fb 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -33,6 +33,9 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l check: t('desktop.update.checkFailed'), 'check-network': t('desktop.update.checkNetworkFailed'), download: t('desktop.update.downloadFailed'), + 'download-space': t('desktop.update.downloadSpaceFailed'), + 'download-permission': t('desktop.update.downloadPermissionFailed'), + 'download-busy': t('desktop.update.downloadBusyFailed'), 'download-network': t('desktop.update.downloadNetworkFailed'), install: t('desktop.update.installFailed'), 'install-network': t('desktop.update.installNetworkFailed'), diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index c76eb90130..9791e9cda5 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -33,6 +33,9 @@ export const zh = { 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:{version}', 'desktop.update.checkFailed': '检查更新失败,请稍后重试。', 'desktop.update.downloadFailed': '下载更新失败,请重试。', + 'desktop.update.downloadSpaceFailed': '磁盘空间不足,无法下载更新。请释放空间后重试。', + 'desktop.update.downloadPermissionFailed': '无法写入更新文件,请检查目录权限后重试。', + 'desktop.update.downloadBusyFailed': '更新文件正被占用,请关闭占用它的程序后重试。', 'desktop.update.installFailed': '安装更新失败,请稍后重试。', 'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。', 'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。', @@ -100,6 +103,9 @@ export const en = { 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: {version}', 'desktop.update.checkFailed': 'Could not check for updates. Please try again later.', 'desktop.update.downloadFailed': 'Could not download the update. Please try again.', + 'desktop.update.downloadSpaceFailed': 'Not enough disk space to download the update. Free up space, then retry.', + 'desktop.update.downloadPermissionFailed': 'Cannot write the update files. Check the folder permissions and retry.', + 'desktop.update.downloadBusyFailed': 'An update file is in use. Close the program using it, then retry.', 'desktop.update.installFailed': 'Could not install the update. Please try again later.', 'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.', 'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.', @@ -164,6 +170,9 @@ export const ja = { 'desktop.update.downloadDetail': '更新をダウンロード中: {percent}%\n対象バージョン: {version}', 'desktop.update.checkFailed': '更新を確認できませんでした。しばらくしてから再試行してください。', 'desktop.update.downloadFailed': '更新をダウンロードできませんでした。再試行してください。', + 'desktop.update.downloadSpaceFailed': '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。', + 'desktop.update.downloadPermissionFailed': '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。', + 'desktop.update.downloadBusyFailed': '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。', 'desktop.update.installFailed': '更新をインストールできませんでした。しばらくしてから再試行してください。', 'desktop.update.checkNetworkFailed': '更新を確認できませんでした。接続を確認して再試行してください。', 'desktop.update.downloadNetworkFailed': '更新をダウンロードできませんでした。接続を確認して再試行してください。', @@ -228,6 +237,9 @@ export const es = { 'desktop.update.downloadDetail': 'Descargando la actualización: {percent}%\nVersión de destino: {version}', 'desktop.update.checkFailed': 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', 'desktop.update.downloadFailed': 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.', + 'desktop.update.downloadSpaceFailed': 'No hay espacio suficiente para descargar la actualización. Libera espacio y vuelve a intentarlo.', + 'desktop.update.downloadPermissionFailed': 'No se pueden escribir los archivos de actualización. Revisa los permisos de la carpeta y vuelve a intentarlo.', + 'desktop.update.downloadBusyFailed': 'Un archivo de actualización está en uso. Cierra el programa que lo usa y vuelve a intentarlo.', 'desktop.update.installFailed': 'No se pudo instalar la actualización. Inténtelo de nuevo más tarde.', 'desktop.update.checkNetworkFailed': 'No se pudo buscar actualizaciones. Comprueba tu conexión y vuelve a intentarlo.', 'desktop.update.downloadNetworkFailed': 'No se pudo descargar la actualización. Comprueba tu conexión y vuelve a intentarlo.', @@ -292,6 +304,9 @@ export const fr = { 'desktop.update.downloadDetail': 'Téléchargement de la mise à jour : {percent}%\nVersion cible : {version}', 'desktop.update.checkFailed': 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', 'desktop.update.downloadFailed': 'Impossible de télécharger la mise à jour. Veuillez réessayer.', + 'desktop.update.downloadSpaceFailed': 'Espace disque insuffisant pour télécharger la mise à jour. Libérez de l’espace, puis réessayez.', + 'desktop.update.downloadPermissionFailed': 'Impossible d’écrire les fichiers de mise à jour. Vérifiez les autorisations du dossier, puis réessayez.', + 'desktop.update.downloadBusyFailed': 'Un fichier de mise à jour est utilisé. Fermez le programme qui l’utilise, puis réessayez.', 'desktop.update.installFailed': "Impossible d'installer la mise à jour. Veuillez réessayer plus tard.", 'desktop.update.checkNetworkFailed': 'Impossible de vérifier les mises à jour. Vérifiez votre connexion et réessayez.', 'desktop.update.downloadNetworkFailed': 'Impossible de télécharger la mise à jour. Vérifiez votre connexion et réessayez.', @@ -356,6 +371,9 @@ export const de = { 'desktop.update.downloadDetail': 'Update wird heruntergeladen: {percent}%\nZielversion: {version}', 'desktop.update.checkFailed': 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', 'desktop.update.downloadFailed': 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', + 'desktop.update.downloadSpaceFailed': 'Nicht genügend Speicherplatz für das Update. Geben Sie Speicherplatz frei und versuchen Sie es erneut.', + 'desktop.update.downloadPermissionFailed': 'Update-Dateien können nicht geschrieben werden. Prüfen Sie die Ordnerberechtigungen und versuchen Sie es erneut.', + 'desktop.update.downloadBusyFailed': 'Eine Update-Datei wird verwendet. Schließen Sie das verwendende Programm und versuchen Sie es erneut.', 'desktop.update.installFailed': 'Das Update konnte nicht installiert werden. Bitte versuchen Sie es später noch einmal.', 'desktop.update.checkNetworkFailed': 'Es konnte nicht nach Updates gesucht werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', 'desktop.update.downloadNetworkFailed': 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', @@ -420,6 +438,9 @@ export const ru = { 'desktop.update.downloadDetail': 'Загрузка обновления: {percent}%\n Целевая версия: {version}', 'desktop.update.checkFailed': 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', 'desktop.update.downloadFailed': 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.', + 'desktop.update.downloadSpaceFailed': 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.', + 'desktop.update.downloadPermissionFailed': 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.', + 'desktop.update.downloadBusyFailed': 'Файл обновления занят. Закройте использующую его программу и повторите попытку.', 'desktop.update.installFailed': 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.', 'desktop.update.checkNetworkFailed': 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.', 'desktop.update.downloadNetworkFailed': 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.', @@ -484,6 +505,9 @@ export const pt = { 'desktop.update.downloadDetail': 'Fazendo download da atualização: {percent}%\nVersão de destino: {version}', 'desktop.update.checkFailed': 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', 'desktop.update.downloadFailed': 'Não foi possível baixar a atualização. Por favor, tente novamente.', + 'desktop.update.downloadSpaceFailed': 'Não há espaço suficiente para baixar a atualização. Libere espaço e tente novamente.', + 'desktop.update.downloadPermissionFailed': 'Não foi possível gravar os arquivos de atualização. Verifique as permissões da pasta e tente novamente.', + 'desktop.update.downloadBusyFailed': 'Um arquivo de atualização está em uso. Feche o programa que o utiliza e tente novamente.', 'desktop.update.installFailed': 'Não foi possível instalar a atualização. Por favor, tente novamente mais tarde.', 'desktop.update.checkNetworkFailed': 'Não foi possível verificar se há atualizações. Verifique sua conexão e tente novamente.', 'desktop.update.downloadNetworkFailed': 'Não foi possível baixar a atualização. Verifique sua conexão e tente novamente.', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index eb69b58020..f3d830eff6 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -7,6 +7,9 @@ export type DesktopUpdateFailureKind = | 'check-network' | 'download' | 'download-network' + | 'download-space' + | 'download-permission' + | 'download-busy' | 'install' | 'install-network' | 'stop-failed' From 26c077aa5fa35c42d6ad15ec957f9f728b9e3f9d Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 14:34:28 +0800 Subject: [PATCH 17/25] test(desktop): verify settings recovery in the browser --- apps/desktop/tests/main-startup.spec.ts | 6 +++--- apps/desktop/tests/update-discard.spec.ts | 2 +- apps/web/tests/desktop-channel.e2e.ts | 11 ++++++++++- apps/web/tests/desktop-updates.e2e.ts | 11 +++++------ .../expected/desktop-channel/save-error.expected.md | 2 +- .../tests/expected/desktop-channel/stable.expected.md | 2 +- .../src/client/DesktopChannelRow.tsx | 4 ++-- .../ui-settings-general/tests/apply.client.spec.ts | 6 +++++- 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 001682a9e6..a6bca78ec9 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -239,6 +239,7 @@ const harness = await vi.hoisted(async () => { set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value }, dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() }, openExternal: vi.fn(async () => {}), + showItemInFolder: vi.fn(), protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise) => void>(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), disableAllPlugins: vi.fn(async () => { @@ -304,7 +305,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, WebContentsView: harness.FakeView, dialog: harness.dialog, - shell: { openExternal: harness.openExternal, showItemInFolder: vi.fn() }, + shell: { openExternal: harness.openExternal, showItemInFolder: harness.showItemInFolder }, nativeTheme: harness.nativeTheme, net: { fetch: vi.fn() }, ipcMain: { @@ -579,8 +580,7 @@ describe('desktop main startup', () => { buttons: [en.updateShowSettingsFile, en.updateClose], cancelId: 1, }) expect(en.updateConfigurationFormat).not.toContain('select') - const { shell } = await import('electron') - expect(shell.showItemInFolder).toHaveBeenCalledWith(path) + expect(harness.showItemInFolder).toHaveBeenCalledWith(path) expect(readFileSync(path, 'utf8')).toBe('{broken') }) diff --git a/apps/desktop/tests/update-discard.spec.ts b/apps/desktop/tests/update-discard.spec.ts index e8ae7f83e7..17a4b54aec 100644 --- a/apps/desktop/tests/update-discard.spec.ts +++ b/apps/desktop/tests/update-discard.spec.ts @@ -39,9 +39,9 @@ it('retires the target despite a removal failure and persists its exact filesyst expect(await readFile(file, 'utf8')).toBe('obsolete') const lines = (await readFile(join(environment.logs, 'update-cleanup.jsonl'), 'utf8')).trim().split('\n') expect(lines).toHaveLength(1) + expect(lines[0]).toMatch(/"time":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"/u) expect(JSON.parse(lines[0]!)).toMatchObject({ event: 'update-cache-cleanup-failed', operation: 'remove', path: file, code: 'EPERM', reason: 'file is locked' }) - expect(Number.isNaN(Date.parse(JSON.parse(lines[0]!).time))).toBe(false) }) it('does not retire a target that the native installer already owns', async () => { diff --git a/apps/web/tests/desktop-channel.e2e.ts b/apps/web/tests/desktop-channel.e2e.ts index cc0bf9c830..a1f3f7cfff 100644 --- a/apps/web/tests/desktop-channel.e2e.ts +++ b/apps/web/tests/desktop-channel.e2e.ts @@ -19,6 +19,8 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on let state: DesktopChannelState = { channel: 'nightly', packagedChannel: 'nightly', changing: false } const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'en-US' }) const tripwire = watchConsole(page) + let detailOpens = 0 + await page.exposeFunction('openUpdateDetails', () => { detailOpens++ }) await page.exposeFunction('readChannel', () => state) await page.exposeFunction('setChannel', async (channel: 'nightly' | 'stable'): Promise => { try { @@ -30,10 +32,11 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on await page.addInitScript(() => { const bridge = globalThis as typeof globalThis & { readChannel(): Promise + openUpdateDetails(): Promise setChannel(channel: 'nightly' | 'stable'): Promise } Object.assign(globalThis, { dshDesktop: { protocolVersion: 1, updates: { - status: async () => ({ phase: 'idle' }), open: async () => {}, subscribe: () => () => {}, + status: async () => ({ phase: 'idle' }), open: () => bridge.openUpdateDetails(), subscribe: () => () => {}, channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'stable') => bridge.setChannel(channel), subscribe: () => () => {} }, } } }) @@ -62,6 +65,12 @@ it('persists a Desktop channel from Settings and keeps external edits startup-on await compareOrRefreshGolden(fileURLToPath(new URL('./expected/desktop-channel/save-error.expected.md', import.meta.url)), await captureStableAria(page, '[role="group"][aria-label="Update channel"]', scaffold.workspaceCwd) + '\n' + await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), webSnapshotMode()) + state = { ...state, error: 'format' } + await page.reload({ waitUntil: 'load' }) + await page.getByRole('alert').filter({ hasText: 'Settings file error; updates are paused' }).waitFor() + await page.getByRole('button', { name: 'View details', exact: true }).click() + await expect.poll(() => detailOpens).toBe(1) + expect(await readFile(settings.path, 'utf8')).toBe('{broken') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) } finally { await browser.close() } diff --git a/apps/web/tests/desktop-updates.e2e.ts b/apps/web/tests/desktop-updates.e2e.ts index 87903d8050..ac596249b3 100644 --- a/apps/web/tests/desktop-updates.e2e.ts +++ b/apps/web/tests/desktop-updates.e2e.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { describe, expect, it } from 'vitest' import { presentDesktopUpdate } from '../../desktop/src/update-presentation.ts' -import { en } from '../../desktop/src/locale.ts' import { launchWebScaffold, watchConsole } from './scaffold.ts' // Mirrors the preload's presentation-only API; importing Client projects would mix compiler faces. @@ -53,8 +52,8 @@ describe('web e2e: Desktop update workspace chrome', () => { const errorDetail = locale === 'zh-CN' ? '下载更新失败,请重试。' : 'Could not download the update. Please try again.' const readyLabel = locale === 'zh-CN' ? '安装并重启' : 'Install and Restart' const version = '0.1.5-nightly.20260911' - // The carrier classification deliberately uses English shell copy; Web copy follows its own locale. - const available = presentDesktopUpdate({ phase: 'available', version }, en) + // The carrier exposes semantic states; Web copy follows its own locale. + const available = presentDesktopUpdate({ phase: 'available', version }) const publish = async (state: Presentation) => page.evaluate((value) => { (window as FixtureWindow).updateFixture.publish(value) }, state) @@ -71,7 +70,7 @@ describe('web e2e: Desktop update workspace chrome', () => { await update.click() await expect.poll(opens).toBe(1) - const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en) + const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }) await publish(progress) const downloading = page.getByRole('button', { name: '58%', exact: true }) await downloading.waitFor() @@ -93,7 +92,7 @@ describe('web e2e: Desktop update workspace chrome', () => { const updateBackground = await badge.evaluate(element => getComputedStyle(element).backgroundColor) await page.screenshot({ path: join(evidence, 'collapsed.png') }) - const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en) + const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }) await publish(error) const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true }) await errorBadge.waitFor() @@ -121,7 +120,7 @@ describe('web e2e: Desktop update workspace chrome', () => { await retry.click() await expect.poll(opens).toBe(2) - await publish(presentDesktopUpdate({ phase: 'ready', version }, en)) + await publish(presentDesktopUpdate({ phase: 'ready', version })) const ready = page.getByRole('button', { name: readyLabel, exact: true }) await ready.waitFor() expect(await ready.getAttribute('aria-disabled')).toBe('false') diff --git a/apps/web/tests/expected/desktop-channel/save-error.expected.md b/apps/web/tests/expected/desktop-channel/save-error.expected.md index 778dd1b16e..00b1838755 100644 --- a/apps/web/tests/expected/desktop-channel/save-error.expected.md +++ b/apps/web/tests/expected/desktop-channel/save-error.expected.md @@ -1,4 +1,4 @@ - group "Update channel": - - text: Update channel Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades. + - text: Update channel Stable is suitable for everyday use. Development offers early access to new features but may be unstable. - button "Update channel": Stable - alert: "Could not save: invalid settings file" diff --git a/apps/web/tests/expected/desktop-channel/stable.expected.md b/apps/web/tests/expected/desktop-channel/stable.expected.md index 0f9a68b006..4f93fab271 100644 --- a/apps/web/tests/expected/desktop-channel/stable.expected.md +++ b/apps/web/tests/expected/desktop-channel/stable.expected.md @@ -1,3 +1,3 @@ - group "Update channel": - - text: Update channel Stable receives stable releases only. Development includes prereleases and may be unstable. Switching never downgrades. + - text: Update channel Stable is suitable for everyday use. Development offers early access to new features but may be unstable. - button "Update channel": Stable diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx index 0d4bae94fa..9be6992cb6 100644 --- a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -63,7 +63,7 @@ export function DesktopChannelToast({ useChannel, dismiss, openConfiguration, t const notice = useChannel(value => value.notice) if (notice === undefined) return null return { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : {}} /> } diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index f262157136..fd983d46dc 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -216,8 +216,9 @@ describe('ui-settings-general apply', () => { const save = vi.fn(async () => ({ kind: 'applied' as const, state })) const channels: DesktopChannelBridge = { get: async () => state, set: save, subscribe: () => unsubscribe } + const open = vi.fn(async () => {}) vi.stubGlobal('dshDesktop', { protocolVersion: 1, updates: { channels, - status: async () => ({ phase: 'idle' }), open: async () => {}, subscribe: () => () => {} } }) + status: async () => ({ phase: 'idle' }), open, subscribe: () => () => {} } }) onTestFinished(() => { vi.unstubAllGlobals() }) const { c } = await client(mock, start) const row = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'desktop-update-channel')! @@ -229,6 +230,9 @@ describe('ui-settings-general apply', () => { const injected = toast.inject!() const hooks = injected.hooks as DesktopChannelToastInjected['hooks'] const dismiss = injected.dismiss as DesktopChannelToastInjected['dismiss'] + const openConfiguration = injected.openConfiguration as DesktopChannelToastInjected['openConfiguration'] + openConfiguration() + expect(open).toHaveBeenCalledOnce() expect(hooks.channel.getSnapshot().notice?.outcome).toBe('applied') dismiss(1) expect(hooks.channel.getSnapshot().notice).toBeUndefined() From f387930ebe75db3b73c8932351e745ec7955cf31 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 15:17:46 +0800 Subject: [PATCH 18/25] docs(desktop): align channel guidance with saved settings --- apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- apps/desktop/publishing/README.i18n.yaml | 4 ++-- apps/desktop/publishing/README.md | 2 +- apps/desktop/publishing/README.zh.md | 2 +- apps/desktop/src/updates/electron-updater/channel.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index c9e8ef139e..86f6278071 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 8d09a324cde88ae1 - zh: 308d4902bd01f046 + en: 8cc17b1e5ae3e422 + zh: 7de3e4f1e76be3e3 /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 96328662e0..9c703bb8e6 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 84581366d2..433379a045 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index ae50a03293..e74d7c1ff0 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -21,8 +21,8 @@ en: eed7c4ed0d4e2758 zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: - en: 81b0df7268e80a87 - zh: 35a3dc184f9a4442 + en: 4723059d6f16b25b + zh: 54e5afad8fff8eb6 /desktop-publication/fixed-installer-downloads: en: ce8296f8f1ee881e zh: 749ab63bd6ed4248 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 2e5ead449f..1584a106e8 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -59,7 +59,7 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Deployment and update channel are independent; see [package channel selection](../packaging/README.md#release-versions). Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Each package publishes only its selected channel: `nightly.yml` / `nightly-mac.yml` or `stable.yml` / `stable-mac.yml`. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 86594f9ddf..b19afbb68f 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -59,7 +59,7 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。部署与更新渠道相互独立;参见[安装包渠道选择](../packaging/README.zh.md#release-versions)。 前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个包仅发布自身选定渠道的清单:`nightly.yml` / `nightly-mac.yml` 或 `stable.yml` / `stable-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts index 169d0cf01b..06f07627b2 100644 --- a/apps/desktop/src/updates/electron-updater/channel.ts +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -2,7 +2,7 @@ import type { AppUpdater } from 'electron-updater' /** - * Select a feed without permitting downgrades or prereleases on latest. + * Select a feed without permitting downgrades or prereleases on stable. * @param updater - Updater whose prior operations have settled. * @param channel - Selected distribution channel. */ From c05a34e1531e28fc647f6e59305d147f2949adf3 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 15:48:13 +0800 Subject: [PATCH 19/25] feat(desktop): reload update settings from configuration errors --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/update-dialog.css | 5 ++ apps/desktop/renderer/update-dialog.js | 8 ++- apps/desktop/src/locale.ts | 56 +++++++++++-------- apps/desktop/src/main.ts | 48 +++++++++++++--- apps/desktop/src/update-dialog.ts | 40 +++++++++++-- apps/desktop/tests/main-startup.spec.ts | 24 +++++++- .../tests/update-configuration.spec.ts | 8 +-- apps/desktop/tests/update-dialog.spec.ts | 36 ++++++++++++ .../src/client/DesktopChannelRow.tsx | 4 +- .../tests/components.client.spec.tsx | 1 + 13 files changed, 188 insertions(+), 50 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ad1dc899fa..ab70a0cb9b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 56fe9d6449691818 - zh: 3abfb88538c1d90e + en: d799e2296e569a98 + zh: 9f312ff06cbe3abf /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0b6d8a1451..2d81f49784 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -306,7 +306,7 @@ Startup loading never overwrites existing documents; an omitted `updates` object In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. -During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file and restart, or select a valid channel in Settings; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. +During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index cac66aa39e..e057362bd1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -306,7 +306,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 -UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后重启,或在设置中选择有效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index dd50411d46..583339a238 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -35,3 +35,8 @@ body.update-dialog.visible { opacity: 1; } .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } .secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); } } + +button[aria-busy="true"] { opacity: 1; display: flex; align-items: center; justify-content: center; gap: 8px; } +button[aria-busy="true"]::before { content: ""; width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: reload-spin 800ms linear infinite; } +@keyframes reload-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { button[aria-busy="true"]::before { animation: none; } } diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index d03eadadf7..44102bc012 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -3,7 +3,7 @@ const api = window.dshUpdateDialog let view let responding = false function respond(index) { - if (responding || view === undefined) return + if (responding || view === undefined || view.busyButton !== undefined) return responding = true void api.respond(view.revision, index).catch(() => { responding = false }) } @@ -11,7 +11,8 @@ document.getElementById('close').addEventListener('click', () => { respond(view. document.addEventListener('keydown', event => { if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) } if (event.key !== 'Tab') return - const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + const controls = [...document.querySelectorAll('button:not(:disabled), details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + if (controls.length === 0) { event.preventDefault(); return } const current = controls.indexOf(document.activeElement) const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length @@ -32,6 +33,7 @@ function render(state) { document.getElementById('title').textContent = state.message document.getElementById('detail').textContent = state.detail document.getElementById('detail').hidden = state.detail === '' + document.getElementById('close').disabled = state.busyButton !== undefined document.getElementById('close').setAttribute('aria-label', state.closeLabel) document.getElementById('technical-details').hidden = state.technicalDetails === '' document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel @@ -42,6 +44,8 @@ function render(state) { const button = document.createElement('button') button.type = 'button' button.textContent = label + button.disabled = state.busyButton !== undefined + if (state.busyButton === index) button.setAttribute('aria-busy', 'true') button.className = index === 0 ? 'primary' : 'secondary' button.addEventListener('click', () => { respond(index) }) document.getElementById('actions').append(button) diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 3991e4bc40..a9d0fb4c3c 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -107,10 +107,11 @@ export const en = { toggleDevToolsMenu: 'Developer tools', restartAppHostMenu: 'Restart App and Host', updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', - updateConfigurationRead: 'Updates paused: cannot read settings file. Fix the settings file and restart.', - updateConfigurationFormat: 'Updates paused: invalid settings file. Fix the settings file and restart.', - updateConfigurationChannel: 'Updates paused: channel must be nightly or stable. Fix the settings file and restart, or select the channel again in Settings.', + updateConfigurationRead: 'Settings file error; updates are paused', + updateConfigurationFormat: 'Settings file error; updates are paused', + updateConfigurationChannel: 'Settings file error; updates are paused', updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', + updateReloadSettings: 'Reload settings', updateShowSettingsFile: 'Show settings file', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', @@ -296,10 +297,11 @@ export const zh = { toggleDevToolsMenu: '开发者工具', restartAppHostMenu: '重启应用与 Host', updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', - updateConfigurationRead: '更新已暂停:无法读取配置文件。请修正配置文件后重启', - updateConfigurationFormat: '更新已暂停:配置文件格式有误。请修正配置文件后重启', - updateConfigurationChannel: '更新已暂停:channel 只能是 nightly 或 stable。请修正配置文件后重启,或在设置中重新选择渠道', + updateConfigurationRead: '配置文件异常,更新已暂停', + updateConfigurationFormat: '配置文件异常,更新已暂停', + updateConfigurationChannel: '配置文件异常,更新已暂停', updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', + updateReloadSettings: '重新读取配置', updateShowSettingsFile: '显示配置文件', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', @@ -483,10 +485,11 @@ export const ja = { toggleDevToolsMenu: '開発者ツール', restartAppHostMenu: 'アプリとホストを再起動', updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', - updateConfigurationRead: '更新を停止中:設定ファイルを読み取れません. 設定ファイルを修正して再起動してください。', - updateConfigurationFormat: '更新を停止中:設定ファイルの形式が不正です. 設定ファイルを修正して再起動してください。', - updateConfigurationChannel: '更新を停止中:channel は nightly または stable にしてください. 設定ファイルを修正して再起動するか、設定でチャンネルを選び直してください。', + updateConfigurationRead: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationFormat: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています', updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', + updateReloadSettings: '設定を再読み込み', updateShowSettingsFile: '設定ファイルを表示', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', @@ -670,10 +673,11 @@ export const es = { toggleDevToolsMenu: 'Herramientas de desarrollo', restartAppHostMenu: 'Reiniciar la aplicación y el host', updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', - updateConfigurationRead: 'Actualizaciones pausadas: no se puede leer la configuración. Corrige el archivo y reinicia.', - updateConfigurationFormat: 'Actualizaciones pausadas: archivo de configuración no válido. Corrige el archivo y reinicia.', - updateConfigurationChannel: 'Actualizaciones pausadas: channel debe ser nightly o stable. Corrige el archivo y reinicia, o vuelve a seleccionar el canal en Ajustes.', + updateConfigurationRead: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationFormat: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationChannel: 'Error en el archivo de ajustes; actualizaciones pausadas', updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', + updateReloadSettings: 'Volver a cargar ajustes', updateShowSettingsFile: 'Mostrar archivo de ajustes', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', @@ -857,10 +861,11 @@ export const fr = { toggleDevToolsMenu: 'Outils de développement', restartAppHostMenu: "Redémarrez l'application et l'hôte", updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', - updateConfigurationRead: 'Mises à jour suspendues : lecture des paramètres impossible. Corrigez le fichier et redémarrez.', - updateConfigurationFormat: 'Mises à jour suspendues : fichier de paramètres invalide. Corrigez le fichier et redémarrez.', - updateConfigurationChannel: 'Mises à jour suspendues : channel doit être nightly ou stable. Corrigez le fichier et redémarrez, ou sélectionnez à nouveau le canal dans les paramètres.', + updateConfigurationRead: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationFormat: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationChannel: 'Erreur du fichier de paramètres ; mises à jour suspendues', updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', + updateReloadSettings: 'Recharger les paramètres', updateShowSettingsFile: 'Afficher le fichier de paramètres', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', @@ -1044,10 +1049,11 @@ export const de = { toggleDevToolsMenu: 'Entwicklertools', restartAppHostMenu: 'Anwendung und Host neu starten', updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', - updateConfigurationRead: 'Updates pausiert: Einstellungsdatei nicht lesbar. Korrigiere die Datei und starte neu.', - updateConfigurationFormat: 'Updates pausiert: ungültige Einstellungsdatei. Korrigiere die Datei und starte neu.', - updateConfigurationChannel: 'Updates pausiert: channel muss nightly oder stable sein. Korrigiere die Datei und starte neu, oder wähle den Kanal erneut in den Einstellungen.', + updateConfigurationRead: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationFormat: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationChannel: 'Fehler in der Einstellungsdatei; Updates pausiert', updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', + updateReloadSettings: 'Einstellungen neu laden', updateShowSettingsFile: 'Einstellungsdatei anzeigen', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', @@ -1231,10 +1237,11 @@ export const ru = { toggleDevToolsMenu: 'Инструменты разработчика', restartAppHostMenu: 'Перезапустите приложение и хост.', updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', - updateConfigurationRead: 'Обновления приостановлены: не удалось прочитать настройки. Исправьте файл и перезапустите приложение', - updateConfigurationFormat: 'Обновления приостановлены: неверный формат файла настроек. Исправьте файл и перезапустите приложение', - updateConfigurationChannel: 'Обновления приостановлены: channel должен быть nightly или stable. Исправьте файл и перезапустите приложение или снова выберите канал в настройках.', + updateConfigurationRead: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationFormat: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены', updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', + updateReloadSettings: 'Перечитать настройки', updateShowSettingsFile: 'Показать файл настроек', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', @@ -1418,10 +1425,11 @@ export const pt = { toggleDevToolsMenu: 'Ferramentas do desenvolvedor', restartAppHostMenu: 'Reinicie o aplicativo e o host', updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', - updateConfigurationRead: 'Atualizações pausadas: não foi possível ler as configurações. Corrija o arquivo e reinicie.', - updateConfigurationFormat: 'Atualizações pausadas: arquivo de configurações inválido. Corrija o arquivo e reinicie.', - updateConfigurationChannel: 'Atualizações pausadas: channel deve ser nightly ou stable. Corrija o arquivo e reinicie, ou selecione o canal novamente nas configurações.', + updateConfigurationRead: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationFormat: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationChannel: 'Erro no arquivo de configurações; atualizações pausadas', updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', + updateReloadSettings: 'Recarregar configurações', updateShowSettingsFile: 'Mostrar arquivo de configurações', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0c538dd17f..d4ad4bfcc8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -939,18 +939,50 @@ async function main(): Promise { await openUpdatePrompt() }) + let configurationPrompt: Promise | undefined + const showConfigurationError = (): Promise => { + configurationPrompt ??= Promise.resolve().then(async () => { + while (channelState?.error !== undefined) { + const settingsError = channelState.error !== 'cleanup' + const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, + message: configurationMessage(channelState.error, locale.messages), + ...(settingsError ? { detail: channelSettings.path, + buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose], + defaultId: 0, cancelId: 2, action: { index: 0, run: async () => { + if (channelRequests > 0 || channelState === undefined) return false + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + const loaded = await channelSettings.load() + if (loaded.kind === 'error') { + publishChannel({ ...channelState, changing: true, error: configurationFailure(loaded.error) }) + return false + } + await updates.switchChannel(loaded.channel, async () => {}) + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true }) + return true + } catch (error) { + console.error('desktop update: settings reload failed', error) + publishChannel({ ...channelState, + ...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) }) + return false + } finally { + publishChannel({ ...channelState, changing: --channelRequests > 0 }) + } + } } } : {}), + }) + if (!settingsError || result.response === 2) return + if (result.response === 1) { shell.showItemInFolder(channelSettings.path); return } + } + }).finally(() => { configurationPrompt = undefined }) + return configurationPrompt + } + let promptOperation: Promise | undefined let policyAuthenticationQueued = false const openUpdatePrompt = (manual = false): Promise => { if (channelState?.error !== undefined) { - const settingsError = channelState.error !== 'cleanup' - return ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, - message: configurationMessage(channelState.error, locale.messages), - ...(settingsError ? { detail: channelSettings.path, - buttons: [locale.messages.updateShowSettingsFile, locale.messages.updateClose], cancelId: 1 } : {}), - }).then((result) => { - if (settingsError && result.response === 0) shell.showItemInFolder(channelSettings.path) - }) + return showConfigurationError() } if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index e7a7916028..4658f942aa 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -8,6 +8,8 @@ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: /** Text and choices supplied by the main process, never by product documents. */ export interface UpdateDialogView { + /** Button running a main-owned action; responses are disabled until it settles. */ + readonly busyButton?: number /** Identifies the displayed choices; a response from an older prompt is rejected. */ readonly revision: number readonly locale: string @@ -24,6 +26,8 @@ export interface UpdateDialogView { /** Electron message options with separately expandable, main-owned diagnostics. */ export interface UpdateDialogOptions extends MessageBoxOptions { readonly technicalDetails?: string + /** Main-owned action kept in this dialog; false keeps the prompt open for correction. */ + readonly action?: { index: number; run: () => Promise } } /** The document can select only a displayed response index. */ @@ -43,7 +47,11 @@ export class DesktopUpdateDialog { private view: DesktopUpdateOverlay | undefined private parent: BrowserWindow | undefined private closing: ReturnType | undefined - private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined + private active: { + view: UpdateDialogView + finish: (index: number, retain?: boolean) => void + respond: (index: number) => Promise + } | undefined /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { @@ -71,7 +79,8 @@ export class DesktopUpdateDialog { || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { throw new Error('desktop update: invalid dialog response') } - active.finish(index) + if (active.view.busyButton !== undefined) return + return active.respond(index) }) } @@ -95,7 +104,7 @@ export class DesktopUpdateDialog { const surface = existing ?? this.overlays.create(parent, this.preload) this.view = surface this.parent = parent - const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, + let view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose, technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: locale.messages.updateTechnicalDetails } return new Promise((resolve) => { @@ -110,7 +119,30 @@ export class DesktopUpdateDialog { } resolve({ response, checkboxChecked: false }) } - this.active = { view, finish } + const respond = async (index: number): Promise => { + if (options.action?.index !== index) { finish(index); return } + const publish = (busy: boolean): void => { + const { busyButton: _busy, ...rest } = view + view = { ...rest, revision: ++this.revision, ...(busy ? { busyButton: index } : {}) } + if (this.active !== undefined) this.active.view = view + surface.webContents.send(UPDATE_DIALOG_IPC.changed, view) + } + publish(true) + // Keep a fast local read perceptible without delaying the operation itself. + const held = new Promise((resolveHold) => { setTimeout(resolveHold, 500) }) + try { + const close = await options.action.run() + await held + if (this.active?.view !== view) return + if (close) finish(index) + else publish(false) + } catch (error) { + await held + if (this.active?.view === view) publish(false) + throw error + } + } + this.active = { view, finish, respond } options.signal?.addEventListener('abort', abort, { once: true }) if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index a6bca78ec9..6d4ccf30b5 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -573,17 +573,37 @@ describe('desktop main startup', () => { const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') writeFileSync(path, '{broken') await readyForUpdate() - harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) await invoke(DESKTOP_IPC.updatesOpen, 'app') expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({ message: en.updateConfigurationFormat, detail: path, - buttons: [en.updateShowSettingsFile, en.updateClose], cancelId: 1, + buttons: [en.updateReloadSettings, en.updateShowSettingsFile, en.updateClose], cancelId: 2, }) expect(en.updateConfigurationFormat).not.toContain('select') expect(harness.showItemInFolder).toHaveBeenCalledWith(path) expect(readFileSync(path, 'utf8')).toBe('{broken') }) + it('rereads a repaired file without writing it or restarting the shell', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + await readyForUpdate() + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(await options.action!.run()).toBe(false) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ error: 'format', changing: false }) + const repaired = JSON.stringify({ updates: { channel: 'stable' }, retained: 'keep' }) + writeFileSync(path, repaired) + expect(await options.action!.run()).toBe(true) + expect(readFileSync(path, 'utf8')).toBe(repaired) + return { response: 0 } + }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const state = await invoke(DESKTOP_IPC.updatesChannelGet, 'app') + expect(state).toMatchObject({ channel: 'stable', changing: false }) + expect(state).not.toHaveProperty('error') + }) + it('shows the invalid startup field and file path until a valid channel is applied', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') diff --git a/apps/desktop/tests/update-configuration.spec.ts b/apps/desktop/tests/update-configuration.spec.ts index 571fb7c1bc..a084fa4bea 100644 --- a/apps/desktop/tests/update-configuration.spec.ts +++ b/apps/desktop/tests/update-configuration.spec.ts @@ -27,10 +27,10 @@ it('only exposes a permission hint for a known filesystem permission failure', ( expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path', { cause: 'private detail' }))).toBe('save') }) -it('provides distinct localized reasons and recovery without raw errors', () => { - expect(configurationMessage('read', en)).toContain('cannot read settings') - expect(configurationMessage('format', en)).toContain('invalid settings') - expect(configurationMessage('channel', zh)).toContain('nightly 或 stable') +it('keeps settings errors concise and cleanup recovery distinct', () => { + expect(configurationMessage('read', en)).toBe('Settings file error; updates are paused') + expect(configurationMessage('format', en)).toBe('Settings file error; updates are paused') + expect(configurationMessage('channel', zh)).toBe('配置文件异常,更新已暂停') expect(configurationMessage('cleanup', zh)).toContain('请重启应用') expect(configurationMessage('cleanup', en)).not.toContain('settings file') }) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 1c43e37037..ec93bc2a19 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -214,3 +214,39 @@ it('reads the current locale and keeps technical diagnostics separate from ordin await pending } }) + + +it('keeps a reload action visible for at least half a second and prevents repeat responses', async () => { + vi.useFakeTimers() + const f = setup() + const run = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const pending = f.show(undefined, { action: { index: 0, run } }) + const first = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ busyButton: 0 }) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect(run).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(499) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ busyButton: 0 }) + await vi.advanceTimersByTimeAsync(1) + await first + expect(dialogs!.isOpen).toBe(true) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).not.toHaveProperty('busyButton') + const second = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + await vi.advanceTimersByTimeAsync(500) + await second + expect(await pending).toMatchObject({ response: 0 }) +}) + +it('does not revive a busy prompt after cancellation', async () => { + vi.useFakeTimers() + const f = setup() + const action = Promise.withResolvers() + const pending = f.show(undefined, { action: { index: 0, run: () => action.promise } }) + const responding = f.invoke(UPDATE_DIALOG_IPC.respond, 0) + dialogs!.cancel() + action.resolve(false) + await vi.advanceTimersByTimeAsync(500) + await responding + expect(await pending).toMatchObject({ response: 1 }) + expect(dialogs!.isOpen).toBe(false) +}) diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx index 9be6992cb6..6c9c213e13 100644 --- a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -1,6 +1,6 @@ /** Desktop channel selection; persistence and updater transitions remain in Electron. */ import { useState } from 'react' -import { Menu, Toast, IconLoadingOutlineRegular, IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, Toast, IconWarningOutlineRegular, IconLoadingOutlineRegular, IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { DesktopChannelSource } from './desktop-channel-source.ts' import type { DesktopUpdateChannel } from '../types.ts' @@ -65,5 +65,5 @@ export function DesktopChannelToast({ useChannel, dismiss, openConfiguration, t return { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : {}} /> + onDone={() => { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : { icon: }} /> } diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 7a49679085..8bad7f073e 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -411,6 +411,7 @@ it('keeps the settings-file notification for ten seconds with a details action', const view = render() try { + expect(screen.getByRole('alert').querySelector('svg')).not.toBeNull() fireEvent.click(screen.getByRole('button', { name: 'View details' })) expect(openConfiguration).toHaveBeenCalledOnce() await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) From 88b01c24ea3f79c1499f86de4ff638ef07cf737e Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 2 Nov 2026 17:12:00 +0800 Subject: [PATCH 20/25] fix(desktop): discard stale channel check prompts and align error copy --- apps/desktop/src/main.ts | 2 ++ apps/desktop/tests/locale.spec.ts | 6 +++++ apps/desktop/tests/main-startup.spec.ts | 22 +++++++++++++++++++ .../ui-settings-general/src/client/locales.ts | 2 +- .../tests/components.client.spec.tsx | 4 ++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d4ad4bfcc8..78207cb6bf 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -980,6 +980,7 @@ async function main(): Promise { let promptOperation: Promise | undefined let policyAuthenticationQueued = false + /** Present check results only while their captured channel revision remains current. */ const openUpdatePrompt = (manual = false): Promise => { if (channelState?.error !== undefined) { return showConfigurationError() @@ -1013,6 +1014,7 @@ async function main(): Promise { } state = await updateSchedule.check(true) } + if (revision !== updates.revision) return if (isMandatory()) { mandatoryUI?.focus(); return } if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } if (state.phase === 'idle') { diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index b53a02e8fb..d1de8206f7 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest' import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh, es, fr, de, ru, pt } from '../src/locale.ts' describe('desktop locale dictionaries', () => { + it('uses the same concise Chinese configuration error in the toast and details', () => { + for (const message of [zh.updateConfigurationRead, zh.updateConfigurationFormat, zh.updateConfigurationChannel]) { + expect(message).toBe('配置文件异常,更新已暂停') + } + }) + it('ships baseline and Japanese keys and falls back for unsupported languages', () => { expect(Object.keys(zh)).toEqual(Object.keys(en)) expect(Object.keys(ja)).toEqual(Object.keys(en)) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 6d4ccf30b5..d3a92e7b62 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1889,6 +1889,28 @@ describe('desktop main startup', () => { })) }) + it.each([ + { phase: 'available', version: '1.0.3' }, + { phase: 'idle' }, + { phase: 'error', failedOperation: 'check', message: 'Old feed unavailable' }, + ] satisfies DesktopUpdateState[])('discards a manual check result after switching channel: $phase', async (result) => { + await readyForUpdate() + const checked = Promise.withResolvers() + const requested = Promise.withResolvers() + harness.updateCheck.mockImplementationOnce(() => { requested.resolve(undefined); return checked.promise }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + const action = applicationMenuItems().find(item => item.label === 'Check for Updates…') + Reflect.apply(action!.click!, undefined, []) + const prompt = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')) + await requested.promise + await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable') + checked.resolve(result) + await prompt + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(harness.updateDownload).not.toHaveBeenCalled() + expect(harness.updateInstall).not.toHaveBeenCalled() + }) + it('reports a manual check failure without offering a download', async () => { await readyForUpdate() harness.updateCheck.mockResolvedValueOnce({ phase: 'error', failedOperation: 'check', message: 'Feed unavailable' }) diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 9791e9cda5..33787eb8a0 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -10,7 +10,7 @@ export const zh = { 'desktop.channel.error': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.update.configurationFailed': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.channel.result.applied': '更新渠道已切换', - 'desktop.channel.result.configuration': '设置文件异常,更新已暂停', + 'desktop.channel.result.configuration': '配置文件异常,更新已暂停', 'desktop.channel.details': '查看详情', 'desktop.channel.result.save': '保存失败', 'desktop.channel.result.format': '保存失败,配置文件格式有误', diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 8bad7f073e..f09b1a6778 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -420,3 +420,7 @@ it('keeps the settings-file notification for ten seconds with a details action', expect(dismiss).toHaveBeenCalledWith(8) } finally { view.unmount(); vi.useRealTimers() } }) + +it('uses the concise Chinese configuration error for the settings notification', () => { + expect(zh['desktop.channel.result.configuration']).toBe('配置文件异常,更新已暂停') +}) From 224fe9a658a2a3f57969ad5cbbe450fede3b6cc4 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 11:26:57 +0800 Subject: [PATCH 21/25] fix(desktop): distinguish committed and uncertain settings saves --- apps/desktop/src/main.ts | 10 ++++- apps/desktop/src/update-configuration.ts | 1 + apps/desktop/src/update-settings.ts | 16 +++++-- apps/desktop/tests/main-startup.spec.ts | 43 +++++++++++++++++++ apps/desktop/tests/settings-file.spec.ts | 37 ++++++++++++++++ .../tests/update-configuration.spec.ts | 8 +++- apps/desktop/tests/update-settings.spec.ts | 20 ++++++++- .../src/client/desktop-channel-source.ts | 3 +- .../ui-settings-general/src/client/locales.ts | 8 ++++ .../client/ui-settings-general/src/types.ts | 2 +- .../tests/components.client.spec.tsx | 4 ++ .../tests/desktop-channel.client.spec.ts | 13 ++++++ 12 files changed, 156 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 78207cb6bf..0b493ae643 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -902,7 +902,15 @@ async function main(): Promise { channelRequests++ publishChannel({ ...channelState, changing: true }) try { - const kind = await updates.switchChannel(channel, () => channelSettings.save(channel)) + const kind = await updates.switchChannel(channel, async () => { + try { await channelSettings.save(channel) } catch (error) { + if (channelSaveFailure(error) === 'uncertain') { + updates.suspendConfiguration('desktop update: settings save outcome is unknown') + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true, error: 'read' }) + } + throw error + } + }) return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } } catch (error) { console.error('desktop update: channel change failed', error) diff --git a/apps/desktop/src/update-configuration.ts b/apps/desktop/src/update-configuration.ts index 7952b1d690..7e732a4a6a 100644 --- a/apps/desktop/src/update-configuration.ts +++ b/apps/desktop/src/update-configuration.ts @@ -21,6 +21,7 @@ export function configurationFailure(error: DesktopSettingsFileError): NonNullab */ export function channelSaveFailure(error: unknown): Extract['reason'] { if (!(error instanceof DesktopSettingsFileError)) return 'apply' + if (error.problem.kind === 'write' && error.problem.effect === 'unknown') return 'uncertain' if (error.problem.kind === 'invalid') return 'format' const cause = error.cause if (cause instanceof Error && 'code' in cause && (cause.code === 'EACCES' || cause.code === 'EPERM')) return 'permission' diff --git a/apps/desktop/src/update-settings.ts b/apps/desktop/src/update-settings.ts index 30a1964ecb..bad7f6ee2b 100644 --- a/apps/desktop/src/update-settings.ts +++ b/apps/desktop/src/update-settings.ts @@ -1,5 +1,6 @@ /** Channel-specific default and failure policy over the shared shell settings document. */ -import { DesktopSettingsFile, type DesktopSettingsFileError } from './settings-file.ts' +import { DesktopSettingsFile, DesktopSettingsFileError } from './settings-file.ts' +import { appendFileSync } from 'node:fs' import type { DesktopUpdateChannel } from './update-channel.ts' /** Invalid explicit channels suspend ordinary updates; only absence uses the package default. */ @@ -34,9 +35,18 @@ export class DesktopUpdateSettings { /** * Save a selection while preserving other fields; never changes the runtime selection itself. * @param channel - Validated UI selection. - * @returns After the atomic save and lock release; failures retain the storage error's effect details. + * @returns After a confirmed write, including logged lock-release failures. + * Uncertain writes reject without retry; diagnostics are appended to settings.json.save.jsonl. */ async save(channel: DesktopUpdateChannel): Promise { - await this.file.saveUpdates({ channel }) + try { await this.file.saveUpdates({ channel }) } catch (error) { + if (!(error instanceof DesktopSettingsFileError) || error.problem.kind !== 'write') throw error + const entry = { time: new Date().toISOString(), event: 'settings-save-failed', effect: error.problem.effect, + path: this.path, reason: error.cause instanceof Error ? error.cause.message : String(error.cause) } + try { appendFileSync(`${this.path}.save.jsonl`, `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) } catch (logError) { + console.error('desktop settings: could not persist save warning', entry, logError) + } + if (error.problem.effect !== 'written') throw error + } } } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index d3a92e7b62..6abcf07275 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -530,6 +530,49 @@ describe('desktop main startup', () => { expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).not.toHaveProperty('configurationError') }) + it('applies a committed selection even when storage lock release fails', async () => { + await readyForUpdate() + const { DesktopSettingsFile, DesktopSettingsFileError } = await import('../src/settings-file.ts') + const save = vi.spyOn(DesktopSettingsFile.prototype, 'saveUpdates').mockRejectedValueOnce( + new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'written' }, '/settings.json')) + try { + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'applied', state: { channel: 'stable', changing: false }, + }) + expect(save).toHaveBeenCalledTimes(1) + } finally { save.mockRestore() } + }) + + it('suspends updates after an uncertain save and leaves recovery explicit', async () => { + await readyForUpdate() + harness.updateState = { phase: 'ready', version: '1.0.1' } + const { DesktopUpdateSettings } = await import('../src/update-settings.ts') + const { DesktopSettingsFileError } = await import('../src/settings-file.ts') + const save = vi.spyOn(DesktopUpdateSettings.prototype, 'save').mockRejectedValueOnce( + new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/settings.json')) + try { + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ + kind: 'failed', reason: 'uncertain', state: { channel: 'nightly', changing: false, error: 'read' }, + }) + expect(harness.updateState).toMatchObject({ phase: 'error', failedOperation: 'configuration' }) + expect(harness.updateInstall).not.toHaveBeenCalled() + expect(save).toHaveBeenCalledTimes(1) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + const disk = JSON.stringify({ updates: { channel: 'stable' }, keep: true }) + writeFileSync(path, disk) + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(await options.action!.run()).toBe(true) + return { response: 0 } + }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const recovered = await invoke(DESKTOP_IPC.updatesChannelGet, 'app') + expect(recovered).toMatchObject({ channel: 'stable', changing: false }) + expect(recovered).not.toHaveProperty('error') + expect(readFileSync(path, 'utf8')).toBe(disk) + expect(save).toHaveBeenCalledTimes(1) + } finally { save.mockRestore() } + }) + it('keeps the previous ready update installable after a channel save fails', async () => { await readyForUpdate() harness.updateState = { phase: 'ready', version: '1.0.1' } diff --git a/apps/desktop/tests/settings-file.spec.ts b/apps/desktop/tests/settings-file.spec.ts index de0de14c71..57efe2c4b9 100644 --- a/apps/desktop/tests/settings-file.spec.ts +++ b/apps/desktop/tests/settings-file.spec.ts @@ -183,3 +183,40 @@ it('rereads fields after acquiring the writer lock instead of saving a stale sna expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'nightly', future: 3 }, other: 4 }) }) + +it('accepts the new channel and persists a warning after a real write with failed lock removal', async () => { + const { root, file } = await fixture('{"updates":{"channel":"nightly"},"keep":true}') + const settings = new DesktopUpdateSettings(root, 'nightly') + const original = fs.rm + vi.spyOn(fs, 'rm').mockImplementation(async (path, options) => { + if (path === `${file.path}.lock`) throw new Error('injected lock removal failure') + return original(path, options) + }) + await expect(settings.save('stable')).resolves.toBeUndefined() + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ updates: { channel: 'stable' }, keep: true }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: 'stable' }) + expect(JSON.parse(await readFile(`${file.path}.save.jsonl`, 'utf8'))).toMatchObject({ + effect: 'written', reason: 'injected lock removal failure', + }) + await expect(fs.access(`${file.path}.lock`)).resolves.toBeUndefined() +}) + +it.each([false, true])('leaves an uncertain write for explicit recovery, committed=%s', async (committed) => { + const { root, file } = await fixture('{"updates":{"channel":"nightly"},"keep":true}') + const settings = new DesktopUpdateSettings(root, 'nightly') + const original = atomic.writeFileAtomic + const write = vi.spyOn(atomic, 'writeFileAtomic').mockImplementationOnce(async (...args) => { + if (committed) await original(...args) + throw new Error('injected uncertain completion') + }) + const read = vi.spyOn(DesktopSettingsFile.prototype, 'read') + await expect(settings.save('stable')).rejects.toMatchObject({ problem: { effect: 'unknown' } }) + expect(write).toHaveBeenCalledTimes(1) + expect(read).toHaveBeenCalledTimes(1) + expect(JSON.parse(await readFile(file.path, 'utf8'))).toEqual({ + updates: { channel: committed ? 'stable' : 'nightly' }, keep: true, + }) + expect(JSON.parse(await readFile(`${file.path}.save.jsonl`, 'utf8'))).toMatchObject({ effect: 'unknown' }) + await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await settings.load()).toEqual({ kind: 'ready', channel: committed ? 'stable' : 'nightly' }) +}) diff --git a/apps/desktop/tests/update-configuration.spec.ts b/apps/desktop/tests/update-configuration.spec.ts index a084fa4bea..799d396bc1 100644 --- a/apps/desktop/tests/update-configuration.spec.ts +++ b/apps/desktop/tests/update-configuration.spec.ts @@ -20,7 +20,7 @@ it('keeps startup suspensions distinct from transient save failures', () => { it('only exposes a permission hint for a known filesystem permission failure', () => { for (const code of ['EACCES', 'EPERM', 'EIO']) { const cause = Object.assign(new Error('private filesystem detail'), { code }) - const error = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/private/path', { cause }) + const error = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unchanged' }, '/private/path', { cause }) expect(channelSaveFailure(error)).toBe(code === 'EIO' ? 'save' : 'permission') } expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path'))).toBe('save') @@ -34,3 +34,9 @@ it('keeps settings errors concise and cleanup recovery distinct', () => { expect(configurationMessage('cleanup', zh)).toContain('请重启应用') expect(configurationMessage('cleanup', en)).not.toContain('settings file') }) + +it('reports an uncertain write separately even when its cause is permission denied', () => { + const error = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/settings.json', + { cause: Object.assign(new Error('denied'), { code: 'EACCES' }) }) + expect(channelSaveFailure(error)).toBe('uncertain') +}) diff --git a/apps/desktop/tests/update-settings.spec.ts b/apps/desktop/tests/update-settings.spec.ts index 010554db4d..ee3be16948 100644 --- a/apps/desktop/tests/update-settings.spec.ts +++ b/apps/desktop/tests/update-settings.spec.ts @@ -1,10 +1,10 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, it, onTestFinished } from 'vitest' +import { expect, it, onTestFinished, vi } from 'vitest' import { readDesktopSettings } from '../src/settings.ts' import { DesktopUpdateSettings } from '../src/update-settings.ts' -import { DesktopSettingsFileError } from '../src/settings-file.ts' +import { DesktopSettingsFile, DesktopSettingsFileError } from '../src/settings-file.ts' async function fixture() { const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) @@ -63,3 +63,19 @@ it('updates the document initialized by shell settings without losing the popup expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(false) expect(await settings.load()).toEqual({ kind: 'ready', channel: 'nightly' }) }) + +// Storage has already classified the write; the channel consumer must not retry it. +it.each(['written', 'unknown', 'unchanged'] as const)('handles the %s write outcome without retrying', async (effect) => { + const settings = await fixture() + const failure = new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect }, settings.path, + { cause: new Error('injected storage failure') }) + const save = vi.spyOn(DesktopSettingsFile.prototype, 'saveUpdates').mockRejectedValueOnce(failure) + const read = vi.spyOn(DesktopSettingsFile.prototype, 'read') + try { + if (effect === 'written') await expect(settings.save('nightly')).resolves.toBeUndefined() + else await expect(settings.save('nightly')).rejects.toBe(failure) + expect(save).toHaveBeenCalledTimes(1) + expect(read).not.toHaveBeenCalled() + expect(JSON.parse(await readFile(`${settings.path}.save.jsonl`, 'utf8'))).toMatchObject({ effect }) + } finally { save.mockRestore(); read.mockRestore() } +}) diff --git a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts index bd7f4e5970..8c2ac0961a 100644 --- a/packages/client/ui-settings-general/src/client/desktop-channel-source.ts +++ b/packages/client/ui-settings-general/src/client/desktop-channel-source.ts @@ -36,7 +36,8 @@ export class DesktopChannelSource { private receive(state: DesktopChannelState): void { const previous = this.store.getSnapshot() - const notify = state.error !== undefined && state.error !== 'cleanup' && state.error !== previous.state?.error + const ownSaveFailure = previous.pending !== undefined || previous.notice?.outcome === 'uncertain' + const notify = !ownSaveFailure && state.error !== undefined && state.error !== 'cleanup' && state.error !== previous.state?.error this.store.set({ ...previous, state, failed: false, ...(notify ? { notice: { sequence: ++this.sequence, outcome: 'configuration' as const } } : {}), }) diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 33787eb8a0..adb6029eec 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -13,6 +13,7 @@ export const zh = { 'desktop.channel.result.configuration': '配置文件异常,更新已暂停', 'desktop.channel.details': '查看详情', 'desktop.channel.result.save': '保存失败', + 'desktop.channel.result.uncertain': '设置保存异常', 'desktop.channel.result.format': '保存失败,配置文件格式有误', 'desktop.channel.result.permission': '保存失败,没有写入权限', 'desktop.channel.result.apply': '保存失败', @@ -83,6 +84,7 @@ export const en = { 'desktop.channel.result.configuration': 'Settings file error; updates are paused', 'desktop.channel.details': 'View details', 'desktop.channel.result.save': 'Could not save', + 'desktop.channel.result.uncertain': 'Settings could not be saved reliably', 'desktop.channel.result.format': 'Could not save: invalid settings file', 'desktop.channel.result.permission': 'Could not save: permission denied', 'desktop.channel.result.apply': 'Could not save', @@ -150,6 +152,7 @@ export const ja = { 'desktop.channel.result.configuration': '設定ファイルに問題があるため、更新を停止しています', 'desktop.channel.details': '詳細を表示', 'desktop.channel.result.save': '保存できませんでした', + 'desktop.channel.result.uncertain': '設定の保存中に問題が発生しました', 'desktop.channel.result.format': '保存できませんでした:設定ファイルの形式が不正です', 'desktop.channel.result.permission': '保存できませんでした:書き込み権限がありません', 'desktop.channel.result.apply': '保存できませんでした', @@ -217,6 +220,7 @@ export const es = { 'desktop.channel.result.configuration': 'Error en el archivo de ajustes; actualizaciones en pausa', 'desktop.channel.details': 'Ver detalles', 'desktop.channel.result.save': 'No se pudo guardar', + 'desktop.channel.result.uncertain': 'Se produjo un problema al guardar los ajustes', 'desktop.channel.result.format': 'No se pudo guardar: archivo de configuración no válido', 'desktop.channel.result.permission': 'No se pudo guardar: permiso denegado', 'desktop.channel.result.apply': 'No se pudo guardar', @@ -284,6 +288,7 @@ export const fr = { 'desktop.channel.result.configuration': 'Erreur du fichier de paramètres ; mises à jour suspendues', 'desktop.channel.details': 'Voir les détails', 'desktop.channel.result.save': 'Échec de l’enregistrement', + 'desktop.channel.result.uncertain': 'Une erreur est survenue lors de l’enregistrement des paramètres', 'desktop.channel.result.format': 'Échec de l’enregistrement : fichier de paramètres invalide', 'desktop.channel.result.permission': 'Échec de l’enregistrement : permission refusée', 'desktop.channel.result.apply': 'Échec de l’enregistrement', @@ -351,6 +356,7 @@ export const de = { 'desktop.channel.result.configuration': 'Fehler in der Einstellungsdatei; Updates sind angehalten', 'desktop.channel.details': 'Details anzeigen', 'desktop.channel.result.save': 'Speichern fehlgeschlagen', + 'desktop.channel.result.uncertain': 'Beim Speichern der Einstellungen ist ein Fehler aufgetreten', 'desktop.channel.result.format': 'Speichern fehlgeschlagen: ungültige Einstellungsdatei', 'desktop.channel.result.permission': 'Speichern fehlgeschlagen: keine Schreibberechtigung', 'desktop.channel.result.apply': 'Speichern fehlgeschlagen', @@ -418,6 +424,7 @@ export const ru = { 'desktop.channel.result.configuration': 'Ошибка файла настроек; обновления приостановлены', 'desktop.channel.details': 'Подробнее', 'desktop.channel.result.save': 'Не удалось сохранить', + 'desktop.channel.result.uncertain': 'Ошибка при сохранении настроек', 'desktop.channel.result.format': 'Не удалось сохранить: неверный формат файла настроек', 'desktop.channel.result.permission': 'Не удалось сохранить: нет прав на запись', 'desktop.channel.result.apply': 'Не удалось сохранить', @@ -485,6 +492,7 @@ export const pt = { 'desktop.channel.result.configuration': 'Erro no arquivo de configurações; atualizações pausadas', 'desktop.channel.details': 'Ver detalhes', 'desktop.channel.result.save': 'Não foi possível salvar', + 'desktop.channel.result.uncertain': 'Ocorreu um erro ao salvar as configurações', 'desktop.channel.result.format': 'Não foi possível salvar: arquivo de configurações inválido', 'desktop.channel.result.permission': 'Não foi possível salvar: permissão negada', 'desktop.channel.result.apply': 'Não foi possível salvar', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index f3d830eff6..33edb975e0 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -55,7 +55,7 @@ export interface DesktopChannelState { /** Outcome of one UI action; failures are not global updater state. */ export type DesktopChannelChangeResult = | { readonly kind: 'applied' | 'unchanged'; readonly state: DesktopChannelState } - | { readonly kind: 'failed'; readonly state: DesktopChannelState; readonly reason: 'save' | 'format' | 'permission' | 'apply' } + | { readonly kind: 'failed'; readonly state: DesktopChannelState; readonly reason: 'save' | 'format' | 'permission' | 'apply' | 'uncertain' } /** UI choices persist in the shell configuration, independently of Host settings. */ export interface DesktopChannelBridge { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index f09b1a6778..6c43764a4d 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -424,3 +424,7 @@ it('keeps the settings-file notification for ten seconds with a details action', it('uses the concise Chinese configuration error for the settings notification', () => { expect(zh['desktop.channel.result.configuration']).toBe('配置文件异常,更新已暂停') }) + +it('labels uncertain settings saves without claiming they were not written', () => { + expect(zh['desktop.channel.result.uncertain']).toBe('设置保存异常') +}) diff --git a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts index 3cd8d1530a..ae69ed9f82 100644 --- a/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts +++ b/packages/client/ui-settings-general/tests/desktop-channel.client.spec.ts @@ -155,3 +155,16 @@ it('announces startup file errors once and keeps their state after the toast clo expect(f.source.store.getSnapshot()).toEqual({ state, failed: false }) f.source.dispose() }) + +it.each(['before', 'after'] as const)('retains uncertain-save feedback when suspension arrives %s the reply', async (order) => { + const f = fixture() + f.emit(nightly) + const pending = f.source.select('stable') + const suspended = { ...nightly, error: 'read' as const } + if (order === 'before') f.emit(suspended) + f.set.resolve({ kind: 'failed', state: suspended, reason: 'uncertain' }) + await pending + if (order === 'after') f.emit(suspended) + expect(f.source.store.getSnapshot()).toMatchObject({ state: suspended, notice: { outcome: 'uncertain' } }) + f.source.dispose() +}) From eb5b32c9c9b06069f2aacb17a0bdfa82a8b2df05 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 11:47:04 +0800 Subject: [PATCH 22/25] fix(desktop): report and recover saved channel apply failures --- apps/desktop/src/main.ts | 7 +++---- apps/desktop/tests/main-startup.spec.ts | 14 ++++++++++---- .../ui-settings-general/src/client/locales.ts | 18 +++++++++--------- .../tests/components.client.spec.tsx | 5 +++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b493ae643..70312cc69c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -951,10 +951,9 @@ async function main(): Promise { const showConfigurationError = (): Promise => { configurationPrompt ??= Promise.resolve().then(async () => { while (channelState?.error !== undefined) { - const settingsError = channelState.error !== 'cleanup' const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, message: configurationMessage(channelState.error, locale.messages), - ...(settingsError ? { detail: channelSettings.path, + ...{ detail: channelSettings.path, buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose], defaultId: 0, cancelId: 2, action: { index: 0, run: async () => { if (channelRequests > 0 || channelState === undefined) return false @@ -977,9 +976,9 @@ async function main(): Promise { } finally { publishChannel({ ...channelState, changing: --channelRequests > 0 }) } - } } } : {}), + } } }, }) - if (!settingsError || result.response === 2) return + if (result.response === 2) return if (result.response === 1) { shell.showItemInFolder(channelSettings.path); return } } }).finally(() => { configurationPrompt = undefined }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 6abcf07275..5d373f92c8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -592,8 +592,7 @@ describe('desktop main startup', () => { expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } }) }) - - it('keeps cleanup suspension visible with restart guidance after a failed switch', async () => { + it('keeps committed settings after apply failure and recovers through explicit reload', async () => { await readyForUpdate() const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts') vi.spyOn(DesktopUpdateCoordinator.prototype, 'switchChannel').mockImplementationOnce(async function (this: InstanceType, _channel, persist) { @@ -606,9 +605,16 @@ describe('desktop main startup', () => { }) expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'cleanup' }) await invoke(DESKTOP_IPC.updatesOpen, 'app') - const options = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions + const options = harness.dialog.showMessageBox.mock.lastCall![0] as import('../src/update-dialog.ts').UpdateDialogOptions expect(options.message).toBe(en.updateConfigurationCleanup) - expect(options.detail).toBeUndefined() + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + expect(options.detail).toBe(path) + const saved = readFileSync(path, 'utf8') + expect(JSON.parse(saved)).toMatchObject({ updates: { channel: 'stable' } }) + expect(await options.action!.run()).toBe(true) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable', changing: false }) + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).not.toHaveProperty('error') + expect(readFileSync(path, 'utf8')).toBe(saved) }) it('offers the actual settings file for malformed JSON without suggesting channel reselection', async () => { diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index adb6029eec..910e304899 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -16,7 +16,7 @@ export const zh = { 'desktop.channel.result.uncertain': '设置保存异常', 'desktop.channel.result.format': '保存失败,配置文件格式有误', 'desktop.channel.result.permission': '保存失败,没有写入权限', - 'desktop.channel.result.apply': '保存失败', + 'desktop.channel.result.apply': '设置保存异常', 'desktop.update.configuration.read': '更新已暂停:无法读取配置文件', 'desktop.update.configuration.format': '更新已暂停:配置文件格式有误', 'desktop.update.configuration.channel': '更新已暂停:channel 只能是 nightly 或 stable', @@ -84,10 +84,10 @@ export const en = { 'desktop.channel.result.configuration': 'Settings file error; updates are paused', 'desktop.channel.details': 'View details', 'desktop.channel.result.save': 'Could not save', - 'desktop.channel.result.uncertain': 'Settings could not be saved reliably', + 'desktop.channel.result.uncertain': 'Settings save error', 'desktop.channel.result.format': 'Could not save: invalid settings file', 'desktop.channel.result.permission': 'Could not save: permission denied', - 'desktop.channel.result.apply': 'Could not save', + 'desktop.channel.result.apply': 'Settings save error', 'desktop.update.configuration.read': 'Updates paused: cannot read settings file', 'desktop.update.configuration.format': 'Updates paused: invalid settings file', 'desktop.update.configuration.channel': 'Updates paused: channel must be nightly or stable', @@ -155,7 +155,7 @@ export const ja = { 'desktop.channel.result.uncertain': '設定の保存中に問題が発生しました', 'desktop.channel.result.format': '保存できませんでした:設定ファイルの形式が不正です', 'desktop.channel.result.permission': '保存できませんでした:書き込み権限がありません', - 'desktop.channel.result.apply': '保存できませんでした', + 'desktop.channel.result.apply': '設定の保存中に問題が発生しました', 'desktop.update.configuration.read': '更新を停止中:設定ファイルを読み取れません', 'desktop.update.configuration.format': '更新を停止中:設定ファイルの形式が不正です', 'desktop.update.configuration.channel': '更新を停止中:channel は nightly または stable にしてください', @@ -223,7 +223,7 @@ export const es = { 'desktop.channel.result.uncertain': 'Se produjo un problema al guardar los ajustes', 'desktop.channel.result.format': 'No se pudo guardar: archivo de configuración no válido', 'desktop.channel.result.permission': 'No se pudo guardar: permiso denegado', - 'desktop.channel.result.apply': 'No se pudo guardar', + 'desktop.channel.result.apply': 'Se produjo un problema al guardar los ajustes', 'desktop.update.configuration.read': 'Actualizaciones pausadas: no se puede leer la configuración', 'desktop.update.configuration.format': 'Actualizaciones pausadas: archivo de configuración no válido', 'desktop.update.configuration.channel': 'Actualizaciones pausadas: channel debe ser nightly o stable', @@ -291,7 +291,7 @@ export const fr = { 'desktop.channel.result.uncertain': 'Une erreur est survenue lors de l’enregistrement des paramètres', 'desktop.channel.result.format': 'Échec de l’enregistrement : fichier de paramètres invalide', 'desktop.channel.result.permission': 'Échec de l’enregistrement : permission refusée', - 'desktop.channel.result.apply': 'Échec de l’enregistrement', + 'desktop.channel.result.apply': 'Une erreur est survenue lors de l’enregistrement des paramètres', 'desktop.update.configuration.read': 'Mises à jour suspendues : lecture des paramètres impossible', 'desktop.update.configuration.format': 'Mises à jour suspendues : fichier de paramètres invalide', 'desktop.update.configuration.channel': 'Mises à jour suspendues : channel doit être nightly ou stable', @@ -359,7 +359,7 @@ export const de = { 'desktop.channel.result.uncertain': 'Beim Speichern der Einstellungen ist ein Fehler aufgetreten', 'desktop.channel.result.format': 'Speichern fehlgeschlagen: ungültige Einstellungsdatei', 'desktop.channel.result.permission': 'Speichern fehlgeschlagen: keine Schreibberechtigung', - 'desktop.channel.result.apply': 'Speichern fehlgeschlagen', + 'desktop.channel.result.apply': 'Beim Speichern der Einstellungen ist ein Fehler aufgetreten', 'desktop.update.configuration.read': 'Updates pausiert: Einstellungsdatei nicht lesbar', 'desktop.update.configuration.format': 'Updates pausiert: ungültige Einstellungsdatei', 'desktop.update.configuration.channel': 'Updates pausiert: channel muss nightly oder stable sein', @@ -427,7 +427,7 @@ export const ru = { 'desktop.channel.result.uncertain': 'Ошибка при сохранении настроек', 'desktop.channel.result.format': 'Не удалось сохранить: неверный формат файла настроек', 'desktop.channel.result.permission': 'Не удалось сохранить: нет прав на запись', - 'desktop.channel.result.apply': 'Не удалось сохранить', + 'desktop.channel.result.apply': 'Ошибка при сохранении настроек', 'desktop.update.configuration.read': 'Обновления приостановлены: не удалось прочитать настройки', 'desktop.update.configuration.format': 'Обновления приостановлены: неверный формат файла настроек', 'desktop.update.configuration.channel': 'Обновления приостановлены: channel должен быть nightly или stable', @@ -495,7 +495,7 @@ export const pt = { 'desktop.channel.result.uncertain': 'Ocorreu um erro ao salvar as configurações', 'desktop.channel.result.format': 'Não foi possível salvar: arquivo de configurações inválido', 'desktop.channel.result.permission': 'Não foi possível salvar: permissão negada', - 'desktop.channel.result.apply': 'Não foi possível salvar', + 'desktop.channel.result.apply': 'Ocorreu um erro ao salvar as configurações', 'desktop.update.configuration.read': 'Atualizações pausadas: não foi possível ler as configurações', 'desktop.update.configuration.format': 'Atualizações pausadas: arquivo de configurações inválido', 'desktop.update.configuration.channel': 'Atualizações pausadas: channel deve ser nightly ou stable', diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 6c43764a4d..9382b5d250 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -428,3 +428,8 @@ it('uses the concise Chinese configuration error for the settings notification', it('labels uncertain settings saves without claiming they were not written', () => { expect(zh['desktop.channel.result.uncertain']).toBe('设置保存异常') }) + +it('does not describe a saved selection that failed to apply as unsaved', () => { + expect(zh['desktop.channel.result.apply']).toBe('设置保存异常') + expect(en['desktop.channel.result.apply']).toBe('Settings save error') +}) From 0535b85a1d1f600d6c998b80f0a37b1fc824b760 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 11:54:27 +0800 Subject: [PATCH 23/25] fix(desktop): recover update settings under mandatory policy --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/mandatory-update.js | 7 ++- apps/desktop/src/locale.ts | 8 ++++ apps/desktop/src/main.ts | 12 +++-- apps/desktop/src/mandatory-update-policy.ts | 12 +++-- apps/desktop/src/settings-file.ts | 5 +-- apps/desktop/tests/main-startup.spec.ts | 44 +++++++++++++++++++ .../tests/mandatory-update-policy.spec.ts | 18 ++++++++ .../tests/update-error-renderer.spec.ts | 17 +++++++ .../updates/electron-updater/README.i18n.yaml | 4 +- .../tests/updates/electron-updater/README.md | 2 +- .../updates/electron-updater/README.zh.md | 2 +- .../src/client/DesktopChannelRow.tsx | 2 +- .../ui-settings-general/src/client/locales.ts | 8 ++++ .../tests/components.client.spec.tsx | 2 +- 17 files changed, 127 insertions(+), 24 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ab70a0cb9b..c4946b47bf 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -60,8 +60,8 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: d799e2296e569a98 - zh: 9f312ff06cbe3abf + en: 01461be5b81e8609 + zh: 58e818df821a3aa2 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2d81f49784..74c714cff1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -306,7 +306,7 @@ Startup loading never overwrites existing documents; an omitted `updates` object In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. -During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends ordinary updates while the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. +During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends update operations; without a mandatory policy block, the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced; its configuration-error action opens the same file-location and reload dialog. Successful channel changes trigger a fresh mandatory-policy check, waiting for an older channel’s in-flight check if necessary. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e057362bd1..76aea07cf8 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -306,7 +306,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett 在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 -UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停普通更新,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index eed28f7718..b4975ba242 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -14,6 +14,7 @@ function render(view) { const { locale: { id, messages }, policy, update, confirmation, navigation } = view const confirming = confirmation !== undefined const failed = update.phase === 'error' + const configurationError = failed && update.failedOperation === 'configuration' const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install') const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download') const authenticationRequired = policy.error === 'authentication-required' @@ -45,7 +46,8 @@ function render(view) { detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting status = ''; primary = ''; action = '' } - const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed + const error = localError ?? view.error ?? (configurationError ? messages.updateConfigurationRead + : authenticationRequired ? messages.policyLoginRequired : failed ? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed : update.failedOperation === 'install' ? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed @@ -73,7 +75,8 @@ function render(view) { element('update').disabled = busy && !confirming element('later').textContent = messages.updateLater element('later').hidden = !confirmation?.active - element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh + element('refresh').textContent = configurationError ? messages.updateConfigurationDetails + : authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback)) element('refresh').disabled = busy || policy.checking element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index a9d0fb4c3c..bddf39b9f8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -112,6 +112,7 @@ export const en = { updateConfigurationChannel: 'Settings file error; updates are paused', updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', updateReloadSettings: 'Reload settings', + updateConfigurationDetails: 'Review settings', updateShowSettingsFile: 'Show settings file', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', @@ -302,6 +303,7 @@ export const zh = { updateConfigurationChannel: '配置文件异常,更新已暂停', updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', updateReloadSettings: '重新读取配置', + updateConfigurationDetails: '查看配置', updateShowSettingsFile: '显示配置文件', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', @@ -490,6 +492,7 @@ export const ja = { updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています', updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', updateReloadSettings: '設定を再読み込み', + updateConfigurationDetails: '設定を確認', updateShowSettingsFile: '設定ファイルを表示', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', @@ -678,6 +681,7 @@ export const es = { updateConfigurationChannel: 'Error en el archivo de ajustes; actualizaciones pausadas', updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', updateReloadSettings: 'Volver a cargar ajustes', + updateConfigurationDetails: 'Revisar ajustes', updateShowSettingsFile: 'Mostrar archivo de ajustes', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', @@ -866,6 +870,7 @@ export const fr = { updateConfigurationChannel: 'Erreur du fichier de paramètres ; mises à jour suspendues', updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', updateReloadSettings: 'Recharger les paramètres', + updateConfigurationDetails: 'Vérifier les paramètres', updateShowSettingsFile: 'Afficher le fichier de paramètres', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', @@ -1054,6 +1059,7 @@ export const de = { updateConfigurationChannel: 'Fehler in der Einstellungsdatei; Updates pausiert', updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', updateReloadSettings: 'Einstellungen neu laden', + updateConfigurationDetails: 'Einstellungen prüfen', updateShowSettingsFile: 'Einstellungsdatei anzeigen', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', @@ -1242,6 +1248,7 @@ export const ru = { updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены', updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', updateReloadSettings: 'Перечитать настройки', + updateConfigurationDetails: 'Проверить настройки', updateShowSettingsFile: 'Показать файл настроек', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', @@ -1430,6 +1437,7 @@ export const pt = { updateConfigurationChannel: 'Erro no arquivo de configurações; atualizações pausadas', updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', updateReloadSettings: 'Recarregar configurações', + updateConfigurationDetails: 'Verificar configurações', updateShowSettingsFile: 'Mostrar arquivo de configurações', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 70312cc69c..656d90763c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -450,11 +450,11 @@ async function main(): Promise { const updateOverlays = new DesktopUpdateOverlays() const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true - const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { + const ordinaryMessageBox = async (options: UpdateDialogOptions, + parent = currentDialogWindow()): Promise => { const controller = new AbortController() ordinaryDialogs.add(controller) try { - const parent = currentDialogWindow() if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false } return await updateDialog.show(parent, { ...options, signal: controller.signal }) } @@ -911,6 +911,7 @@ async function main(): Promise { throw error } }) + if (kind === 'applied') void mandatoryPolicy?.check('channel-change', true).catch((error: unknown) => { console.error(error) }) return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } } catch (error) { console.error('desktop update: channel change failed', error) @@ -977,7 +978,7 @@ async function main(): Promise { publishChannel({ ...channelState, changing: --channelRequests > 0 }) } } } }, - }) + }, mandatoryUI?.confirmationWindow ?? currentDialogWindow()) if (result.response === 2) return if (result.response === 1) { shell.showItemInFolder(channelSettings.path); return } } @@ -1523,7 +1524,10 @@ async function main(): Promise { preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, policy: () => policy.state, update: () => updates.state, - refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) }, + refresh: async () => { + if (channelState?.error !== undefined) await showConfigurationError() + if (channelState?.error === undefined) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) + }, download: downloadUpdate, install: version => updates.install(version), }) void mandatoryPolicy.check('launch').then((state) => { diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index e90125feef..6c65da3a68 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -129,6 +129,7 @@ function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): D export class DesktopMandatoryUpdatePolicy { private current: DesktopPolicyState = { blocking: false, checking: false } private pending: Promise | undefined + private pendingChannel: DesktopPolicyIdentity['channel'] | undefined private controller: AbortController | undefined private timer: ReturnType | undefined private disposed = false @@ -156,10 +157,10 @@ export class DesktopMandatoryUpdatePolicy { } /** Platform headers for one check; the calling UI's language and UTC offset are read now. */ - private requestHeaders(): Record { + private requestHeaders(channel: DesktopPolicyIdentity['channel']): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, + 'x-client-arch': this.identity.arch, 'x-client-update-channel': channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } @@ -175,9 +176,12 @@ export class DesktopMandatoryUpdatePolicy { */ check(scenario: string, manual = false): Promise { if (this.disposed) return Promise.reject(new Error('desktop policy: disposed')) - if (this.pending !== undefined) return this.pending + if (this.pending !== undefined) return this.pendingChannel === this.identity.channel + ? this.pending : this.pending.then(() => this.check(scenario, true)) if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current) clearTimeout(this.timer) + const channel = this.identity.channel + this.pendingChannel = channel this.pending = Promise.resolve().then(async () => { if (this.disposed) return this.current const controller = new AbortController() @@ -187,7 +191,7 @@ export class DesktopMandatoryUpdatePolicy { try { const url = new URL('/api/v0/check_client_update', this.config.origin) url.searchParams.set('scenario', scenario) - const response = await this.request(url, { headers: this.requestHeaders(), signal: controller.signal, + const response = await this.request(url, { headers: this.requestHeaders(channel), signal: controller.signal, credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' }) const body: unknown = await response.json() if (this.config.authentication === 'feishu-test' && response.status === 401 diff --git a/apps/desktop/src/settings-file.ts b/apps/desktop/src/settings-file.ts index 17eb8e7b9d..023e2e29a6 100644 --- a/apps/desktop/src/settings-file.ts +++ b/apps/desktop/src/settings-file.ts @@ -31,9 +31,6 @@ export class DesktopSettingsFileError extends Error { constructor(readonly problem: DesktopSettingsProblem, readonly path: string, options?: ErrorOptions) { super(`desktop settings: ${problem.kind}: ${path}`, options) } - - /** Coarse category consumed by localized configuration guidance. */ - get kind(): DesktopSettingsProblem['kind'] { return this.problem.kind } } /** Absence permits a caller default; an invalid explicit field does not. */ @@ -73,7 +70,7 @@ export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdatePr * @param userData - Electron userData, independent of Host and DSH_HOME. * @returns Settings file path without filesystem changes. */ -export function desktopSettingsPath(userData: string): string { +function desktopSettingsPath(userData: string): string { return join(userData, 'desktop', 'settings.json') } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 5d373f92c8..c7c2962af9 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -617,6 +617,50 @@ describe('desktop main startup', () => { expect(readFileSync(path, 'utf8')).toBe(saved) }) + it('recovers invalid settings from the mandatory block without bypassing that block', async () => { + configureTestAuthPopup(false) + const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') + writeFileSync(path, '{broken') + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ code: 40005, + data: { show_content: { title: 'Update', detail: 'Required' }, desktop_app_link: 'https://downloads.example.com/' } }))) + await readyForUpdate() + await harness.policyBlocked.promise + const modal = harness.windows[0]! + const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(harness.showItemInFolder).toHaveBeenCalledWith(path) + expect(harness.updateDownload).not.toHaveBeenCalled() + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + expect(options.detail).toBe(path) + expect(await options.action!.run()).toBe(false) + writeFileSync(path, JSON.stringify({ updates: { channel: 'stable' } })) + expect(await options.action!.run()).toBe(true) + return { response: 0 } + }) + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' }) + expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({ policy: { blocking: true } }) + expect(harness.updateDownload).not.toHaveBeenCalled() + }) + + it('checks mandatory policy immediately after a successful channel switch', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + const checked = Promise.withResolvers() + const request = vi.fn().mockImplementation(async (url, options) => { + if (url instanceof URL && url.searchParams.get('scenario') === 'channel-change') { + expect(new Headers(options?.headers).get('x-client-update-channel')).toBe('stable') + checked.resolve(undefined) + } + return Response.json({ code: 0, data: { biz_code: 0 } }) + }) + vi.stubGlobal('fetch', request) + await readyForUpdate() + expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'applied' }) + await checked.promise + }) + it('offers the actual settings file for malformed JSON without suggesting channel reselection', async () => { configureTestAuthPopup(false) const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index a04353810d..7f6066d7aa 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -77,6 +77,24 @@ describe('mandatory update policy', () => { expect(headers[1]).toMatchObject({ 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '28800' }) }) + it('checks the selected channel after an old-channel request finishes', async () => { + let channel: 'nightly' | 'stable' = 'nightly' + const old = Promise.withResolvers() + const request = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(Response.json(clear)) + const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig(deployment)!, + { ...identity, get channel() { return channel } }, () => {}, request, () => client) + instances.push(policy) + const first = policy.check('launch') + channel = 'stable' + const second = policy.check('channel-change', true) + old.resolve(Response.json(force)) + await first + expect(await second).toMatchObject({ blocking: false }) + expect(request).toHaveBeenCalledTimes(2) + expect(request.mock.calls.map(([, options]) => new Headers(options?.headers).get('x-client-update-channel'))) + .toEqual(['nightly', 'stable']) + }) + it('coalesces manual checks and honors the interval on foreground/resume', async () => { const { policy, request } = fixture() const pending = Promise.withResolvers() diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index b2778c3b15..4c50638cb0 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -259,3 +259,20 @@ it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the man expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false) expect(action).not.toHaveBeenCalled() }) + +it('offers configuration recovery from a mandatory update error', async () => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale('zh-CN') + const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, + update: { phase: 'error', failedOperation: 'configuration' } } + const action = vi.fn(async () => {}) + const api: MandatoryUpdateApi = { status: async () => state, action, subscribe: () => () => {} } + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api }) + p.run() + await expect.poll(() => p.element('refresh').textContent).toBe(locale.messages.updateConfigurationDetails) + expect(p.element('refresh').hidden).toBe(false) + expect(p.element('error').textContent).toBe(locale.messages.updateConfigurationRead) + expect(p.element('update').hidden).toBe(true) + p.element('refresh').click() + expect(action).toHaveBeenCalledWith('refresh', undefined, undefined) +}) diff --git a/apps/desktop/tests/updates/electron-updater/README.i18n.yaml b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml index 211325c041..8966a2eb27 100644 --- a/apps/desktop/tests/updates/electron-updater/README.i18n.yaml +++ b/apps/desktop/tests/updates/electron-updater/README.i18n.yaml @@ -6,5 +6,5 @@ en: ff2d052de11d825a zh: aa36e8c136562d5a /electron-updater-compatibility: - en: 328e0a77d9abcc03 - zh: aeb6ba53a6497d64 + en: c28a2b4667f4ee10 + zh: 51ffd9e5e08a39e4 diff --git a/apps/desktop/tests/updates/electron-updater/README.md b/apps/desktop/tests/updates/electron-updater/README.md index 8ea87e9734..c8e9a1334f 100644 --- a/apps/desktop/tests/updates/electron-updater/README.md +++ b/apps/desktop/tests/updates/electron-updater/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) The adapter in `src/updates/electron-updater/` owns library channel policy, cache retirement and Electron HTTP handling. This qualification calls that adapter directly; coordinator, configuration and UI regressions belong to ordinary tests. -Every PR runs a Node-only version check in Windows CI. `verified-versions.json` records the installed Electron, electron-updater and its builder-util-runtime versions separately for Windows and macOS. Missing records and mismatches fail; `null` means pending qualification. Git retains prior records. No Electron process or network access is required by this check. +Every PR runs a Node-only version check in Windows CI. `verified-versions.json` records the installed Electron, electron-updater and its builder-util-runtime versions separately for Windows and macOS. Missing records, `null` (pending qualification), and version mismatches all fail the check. Git retains prior records. No Electron process or network access is required by this check. After changing those dependencies, the adapter, or its qualification scripts, run on each affected native platform from the repository root: diff --git a/apps/desktop/tests/updates/electron-updater/README.zh.md b/apps/desktop/tests/updates/electron-updater/README.zh.md index 1af4292c33..d00544eae5 100644 --- a/apps/desktop/tests/updates/electron-updater/README.zh.md +++ b/apps/desktop/tests/updates/electron-updater/README.zh.md @@ -8,7 +8,7 @@ description: "Electron updater 适配层的平台版本记录与本地验证。" `src/updates/electron-updater/` 中的适配层负责库的渠道策略、缓存撤销和 Electron HTTP 处理。本验证直接调用该适配层;协调器、配置和 UI 回归由常规测试负责。 -每个 PR 在 Windows CI 中运行纯 Node 版本检查。`verified-versions.json` 分别记录 Windows 和 macOS 已验证的实际 Electron、electron-updater 及其 builder-util-runtime 版本。缺少记录或版本不匹配都会失败;`null` 表示待验证。Git 保留历史记录。此检查不启动 Electron,也不访问网络。 +每个 PR 在 Windows CI 中运行纯 Node 版本检查。`verified-versions.json` 分别记录 Windows 和 macOS 已验证的实际 Electron、electron-updater 及其 builder-util-runtime 版本。缺少记录、`null`(待验证)或版本不匹配都会使检查失败。Git 保留历史记录。此检查不启动 Electron,也不访问网络。 修改这些依赖、适配层或验证脚本后,在每个受影响的原生平台上从仓库根目录运行: diff --git a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx index 6c9c213e13..4027e7bf47 100644 --- a/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopChannelRow.tsx @@ -27,7 +27,7 @@ export function DesktopChannelRow({ useChannel, select, t }:
{t('desktop.channel.title')}
{t('desktop.channel.description')}
- {(failed || state?.error !== undefined) &&
{t('desktop.channel.error')}
} + {(failed || state?.error !== undefined) &&
{t(failed ? 'desktop.channel.readError' : 'desktop.channel.error')}
}
{ setOpen(false) }} portal align="end" selectedId={channel ?? ''} diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 910e304899..a6a6bc28a4 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -8,6 +8,7 @@ export const zh = { 'desktop.channel.nightly': '开发版', 'desktop.channel.unavailable': '配置不可用', 'desktop.channel.error': '未能应用更新配置。请点击桌面更新入口查看原因。', + 'desktop.channel.readError': '无法读取更新渠道状态,请重启应用。', 'desktop.update.configurationFailed': '未能应用更新配置。请点击桌面更新入口查看原因。', 'desktop.channel.result.applied': '更新渠道已切换', 'desktop.channel.result.configuration': '配置文件异常,更新已暂停', @@ -79,6 +80,7 @@ export const en = { 'desktop.channel.nightly': 'Development', 'desktop.channel.unavailable': 'Configuration unavailable', 'desktop.channel.error': 'Could not apply update settings. Open the desktop update indicator for details.', + 'desktop.channel.readError': 'Could not read the update channel. Restart the app.', 'desktop.update.configurationFailed': 'Could not apply update settings. Open the desktop update indicator for details.', 'desktop.channel.result.applied': 'Update channel changed', 'desktop.channel.result.configuration': 'Settings file error; updates are paused', @@ -147,6 +149,7 @@ export const ja = { 'desktop.channel.nightly': '開発版', 'desktop.channel.unavailable': '設定を利用できません', 'desktop.channel.error': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', + 'desktop.channel.readError': '更新チャンネルを読み取れません。アプリを再起動してください。', 'desktop.update.configurationFailed': '更新設定を適用できませんでした。デスクトップの更新表示で詳細を確認してください。', 'desktop.channel.result.applied': '更新チャンネルを変更しました', 'desktop.channel.result.configuration': '設定ファイルに問題があるため、更新を停止しています', @@ -215,6 +218,7 @@ export const es = { 'desktop.channel.nightly': 'Desarrollo', 'desktop.channel.unavailable': 'Configuración no disponible', 'desktop.channel.error': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', + 'desktop.channel.readError': 'No se pudo leer el canal de actualización. Reinicia la aplicación.', 'desktop.update.configurationFailed': 'No se pudo aplicar la configuración. Abre el indicador de actualización para ver los detalles.', 'desktop.channel.result.applied': 'Canal de actualización cambiado', 'desktop.channel.result.configuration': 'Error en el archivo de ajustes; actualizaciones en pausa', @@ -283,6 +287,7 @@ export const fr = { 'desktop.channel.nightly': 'Développement', 'desktop.channel.unavailable': 'Configuration indisponible', 'desktop.channel.error': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', + 'desktop.channel.readError': 'Impossible de lire le canal de mise à jour. Redémarrez l’application.', 'desktop.update.configurationFailed': 'Impossible d’appliquer les paramètres. Ouvrez l’indicateur de mise à jour pour plus de détails.', 'desktop.channel.result.applied': 'Canal de mise à jour modifié', 'desktop.channel.result.configuration': 'Erreur du fichier de paramètres ; mises à jour suspendues', @@ -351,6 +356,7 @@ export const de = { 'desktop.channel.nightly': 'Entwicklung', 'desktop.channel.unavailable': 'Konfiguration nicht verfügbar', 'desktop.channel.error': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', + 'desktop.channel.readError': 'Der Update-Kanal konnte nicht gelesen werden. Starten Sie die App neu.', 'desktop.update.configurationFailed': 'Update-Einstellungen konnten nicht angewendet werden. Details finden Sie in der Update-Anzeige.', 'desktop.channel.result.applied': 'Update-Kanal geändert', 'desktop.channel.result.configuration': 'Fehler in der Einstellungsdatei; Updates sind angehalten', @@ -419,6 +425,7 @@ export const ru = { 'desktop.channel.nightly': 'Разработка', 'desktop.channel.unavailable': 'Настройки недоступны', 'desktop.channel.error': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', + 'desktop.channel.readError': 'Не удалось прочитать канал обновлений. Перезапустите приложение.', 'desktop.update.configurationFailed': 'Не удалось применить настройки. Откройте индикатор обновления для подробностей.', 'desktop.channel.result.applied': 'Канал обновлений изменён', 'desktop.channel.result.configuration': 'Ошибка файла настроек; обновления приостановлены', @@ -487,6 +494,7 @@ export const pt = { 'desktop.channel.nightly': 'Desenvolvimento', 'desktop.channel.unavailable': 'Configuração indisponível', 'desktop.channel.error': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', + 'desktop.channel.readError': 'Não foi possível ler o canal de atualização. Reinicie o aplicativo.', 'desktop.update.configurationFailed': 'Não foi possível aplicar as configurações. Abra o indicador de atualização para ver detalhes.', 'desktop.channel.result.applied': 'Canal de atualização alterado', 'desktop.channel.result.configuration': 'Erro no arquivo de configurações; atualizações pausadas', diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 9382b5d250..5d88d4d73a 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -333,7 +333,7 @@ it('shows channel load errors and waits for an explicit selection to settle', as expect(control.hasAttribute('disabled')).toBe(true) state = { failed: true } view.rerender() - expect(screen.getByRole('alert')).toBeTruthy() + expect(screen.getByRole('alert').textContent).toBe(en['desktop.channel.readError']) state = { failed: false, state: { channel: null, packagedChannel: 'nightly', changing: false, error: 'format' } } view.rerender() expect(control.textContent).toContain('Configuration unavailable') From d7ca2e0ac2a3df4b6a18c6835d6202a2125ed6bf Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 12:50:36 +0800 Subject: [PATCH 24/25] fix(desktop): keep configuration recovery dialog interactive --- apps/desktop/renderer/mandatory-update.js | 2 +- apps/desktop/src/main.ts | 7 +++--- apps/desktop/src/update-dialog.ts | 9 ++++++- apps/desktop/tests/main-startup.spec.ts | 10 ++++++-- apps/desktop/tests/update-dialog.spec.ts | 17 +++++++++++++ .../tests/update-error-renderer.spec.ts | 25 ++++++++++++++++++- 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index b4975ba242..27b98bc350 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -53,7 +53,7 @@ function render(view) { ? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed : messages.mandatoryUnavailable : update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined) - const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : '' + const technicalDetails = failed && !configurationError ? update.technicalDetails ?? update.message ?? '' : '' document.documentElement.lang = id document.title = messages.mandatoryTitle element('title').textContent = title diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 656d90763c..dd62226016 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -956,7 +956,9 @@ async function main(): Promise { message: configurationMessage(channelState.error, locale.messages), ...{ detail: channelSettings.path, buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose], - defaultId: 0, cancelId: 2, action: { index: 0, run: async () => { + defaultId: 0, cancelId: 2, + keepOpenAction: { index: 1, run: () => { shell.showItemInFolder(channelSettings.path) } }, + action: { index: 0, run: async () => { if (channelRequests > 0 || channelState === undefined) return false channelRequests++ publishChannel({ ...channelState, changing: true }) @@ -979,8 +981,7 @@ async function main(): Promise { } } } }, }, mandatoryUI?.confirmationWindow ?? currentDialogWindow()) - if (result.response === 2) return - if (result.response === 1) { shell.showItemInFolder(channelSettings.path); return } + if (result.response !== 0) return } }).finally(() => { configurationPrompt = undefined }) return configurationPrompt diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index 4658f942aa..65efdd80cd 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -26,6 +26,8 @@ export interface UpdateDialogView { /** Electron message options with separately expandable, main-owned diagnostics. */ export interface UpdateDialogOptions extends MessageBoxOptions { readonly technicalDetails?: string + /** Synchronous auxiliary action that leaves the current prompt and choices open. */ + readonly keepOpenAction?: { index: number; run: () => void } /** Main-owned action kept in this dialog; false keeps the prompt open for correction. */ readonly action?: { index: number; run: () => Promise } } @@ -120,13 +122,18 @@ export class DesktopUpdateDialog { resolve({ response, checkboxChecked: false }) } const respond = async (index: number): Promise => { - if (options.action?.index !== index) { finish(index); return } const publish = (busy: boolean): void => { const { busyButton: _busy, ...rest } = view view = { ...rest, revision: ++this.revision, ...(busy ? { busyButton: index } : {}) } if (this.active !== undefined) this.active.view = view surface.webContents.send(UPDATE_DIALOG_IPC.changed, view) } + if (options.keepOpenAction?.index === index) { + options.keepOpenAction.run() + publish(false) + return + } + if (options.action?.index !== index) { finish(index); return } publish(true) // Keep a fast local read perceptible without delaying the operation itself. const held = new Promise((resolveHold) => { setTimeout(resolveHold, 500) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index c7c2962af9..66f529ccae 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -628,7 +628,10 @@ describe('desktop main startup', () => { await harness.policyBlocked.promise const modal = harness.windows[0]! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } - harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + options.keepOpenAction!.run() + return { response: 2 } + }) await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') expect(harness.showItemInFolder).toHaveBeenCalledWith(path) expect(harness.updateDownload).not.toHaveBeenCalled() @@ -666,7 +669,10 @@ describe('desktop main startup', () => { const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json') writeFileSync(path, '{broken') await readyForUpdate() - harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) + harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => { + options.keepOpenAction!.run() + return { response: 2 } + }) await invoke(DESKTOP_IPC.updatesOpen, 'app') expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({ message: en.updateConfigurationFormat, detail: path, diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index ec93bc2a19..29cbf832ab 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -250,3 +250,20 @@ it('does not revive a busy prompt after cancellation', async () => { expect(await pending).toMatchObject({ response: 1 }) expect(dialogs!.isOpen).toBe(false) }) + +it('keeps the same dialog open after revealing a settings file', async () => { + const f = setup() + const reveal = vi.fn() + const pending = f.show(undefined, { buttons: ['Reload', 'Show file', 'Close'], cancelId: 2, + keepOpenAction: { index: 1, run: reveal } }) + const before = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number } + await f.invoke(UPDATE_DIALOG_IPC.respond, 1) + expect(reveal).toHaveBeenCalledOnce() + expect((f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }).revision).toBe(before.revision + 1) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).not.toEqual(before) + await f.invoke(UPDATE_DIALOG_IPC.respond, 1) + expect(reveal).toHaveBeenCalledTimes(2) + expect(dialogs!.isOpen).toBe(true) + await f.invoke(UPDATE_DIALOG_IPC.respond, 2) + expect(await pending).toMatchObject({ response: 2 }) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 4c50638cb0..f13c58a588 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -264,7 +264,7 @@ it('offers configuration recovery from a mandatory update error', async () => { const p = page('mandatory-update') const locale = resolveDesktopLocale('zh-CN') const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, - update: { phase: 'error', failedOperation: 'configuration' } } + update: { phase: 'error', failedOperation: 'configuration', message: 'settings parse failure', technicalDetails: 'diagnostic' } } const action = vi.fn(async () => {}) const api: MandatoryUpdateApi = { status: async () => state, action, subscribe: () => () => {} } Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api }) @@ -272,7 +272,30 @@ it('offers configuration recovery from a mandatory update error', async () => { await expect.poll(() => p.element('refresh').textContent).toBe(locale.messages.updateConfigurationDetails) expect(p.element('refresh').hidden).toBe(false) expect(p.element('error').textContent).toBe(locale.messages.updateConfigurationRead) + expect(p.element('technical-details').hidden).toBe(true) + expect(p.element('technical-details-content').textContent).toBe('') expect(p.element('update').hidden).toBe(true) p.element('refresh').click() expect(action).toHaveBeenCalledWith('refresh', undefined, undefined) }) + +it('accepts another response after a keep-open action publishes its next revision', async () => { + const p = page('update-dialog') + const locale = resolveDesktopLocale('zh-CN') + let state: UpdateDialogView = { revision: 1, locale: locale.id, title: 'Settings', message: 'Settings error', detail: '', + buttons: ['Reload', 'Show file', 'Close'], cancelId: 2, closeLabel: 'Close', technicalDetails: '', technicalDetailsLabel: '' } + let publish!: (view: UpdateDialogView | null) => void + const respond = vi.fn(async (_revision: number, index: number) => { + if (index === 1) { state = { ...state, revision: state.revision + 1 }; publish(state) } + else publish(null) + }) + const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } } + Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api }) + p.run() + await expect.poll(() => p.element('actions').children.length).toBe(3) + p.element('actions').querySelectorAll('button')[1]!.click() + p.element('actions').querySelectorAll('button')[1]!.click() + p.element('actions').querySelectorAll('button')[2]!.click() + expect(respond.mock.calls).toEqual([[1, 1], [2, 1], [3, 2]]) + expect(p.document.body.classList.contains('visible')).toBe(false) +}) From 0753977b604fc413d46aef4289e9b842e19234a5 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 13:20:35 +0800 Subject: [PATCH 25/25] test(desktop): assert structured settings failures --- apps/desktop/tests/update-settings.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/tests/update-settings.spec.ts b/apps/desktop/tests/update-settings.spec.ts index ee3be16948..ae8145296f 100644 --- a/apps/desktop/tests/update-settings.spec.ts +++ b/apps/desktop/tests/update-settings.spec.ts @@ -34,7 +34,7 @@ it('preserves other fields during explicit saves and only rereads external edits it.each(['{', 'null', '{"updates":[]}', '{"updates":{"channel":"alpha"}}', '{"updates":{"channel":null}}'])('suspends updates for invalid channel settings: %s', async (raw) => { const settings = await fixture() await writeFile(settings.path, raw) - expect(await settings.load()).toMatchObject({ kind: 'error', error: { kind: 'invalid', path: settings.path } }) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid' }, path: settings.path } }) expect(await readFile(settings.path, 'utf8')).toBe(raw) }) @@ -48,7 +48,7 @@ it('refuses to replace malformed user JSON during a UI save', async () => { it('reports unreadable configuration instead of silently choosing a default', async () => { const settings = await fixture() await mkdir(settings.path) - expect(await settings.load()).toMatchObject({ kind: 'error', error: { kind: 'read' } }) + expect(await settings.load()).toMatchObject({ kind: 'error', error: { problem: { kind: 'read' } } }) }) it('updates the document initialized by shell settings without losing the popup preference', async () => {