Merge pull request #7495 from deepseek-harness/feat/desktop-update-channels
feat(desktop): 在设置中切换更新渠道
This commit is contained in:
commit
ece8f7cb77
110 files changed
+3285
-248
No files matched your search
@@ -73,6 +73,12 @@ jobs:
|
||||
- name: Run blocking Windows builds
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-blocking
|
||||
- name: Verify Desktop updater dependency versions
|
||||
shell: pwsh
|
||||
run: |
|
||||
node --test apps/desktop/tests/updates/electron-updater/versions.test.mjs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
node apps/desktop/tests/updates/electron-updater/check-versions.mjs
|
||||
- name: Parse Windows runner supervision scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
en: 67bf0a18faa7aa76
|
||||
zh: 782e14ce62bb939f
|
||||
/deepseek-harness-desktop/updates/local-desktop-settings:
|
||||
en: 2e57457c44c85b5f
|
||||
zh: 5b8510a3dc4c6c9d
|
||||
en: 01461be5b81e8609
|
||||
zh: 58e818df821a3aa2
|
||||
/deepseek-harness-desktop/updates/mandatory-update-policy:
|
||||
en: 182a9c6705213e13
|
||||
zh: 681762f83490f4c6
|
||||
|
||||
+11
-1
@@ -302,7 +302,17 @@ 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 `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 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.
|
||||
|
||||
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).
|
||||
|
||||
<a id="mandatory-update-policy"></a>
|
||||
|
||||
|
||||
@@ -302,7 +302,17 @@ 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)删除它。
|
||||
|
||||
在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。
|
||||
|
||||
UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。
|
||||
|
||||
共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。
|
||||
|
||||
撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。有结构化文件系统错误时,区分空间不足、无法写入和文件占用;错误弹窗详情保留当次错误码及已提供的文件路径。没有结构化依据时保留通用下载失败提示。
|
||||
|
||||
更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。
|
||||
|
||||
<a id="mandatory-update-policy"></a>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 0b35c7471c09b27a
|
||||
zh: cc4a382b99fc3210
|
||||
/desktop-packaging/release-versions:
|
||||
en: 3f38ab26ee195b39
|
||||
zh: f00eddafa8f3b9ca
|
||||
en: 8cc17b1e5ae3e422
|
||||
zh: 7de3e4f1e76be3e3
|
||||
/desktop-packaging/configuration-and-local-builds:
|
||||
en: 5b4f0f80a117f202
|
||||
zh: ff05be196c181087
|
||||
|
||||
@@ -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<version>` 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.<date>.<sequence>` 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.<date>.<sequence>` 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).
|
||||
|
||||
<a id="configuration-and-local-builds"></a>
|
||||
|
||||
|
||||
@@ -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.<date>.<sequence>` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。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.<date>.<sequence>` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。
|
||||
|
||||
<a id="configuration-and-local-builds"></a>
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: eed7c4ed0d4e2758
|
||||
zh: 026fe6076e11018e
|
||||
/desktop-publication/publish-installers-and-update-feeds:
|
||||
en: 3c3e3a09573c4386
|
||||
zh: c851ae6a94f2284a
|
||||
en: 4723059d6f16b25b
|
||||
zh: 54e5afad8fff8eb6
|
||||
/desktop-publication/fixed-installer-downloads:
|
||||
en: ce8296f8f1ee881e
|
||||
zh: 749ab63bd6ed4248
|
||||
|
||||
@@ -59,9 +59,9 @@ 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. 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.
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ 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`;稳定版本还发布指向相同产物的 `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 生成器,而不是手工编造内嵌映射字段。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 行为;公开下载需单独验收。
|
||||
|
||||
|
||||
@@ -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,13 +46,14 @@ 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
|
||||
: 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
|
||||
@@ -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
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,7 +105,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>} 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<void>} Resolves when the packaged configuration matches the release destination.
|
||||
*/
|
||||
|
||||
@@ -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']) })
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 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'
|
||||
import { recordVerifiedVersions } from '../tests/updates/electron-updater/versions.mjs'
|
||||
|
||||
if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Qualification requires Windows or macOS')
|
||||
if (process.argv.slice(2).some(arg => arg !== '--record')) throw new Error('Usage: test-update-channels.mjs [--record]')
|
||||
|
||||
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/updates/electron-updater/qualification.mjs', import.meta.url))], {
|
||||
env: environment, stdio: 'inherit', windowsHide: true,
|
||||
})
|
||||
let timedOut = false
|
||||
const deadline = setTimeout(() => { timedOut = true; 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: timedOut=${timedOut}, signal=${signal}`)) : resolve(code))
|
||||
})
|
||||
if (timedOut) throw new Error('Channel qualification timed out after 30000 ms')
|
||||
if (code !== 0) throw new Error(`Channel qualification failed: ${code}`)
|
||||
if (process.argv.includes('--record')) await recordVerifiedVersions()
|
||||
} finally { clearTimeout(deadline); await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) }
|
||||
+14
-1
@@ -1,6 +1,8 @@
|
||||
import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types'
|
||||
/** Typed preload operations exposed only by the Electron shell. */
|
||||
|
||||
export type { DesktopChannelState, DesktopChannelChangeResult } 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'
|
||||
@@ -32,6 +34,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',
|
||||
@@ -51,17 +56,23 @@ 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
|
||||
/** 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. */
|
||||
export type DesktopUpdateFailureKind =
|
||||
| 'configuration'
|
||||
| 'check'
|
||||
| 'check-network'
|
||||
| 'download'
|
||||
| 'download-network'
|
||||
| 'download-space'
|
||||
| 'download-permission'
|
||||
| 'download-busy'
|
||||
| 'install'
|
||||
| 'install-network'
|
||||
| 'stop-failed'
|
||||
@@ -75,6 +86,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. */
|
||||
@@ -91,6 +103,7 @@ export interface DshDesktopProductApi {
|
||||
*/
|
||||
deviceInfo(): Promise<string>
|
||||
readonly updates: {
|
||||
readonly channels: DesktopChannelBridge
|
||||
status(): Promise<DesktopUpdatePresentation>
|
||||
open(): Promise<void>
|
||||
subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void
|
||||
|
||||
@@ -106,9 +106,20 @@ export const en = {
|
||||
reloadPageMenu: 'Reload Page',
|
||||
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: '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',
|
||||
updateConfigurationDetails: 'Review settings',
|
||||
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.',
|
||||
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.',
|
||||
@@ -286,9 +297,20 @@ export const zh = {
|
||||
reloadPageMenu: '刷新页面',
|
||||
toggleDevToolsMenu: '开发者工具',
|
||||
restartAppHostMenu: '重启应用与 Host',
|
||||
updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。',
|
||||
updateConfigurationRead: '配置文件异常,更新已暂停',
|
||||
updateConfigurationFormat: '配置文件异常,更新已暂停',
|
||||
updateConfigurationChannel: '配置文件异常,更新已暂停',
|
||||
updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用',
|
||||
updateReloadSettings: '重新读取配置',
|
||||
updateConfigurationDetails: '查看配置',
|
||||
updateShowSettingsFile: '显示配置文件',
|
||||
updateCheckFailedTitle: '更新检查失败',
|
||||
updateCheckFailed: '检查更新失败,请稍后重试。',
|
||||
updateDownloadFailed: '下载更新失败,请重试。',
|
||||
updateDownloadSpaceFailed: '磁盘空间不足,无法下载更新。请释放空间后重试。',
|
||||
updateDownloadPermissionFailed: '无法写入更新文件,请检查目录权限后重试。',
|
||||
updateDownloadBusyFailed: '更新文件正被占用,请关闭占用它的程序后重试。',
|
||||
updateInstallFailed: '安装更新失败,请稍后重试。',
|
||||
updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。',
|
||||
updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。',
|
||||
@@ -464,9 +486,20 @@ export const ja = {
|
||||
reloadPageMenu: 'ページを再読み込み',
|
||||
toggleDevToolsMenu: '開発者ツール',
|
||||
restartAppHostMenu: 'アプリとホストを再起動',
|
||||
updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。',
|
||||
updateConfigurationRead: '設定ファイルに問題があるため、更新を停止しています',
|
||||
updateConfigurationFormat: '設定ファイルに問題があるため、更新を停止しています',
|
||||
updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています',
|
||||
updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください',
|
||||
updateReloadSettings: '設定を再読み込み',
|
||||
updateConfigurationDetails: '設定を確認',
|
||||
updateShowSettingsFile: '設定ファイルを表示',
|
||||
updateCheckFailedTitle: 'アップデートの確認に失敗しました',
|
||||
updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。',
|
||||
updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。',
|
||||
updateDownloadSpaceFailed: '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。',
|
||||
updateDownloadPermissionFailed: '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。',
|
||||
updateDownloadBusyFailed: '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。',
|
||||
updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。',
|
||||
updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。',
|
||||
updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。',
|
||||
@@ -642,9 +675,20 @@ export const es = {
|
||||
reloadPageMenu: 'Recargar página',
|
||||
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: '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',
|
||||
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.',
|
||||
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.',
|
||||
@@ -820,9 +864,20 @@ export const fr = {
|
||||
reloadPageMenu: 'Recharger la page',
|
||||
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: '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',
|
||||
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.',
|
||||
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.',
|
||||
@@ -998,9 +1053,20 @@ export const de = {
|
||||
reloadPageMenu: 'Seite neu laden',
|
||||
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: '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',
|
||||
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.',
|
||||
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.',
|
||||
@@ -1176,9 +1242,20 @@ export const ru = {
|
||||
reloadPageMenu: 'Обновить страницу',
|
||||
toggleDevToolsMenu: 'Инструменты разработчика',
|
||||
restartAppHostMenu: 'Перезапустите приложение и хост.',
|
||||
updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.',
|
||||
updateConfigurationRead: 'Ошибка файла настроек; обновления приостановлены',
|
||||
updateConfigurationFormat: 'Ошибка файла настроек; обновления приостановлены',
|
||||
updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены',
|
||||
updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение',
|
||||
updateReloadSettings: 'Перечитать настройки',
|
||||
updateConfigurationDetails: 'Проверить настройки',
|
||||
updateShowSettingsFile: 'Показать файл настроек',
|
||||
updateCheckFailedTitle: 'Проверка обновления не удалась',
|
||||
updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.',
|
||||
updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.',
|
||||
updateDownloadSpaceFailed: 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.',
|
||||
updateDownloadPermissionFailed: 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.',
|
||||
updateDownloadBusyFailed: 'Файл обновления занят. Закройте использующую его программу и повторите попытку.',
|
||||
updateInstallFailed: 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.',
|
||||
updateCheckNetworkFailed: 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.',
|
||||
updateDownloadNetworkFailed: 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.',
|
||||
@@ -1354,9 +1431,20 @@ export const pt = {
|
||||
reloadPageMenu: 'Recarregar página',
|
||||
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: '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',
|
||||
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.',
|
||||
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.',
|
||||
|
||||
+118
-17
@@ -1,8 +1,12 @@
|
||||
import { installFileActions } from './file-actions.ts'
|
||||
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 { installFileActions } from './file-actions.ts'
|
||||
import { configurationFailure, channelSaveFailure, configurationMessage } from './update-configuration.ts'
|
||||
import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types'
|
||||
import { DesktopUpdateSettings } from './update-settings.ts'
|
||||
import { isDesktopUpdateChannel } from './settings-file.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'
|
||||
@@ -365,7 +369,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const desktopSettings = readDesktopSettings(app.getPath('userData'))
|
||||
const desktopSettings = await readDesktopSettings(app.getPath('userData'))
|
||||
void pruneCrashReports(app.getPath('logs'))
|
||||
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
|
||||
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
|
||||
@@ -421,6 +425,9 @@ async function main(): Promise<void> {
|
||||
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)
|
||||
@@ -442,11 +449,11 @@ async function main(): Promise<void> {
|
||||
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<Electron.MessageBoxReturnValue> => {
|
||||
const ordinaryMessageBox = async (options: UpdateDialogOptions,
|
||||
parent = currentDialogWindow()): Promise<Electron.MessageBoxReturnValue> => {
|
||||
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 })
|
||||
}
|
||||
@@ -614,9 +621,9 @@ async function main(): Promise<void> {
|
||||
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
|
||||
@@ -678,7 +685,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
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: configurationFailure(initialChannel.error) } : {}) }
|
||||
const updateChannel = initialChannel.kind === 'ready' ? initialChannel.channel : packagedChannel
|
||||
const updates = new DesktopUpdateCoordinator(
|
||||
publishUpdate,
|
||||
async () => {
|
||||
@@ -739,12 +752,22 @@ async function main(): Promise<void> {
|
||||
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<DesktopUpdateState> => {
|
||||
const downloadUpdate = async (version: string, revision = updates.revision): Promise<DesktopUpdateState> => {
|
||||
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
|
||||
@@ -752,7 +775,7 @@ async function main(): Promise<void> {
|
||||
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<void> => new Promise((resolve) => {
|
||||
const window = currentDialogWindow()
|
||||
@@ -869,9 +892,39 @@ async function main(): Promise<void> {
|
||||
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): Promise<DesktopChannelChangeResult> => {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
})
|
||||
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)
|
||||
const state = publishChannel({ ...channelState, changing: --channelRequests > 0,
|
||||
...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) })
|
||||
return { kind: 'failed', state, reason: channelSaveFailure(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
|
||||
assertProductSender(event)
|
||||
return presentDesktopUpdate(updates.state)
|
||||
return updatePresentation(updates.state)
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => {
|
||||
assertProductSender(event)
|
||||
@@ -897,9 +950,52 @@ async function main(): Promise<void> {
|
||||
await openUpdatePrompt()
|
||||
})
|
||||
|
||||
let configurationPrompt: Promise<void> | undefined
|
||||
const showConfigurationError = (): Promise<void> => {
|
||||
configurationPrompt ??= Promise.resolve().then(async () => {
|
||||
while (channelState?.error !== undefined) {
|
||||
const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle,
|
||||
message: configurationMessage(channelState.error, locale.messages),
|
||||
...{ detail: channelSettings.path,
|
||||
buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose],
|
||||
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 })
|
||||
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 })
|
||||
}
|
||||
} } },
|
||||
}, mandatoryUI?.confirmationWindow ?? currentDialogWindow())
|
||||
if (result.response !== 0) return
|
||||
}
|
||||
}).finally(() => { configurationPrompt = undefined })
|
||||
return configurationPrompt
|
||||
}
|
||||
|
||||
let promptOperation: Promise<void> | undefined
|
||||
let policyAuthenticationQueued = false
|
||||
/** Present check results only while their captured channel revision remains current. */
|
||||
const openUpdatePrompt = (manual = false): Promise<void> => {
|
||||
if (channelState?.error !== undefined) {
|
||||
return showConfigurationError()
|
||||
}
|
||||
if (authenticationOperation !== undefined) {
|
||||
policyAuth?.focus(); updateDialog.focus()
|
||||
}
|
||||
@@ -917,6 +1013,7 @@ async function main(): Promise<void> {
|
||||
let progress: Promise<unknown> | 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)
|
||||
@@ -928,6 +1025,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
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') {
|
||||
@@ -939,7 +1037,7 @@ async function main(): Promise<void> {
|
||||
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
|
||||
}
|
||||
@@ -954,7 +1052,7 @@ async function main(): Promise<void> {
|
||||
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()
|
||||
@@ -1411,7 +1509,7 @@ async function main(): Promise<void> {
|
||||
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
|
||||
@@ -1429,7 +1527,10 @@ async function main(): Promise<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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. */
|
||||
@@ -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<DesktopPolicyState> | undefined
|
||||
private pendingChannel: DesktopPolicyIdentity['channel'] | undefined
|
||||
private controller: AbortController | undefined
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private disposed = false
|
||||
@@ -137,7 +138,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.
|
||||
@@ -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<string, string> {
|
||||
private requestHeaders(channel: DesktopPolicyIdentity['channel']): Record<string, string> {
|
||||
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<DesktopPolicyState> {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types'
|
||||
/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */
|
||||
|
||||
import type { DesktopChannelState, DesktopChannelChangeResult } 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'
|
||||
@@ -65,6 +66,15 @@ function createProductApi(): DshDesktopProductApi {
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
channels: {
|
||||
get: () => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelGet) as Promise<DesktopChannelState>,
|
||||
set: channel => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelSet, channel) as Promise<DesktopChannelChangeResult>,
|
||||
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<DesktopUpdatePresentation>,
|
||||
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/** Shared shell settings storage; reads have no side effects and fields fail independently. */
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { z } from 'zod'
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
allowTestAuthPopupWindow: z.boolean(),
|
||||
channel: z.enum(['nightly', 'stable']),
|
||||
})
|
||||
const documentSchema = z.looseObject({ updates: z.looseObject({}).optional() })
|
||||
|
||||
/** Known field values before consumers supply defaults for absent fields. */
|
||||
export type DesktopUpdatePreferences = z.infer<typeof preferencesSchema>
|
||||
/** Unknown root and updates fields survive a read-modify-write operation. */
|
||||
export type DesktopSettingsDocument = z.infer<typeof documentSchema>
|
||||
/** Handling-relevant failure details; validation failures never include document contents. */
|
||||
export type DesktopSettingsProblem =
|
||||
| { kind: 'read' }
|
||||
| { kind: 'invalid'; reason: 'json' | 'structure' }
|
||||
| { kind: 'invalid'; reason: 'field'; field: keyof DesktopUpdatePreferences }
|
||||
| { kind: 'write'; operation: 'initialize' | 'save'; effect: 'unchanged' | 'unknown' | 'written' }
|
||||
|
||||
/** A storage or validation failure; business consumers decide fallback or suspension. */
|
||||
export class DesktopSettingsFileError extends Error {
|
||||
/**
|
||||
* @param problem - Stable failure classification and mutation outcome, when applicable.
|
||||
* @param path - Settings file location, never file contents.
|
||||
* @param options - Original filesystem error, when available.
|
||||
*/
|
||||
constructor(readonly problem: DesktopSettingsProblem, readonly path: string, options?: ErrorOptions) {
|
||||
super(`desktop settings: ${problem.kind}: ${path}`, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Absence permits a caller default; an invalid explicit field does not. */
|
||||
export type DesktopSettingsField<T> =
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'valid'; value: T }
|
||||
| { kind: 'invalid'; error: DesktopSettingsFileError }
|
||||
|
||||
/** File state and independent field results; a missing file is never implicitly created. */
|
||||
export type DesktopSettingsRead =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'error'; error: DesktopSettingsFileError }
|
||||
| {
|
||||
kind: 'ready'
|
||||
document: DesktopSettingsDocument
|
||||
fields: { [K in keyof DesktopUpdatePreferences]: DesktopSettingsField<DesktopUpdatePreferences[K]> }
|
||||
}
|
||||
|
||||
function field<T>(schema: z.ZodType<T>, value: unknown, name: keyof DesktopUpdatePreferences, path: string): DesktopSettingsField<T> {
|
||||
if (value === undefined) return { kind: 'absent' }
|
||||
const result = schema.safeParse(value)
|
||||
return result.success ? { kind: 'valid', value: result.data }
|
||||
: { kind: 'invalid', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: name }, path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a channel at the IPC boundary using the same schema as file loading.
|
||||
* @param value - Untrusted input.
|
||||
* @returns Whether the input is a supported channel.
|
||||
*/
|
||||
export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdatePreferences['channel'] {
|
||||
return preferencesSchema.shape.channel.safeParse(value).success
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the document shared by shell preferences and update channels.
|
||||
* @param userData - Electron userData, independent of Host and DSH_HOME.
|
||||
* @returns Settings file path without filesystem changes.
|
||||
*/
|
||||
function desktopSettingsPath(userData: string): string {
|
||||
return join(userData, 'desktop', 'settings.json')
|
||||
}
|
||||
|
||||
/** File operations do not watch, cache, choose defaults or apply runtime preferences. */
|
||||
export class DesktopSettingsFile {
|
||||
readonly path: string
|
||||
|
||||
/** @param userData - Electron userData, independent of Host and DSH_HOME. */
|
||||
constructor(userData: string) { this.path = desktopSettingsPath(userData) }
|
||||
|
||||
/** @returns Fresh file and field states; performs no creation or repair. */
|
||||
async read(): Promise<DesktopSettingsRead> {
|
||||
let raw: string
|
||||
try { raw = await readFile(this.path, 'utf8') } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }
|
||||
return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'read' }, this.path, { cause: error }) }
|
||||
}
|
||||
let value: unknown
|
||||
try { value = JSON.parse(raw) } catch (_error) {
|
||||
// SyntaxError can quote user content; diagnostics expose only the file and failure category.
|
||||
return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, this.path) }
|
||||
}
|
||||
return this.inspect(value)
|
||||
}
|
||||
|
||||
private inspect(value: unknown): DesktopSettingsRead {
|
||||
const result = documentSchema.safeParse(value)
|
||||
if (!result.success) return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, this.path) }
|
||||
const document = result.data
|
||||
return { kind: 'ready', document, fields: {
|
||||
allowTestAuthPopupWindow: field(preferencesSchema.shape.allowTestAuthPopupWindow,
|
||||
document.updates?.allowTestAuthPopupWindow, 'allowTestAuthPopupWindow', this.path),
|
||||
channel: field(preferencesSchema.shape.channel, document.updates?.channel, 'channel', this.path),
|
||||
} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit startup initialization; existing files, including invalid ones, are never replaced.
|
||||
* @returns Current file state after exclusive creation if missing, or a structured failure.
|
||||
*/
|
||||
async initialize(): Promise<DesktopSettingsRead> {
|
||||
const current = await this.read()
|
||||
if (current.kind !== 'missing') return current
|
||||
try { return await this.mutate({ kind: 'initialize' }) } catch (error) {
|
||||
if (!(error instanceof DesktopSettingsFileError)) throw error
|
||||
return { kind: 'error', error }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch known fields under the shared writer lock, preserving unknown and unrelated invalid fields.
|
||||
* Malformed JSON/structure is rejected; a missing document starts empty. External editors must
|
||||
* not write concurrently. No runtime settings change here. Write errors distinguish unchanged,
|
||||
* uncertain write completion, and a committed write whose lock cleanup failed; no rollback is attempted.
|
||||
* @param updates - Typed fields to replace; omitted keys remain unchanged.
|
||||
* @returns After atomic replacement and lock release; otherwise throws DesktopSettingsFileError.
|
||||
*/
|
||||
async saveUpdates(updates: Partial<DesktopUpdatePreferences>): Promise<void> {
|
||||
const result = await this.mutate({ kind: 'save', updates })
|
||||
if (result.kind === 'error') throw result.error
|
||||
}
|
||||
|
||||
private async mutate(request: { kind: 'initialize' } | { kind: 'save'; updates: Partial<DesktopUpdatePreferences> }): Promise<DesktopSettingsRead> {
|
||||
let effect: 'unchanged' | 'unknown' | 'written' = 'unchanged'
|
||||
try {
|
||||
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 })
|
||||
return await withFileLock(this.path, async () => {
|
||||
const current = await this.read()
|
||||
if (current.kind === 'error') throw current.error
|
||||
if (request.kind === 'initialize' && current.kind !== 'missing') return current
|
||||
const document = current.kind === 'ready' ? current.document : {}
|
||||
const updates = request.kind === 'initialize' ? { allowTestAuthPopupWindow: false } : request.updates
|
||||
const next = { ...document, updates: { ...document.updates, ...updates } }
|
||||
const raw = `${JSON.stringify(next, null, 2)}\n`
|
||||
effect = 'unknown'
|
||||
if (request.kind === 'initialize') {
|
||||
try { await writeFile(this.path, raw, { flag: 'wx', mode: 0o600 }) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
effect = 'unchanged'
|
||||
return this.read()
|
||||
}
|
||||
} else {
|
||||
await writeFileAtomic(this.path, raw, { mode: 0o600 })
|
||||
}
|
||||
effect = 'written'
|
||||
return this.inspect(next)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DesktopSettingsFileError) throw error
|
||||
throw new DesktopSettingsFileError({ kind: 'write', operation: request.kind, effect }, this.path, { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,26 @@
|
||||
/**
|
||||
* Local Electron shell settings, loaded once per process independently of the Host.
|
||||
* Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
/** Startup policy for optional Electron shell preferences, independent of the Host. */
|
||||
import { DesktopSettingsFile, type DesktopUpdatePreferences } from './settings-file.ts'
|
||||
|
||||
/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */
|
||||
/** Resolved popup preference; channel failures do not disable a valid popup preference. */
|
||||
export interface DesktopSettings {
|
||||
readonly updates: {
|
||||
readonly allowTestAuthPopupWindow: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
readonly updates: Pick<DesktopUpdatePreferences, 'allowTestAuthPopupWindow'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read shell settings, creating the default document exclusively when missing.
|
||||
* @param userData - Electron's userData directory, independent of DSH_HOME.
|
||||
* @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart.
|
||||
* Initialize the shared document explicitly at startup, then resolve the popup preference.
|
||||
* @param userData - Electron userData, independent of DSH_HOME.
|
||||
* @returns Validated popup preference, or false with a diagnostic on failure; changes require restart.
|
||||
*/
|
||||
export function readDesktopSettings(userData: string): DesktopSettings {
|
||||
const directory = join(userData, 'desktop')
|
||||
const path = join(directory, 'settings.json')
|
||||
const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } }
|
||||
try {
|
||||
let raw: string
|
||||
try { raw = readFileSync(path, 'utf8') } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) {
|
||||
// An existing document belongs to the user, including one created during initialization.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
}
|
||||
raw = readFileSync(path, 'utf8')
|
||||
export async function readDesktopSettings(userData: string): Promise<DesktopSettings> {
|
||||
const file = new DesktopSettingsFile(userData)
|
||||
const result = await file.initialize()
|
||||
const preference = result.kind === 'ready' ? result.fields.allowTestAuthPopupWindow : undefined
|
||||
if (preference?.kind === 'valid') return { updates: { allowTestAuthPopupWindow: preference.value } }
|
||||
const error = result.kind === 'error' ? result.error : preference?.kind === 'invalid' ? preference.error : undefined
|
||||
if (error) {
|
||||
try { console.warn(`desktop settings: ${file.path}: using defaults because settings could not be loaded`, error) } catch (_logError) {
|
||||
// Optional popup settings remain disabled even when diagnostic output is unavailable.
|
||||
}
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!record(value) || (value.updates !== undefined && !record(value.updates))) {
|
||||
throw new Error('expected a JSON object with an optional updates object')
|
||||
}
|
||||
const allow = value.updates?.allowTestAuthPopupWindow
|
||||
if (allow !== undefined && typeof allow !== 'boolean') {
|
||||
throw new Error('updates.allowTestAuthPopupWindow must be true or false')
|
||||
}
|
||||
return { updates: { allowTestAuthPopupWindow: allow ?? false } }
|
||||
} catch (error) {
|
||||
try {
|
||||
console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error)
|
||||
} catch (_logError) {
|
||||
// Diagnostic output is best effort; its failure must not make optional settings fatal.
|
||||
// TODO: Persist settings diagnostics when console output is unavailable, without blocking startup.
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
return { updates: { allowTestAuthPopupWindow: false } }
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Resolve the update channel sealed into the installed application manifest. */
|
||||
import type { DesktopUpdatePreferences } from './settings-file.ts'
|
||||
|
||||
/** Installer-owned channel, independent of release version and upload environment. */
|
||||
export type DesktopUpdateChannel = 'nightly' | 'latest'
|
||||
export type DesktopUpdateChannel = DesktopUpdatePreferences['channel']
|
||||
|
||||
/**
|
||||
* Read the packaged channel; older manifests retain their existing nightly behavior.
|
||||
@@ -12,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Safe user-facing classifications for shell update settings failures. */
|
||||
import type { DesktopChannelChangeResult, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types'
|
||||
import { DesktopSettingsFileError } from './settings-file.ts'
|
||||
import type { DesktopMessages } from './locale.ts'
|
||||
|
||||
/**
|
||||
* Classify an unreadable startup selection without exposing file contents.
|
||||
* @param error - Validated storage failure.
|
||||
* @returns The persistent reason updates are suspended.
|
||||
*/
|
||||
export function configurationFailure(error: DesktopSettingsFileError): NonNullable<DesktopChannelState['error']> {
|
||||
const problem = error.problem
|
||||
if (problem.kind !== 'invalid') return 'read'
|
||||
return problem.reason === 'field' && problem.field === 'channel' ? 'channel' : 'format'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an operation failure to a short, trustworthy toast reason.
|
||||
* @param error - Failed save or channel transition.
|
||||
* @returns Safe detail for the initiating settings view.
|
||||
*/
|
||||
export function channelSaveFailure(error: unknown): Extract<DesktopChannelChangeResult, { kind: 'failed' }>['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'
|
||||
return 'save'
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain an actual suspension and its recovery action.
|
||||
* @param reason - Persistent updater configuration failure.
|
||||
* @param messages - Current desktop locale.
|
||||
* @returns Localized dialog message.
|
||||
*/
|
||||
export function configurationMessage(reason: NonNullable<DesktopChannelState['error']>, messages: DesktopMessages): string {
|
||||
const key = {
|
||||
read: 'updateConfigurationRead',
|
||||
format: 'updateConfigurationFormat',
|
||||
channel: 'updateConfigurationChannel',
|
||||
cleanup: 'updateConfigurationCleanup',
|
||||
} as const
|
||||
return messages[key[reason]]
|
||||
}
|
||||
@@ -3,33 +3,41 @@
|
||||
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 { DesktopUpdateHttpExecutor } from './updates/electron-updater/http-executor.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'
|
||||
|
||||
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<void> = Promise.resolve()
|
||||
private cancellation: UpdateCheckResult['cancellationToken']
|
||||
private configurationError: 'startup' | 'cleanup' | undefined
|
||||
private checkOperation: Promise<DesktopUpdateState> | undefined
|
||||
private downloadOperation: Promise<DesktopUpdateState> | undefined
|
||||
private installOperation: Promise<DesktopUpdateState> | 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 +54,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 +64,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<void> = discardDesktopUpdate,
|
||||
) {
|
||||
if (updater === autoUpdater) {
|
||||
// electron-updater omits this internal transport property from its public declarations.
|
||||
@@ -68,10 +78,7 @@ 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
|
||||
selectUpdateChannel(this.updater, channel)
|
||||
this.updater.on('download-progress', this.onProgress)
|
||||
this.updater.on('update-downloaded', this.onDownloaded)
|
||||
this.updater.on('error', this.onError)
|
||||
@@ -80,6 +87,66 @@ export class DesktopUpdateCoordinator {
|
||||
/** Latest observable state; complete download identity remains main-process-owned. */
|
||||
get state(): DesktopUpdateState { return this.current }
|
||||
|
||||
/** Reason ordinary updates are suspended, independently of the last operation result. */
|
||||
get configurationFailure(): 'startup' | 'cleanup' | undefined { return this.configurationError }
|
||||
|
||||
/** 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 settings or previous-target cleanup are invalid.
|
||||
* @param detail - File path and safe diagnostic, without document contents.
|
||||
* @param reason - Startup validation or retirement of a previous target.
|
||||
*/
|
||||
suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup'): void {
|
||||
this.configurationError = reason
|
||||
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 Whether the selection changed; healthy repeated choices preserve the current operation.
|
||||
*/
|
||||
switchChannel(channel: DesktopUpdateChannel, persist: () => Promise<void>): 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'))
|
||||
}
|
||||
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()
|
||||
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', 'cleanup')
|
||||
throw error
|
||||
}
|
||||
this.channel = channel
|
||||
selectUpdateChannel(this.updater, channel)
|
||||
this.configurationError = undefined
|
||||
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.then(() => {}).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 +154,8 @@ export class DesktopUpdateCoordinator {
|
||||
*/
|
||||
async check(manual = false): Promise<DesktopUpdateState> {
|
||||
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 +166,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<DesktopUpdateState> {
|
||||
async download(version: string, revision = this.generation): Promise<DesktopUpdateState> {
|
||||
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 +201,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<DesktopUpdateState> {
|
||||
async install(version: string, revision = this.generation): Promise<DesktopUpdateState> {
|
||||
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 +231,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')
|
||||
}
|
||||
@@ -171,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 }),
|
||||
@@ -182,17 +266,22 @@ export class DesktopUpdateCoordinator {
|
||||
}
|
||||
|
||||
private async doCheck(): Promise<DesktopUpdateState> {
|
||||
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
|
||||
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 })
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || this.disposed) return this.current
|
||||
return this.failure(error, 'check')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ 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<boolean> }
|
||||
}
|
||||
|
||||
/** The document can select only a displayed response index. */
|
||||
@@ -43,7 +49,11 @@ export class DesktopUpdateDialog {
|
||||
private view: DesktopUpdateOverlay | undefined
|
||||
private parent: BrowserWindow | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | 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<void>
|
||||
} | undefined
|
||||
|
||||
/** Focus the current explanation or confirmation without replacing it or granting permission. */
|
||||
focus(): void {
|
||||
@@ -71,7 +81,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 +106,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 +121,35 @@ export class DesktopUpdateDialog {
|
||||
}
|
||||
resolve({ response, checkboxChecked: false })
|
||||
}
|
||||
this.active = { view, finish }
|
||||
const respond = async (index: number): Promise<void> => {
|
||||
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<void>((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() } }
|
||||
|
||||
@@ -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<DesktopUpdateState, 'downloadFailure' | 'technicalDetails'> {
|
||||
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'),
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ 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
|
||||
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
|
||||
}
|
||||
@@ -24,9 +26,13 @@ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailu
|
||||
export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string {
|
||||
const kind = desktopUpdateFailureKind(state)
|
||||
const summaries: Readonly<Record<DesktopUpdateFailureKind, string>> = {
|
||||
configuration: messages.updateConfigurationFailed,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Channel-specific default and failure policy over the shared shell settings document. */
|
||||
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. */
|
||||
export type DesktopUpdateSettingsResult =
|
||||
| { kind: 'ready'; channel: DesktopUpdateChannel }
|
||||
| { kind: 'error'; error: DesktopSettingsFileError }
|
||||
|
||||
/** No file watching; loading and explicit UI saves share shell storage and validation. */
|
||||
export class DesktopUpdateSettings {
|
||||
private readonly file: DesktopSettingsFile
|
||||
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.file = new DesktopSettingsFile(userData)
|
||||
this.path = this.file.path
|
||||
}
|
||||
|
||||
/** @returns Startup channel or an error requiring suspension; never creates a missing file. */
|
||||
async load(): Promise<DesktopUpdateSettingsResult> {
|
||||
const result = await this.file.read()
|
||||
if (result.kind === 'error') return result
|
||||
if (result.kind === 'missing') return { kind: 'ready', channel: this.packagedChannel }
|
||||
const channel = result.fields.channel
|
||||
if (channel.kind === 'invalid') return { kind: 'error', error: channel.error }
|
||||
return { kind: 'ready', channel: channel.kind === 'valid' ? channel.value : this.packagedChannel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a selection while preserving other fields; never changes the runtime selection itself.
|
||||
* @param channel - Validated UI selection.
|
||||
* @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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Channel policy applied after electron-updater's channel setter. */
|
||||
import type { AppUpdater } from 'electron-updater'
|
||||
|
||||
/**
|
||||
* Select a feed without permitting downgrades or prereleases on stable.
|
||||
* @param updater - Updater whose prior operations have settled.
|
||||
* @param channel - Selected distribution channel.
|
||||
*/
|
||||
export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'stable'): void {
|
||||
updater.channel = channel
|
||||
// The library channel setter enables downgrades.
|
||||
updater.allowDowngrade = false
|
||||
updater.allowPrerelease = channel === 'nightly'
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** 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'
|
||||
|
||||
/**
|
||||
* 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 old in-memory targets and the local feed server are retired; residual files only produce durable warnings.
|
||||
*/
|
||||
export async function discardDesktopUpdate(updater: AppUpdater): Promise<void> {
|
||||
// The dependency omits these private fields from its public integration declarations.
|
||||
const internal = updater as object as {
|
||||
updateInfoAndProvider: unknown
|
||||
downloadedUpdateHelper?: {
|
||||
_file: string | null
|
||||
_packageFile: string | null
|
||||
versionInfo: unknown
|
||||
fileInfo: unknown
|
||||
_downloadedFileInfo: unknown
|
||||
cacheDirForPendingUpdate: string
|
||||
} | 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<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
})
|
||||
}
|
||||
internal.server = null
|
||||
internal.updateInfoAndProvider = null
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -21,8 +21,8 @@
|
||||
en: 6f4de4503748f3e2
|
||||
zh: 648370375f16e463
|
||||
/desktop-local-verification/manual-walkthrough:
|
||||
en: c4b4ee8763d8464a
|
||||
zh: ee2159bc08966592
|
||||
en: 1f333416e5a3fef1
|
||||
zh: fe0f30418db59cb0
|
||||
/desktop-local-verification/evidence:
|
||||
en: dcb2473264c825d4
|
||||
zh: cebb2832efa15974
|
||||
|
||||
@@ -65,6 +65,8 @@ With Host, client, and Desktop artifacts built, run `node --import tsx apps/desk
|
||||
|
||||
<a id="verification-evidence"></a>
|
||||
|
||||
After building Desktop, `node apps/desktop/scripts/test-update-channels.mjs` checks the pinned Electron updater against loopback feeds and downloaded inert files. It verifies channel filenames, downgrade prohibition, rollout eligibility, and retirement of actual download caches and stale confirmations after channel switching; on macOS it also closes the updater-created local handoff server. Windows pull-request CI runs this check after the blocking build. 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.
|
||||
|
||||
@@ -65,6 +65,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
|
||||
|
||||
<a id="verification-evidence"></a>
|
||||
|
||||
构建 Desktop 后,`node apps/desktop/scripts/test-update-channels.mjs` 使用本地回环清单和下载的无执行能力文件检查固定版本的 Electron updater。它验证渠道文件名、禁止降级、灰度命中,以及切换渠道后实际下载缓存和过期确认的撤销,在 macOS 上还关闭 updater 创建的本地交接服务。Windows pull request CI 在阻塞性构建后运行此检查。不运行安装器、Host 或远程发布。此验证不能替代 Windows 和 macOS 的签名安装应用测试。
|
||||
|
||||
## 验证证据
|
||||
|
||||
本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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<typeof put>) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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<Fixture> {
|
||||
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 () => {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
| --- | --- |
|
||||
| 发布环境 | <code>production</code> |
|
||||
| 版本号 | <code>1.0.0</code> |
|
||||
| 升级通道 | <code>latest</code> |
|
||||
| 升级通道 | <code>stable</code> |
|
||||
| 平台 | <code>win-x64</code> |
|
||||
| 下载地址预览 | <a href="https://download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe">https://download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
|
||||
| 升级 YAML | <a href="https://download.example.com/dsh-desk/feeds/win-x64/latest.yml">https://download.example.com/dsh-desk/feeds/win-x64/latest.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
|
||||
| 下载地址预览 | <a href="https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
|
||||
| 升级 YAML | <a href="https://download.example.com/dsh-desk/feeds/win-x64/stable.yml">https://download.example.com/dsh-desk/feeds/win-x64/stable.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
|
||||
| 打包来源 | <a href="https://github.com/owner/repo/actions/runs/20/attempts/3">查看打包运行 #20 · 第 3 次尝试</a><br>源码:<code>dsh-v1.0.0 @ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code><br>打包机器:<code>windows-publisher</code> |
|
||||
| 签名与公证 | EXE 签名校验通过 |
|
||||
| Git tag | <code>desktop-v1.0.0</code> |
|
||||
@@ -33,8 +33,8 @@ COS 存储桶:<code>production-1234567890</code>
|
||||
|
||||
| 文件 | 大小 | SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| <a href="https://download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe">deepseek-harness-1.0.0-win-x64.exe</a> | 15 字节 | <code>e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c</code> |
|
||||
| <a href="https://download.example.com/dsh-desk/feeds/win-x64/latest.yml">latest.yml</a> | 15 字节 | <code>8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1</code> |
|
||||
| <a href="https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">deepseek-harness-1.0.0-win-x64.exe</a> | 15 字节 | <code>e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c</code> |
|
||||
| <a href="https://download.example.com/dsh-desk/feeds/win-x64/stable.yml">stable.yml</a> | 15 字节 | <code>8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1</code> |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { createUpdateServer } from './update-server.mjs'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../lib/types/mandatory-update-policy.js'
|
||||
import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js'
|
||||
import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import { qualifyUpdateDialogs } from './update-dialogs.mjs'
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import updaterModule from 'electron-updater'
|
||||
import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
|
||||
import { resolveWindowsUpdatePublisher } from '../../scripts/windows-sign.mjs'
|
||||
import { artifactDigest, createArtifactUpdateServer } from './artifact-update-server.mjs'
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { app, BrowserWindow, Menu, webContents } from 'electron'
|
||||
import updaterModule from 'electron-updater'
|
||||
import { createUpdateServer } from './update-server.mjs'
|
||||
import { fixture } from './workspace-update-adapters.mjs'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
|
||||
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
|
||||
|
||||
const root = process.env.DSH_WORKSPACE_UPDATE_ROOT
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -72,8 +72,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<DesktopUpdateState> => updateState)
|
||||
const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
const updateInstall = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
const updateDownload = vi.fn(async (_version: string, _revision?: number): Promise<DesktopUpdateState> => updateState)
|
||||
const updateInstall = vi.fn(async (_version: string, _revision?: number): Promise<DesktopUpdateState> => 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() })
|
||||
@@ -243,6 +243,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<Response>) => void>(),
|
||||
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
||||
disableAllPlugins: vi.fn(async () => {
|
||||
@@ -308,7 +309,7 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: harness.FakeWindow,
|
||||
WebContentsView: harness.FakeView,
|
||||
dialog: harness.dialog,
|
||||
shell: { openExternal: harness.openExternal },
|
||||
shell: { openExternal: harness.openExternal, showItemInFolder: harness.showItemInFolder },
|
||||
nativeTheme: harness.nativeTheme,
|
||||
ipcMain: {
|
||||
on: harness.ipcOn,
|
||||
@@ -370,10 +371,28 @@ vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
|
||||
dispose() {}
|
||||
} }))
|
||||
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class {
|
||||
constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise<boolean>) {
|
||||
selectedChannel: 'nightly' | 'stable' = 'nightly'
|
||||
revision = 0
|
||||
configurationFailure: 'startup' | 'cleanup' | undefined
|
||||
constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise<boolean>,
|
||||
_updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'stable') {
|
||||
this.selectedChannel = channel
|
||||
harness.prepareUpdate = beforeRestart
|
||||
harness.publishUpdate = publish
|
||||
}
|
||||
async switchChannel(channel: 'nightly' | 'stable', persist: () => Promise<void>) {
|
||||
await persist()
|
||||
this.selectedChannel = channel
|
||||
this.configurationFailure = undefined
|
||||
this.revision++
|
||||
harness.updateState = { phase: 'idle' }
|
||||
return 'applied' as const
|
||||
}
|
||||
suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup') {
|
||||
this.configurationFailure = reason
|
||||
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
|
||||
@@ -493,6 +512,219 @@ 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', '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: 'stable' } })
|
||||
writeFileSync(path, JSON.stringify({ updates: { channel: 'nightly' } }))
|
||||
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' })
|
||||
expect(harness.windows[0]!.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged,
|
||||
{ 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: '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', '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')
|
||||
})
|
||||
|
||||
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' }
|
||||
writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{broken')
|
||||
await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')
|
||||
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0)
|
||||
})
|
||||
|
||||
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: 'channel' })
|
||||
expect(harness.hosts).toHaveLength(1)
|
||||
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } })
|
||||
})
|
||||
|
||||
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<typeof DesktopUpdateCoordinator>, _channel, persist) {
|
||||
await persist()
|
||||
this.suspendConfiguration('cache retirement failed', 'cleanup')
|
||||
throw new Error('cache retirement failed')
|
||||
})
|
||||
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' })
|
||||
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
const options = harness.dialog.showMessageBox.mock.lastCall![0] as import('../src/update-dialog.ts').UpdateDialogOptions
|
||||
expect(options.message).toBe(en.updateConfigurationCleanup)
|
||||
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('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<typeof fetch>().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.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()
|
||||
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<undefined>()
|
||||
const request = vi.fn<typeof fetch>().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')
|
||||
writeFileSync(path, '{broken')
|
||||
await readyForUpdate()
|
||||
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,
|
||||
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')
|
||||
writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } }))
|
||||
await readyForUpdate()
|
||||
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({
|
||||
message: en.updateConfigurationChannel, detail: path,
|
||||
})
|
||||
writeFileSync(path, '{broken')
|
||||
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' })
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -822,7 +1054,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()
|
||||
})
|
||||
|
||||
@@ -881,7 +1113,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 [
|
||||
@@ -1577,12 +1809,12 @@ describe('desktop main startup', () => {
|
||||
window.visible = false
|
||||
const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise<void>
|
||||
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 () => {
|
||||
@@ -1777,6 +2009,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<DesktopUpdateState>()
|
||||
const requested = Promise.withResolvers<undefined>()
|
||||
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' })
|
||||
@@ -1809,7 +2063,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 () => {
|
||||
@@ -1823,7 +2077,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
|
||||
@@ -1910,11 +2164,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()
|
||||
})
|
||||
|
||||
@@ -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<Response>()
|
||||
const request = vi.fn<typeof fetch>().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<Response>()
|
||||
|
||||
@@ -292,3 +292,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('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: 'stable', packagedChannel: 'nightly', changing: false }
|
||||
handler({}, state)
|
||||
expect(listener).toHaveBeenCalledWith(state)
|
||||
dispose()
|
||||
expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged, handler)
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as atomic from '@deepseek-ai/dsh-atomic-write'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { DesktopSettingsFile, isDesktopUpdateChannel } from '../src/settings-file.ts'
|
||||
import { readDesktopSettings } from '../src/settings.ts'
|
||||
import { DesktopUpdateSettings } from '../src/update-settings.ts'
|
||||
|
||||
vi.mock('node:fs/promises', async original => ({ ...await original<typeof import('node:fs/promises')>() }))
|
||||
vi.mock('@deepseek-ai/dsh-atomic-write', async original => ({ ...await original<typeof import('@deepseek-ai/dsh-atomic-write')>() }))
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
async function fixture(raw?: string) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-settings-file-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const file = new DesktopSettingsFile(root)
|
||||
if (raw !== undefined) {
|
||||
await mkdir(dirname(file.path))
|
||||
await writeFile(file.path, raw)
|
||||
}
|
||||
return { root, file }
|
||||
}
|
||||
|
||||
it('distinguishes missing files from absent fields without creating a file during reads', async () => {
|
||||
const { file } = await fixture()
|
||||
expect(await file.read()).toEqual({ kind: 'missing' })
|
||||
await expect(readFile(file.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await file.saveUpdates({})
|
||||
expect(await file.read()).toMatchObject({ kind: 'ready', fields: {
|
||||
channel: { kind: 'absent' }, allowTestAuthPopupWindow: { kind: 'absent' },
|
||||
} })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['{secret-value', 'json'], ['null', 'structure'], ['[]', 'structure'],
|
||||
['{"updates":null}', 'structure'], ['{"updates":[]}', 'structure'], ['{"updates":true}', 'structure'],
|
||||
])('classifies malformed documents and refuses to replace them: %s', async (raw, reason) => {
|
||||
const { file } = await fixture(raw)
|
||||
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: 'stable' })).rejects.toMatchObject({ problem: { kind: 'invalid', reason } })
|
||||
expect(await readFile(file.path, 'utf8')).toBe(raw)
|
||||
})
|
||||
|
||||
it('keeps an invalid channel independent from the popup preference and repairs only the selected field', async () => {
|
||||
const { root, file } = await fixture('{"other":[1],"updates":{"channel":"alpha","allowTestAuthPopupWindow":true,"future":{"x":2}}}')
|
||||
expect(await file.read()).toMatchObject({ kind: 'ready', fields: {
|
||||
channel: { kind: 'invalid', error: { problem: { kind: 'invalid', reason: 'field', field: 'channel' } } },
|
||||
allowTestAuthPopupWindow: { kind: 'valid', value: true },
|
||||
} })
|
||||
expect((await readDesktopSettings(root)).updates.allowTestAuthPopupWindow).toBe(true)
|
||||
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 },
|
||||
} })
|
||||
})
|
||||
|
||||
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, '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: 'stable' },
|
||||
} })
|
||||
})
|
||||
|
||||
it('classifies unreadable files without attempting initialization', async () => {
|
||||
const { file } = await fixture()
|
||||
await mkdir(file.path, { recursive: true })
|
||||
expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: { kind: 'read' } } })
|
||||
expect((await fs.stat(file.path)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it('serializes initializers and patches from separate storage instances without losing fields', async () => {
|
||||
const { root, file } = await fixture()
|
||||
const other = new DesktopSettingsFile(root)
|
||||
await Promise.all([file.initialize(), other.initialize()])
|
||||
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' })
|
||||
})
|
||||
|
||||
it('preserves a file created by an external writer during exclusive initialization', async () => {
|
||||
const { file } = await fixture()
|
||||
const original = fs.writeFile
|
||||
const external = '{"updates":{"channel":"nightly"},"external":true}'
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (path, contents, options) => {
|
||||
if (path === file.path) await original(path, external)
|
||||
return original(path, contents, options)
|
||||
})
|
||||
expect(await file.initialize()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'valid', value: 'nightly' } } })
|
||||
expect(await readFile(file.path, 'utf8')).toBe(external)
|
||||
})
|
||||
|
||||
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: 'stable' })).rejects.toMatchObject({ problem: {
|
||||
kind: 'write', operation: 'save', effect: 'unchanged',
|
||||
} })
|
||||
await expect(readFile(file.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
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: 'stable' })).rejects.toMatchObject({ problem: {
|
||||
kind: 'write', operation: 'save', effect: 'unknown',
|
||||
} })
|
||||
expect(await readFile(file.path, 'utf8')).toBe('{}')
|
||||
await expect(fs.access(`${file.path}.lock`)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('reports a committed write when lock cleanup fails, without claiming rollback', async () => {
|
||||
const { file } = await fixture('{}')
|
||||
const original = fs.rm
|
||||
vi.spyOn(fs, 'rm').mockImplementation(async (path, options) => {
|
||||
if (path === `${file.path}.lock`) throw new Error('lock cleanup failed')
|
||||
return original(path, options)
|
||||
})
|
||||
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: 'stable' } })
|
||||
})
|
||||
|
||||
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 } } })
|
||||
})
|
||||
|
||||
it.each([null, false, 1, 'alpha', ''])('rejects an explicit invalid channel without treating it as absent: %s', async (channel) => {
|
||||
const { file } = await fixture(JSON.stringify({ updates: { channel } }))
|
||||
expect(isDesktopUpdateChannel(channel)).toBe(false)
|
||||
expect(await file.read()).toMatchObject({ kind: 'ready', fields: { channel: { kind: 'invalid' } } })
|
||||
})
|
||||
|
||||
it('reports a partial initialization write as uncertain and preserves it for diagnosis', async () => {
|
||||
const { file } = await fixture()
|
||||
const original = fs.writeFile
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (path, contents, options) => {
|
||||
if (path !== file.path) return original(path, contents, options)
|
||||
await original(path, '{', options)
|
||||
throw new Error('disk write interrupted')
|
||||
})
|
||||
expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: {
|
||||
kind: 'write', operation: 'initialize', effect: 'unknown',
|
||||
} } })
|
||||
expect(await readFile(file.path, 'utf8')).toBe('{')
|
||||
expect(await file.initialize()).toMatchObject({ kind: 'error', error: { problem: { kind: 'invalid', reason: 'json' } } })
|
||||
})
|
||||
|
||||
it('rereads fields after acquiring the writer lock instead of saving a stale snapshot', async () => {
|
||||
const { file } = await fixture('{"updates":{"channel":"stable"}}')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const holder = atomic.withFileLock(file.path, async () => {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
await writeFile(file.path, '{"updates":{"channel":"stable","future":3},"other":4}')
|
||||
})
|
||||
await started.promise
|
||||
const attempting = Promise.withResolvers<undefined>()
|
||||
const originalLock = atomic.withFileLock
|
||||
vi.spyOn(atomic, 'withFileLock').mockImplementation(<T>(path: string, operation: () => Promise<T>, options?: atomic.FileLockOptions): Promise<T> => {
|
||||
attempting.resolve(undefined)
|
||||
return originalLock(path, operation, options)
|
||||
})
|
||||
const save = file.saveUpdates({ channel: 'nightly' })
|
||||
onTestFinished(async () => { release.resolve(undefined); await Promise.allSettled([holder, save]) })
|
||||
await attempting.promise
|
||||
release.resolve(undefined)
|
||||
const outcomes = await Promise.allSettled([holder, save])
|
||||
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' })
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { readDesktopSettings } from '../src/settings.ts'
|
||||
|
||||
vi.mock('node:fs', async importOriginal => ({ ...await importOriginal<typeof import('node:fs')>() }))
|
||||
vi.mock('node:fs/promises', async importOriginal => ({ ...await importOriginal<typeof import('node:fs/promises')>() }))
|
||||
|
||||
beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
@@ -18,66 +19,66 @@ function fixture() {
|
||||
return { userData, path: join(directory, 'settings.json') }
|
||||
}
|
||||
|
||||
it('creates a discoverable default file on first launch without touching other preferences', () => {
|
||||
it('creates a discoverable default file on first launch without touching other preferences', async () => {
|
||||
const { userData, path } = fixture()
|
||||
rmSync(join(userData, 'desktop'), { recursive: true })
|
||||
writeFileSync(join(userData, 'keybindings.json'), 'existing shortcuts\n')
|
||||
expect(readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } })
|
||||
expect(await readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } })
|
||||
expect(readFileSync(path, 'utf8')).toBe('{\n "updates": {\n "allowTestAuthPopupWindow": false\n }\n}\n')
|
||||
expect(readFileSync(join(userData, 'keybindings.json'), 'utf8')).toBe('existing shortcuts\n')
|
||||
})
|
||||
|
||||
it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', (raw) => {
|
||||
it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', async (raw) => {
|
||||
const { userData, path } = fixture()
|
||||
writeFileSync(path, raw)
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(readFileSync(path, 'utf8')).toBe(raw)
|
||||
})
|
||||
|
||||
it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', () => {
|
||||
it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', async () => {
|
||||
const { userData, path } = fixture()
|
||||
for (const allow of [true, false]) {
|
||||
const raw = JSON.stringify({ updates: { allowTestAuthPopupWindow: allow }, custom: 'preserve' })
|
||||
writeFileSync(path, raw)
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(allow)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(allow)
|
||||
expect(readFileSync(path, 'utf8')).toBe(raw)
|
||||
}
|
||||
const previous = readDesktopSettings(userData)
|
||||
const previous = await readDesktopSettings(userData)
|
||||
writeFileSync(path, '{"updates":{"allowTestAuthPopupWindow":true}}')
|
||||
expect(previous.updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(true)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['', '{', 'null', '[]', '{"updates":null}', '{"updates":[]}',
|
||||
'{"updates":{"allowTestAuthPopupWindow":"false"}}', '{"updates":{"allowTestAuthPopupWindow":1}}',
|
||||
'{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', (raw) => {
|
||||
'{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', async (raw) => {
|
||||
const { userData, path } = fixture()
|
||||
writeFileSync(path, raw)
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
|
||||
expect(readFileSync(path, 'utf8')).toBe(raw)
|
||||
})
|
||||
|
||||
it('reports filesystem errors instead of replacing unreadable settings', () => {
|
||||
it('reports filesystem errors instead of replacing unreadable settings', async () => {
|
||||
const { userData, path } = fixture()
|
||||
mkdirSync(path)
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
|
||||
expect(fs.statSync(path).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['mkdirSync', 'writeFileSync'] as const)('continues with disabled popups when %s fails', (operation) => {
|
||||
it.each(['mkdir', 'writeFile'] as const)('continues with disabled popups when %s fails', async (operation) => {
|
||||
const { userData, path } = fixture()
|
||||
const failure = Object.assign(new Error('settings creation denied'), { code: 'EACCES' })
|
||||
vi.spyOn(fs, operation).mockImplementation(() => { throw failure })
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), failure)
|
||||
vi.spyOn(fsp, operation).mockRejectedValue(failure)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.objectContaining({ cause: failure }))
|
||||
})
|
||||
|
||||
it('continues with defaults even when warning output fails', () => {
|
||||
it('continues with defaults even when warning output fails', async () => {
|
||||
const { userData, path } = fixture()
|
||||
writeFileSync(path, '{')
|
||||
vi.mocked(console.warn).mockImplementation(() => { throw new Error('output unavailable') })
|
||||
expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect((await readDesktopSettings(userData)).updates.allowTestAuthPopupWindow).toBe(false)
|
||||
expect(readFileSync(path, 'utf8')).toBe('{')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { channelSaveFailure, configurationFailure, configurationMessage } from '../src/update-configuration.ts'
|
||||
import { DesktopSettingsFileError } from '../src/settings-file.ts'
|
||||
import { en, zh } from '../src/locale.ts'
|
||||
|
||||
it('keeps startup suspensions distinct from transient save failures', () => {
|
||||
const json = new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, '/settings.json')
|
||||
const structure = new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, '/settings.json')
|
||||
const channel = new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: 'channel' }, '/settings.json')
|
||||
const popup = new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: 'allowTestAuthPopupWindow' }, '/settings.json')
|
||||
expect(configurationFailure(json)).toBe('format')
|
||||
expect(configurationFailure(structure)).toBe('format')
|
||||
expect(configurationFailure(channel)).toBe('channel')
|
||||
expect(configurationFailure(popup)).toBe('format')
|
||||
expect(configurationFailure(new DesktopSettingsFileError({ kind: 'read' }, '/settings.json'))).toBe('read')
|
||||
expect(channelSaveFailure(json)).toBe('format')
|
||||
expect(channelSaveFailure(new Error('native apply failed'))).toBe('apply')
|
||||
})
|
||||
|
||||
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: 'unchanged' }, '/private/path', { cause })
|
||||
expect(channelSaveFailure(error)).toBe(code === 'EIO' ? 'save' : 'permission')
|
||||
}
|
||||
expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path'))).toBe('save')
|
||||
expect(channelSaveFailure(new DesktopSettingsFileError({ kind: 'read' }, '/private/path', { cause: 'private detail' }))).toBe('save')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -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<typeof DesktopUpdateCoordinator>[] = []
|
||||
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', discard?: (updater: AppUpdater) => Promise<void>) {
|
||||
const events = new EventEmitter()
|
||||
const checkForUpdates = vi.fn(async () => ({
|
||||
isUpdateAvailable: true,
|
||||
@@ -66,7 +66,7 @@ function fixture(channel: 'nightly' | 'latest' = '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 }
|
||||
@@ -244,16 +244,198 @@ 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()
|
||||
})
|
||||
|
||||
|
||||
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('stable', save)
|
||||
expect(save).toHaveBeenCalledOnce()
|
||||
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)
|
||||
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('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')
|
||||
expect(f.quitAndInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('discards a check result that belongs to the previous channel', async () => {
|
||||
const f = fixture()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
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<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' } })
|
||||
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')
|
||||
expect(f.coordinator.configurationFailure).toBe('startup')
|
||||
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')
|
||||
expect(f.coordinator.configurationFailure).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
it('waits for a cancelled download before checking the new channel and ignores late progress', async () => {
|
||||
const f = fixture()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const finished = Promise.withResolvers<string[]>()
|
||||
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<undefined>()
|
||||
const switching = f.coordinator.switchChannel('stable', 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<boolean>()
|
||||
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('stable', 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('stable', save)).rejects.toThrow('installation is in progress')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suspends updates if retiring a downloaded target fails and recovers on a later switch', async () => {
|
||||
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')
|
||||
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
|
||||
await f.coordinator.check(true)
|
||||
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('stable', 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<string[]>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
let downloading: Promise<DesktopUpdateState> | 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
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -214,3 +214,56 @@ 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<boolean>()
|
||||
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)
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
@@ -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<typeof import('node:fs/promises')>()
|
||||
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(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' })
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
@@ -259,3 +259,43 @@ 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', 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 })
|
||||
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('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)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClientRequest } from 'electron'
|
||||
import { DesktopUpdateHttpExecutor } from '../src/update-http-executor.ts'
|
||||
import { DesktopUpdateHttpExecutor } from '../src/updates/electron-updater/http-executor.ts'
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
@@ -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}` })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { readDesktopSettings } from '../src/settings.ts'
|
||||
import { DesktopUpdateSettings } from '../src/update-settings.ts'
|
||||
import { DesktopSettingsFile, DesktopSettingsFileError } from '../src/settings-file.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, '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: 'stable' })
|
||||
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: '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: 'stable' })
|
||||
})
|
||||
|
||||
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: { problem: { 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(DesktopSettingsFileError)
|
||||
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: { problem: { kind: 'read' } } })
|
||||
})
|
||||
|
||||
it('updates the document initialized by shell settings without losing the popup preference', async () => {
|
||||
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, 'stable')
|
||||
await settings.save('nightly')
|
||||
expect(JSON.parse(await readFile(settings.path, 'utf8'))).toEqual({
|
||||
updates: { allowTestAuthPopupWindow: false, channel: 'nightly' },
|
||||
})
|
||||
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() }
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
# Bilingual-pair consistency record for README.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 apps/desktop/tests/updates/electron-updater/README.md
|
||||
/:
|
||||
en: ff2d052de11d825a
|
||||
zh: aa36e8c136562d5a
|
||||
/electron-updater-compatibility:
|
||||
en: c28a2b4667f4ee10
|
||||
zh: 51ffd9e5e08a39e4
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: "Platform version records and local qualification for the Electron updater adapter."
|
||||
---
|
||||
|
||||
# Electron updater compatibility
|
||||
|
||||
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, `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:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @deepseek-ai/dsh-desktop exec install-electron
|
||||
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, 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: "Electron updater 适配层的平台版本记录与本地验证。"
|
||||
---
|
||||
|
||||
# Electron updater 兼容性
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`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,也不访问网络。
|
||||
|
||||
修改这些依赖、适配层或验证脚本后,在每个受影响的原生平台上从仓库根目录运行:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @deepseek-ai/dsh-desktop exec install-electron
|
||||
pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record
|
||||
```
|
||||
|
||||
需要先构建 workspace 依赖(新检出目录运行 `pnpm run build:artifacts`)。最后一条命令重新构建 Desktop,启动不打开窗口的 Electron,并且只在成功退出后记录当前平台。将 JSON 改动与依赖或适配层改动一起提交。不带 `--record` 可只测试、不改记录。轻量检查只比较版本;版本未变不代表修改后的适配层已经通过验证。
|
||||
|
||||
fixture 使用独立临时目录、回环 HTTP 服务和惰性载荷,检查渠道清单文件名、降级拒绝、灰度资格、下载缓存撤销、新 updater 实例拒绝旧缓存,以及 macOS 代理关闭。它不执行安装器,也不访问生产更新源。安装依赖可能需要外网。30 秒看门狗覆盖 Electron 启动、测试和退出;超时或失败不会更新记录。签名安装和应用重启另行验证。
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Check every supported platform without starting Electron or making network requests. */
|
||||
import { installedVersions, readVerifiedVersions, versionFailures } from './versions.mjs'
|
||||
|
||||
const installed = installedVersions()
|
||||
const failures = versionFailures(await readVerifiedVersions(), installed)
|
||||
if (failures.length) {
|
||||
throw new Error(`Unverified Electron updater dependencies for: ${failures.join(', ')}. Installed: ${JSON.stringify(installed)}. On each listed platform run pnpm --filter @deepseek-ai/dsh-desktop run test:updates:channels --record, then commit verified-versions.json.`)
|
||||
}
|
||||
console.log('PASS: Electron updater dependency versions match all platform records')
|
||||
@@ -0,0 +1,132 @@
|
||||
/** 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 { writeFile, readFile, access } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import updaterModule from 'electron-updater'
|
||||
import { selectUpdateChannel } from '../../../lib/types/updates/electron-updater/channel.js'
|
||||
import { discardDesktopUpdate } from '../../../lib/types/updates/electron-updater/discard.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../../lib/types/updates/electron-updater/http-executor.js'
|
||||
|
||||
async function main() {
|
||||
await app.whenReady()
|
||||
const root = process.env.DSH_CHANNEL_TEST_ROOT
|
||||
assert.ok(root)
|
||||
const requests = []
|
||||
let percentage
|
||||
let version = '2.0.0'
|
||||
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)
|
||||
if (path === '/inert.exe' || path === '/inert.zip') {
|
||||
response.writeHead(200, { 'Content-Length': payload.length })
|
||||
response.end(payload)
|
||||
return
|
||||
}
|
||||
response.end(JSON.stringify({ version, stagingPercentage: percentage,
|
||||
files: ['inert.exe', 'inert.zip'].map(url => ({ url, sha512, size: payload.length })) }))
|
||||
})
|
||||
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 ownedUpdater
|
||||
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 Target = process.platform === 'darwin' ? updaterModule.MacUpdater : updaterModule.NsisUpdater
|
||||
const target = new Target(undefined, adapter)
|
||||
ownedUpdater = target
|
||||
target.logger = null
|
||||
target.autoDownload = false
|
||||
target.autoInstallOnAppQuit = false
|
||||
target.disableDifferentialDownload = true
|
||||
target.httpExecutor = new DesktopUpdateHttpExecutor(5000)
|
||||
target.setFeedURL({ provider: 'generic', url: origin })
|
||||
target.quitAndInstall = forbidden
|
||||
target.on('error', () => {}) // Awaited check/download promises report failures.
|
||||
selectUpdateChannel(target, 'nightly')
|
||||
assert.ok((await target.checkForUpdates()).updateInfo)
|
||||
assert.equal(requests.at(-1), `/nightly${process.platform === 'darwin' ? '-mac' : ''}.yml`)
|
||||
selectUpdateChannel(target, 'stable')
|
||||
await target.checkForUpdates()
|
||||
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'
|
||||
await discardDesktopUpdate(target)
|
||||
await target.checkForUpdates()
|
||||
assert.equal(target.updateInfoAndProvider, null)
|
||||
version = '2.0.0'
|
||||
percentage = 0
|
||||
await discardDesktopUpdate(target)
|
||||
await target.checkForUpdates()
|
||||
assert.equal(target.updateInfoAndProvider, null)
|
||||
percentage = 100
|
||||
selectUpdateChannel(target, 'nightly')
|
||||
await target.checkForUpdates()
|
||||
assert.ok(target.updateInfoAndProvider)
|
||||
assert.equal(target.allowDowngrade, false)
|
||||
assert.equal(await target.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 }),
|
||||
await target.isUserWithinRollout({ version: '2.0.0', stagingPercentage: 50 }))
|
||||
await target.downloadUpdate()
|
||||
const helper = target.downloadedUpdateHelper
|
||||
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)
|
||||
selectUpdateChannel(target, 'stable')
|
||||
if (proxy) 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), { 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 {
|
||||
if (ownedUpdater) await discardDesktopUpdate(ownedUpdater)
|
||||
} finally {
|
||||
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) })
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"windows": {
|
||||
"electron": "44.7.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"builder-util-runtime": "9.7.0"
|
||||
},
|
||||
"macos": {
|
||||
"electron": "44.7.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"builder-util-runtime": "9.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Installed dependency versions and platform-specific qualification records. */
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFile, writeFile, rename, rm } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const recordUrl = new URL('./verified-versions.json', import.meta.url)
|
||||
|
||||
/** @returns The actual installed versions, without loading the Electron binary. */
|
||||
export function installedVersions() {
|
||||
const updaterRequire = createRequire(require.resolve('electron-updater/package.json'))
|
||||
return {
|
||||
electron: require('electron/package.json').version,
|
||||
'electron-updater': require('electron-updater/package.json').version,
|
||||
'builder-util-runtime': updaterRequire('builder-util-runtime/package.json').version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param records - Parsed platform records.
|
||||
* @param installed - Actual installed dependency versions.
|
||||
* @returns Missing or mismatched platform records.
|
||||
*/
|
||||
export function versionFailures(records, installed) {
|
||||
return ['windows', 'macos'].filter(platform => {
|
||||
const record = records?.[platform]
|
||||
return !record || Object.keys(record).length !== Object.keys(installed).length
|
||||
|| Object.entries(installed).some(([name, version]) => record[name] !== version)
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns Persisted platform qualification records. */
|
||||
export async function readVerifiedVersions() {
|
||||
return JSON.parse(await readFile(recordUrl, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record only the current platform after successful native qualification.
|
||||
* @returns After atomically replacing the record file.
|
||||
*/
|
||||
export async function recordVerifiedVersions() {
|
||||
const platform = { win32: 'windows', darwin: 'macos' }[process.platform]
|
||||
if (!platform) throw new Error('Qualification records require Windows or macOS')
|
||||
const records = await readVerifiedVersions()
|
||||
records[platform] = installedVersions()
|
||||
const temporary = new URL(`./verified-versions.${randomUUID()}.tmp`, import.meta.url)
|
||||
try {
|
||||
await writeFile(temporary, `${JSON.stringify(records, null, 2)}\n`, { flag: 'wx' })
|
||||
await rename(temporary, recordUrl)
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Negative controls for the platform version gate. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { versionFailures, installedVersions } from './versions.mjs'
|
||||
|
||||
test('accepts only matching complete records for both platforms', () => {
|
||||
const installed = installedVersions()
|
||||
assert.deepEqual(versionFailures({ windows: installed, macos: installed }, installed), [])
|
||||
assert.deepEqual(versionFailures({ windows: installed, macos: null }, installed), ['macos'])
|
||||
assert.deepEqual(versionFailures({}, installed), ['windows', 'macos'])
|
||||
for (const name of Object.keys(installed)) {
|
||||
assert.deepEqual(versionFailures({ windows: { ...installed, [name]: '0.0.0' }, macos: installed }, installed), ['windows'])
|
||||
const missing = { ...installed }
|
||||
delete missing[name]
|
||||
assert.deepEqual(versionFailures({ windows: installed, macos: missing }, installed), ['macos'])
|
||||
}
|
||||
assert.deepEqual(versionFailures({ windows: { ...installed, obsolete: '1' }, macos: installed }, installed), ['windows'])
|
||||
})
|
||||
@@ -41,6 +41,9 @@
|
||||
{
|
||||
"path": "../../packages/util/file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings-general/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-file-actions"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/** 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, DesktopChannelChangeResult } 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)
|
||||
let detailOpens = 0
|
||||
await page.exposeFunction('openUpdateDetails', () => { detailOpens++ })
|
||||
await page.exposeFunction('readChannel', () => state)
|
||||
await page.exposeFunction('setChannel', async (channel: 'nightly' | 'stable'): Promise<DesktopChannelChangeResult> => {
|
||||
try {
|
||||
await settings.save(channel)
|
||||
state = { ...state, channel, error: undefined }
|
||||
return { kind: 'applied', state }
|
||||
} catch (_error) { return { kind: 'failed', state, reason: 'format' } }
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
const bridge = globalThis as typeof globalThis & {
|
||||
readChannel(): Promise<DesktopChannelState>
|
||||
openUpdateDetails(): Promise<void>
|
||||
setChannel(channel: 'nightly' | 'stable'): Promise<DesktopChannelChangeResult>
|
||||
}
|
||||
Object.assign(globalThis, { dshDesktop: { protocolVersion: 1, updates: {
|
||||
status: async () => ({ phase: 'idle' }), open: () => bridge.openUpdateDetails(), subscribe: () => () => {},
|
||||
channels: { get: () => bridge.readChannel(), set: (channel: 'nightly' | 'stable') => 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()
|
||||
await page.getByText('Update channel changed', { exact: true }).waitFor()
|
||||
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' } }))
|
||||
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 page.getByText('Could not save: invalid settings file', { exact: true }).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) + '\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() }
|
||||
} finally { await scaffold.close() }
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- group "Update channel":
|
||||
- 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"
|
||||
@@ -0,0 +1,3 @@
|
||||
- group "Update channel":
|
||||
- text: Update channel Stable is suitable for everyday use. Development offers early access to new features but may be unstable.
|
||||
- button "Update channel": Stable
|
||||
@@ -133,6 +133,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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 清单的旧包不会自动迁移。
|
||||
@@ -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: 7600861d064dbc99
|
||||
zh: a52ba446016e3a21
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation:
|
||||
en: 9c8c35ae0e672b14
|
||||
zh: 503c8e9d24a93d88
|
||||
|
||||
@@ -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. 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).
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
|
||||
-----
|
||||
|
||||
当预加载提供渠道 API 时,桌面版还会在通用设置中提供**更新渠道**。重复选择当前正常渠道不会保存、提示或影响下载与待安装更新。选择其他渠道直接调用 Electron,等待期间显示所选项和加载指示;返回后按实际运行状态确定选项,失败则回滚。由 shell 持有的 toast 显示成功或简短保存失败,关闭设置后仍可见。启动配置错误使用可查看详情的十秒 toast;提示消失不会清除错误。持续的更新暂停保留在更新入口;保存失败本身不会产生暂停。普通浏览器不显示该行。文件归属和重启规则见[本地桌面设置](../../../apps/desktop/README.zh.md#local-desktop-settings)。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
flex: none;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Desktop channel selection; persistence and updater transitions remain in Electron. */
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DesktopChannelRowInjected>) {
|
||||
const { state, failed, pending } = useChannel(value => value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const busy = pending !== undefined || state?.changing === true
|
||||
const channel = pending ?? state?.channel
|
||||
return <div className={css.row} role="group" aria-label={t('desktop.channel.title')}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('desktop.channel.title')}</div>
|
||||
<div className={css.description}>{t('desktop.channel.description')}</div>
|
||||
{(failed || state?.error !== undefined) && <div role="alert">{t(failed ? 'desktop.channel.readError' : 'desktop.channel.error')}</div>}
|
||||
</div>
|
||||
<Menu open={open} onClose={() => { setOpen(false) }} portal align="end"
|
||||
selectedId={channel ?? ''}
|
||||
items={[{ id: 'stable', label: t('desktop.channel.stable') }, { id: 'nightly', label: t('desktop.channel.nightly') }]}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id as DesktopUpdateChannel)
|
||||
}}
|
||||
anchor={<button type="button" className={css.selector} disabled={busy || state === undefined}
|
||||
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 === 'stable' ? 'desktop.channel.stable' : 'desktop.channel.nightly')}
|
||||
{busy ? <IconLoadingOutlineRegular className={css.spinner} /> : <IconChevronDownOutlineRegular className={css.chevron} />}
|
||||
</button>} />
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Shell-owned operation feedback that survives closing Settings. */
|
||||
export interface DesktopChannelToastInjected {
|
||||
hooks: { channel: DesktopChannelSource['store'] }
|
||||
dismiss(sequence: number): void
|
||||
openConfiguration(): 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, openConfiguration, t }:
|
||||
PropsRuntime<'shell.overlay'> & PropsLocale<'settings'> & InjectFace<DesktopChannelToastInjected>) {
|
||||
const notice = useChannel(value => value.notice)
|
||||
if (notice === undefined) return null
|
||||
return <Toast key={notice.sequence} text={t(`desktop.channel.result.${notice.outcome}`)}
|
||||
{...notice.outcome === 'configuration' ? { holdMs: 10_000,
|
||||
actions: [{ label: t('desktop.channel.details'), onClick: openConfiguration }] } : {}}
|
||||
onDone={() => { dismiss(notice.sequence) }} {...notice.outcome === 'applied' ? { tone: 'success' as const } : { icon: <IconWarningOutlineRegular /> }} />
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const BUSY_PHASES: ReadonlySet<DesktopUpdatePresentation['phase']> = 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.configuration.${state.configurationError}`) }
|
||||
const labels: Readonly<Record<DesktopUpdatePresentation['phase'], string>> = {
|
||||
idle: '',
|
||||
checking: t('desktop.update.checking'),
|
||||
@@ -28,9 +29,13 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l
|
||||
}
|
||||
if (state.phase === 'error') {
|
||||
const failures: Readonly<Record<DesktopUpdateFailureKind, string>> = {
|
||||
configuration: t('desktop.update.configurationFailed'),
|
||||
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'),
|
||||
@@ -62,12 +67,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 <Tooltip label={failed ? retryLabel : copy.detail} side="top">
|
||||
<button type="button" className={css.indicator}
|
||||
aria-label={label} aria-disabled={busy} onClick={() => { if (!busy) onOpen() }}>
|
||||
@@ -91,7 +96,7 @@ export function DesktopUpdateBadge({ useDesktopUpdate, useConnectionState, t }:
|
||||
const { presentation: state, failed } = useDesktopUpdate(value => value)
|
||||
const connection = useConnectionState(value => value)
|
||||
if (((connection === 'disconnected' || connection === 'connecting') && state?.phase !== 'installing')
|
||||
|| (!failed && (state === undefined || state.phase === 'idle'))) return null
|
||||
|| (!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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** 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, DesktopChannelChangeResult } from '../types.ts'
|
||||
|
||||
type Notice = { sequence: number; outcome: 'configuration' | 'applied' | Extract<DesktopChannelChangeResult, { kind: 'failed' }>['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 observation shared by mounted General views and the shell toast. */
|
||||
readonly store = createSnapshotStore<DesktopChannelSnapshot>({ failed: false })
|
||||
private live = true
|
||||
private revision = 0
|
||||
private sequence = 0
|
||||
private readonly unsubscribe: () => void
|
||||
|
||||
/** @param bridge - Isolated shell API, present only in Desktop. */
|
||||
constructor(private readonly bridge: DesktopChannelBridge) {
|
||||
this.unsubscribe = bridge.subscribe((state) => {
|
||||
if (!this.live) return
|
||||
this.revision++
|
||||
this.receive(state)
|
||||
})
|
||||
void bridge.get().then((state) => {
|
||||
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 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 } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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 })
|
||||
let result: DesktopChannelChangeResult | undefined
|
||||
try {
|
||||
result = await this.bridge.set(channel)
|
||||
} catch (_error) {
|
||||
// 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 } : {}),
|
||||
...(result?.kind === 'unchanged' ? {} : {
|
||||
notice: { sequence: ++this.sequence, outcome: result?.kind === 'applied' ? 'applied' as const : result?.kind === 'failed' ? result.reason : 'save' as const },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
/** Detach the shell subscription and ignore late IPC responses. */
|
||||
dispose(): void { this.live = false; this.unsubscribe() }
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user