Selaa lähdekoodia

feat(desktop): load platform packaging settings from local dotenv files

Load target-owned Windows and macOS release settings, validate them before
preparation, and provide a configuration-only check command. Share the local
settings with upload, installer tests, and manual macOS signature checks.

Exercise English and Chinese installer fixtures using visible UI labels and
cover registered installation paths with trailing separators.
winewill 2 viikkoa sitten
vanhempi
sitoutus
c47a2c16b4
24 muutettua tiedostoa jossa 384 lisäystä ja 80 poistoa
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 0 1
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md
  7. 2 0
      .gitignore
  8. 29 0
      apps/desktop/.env.macos.example
  9. 17 0
      apps/desktop/.env.windows.example
  10. 2 2
      apps/desktop/README.i18n.yaml
  11. 10 22
      apps/desktop/README.md
  12. 10 22
      apps/desktop/README.zh.md
  13. 1 0
      apps/desktop/package.json
  14. 27 0
      apps/desktop/scripts/desktop-package-environment.d.mts
  15. 100 0
      apps/desktop/scripts/desktop-package-environment.mjs
  16. 12 2
      apps/desktop/scripts/package-target.ts
  17. 18 12
      apps/desktop/scripts/test-windows-installer.mjs
  18. 6 3
      apps/desktop/scripts/upload-target.ts
  19. 2 1
      apps/desktop/scripts/verify-macos-signature.mjs
  20. 108 0
      apps/desktop/tests/desktop-package-environment.spec.ts
  21. 1 0
      apps/desktop/tests/expected/windows-installer.json
  22. 2 0
      apps/desktop/tests/package-target.spec.ts
  23. 22 7
      apps/desktop/tests/windows-installer-smoke.ps1
  24. 7 0
      apps/desktop/tests/windows-sign.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: f257a387cdd1fdfd770dd0bb91052aa75b95e424
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: a1f5f65c5ab2d7ac7fa7f9f3ccb090e703a12444
+2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -77,7 +77,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
 
-Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
@@ -122,7 +122,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 
 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
 
-**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。
+**把凭据写进已跟踪脚本或系统环境。** 本地平台文件把配置限制在单个 checkout,并使打包输入明确。代价是凭据以明文落盘:构建账号需要限制文件访问权限,CI 必须清理临时配置,Git 与发布文件映射都必须排除真实配置。已提交的模板不含凭据;Windows CMD 只包含变量引用,签名串行执行并在首次失败后停止,文件格式不会免除 Token 的错误 PIN 计数。
 
 **让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
-2026-09-10-windows-native-installer-pages.md: ca2ae100c6d7277ca38ec82283c16a02e296e4a7
-2026-09-10-windows-native-installer-pages.zh.md: 44ac24c91ef1b9a0ac6602a22129e252142be28d
+2026-09-10-windows-native-installer-pages.md: 93aa4839620207447ad85b1027a68fcfdc5d3e2c
+2026-09-10-windows-native-installer-pages.zh.md: da28e22f82c6e403696904362bcbe6c8b96881b8

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md

@@ -28,4 +28,4 @@ Installation is per-user. Welcome-page leave validation reads the current edit c
 
 Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed before embedding by the same signer as other Windows artifacts. The preparation hook returns true on every platform so electron-builder collects production dependencies. The directory installer owns staging, promotion, registration, and recovery. Its extraction hook invokes the pinned 7-Zip executable with a dedicated progress pipe and a separate diagnostic file. The child inherits only its standard streams and joins a kill-on-close job at creation, so installer termination also stops extraction. Only a zero exit code permits promotion. The helper parses percentages across pipe-read boundaries; it does not implement archive extraction or recursive copying. Preparation, promotion, registration, and cleanup retain bounded estimates. Stage weights express completed work, not remaining time. Displayed progress never regresses, and captions follow the displayed stage. NSIS success authorizes a 600 ms fill animation and a brief 100% frame, with a 750 ms transition target; timers cannot authorize success. The frame stays hidden until branded controls are ready and is initialized once; page transitions preserve its position. Finish hides the window before creating the installed process directly under the current user. An explicitly elevated installer retains shell-mediated launch. Launch failure restores the finish page. Application startup time is independent of installer dismissal.
 
-The native installer regression uses a unique product identity and private installation directory to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Deterministic native progress tests cover fast and slow extraction, stalls, source resets, fragmented progress tokens, short cleanup, and success between UI ticks. Directory tests exercise extraction through the helper, long paths, new installation, replacement, locked-file recovery, missing staged directories, broken archives, and cancellation. Screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.
+The native installer regression builds English-only and Chinese-only variants and identifies their language from the visible welcome button; the Windows installation language does not determine test labels. Sequential runs use a unique product identity and private installation directories to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Registered paths with trailing separators remain upgrade destinations, while drive roots remain invalid. Deterministic native progress tests cover fast and slow extraction, stalls, source resets, fragmented progress tokens, short cleanup, and success between UI ticks. Directory tests exercise extraction through the helper, long paths, new installation, replacement, locked-file recovery, missing staged directories, broken archives, and cancellation. Screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md

@@ -28,4 +28,4 @@ NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交
 
 Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库在嵌入前使用与其他 Windows 产物相同的签名器签名。准备钩子在所有平台返回 true,使 electron-builder 收集生产依赖。目录安装器负责暂存、替换、注册和恢复。其解压钩子通过独立进度管道和单独的诊断文件调用锁定版本的 7-Zip 可执行文件。子进程仅继承标准流句柄,并在创建时加入关闭即终止的 Job,因此终止安装程序也会停止解压。只有退出码为零才允许替换目录。辅助库跨管道读取边界解析百分比,不自行实现压缩包解压或递归复制。准备、替换、注册和清理仍使用有界估算。阶段权重表示已完成工作量,而非剩余时间。显示进度不回退,文案跟随显示阶段。NSIS 成功信号允许执行 600 毫秒补满动画并短暂显示 100%,切换目标时长为 750 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
 
-原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。确定性的原生进度测试覆盖快速和慢速解压、停滞、进度来源重置、分片进度文本、短暂清理,以及两次界面刷新之间成功的情况。目录测试通过辅助库执行解压,覆盖长路径、新装、替换、文件占用恢复、暂存目录缺失、损坏压缩包和取消。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。
+原生安装回归构建仅英文和仅中文的变体,并从可见的欢迎页按钮识别语言;Windows 安装语言不决定测试文案。顺序执行的测试使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。末尾带分隔符的已登记路径仍可用于升级,磁盘根目录仍然无效。确定性的原生进度测试覆盖快速和慢速解压、停滞、进度来源重置、分片进度文本、短暂清理,以及两次界面刷新之间成功的情况。目录测试通过辅助库执行解压,覆盖长路径、新装、替换、文件占用恢复、暂存目录缺失、损坏压缩包和取消。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

+ 2 - 0
.gitignore

@@ -1,5 +1,7 @@
 CLAUDE.local.md
 .env
+apps/desktop/.env.windows
+apps/desktop/.env.macos
 node_modules/
 lib/
 *.tsbuildinfo

+ 29 - 0
apps/desktop/.env.macos.example

@@ -0,0 +1,29 @@
+# Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
+# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
+DSH_DESKTOP_APP_ID=com.deepseek.harness
+DSH_DESKTOP_AUTO_UPDATE_ENV=test
+DOWNLOAD_TEST_ORIGIN=
+DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
+DSH_DESKTOP_MACOS_TEAM_ID=
+
+# Choose exactly one notarization strategy; leave the others commented out.
+APPLE_KEYCHAIN_PROFILE=
+# APPLE_KEYCHAIN=
+# APPLE_API_KEY=
+# APPLE_API_KEY_ID=
+# APPLE_API_ISSUER=
+# APPLE_ID=
+# APPLE_APP_SPECIFIC_PASSWORD=
+# APPLE_TEAM_ID=
+
+# Optional electron-builder certificate import instead of an existing keychain identity.
+# CSC_LINK=
+# CSC_KEY_PASSWORD=
+
+# Optional upload configuration; packaging does not require these credentials.
+# DOWNLOAD_TEST_COS_BUCKET=
+# DOWNLOAD_TEST_COS_SECRET_ID=
+# DOWNLOAD_TEST_COS_SECRET_KEY=
+# DOWNLOAD_PROD_COS_BUCKET=
+# DOWNLOAD_PROD_COS_SECRET_ID=
+# DOWNLOAD_PROD_COS_SECRET_KEY=

+ 17 - 0
apps/desktop/.env.windows.example

@@ -0,0 +1,17 @@
+# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
+# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
+DSH_DESKTOP_APP_ID=com.deepseek.harness
+DSH_DESKTOP_AUTO_UPDATE_ENV=test
+DOWNLOAD_TEST_ORIGIN=
+DSH_DESKTOP_WINDOWS_CER_FILE=
+DSH_DESKTOP_WINDOWS_SIGNTOOL=
+DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
+DSH_DESKTOP_WINDOWS_TOKEN_PIN=
+
+# Optional upload configuration; packaging does not require these credentials.
+# DOWNLOAD_TEST_COS_BUCKET=
+# DOWNLOAD_TEST_COS_SECRET_ID=
+# DOWNLOAD_TEST_COS_SECRET_KEY=
+# DOWNLOAD_PROD_COS_BUCKET=
+# DOWNLOAD_PROD_COS_SECRET_ID=
+# DOWNLOAD_PROD_COS_SECRET_KEY=

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: d5e53d38299b7c82ff735e2da3c730f8b3df7b73
-README.zh.md: 5fadc33d847bafdc449902f3d069b67b1f00765d
+README.md: 6910710797b68bb6f7140469b790d1f61337ac9a
+README.zh.md: 436dcad5f8707680d63564e54284eee7a80d202b

+ 10 - 22
apps/desktop/README.md

@@ -73,15 +73,12 @@ Workspace development runs the current CLI and private Desktop Host packages und
 
 ## Package
 
-The normal packaging path is one complete command. It performs release preparation before creating the host platform's installers and update metadata. Every target requires a reverse-DNS `DSH_DESKTOP_APP_ID`. macOS targets additionally require the electron-builder certificate qualifier in `DSH_DESKTOP_MACOS_SIGNING_IDENTITY`, its 10-character Apple Team ID in `DSH_DESKTOP_MACOS_TEAM_ID`, and one complete notarytool credential strategy. The App Store Connect API-key strategy uses these variables:
+Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
+
+Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately:
 
 ```sh
-export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
-export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
-export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
-export APPLE_API_KEY='<absolute path to the .p8 file>'
-export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
-export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
+pnpm --dir apps/desktop run check:package
 ```
 
 `prepare:desktop` is not a prerequisite:
@@ -127,19 +124,14 @@ The update destination and upload credentials follow the selected deployment:
 | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` |
 | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` |
 
-Package and upload one target under the same environment. For example, the default test deployment uses:
+Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target:
 
 ```sh
-export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
 pnpm run package:desktop:mac:arm64
-
-export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
-export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
-export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
 pnpm run upload:mac:arm64
 ```
 
-Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
+Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` in the target dotenv file before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
 
 The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
 
@@ -165,7 +157,7 @@ The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME
 
 Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks.
 
-Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity, installs into its own directory, uninstalls it, and retains screenshots and results under `.desktop-build/installer-tests/`. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
+Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
 
 ### Windows EV signing
 
@@ -175,19 +167,15 @@ Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bun
 
 NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
 
-Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
+Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git.
 
-```powershell
-$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
-$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
-$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
-$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
+```sh
 pnpm run package:desktop:win:x64
 ```
 
 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
 
-The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
+The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
 
 Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
 

+ 10 - 22
apps/desktop/README.zh.md

@@ -73,15 +73,12 @@ Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Hos
 
 ## 打包
 
-正常打包只需执行一条完整命令。该命令会先准备发布资源,再生成宿主平台的安装包与更新元数据。所有目标都要求通过 `DSH_DESKTOP_APP_ID` 提供反向域名形式的应用 ID。macOS 目标还要求通过 `DSH_DESKTOP_MACOS_SIGNING_IDENTITY` 提供 electron-builder 证书限定名,通过 `DSH_DESKTOP_MACOS_TEAM_ID` 提供对应的 10 字符 Apple Team ID,并提供一套完整的 notarytool 凭据方案。App Store Connect API Key 方式使用以下变量:
+打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
+
+每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查:
 
 ```sh
-export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
-export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
-export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
-export APPLE_API_KEY='<absolute path to the .p8 file>'
-export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
-export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
+pnpm --dir apps/desktop run check:package
 ```
 
 无需提前执行 `prepare:desktop`:
@@ -127,19 +124,14 @@ Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用
 | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` |
 | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` |
 
-同一目标必须在同一环境下完成打包与上传。例如,默认测试环境使用:
+在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标:
 
 ```sh
-export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
 pnpm run package:desktop:mac:arm64
-
-export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
-export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
-export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
 pnpm run upload:mac:arm64
 ```
 
-生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
+生产发布需在打包前在目标 `.env` 中设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
 
 macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
 
@@ -165,7 +157,7 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影
 
 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。
 
-在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,安装到自己的目录后卸载,并将截图和结果保留在 `.desktop-build/installer-tests/` 下。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
+在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
 
 ### Windows EV 签名
 
@@ -175,19 +167,15 @@ Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解
 
 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
 
-Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
+在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。
 
-```powershell
-$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
-$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
-$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
-$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
+```sh
 pnpm run package:desktop:win:x64
 ```
 
 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
 
-PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。
+PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
 
 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
 

+ 1 - 0
apps/desktop/package.json

@@ -17,6 +17,7 @@
     "prepare:package": "tsx scripts/package-target.ts --prepare-only",
     "verify:mac-signature": "node scripts/verify-macos-signature.mjs",
     "package": "tsx scripts/package-target.ts",
+    "check:package": "tsx scripts/package-target.ts --check",
     "package:dir": "tsx scripts/package-target.ts --dir",
     "package:mac:arm64": "tsx scripts/package-target.ts mac-arm64",
     "package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir",

+ 27 - 0
apps/desktop/scripts/desktop-package-environment.d.mts

@@ -0,0 +1,27 @@
+/** Load platform-local release settings without changing the caller's process environment. */
+
+/**
+ * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
+ * @param platform Target platform.
+ * @param environment Parent environment, retained only for unrelated build tools.
+ * @param appRoot Desktop application directory; relative credential paths resolve here.
+ * @returns Isolated environment with file-owned release settings.
+ */
+export function loadDesktopPackageEnvironment(
+  platform: 'win32' | 'darwin',
+  environment?: NodeJS.ProcessEnv,
+  appRoot?: string,
+): NodeJS.ProcessEnv
+
+/**
+ * Validate release configuration before preparation without invoking a token or Apple's services.
+ * @param environment File-owned release settings.
+ * @param target Selected release target.
+ * @param options Explicit packaging mode.
+ * @returns Nothing.
+ */
+export function validateDesktopPackageEnvironment(
+  environment: NodeJS.ProcessEnv,
+  target: { platform: 'win32' | 'darwin', arch: string },
+  options?: { unsigned?: boolean, prepareOnly?: boolean },
+): void

+ 100 - 0
apps/desktop/scripts/desktop-package-environment.mjs

@@ -0,0 +1,100 @@
+/** Load platform-local release settings without changing the caller's process environment. */
+
+import { accessSync, constants, readFileSync, statSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { parseEnv } from 'node:util'
+import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
+import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
+import { createWindowsTokenSigner } from './windows-sign.mjs'
+
+const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
+const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
+const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
+const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
+const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
+const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN']
+
+/**
+ * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
+ * @param {'win32' | 'darwin'} platform Target platform.
+ * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
+ * @param {string} appRoot Desktop application directory; relative credential paths resolve here.
+ * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
+ */
+export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) {
+  const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
+  let contents
+  try {
+    contents = readFileSync(path, 'utf8')
+  }
+  catch {
+    throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
+  }
+  let settings
+  try {
+    settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
+  }
+  catch {
+    // Parser diagnostics can contain credential-bearing input.
+    throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
+  }
+  const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
+  for (const name of Object.keys(settings)) {
+    if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) {
+      throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
+    }
+    if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
+  }
+  for (const name of FILE_SETTINGS) {
+    if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
+  }
+  return {
+    ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
+    ...settings,
+  }
+}
+
+function requireReadableFile(environment, name) {
+  try {
+    if (!statSync(environment[name]).isFile()) throw new Error('not a file')
+    accessSync(environment[name], constants.R_OK)
+  }
+  catch {
+    throw new Error(`desktop package: ${name} must identify a readable local file`)
+  }
+}
+
+/**
+ * Validate release configuration before preparation without invoking a token or Apple's services.
+ * @param {NodeJS.ProcessEnv} environment File-owned release settings.
+ * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target.
+ * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode.
+ * @returns {void}
+ */
+export function validateDesktopPackageEnvironment(environment, target, options = {}) {
+  resolveDesktopAppId(environment)
+  if (options.unsigned) return
+  if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
+  if (target.platform === 'win32') {
+    if (!options.prepareOnly) createWindowsTokenSigner({
+      certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE,
+      signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+      tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+      keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+    })
+  } else {
+    resolveMacOSSigningEnvironment(environment)
+    const strategies = [
+      ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'],
+      ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'],
+      ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'],
+    ]
+    if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) {
+      throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies')
+    }
+    const credentials = resolveMacOSNotarizationEnvironment(environment)
+    if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY')
+    if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN')
+  }
+}

+ 12 - 2
apps/desktop/scripts/package-target.ts

@@ -10,6 +10,7 @@ import {
 } from './desktop-auto-update-environment.mjs'
 import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
 import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
+import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -173,6 +174,7 @@ interface DesktopPackageInvocation {
   readonly directory: boolean
   readonly prepareOnly: boolean
   readonly unsigned: boolean
+  readonly check: boolean
 }
 
 function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
@@ -200,6 +202,7 @@ export function parseDesktopPackageInvocation(
       dir: { type: 'boolean', default: false },
       'prepare-only': { type: 'boolean', default: false },
       unsigned: { type: 'boolean', default: false },
+      check: { type: 'boolean', default: false },
     },
   })
   if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
@@ -211,6 +214,7 @@ export function parseDesktopPackageInvocation(
     directory: values.dir,
     prepareOnly: values['prepare-only'],
     unsigned: values.unsigned,
+    check: values.check,
   }
 }
 
@@ -271,13 +275,19 @@ function runPnpm(
 async function main(): Promise<void> {
   const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
   const { target } = invocation
+  const environment = loadDesktopPackageEnvironment(target.platform)
+  validateDesktopPackageEnvironment(environment, target, invocation)
+  if (invocation.check) {
+    process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
+    return
+  }
   const buildPaths = desktopTargetBuildPaths(target.name)
   const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
   if (!invocation.prepareOnly && !invocation.unsigned) {
     rmSync(releaseRecordPath, { force: true })
     rmSync(`${releaseRecordPath}.tmp`, { force: true })
   }
-  const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env))
+  const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(environment))
   const targetEnv: NodeJS.ProcessEnv = {
     ...buildEnv,
     DSH_DESKTOP_TARGET_PLATFORM: target.platform,
@@ -285,7 +295,7 @@ async function main(): Promise<void> {
   }
   const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
   for (const name of WINDOWS_SIGNING_ENV_NAMES) {
-    if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
+    if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
   }
   await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
   await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)

+ 18 - 12
apps/desktop/scripts/test-windows-installer.mjs

@@ -7,6 +7,7 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
 import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
+import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
 
 if (process.platform !== 'win32' || process.arch !== 'x64') {
   throw new Error('Installer UI checks require an interactive Windows x64 desktop')
@@ -25,11 +26,12 @@ const output = await mkdtemp(join(outputRoot, 'run-'))
 const payload = join(output, 'payload')
 await mkdir(join(payload, 'resources'), { recursive: true })
 const previousEnvironment = { ...process.env }
+const signingEnvironment = process.argv.includes('--signed') ? loadDesktopPackageEnvironment('win32') : {}
 const sign = process.argv.includes('--signed') ? createWindowsTokenSigner({
-  certificateFile: process.env.DSH_DESKTOP_WINDOWS_CER_FILE,
-  signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
-  tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
-  keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+  certificateFile: signingEnvironment.DSH_DESKTOP_WINDOWS_CER_FILE,
+  signTool: signingEnvironment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+  tokenPin: signingEnvironment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+  keyContainer: signingEnvironment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
 }) : undefined
 try {
   Object.assign(process.env, {
@@ -72,14 +74,18 @@ SectionEnd
     config.win.forceCodeSigning = true
     config.win.signtoolOptions.sign = sign
   }
-  await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
-    config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output },
-      nsis: { ...config.nsis, guid, include }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
-  })
-  const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
-    join(appRoot, 'tests', 'windows-installer-smoke.ps1'), '-Installer', join(output, 'installer-test.exe'),
-    '-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', output], childOptions)
-  process.stdout.write(result.stdout)
+  for (const language of ['en_US', 'zh_CN']) {
+    const languageOutput = join(output, language)
+    await mkdir(languageOutput)
+    await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
+      config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output: languageOutput },
+        nsis: { ...config.nsis, guid, include, installerLanguages: [language] }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
+    })
+    const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
+      join(appRoot, 'tests', 'windows-installer-smoke.ps1'), '-Installer', join(languageOutput, 'installer-test.exe'),
+      '-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput], childOptions)
+    process.stdout.write(`${language}\n${result.stdout}`)
+  }
 } finally {
   for (const name of Object.keys(process.env)) if (!(name in previousEnvironment)) delete process.env[name]
   Object.assign(process.env, previousEnvironment)

+ 6 - 3
apps/desktop/scripts/upload-target.ts

@@ -6,6 +6,7 @@ import { resolve } from 'node:path'
 import { parseArgs } from 'node:util'
 import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
 import type { DesktopPackageTargetName } from './package-target.ts'
+import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
 import {
   createDesktopUploadPlan,
   type DesktopUploadArtifact,
@@ -57,13 +58,15 @@ async function main(): Promise<void> {
   if (target === undefined || positionals.length !== 1) {
     throw new Error('desktop upload: expected exactly one target')
   }
-  const plan = await createDesktopUploadPlan(targetName(target))
+  const name = targetName(target)
+  const environment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
+  const plan = await createDesktopUploadPlan(name, { environment })
   const client = new S3Client({
     region: 'Auto',
     endpoint: 'https://cos.ap-beijing.myqcloud.com',
     credentials: {
-      accessKeyId: requiredEnvironmentValue(process.env, plan.secretIdEnvName),
-      secretAccessKey: requiredEnvironmentValue(process.env, plan.secretKeyEnvName),
+      accessKeyId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
+      secretAccessKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
     },
   })
   process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)

+ 2 - 1
apps/desktop/scripts/verify-macos-signature.mjs

@@ -3,6 +3,7 @@
 import { spawn, spawnSync } from 'node:child_process'
 import { resolve } from 'node:path'
 import { resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
+import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
 
 /**
  * Reject signature metadata that does not name the company release authority and team.
@@ -192,7 +193,7 @@ if (process.argv[1] !== undefined && import.meta.filename === resolve(process.ar
   if (appPath === undefined || cliArgs.length !== 1) {
     throw new Error('usage: node scripts/verify-macos-signature.mjs <path-to-app>')
   }
-  const expected = resolveMacOSSigningEnvironment(process.env)
+  const expected = resolveMacOSSigningEnvironment(loadDesktopPackageEnvironment('darwin'))
   verifyMacOSSignature(resolve(appPath), expected)
   process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`)
 }

+ 108 - 0
apps/desktop/tests/desktop-package-environment.spec.ts

@@ -0,0 +1,108 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
+
+const WINDOWS = { platform: 'win32', arch: 'x64' } as const
+const MACOS = { platform: 'darwin', arch: 'arm64' } as const
+const RELEASE = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' }
+const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' }
+
+async function withDirectory(action: (directory: string) => Promise<void>): Promise<void> {
+  const directory = await mkdtemp(join(tmpdir(), 'desktop-env-'))
+  try {
+    await action(directory)
+  } finally {
+    await rm(directory, { recursive: true, force: true })
+  }
+}
+
+describe('Desktop local packaging configuration', () => {
+  it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => {
+    await withDirectory(async (directory) => {
+      await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n')
+      await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_APP_ID=com.example.mac\nAPPLE_KEYCHAIN_PROFILE=release\n')
+      const parent = {
+        PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.stale.desktop',
+        DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'stale-pin', APPLE_ID: 'stale-apple-id',
+        CSC_LINK: 'stale-certificate', DOWNLOAD_TEST_ORIGIN: 'https://stale.example.com',
+        dsh_desktop_windows_key_container: 'case-insensitive-stale-container',
+      }
+      expect(loadDesktopPackageEnvironment('win32', parent, directory)).toEqual({
+        PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.example.windows',
+        DSH_DESKTOP_WINDOWS_TOKEN_PIN: ' #!$%&literal ',
+        DSH_DESKTOP_WINDOWS_CER_FILE: join(directory, 'keys', 'public certificate.cer'),
+      })
+      expect(loadDesktopPackageEnvironment('darwin', parent, directory)).toEqual({
+        PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.example.mac', APPLE_KEYCHAIN_PROFILE: 'release',
+      })
+      expect(parent.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('stale-pin')
+    })
+  })
+
+  it('requires the local file even when ambient configuration exists and rejects other-platform fields', async () => {
+    await withDirectory(async (directory) => {
+      expect(() => loadDesktopPackageEnvironment('win32', RELEASE, directory)).toThrow(/copy .*\.env.windows.example/u)
+      await writeFile(join(directory, '.env.windows'), 'APPLE_APP_SPECIFIC_PASSWORD=secret-sentinel\n')
+      expect(() => loadDesktopPackageEnvironment('win32', {}, directory)).toThrow(/unsupported setting APPLE_APP_SPECIFIC_PASSWORD/u)
+      expect(() => loadDesktopPackageEnvironment('win32', {}, directory)).not.toThrow(/secret-sentinel/u)
+    })
+  })
+
+  it('checks application and update configuration before Windows credentials while preserving unsigned and preparation modes', () => {
+    expect(() => {
+      validateDesktopPackageEnvironment({}, WINDOWS, { unsigned: true })
+    }).toThrow(/DSH_DESKTOP_APP_ID/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'invalid' }, WINDOWS)
+    }).toThrow(/reverse-DNS/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS)
+    }).toThrow(/DOWNLOAD_TEST_ORIGIN/u)
+    expect(() => {
+      validateDesktopPackageEnvironment(RELEASE, WINDOWS)
+    }).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { unsigned: true })
+    }).not.toThrow()
+    expect(() => {
+      validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { prepareOnly: true })
+    }).not.toThrow()
+  })
+
+  it('rejects incomplete macOS identity and credentials and checks referenced files without contacting Apple', async () => {
+    expect(() => {
+      validateDesktopPackageEnvironment(RELEASE, MACOS)
+    }).toThrow(/DSH_DESKTOP_MACOS_SIGNING_IDENTITY/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY }, MACOS)
+    }).toThrow(/macOS packaging requires/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_API_KEY: 'missing.p8' }, MACOS)
+    }).toThrow(/APPLE_API_KEY_ID/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release' }, MACOS)
+    }).not.toThrow()
+    expect(() => {
+      validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_API_KEY: '' }, MACOS)
+    }).toThrow(/exactly one macOS notarization strategy/u)
+    expect(() => {
+      validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_ID: 'user@example.com', APPLE_APP_SPECIFIC_PASSWORD: 'fixture', APPLE_TEAM_ID: 'TEAMID1234' }, MACOS)
+    }).not.toThrow()
+    await withDirectory(async (directory) => {
+      const appleApiKey = join(directory, 'AuthKey.p8')
+      const environment = { ...RELEASE, ...MAC_IDENTITY, APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555' }
+      expect(() => {
+        validateDesktopPackageEnvironment(environment, MACOS)
+      }).toThrow(/APPLE_API_KEY must identify a readable local file/u)
+      await writeFile(appleApiKey, 'local-file-fixture')
+      expect(() => {
+        validateDesktopPackageEnvironment(environment, MACOS)
+      }).not.toThrow()
+      expect(() => {
+        validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_KEYCHAIN: directory }, MACOS)
+      }).toThrow(/APPLE_KEYCHAIN/u)
+    })
+  })
+})

+ 1 - 0
apps/desktop/tests/expected/windows-installer.json

@@ -6,6 +6,7 @@
     "completion-preserves-window-position",
     "welcome-ready-before-first-show",
     "successful-install-paints-100-before-finish",
+    "registered-directory-with-trailing-separators",
     "registered-directory-and-checked-launch",
     "launch-failure-retry-and-prompt-dismissal",
     "progress-remains-monotonic-across-native-resets",

+ 2 - 0
apps/desktop/tests/package-target.spec.ts

@@ -38,6 +38,8 @@ describe('desktop package target', () => {
     expect(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64').directory).toBe(true)
     expect(parseDesktopPackageInvocation([], 'darwin', 'arm64').target.name).toBe('mac-arm64')
     expect(parseDesktopPackageInvocation(['--prepare-only'], 'darwin', 'arm64').prepareOnly).toBe(true)
+    expect(parseDesktopPackageInvocation(['--check'], 'darwin', 'arm64').check).toBe(true)
+    expect(parseDesktopPackageInvocation(['win-x64', '--check', '--unsigned'], 'win32', 'x64')).toMatchObject({ check: true, unsigned: true })
     expect(() => parseDesktopPackageInvocation(['mac-arm64', 'mac-x64'], 'darwin', 'arm64'))
       .toThrow(/at most one target/u)
   })

+ 22 - 7
apps/desktop/tests/windows-installer-smoke.ps1

@@ -13,10 +13,11 @@ $uninstaller = Join-Path $installPath ('Uninstall ' + $ProductName + '.exe')
 $processes = [Collections.Generic.List[Diagnostics.Process]]::new()
 $results = [Collections.Generic.List[string]]::new()
 $expected = Get-Content (Join-Path $PSScriptRoot 'expected/windows-installer.json') -Raw | ConvertFrom-Json
-$copy = @{}
-$locale = if ([Globalization.CultureInfo]::InstalledUICulture.TwoLetterISOLanguageName -eq 'zh') { 'SIMPCHINESE' } else { 'ENGLISH' }
+$localizedCopy = @{ ENGLISH = @{}; SIMPCHINESE = @{} }
 Get-Content (Join-Path $PSScriptRoot '../installer/strings.nsh') -Encoding UTF8 | ForEach-Object {
-    if ($_ -match ('^LangString (INSTALLER_\w+) \$\{LANG_' + $locale + '\} "(.*)"$')) { $copy[$Matches[1]] = $Matches[2] }
+    if ($_ -match '^LangString (INSTALLER_\w+) \$\{LANG_(ENGLISH|SIMPCHINESE)\} "(.*)"$') {
+        $localizedCopy[$Matches[2]][$Matches[1]] = $Matches[3]
+    }
 }
 function Wait-Control([Diagnostics.Process]$Process, [string]$Text, [switch]$Dialog) {
     $timer = [Diagnostics.Stopwatch]::StartNew()
@@ -41,7 +42,13 @@ function Start-Setup([string]$Theme, [string]$Path = $installPath) {
         if ($window -ne [IntPtr]::Zero) { break }
         Start-Sleep -Milliseconds 5
     } while ($timer.Elapsed.TotalSeconds -lt 30)
+    if ($window -eq [IntPtr]::Zero) { throw 'Installer welcome window did not appear' }
     [InstallerCapture]::Reveal($window)
+    $languages = @($localizedCopy.Keys | Where-Object {
+        [InstallerCapture]::FindButton($process.Id, $localizedCopy[$_].INSTALLER_INSTALL) -ne [IntPtr]::Zero
+    })
+    if ($languages.Count -ne 1) { throw "Cannot identify installer language: $([InstallerCapture]::VisibleText($process.Id))" }
+    $script:copy = $localizedCopy[$languages[0]]
     [void](Wait-Control $process $copy.INSTALLER_INSTALL)
     return $process
 }
@@ -128,9 +135,11 @@ try {
     Click-Control $process $copy.INSTALLER_BROWSE
     Dismiss $process $copy.INSTALLER_CHOOSE_PATH
     [void][InstallerCapture]::SendMessage($window, 0x28, $edit, [IntPtr]1)
-    [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, 'C:\Windows\Harness Installer Test')
-    [void][InstallerCapture]::PostMessage($edit, 0x100, [IntPtr]13, [IntPtr]::Zero)
-    Dismiss $process $copy.INSTALLER_PATH_INVALID
+    foreach ($invalidPath in @('C:\Windows\Harness Installer Test', [IO.Path]::GetPathRoot($installPath), ([IO.Path]::GetPathRoot($installPath) + '\'))) {
+        [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, $invalidPath)
+        [void][InstallerCapture]::PostMessage($edit, 0x100, [IntPtr]13, [IntPtr]::Zero)
+        Dismiss $process $copy.INSTALLER_PATH_INVALID
+    }
     [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, $installPath)
     [InstallerCapture]::MoveBy($window, 73, -41)
     $bounds = [InstallerCapture]::Bounds($window)
@@ -144,11 +153,17 @@ try {
 
     $process = Start-Setup dark ''
     Click-Control $process $copy.INSTALLER_CHOOSE_PATH
-    [void](Wait-Control $process $installPath)
+    $edit = Wait-Control $process $installPath
+    [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, ($installPath + '\\'))
     [void][InstallerCapture]::Save([InstallerCapture]::Find($process.Id), (Join-Path $OutputDirectory 'dark-welcome.png'))
     $bounds = [InstallerCapture]::Bounds([InstallerCapture]::Find($process.Id))
     Click-Control $process $copy.INSTALLER_INSTALL
     Finish-Setup $process $true dark $bounds
+    $registration = Get-ItemProperty ('HKCU:\Software\' + $RegistryKey)
+    if ($registration.InstallLocation.TrimEnd('\') -ne $installPath -or -not (Test-Path -LiteralPath $appPath)) {
+        throw 'Trailing separators changed the registered installation directory'
+    }
+    $results.Add('registered-directory-with-trailing-separators')
     $timer = [Diagnostics.Stopwatch]::StartNew()
     do {
         $app = Get-Process -Name $ProductName -ErrorAction SilentlyContinue

+ 7 - 0
apps/desktop/tests/windows-sign.spec.ts

@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
+import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
 import {
   buildWindowsSigningEnvironment,
   createRedactedWindowsSigningError,
@@ -43,6 +44,12 @@ describe('Windows token signing', () => {
       await writeFile(certificateFile, 'code-signing-certificate-fixture')
       await writeFile(signTool, 'fixture')
       await writeFile(path, 'fixture')
+      validateDesktopPackageEnvironment({
+        DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
+        DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool,
+        DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container',
+      }, { platform: 'win32', arch: 'x64' })
+      expect(execFile).not.toHaveBeenCalled()
       vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
         const callback = args.at(-1) as (error: Error) => void
         callback(Object.assign(new Error('signing failed'), { stderr: 'SignTool Error: No private key is available.', code: 1 }))

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä