1
0
Эх сурвалжийг харах

fix(desktop): release mandatory modal on install quit and bind DPAPI upload target

winewill 3 өдөр өмнө
parent
commit
d1585abdd9

+ 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: c9b2fac719908778afc6026165b1961b0568193f
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5879eb5c9a7bf393f5ed13bc7464123b00b24788
+2026-08-25-electron-desktop-packaging-and-updates.md: 233b3e01816ebe8ba91b221197ac337d422bafc8
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 60a3e6e12f631fa4a1d18c6f03e8c6ed5cb176a4

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

@@ -85,7 +85,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
 
 Windows NSIS distribution includes the external `.exe.blockmap` emitted by the pinned builder. Upload rejects a missing or empty blockmap and schedules it before feed publication. The builder's external-map metadata has no required `blockMapSize`; demanding that web-installer field rejects valid NSIS output. Regression fixtures execute the actual blockmap generator and prove that the mismatched validation fails before the fix. File validation still does not establish signature or installed-upgrade success.
 
-Local Windows COS uploads use an external DPAPI-encrypted CLIXML credential object through the [credential launcher](../../../../apps/desktop/scripts/upload-with-credentials.ps1). Explicit deployment selection prevents filename-based credential routing; the default action checks local child injection without contacting COS. Only an explicit upload invokes the release uploader. Decrypted credentials exist in process memory and the upload child's environment, not command arguments or persistent environment settings. The launcher removes unrelated secrets and Node preload hooks from that child, suppresses raw stderr, and redacts credential values in stdout. DPAPI binds the file to its Windows user and machine; it does not isolate credentials from other code running as that user. The [Windows credential tests](../../../../apps/desktop/tests/upload-with-credentials.spec.ts) cover local injection, unchanged parent state, and rejection without secret output; cloud authorization and signed release upload require separate release qualification.
+Local Windows COS uploads use an external DPAPI-encrypted CLIXML credential object through the [credential launcher](../../../../apps/desktop/scripts/upload-with-credentials.ps1). Explicit deployment selection prevents filename-based credential routing; the default action checks local child injection without contacting COS. Only an explicit upload invokes the release uploader. The uploader rejects a deployment or bucket that differs from the target dotenv and uses the selected DPAPI credential pair instead of dotenv credentials. Decrypted credentials exist in process memory and the upload child's environment, not command arguments or persistent environment settings. The launcher removes unrelated secrets and Node preload hooks from that child, suppresses raw stderr, and redacts credential values in stdout. DPAPI binds the file to its Windows user and machine; it does not isolate credentials from other code running as that user. The [Windows credential tests](../../../../apps/desktop/tests/upload-with-credentials.spec.ts) cover local injection, unchanged parent state, and rejection without secret output; cloud authorization and signed release upload require separate release qualification.
 
 Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
 

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

@@ -85,7 +85,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 Windows NSIS 分发包含固定版本构建器生成的独立 `.exe.blockmap`。上传拒绝缺失或空 blockmap,并在发布 feed 前上传它。构建器的外部映射元数据不要求 `blockMapSize`;要求该 web-installer 字段会拒绝有效 NSIS 产物。回归 fixture 执行真实 blockmap 生成器,并证明修复前不匹配的验证会失败。文件验证仍不能证明签名合格或已安装应用升级成功。
 
-本地 Windows COS 上传通过[凭据启动器](../../../../apps/desktop/scripts/upload-with-credentials.ps1)读取仓库外经 DPAPI 加密的 CLIXML 凭据对象。显式选择部署环境,避免根据文件名路由凭据;默认动作只检查本地子进程注入,不连接 COS。只有显式上传才调用发布上传程序。解密后的凭据仅存在于进程内存和上传子进程环境中,不进入命令参数或持久环境设置。启动器清除该子进程的无关密钥及 Node 预加载钩子,不显示原始 stderr,并遮盖 stdout 中的凭据值。DPAPI 将文件绑定到 Windows 用户和机器,但不能隔离以同一用户身份运行的其他代码。[Windows 凭据测试](../../../../apps/desktop/tests/upload-with-credentials.spec.ts)覆盖本地注入、父进程状态不变及拒绝时不输出密钥;云端授权和签名发布上传需要单独验收。
+本地 Windows COS 上传通过[凭据启动器](../../../../apps/desktop/scripts/upload-with-credentials.ps1)读取仓库外经 DPAPI 加密的 CLIXML 凭据对象。显式选择部署环境,避免根据文件名路由凭据;默认动作只检查本地子进程注入,不连接 COS。只有显式上传才调用发布上传程序。上传程序会拒绝与目标 dotenv 文件不一致的部署环境或 bucket,并使用选定的 DPAPI 凭据对而不是 dotenv 凭据。解密后的凭据仅存在于进程内存和上传子进程环境中,不进入命令参数或持久环境设置。启动器清除该子进程的无关密钥及 Node 预加载钩子,不显示原始 stderr,并遮盖 stdout 中的凭据值。DPAPI 将文件绑定到 Windows 用户和机器,但不能隔离以同一用户身份运行的其他代码。[Windows 凭据测试](../../../../apps/desktop/tests/upload-with-credentials.spec.ts)覆盖本地注入、父进程状态不变及拒绝时不输出密钥;云端授权和签名发布上传需要单独验收。
 
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.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/bug-fix/2026-09-16-movable-mandatory-update-window.md
-2026-09-16-movable-mandatory-update-window.md: 99815ffa6cfe440363a8f4665debb428f6c93ef9
-2026-09-16-movable-mandatory-update-window.zh.md: 5ca15d69c521d2bc10009c63cf4cedd17f76b579
+2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
+2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md

@@ -20,4 +20,4 @@ On Windows, mandatory policy uses a separate native framed modal with move, resi
 
 ## Consequences
 
-Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay.
+Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. Installer-owned quit disposes the modal before Electron closes windows, so its close guard cannot block installation. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay.

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md

@@ -20,4 +20,4 @@ Windows 强更页面原先使用与产品窗口等大的无边框模态覆盖层
 
 ## Consequences
 
-Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。
+Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。安装器接管退出时,壳会在 Electron 关闭窗口前释放模态窗口,避免关闭拦截阻止安装。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。

+ 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: 8a7e99ebcb7dc2316f946fd0771a7bb54c388815
-README.zh.md: 3e29d165dfb18a90d353a0778248fd98feb2f75e
+README.md: dc4b69076a92b13b2368e85d365917268bb56e3e
+README.zh.md: e6d0eac9a9b25610562256539729abc8eea7e186

+ 2 - 2
apps/desktop/README.md

@@ -161,7 +161,7 @@ The Windows installer extracts the new version beside the installation directory
 
 Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/<environment>-<target>-*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result.
 
-Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials, and never changes the packaged update destination.
+Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds/<target>/`; versioned packages and blockmaps live in `dsh-desk/bin/<target>/`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
 
@@ -292,7 +292,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT
 
 Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
 
-A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server content is plain text. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
+A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server content is plain text. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
 
 Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -161,7 +161,7 @@ Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用
 
 test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/<environment>-<target>-*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。
 
-Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据,也不会改变安装包中的更新目标
+Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds/<target>/`;带版本的安装包和 blockmap 位于 `dsh-desk/bin/<target>/`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
 
@@ -292,7 +292,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
 
 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
 
-扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端内容按纯文本展示。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
+扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端内容按纯文本展示。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
 
 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
 

+ 47 - 2
apps/desktop/scripts/upload-target.ts

@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
 import { parseArgs } from 'node:util'
 import type { DesktopPackageTargetName } from './package-target.ts'
 import { createDesktopCos } from './desktop-cos.ts'
+import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
 import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
 import { createDesktopUploadPlan } from './desktop-upload-plan.ts'
 import { uploadDesktopRelease } from './desktop-upload-run.ts'
@@ -25,14 +26,58 @@ function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string):
   return value
 }
 
+/**
+ * Use the credential launcher's selected deployment only when it matches the packaged release destination.
+ * @param fileEnvironment Target dotenv settings that own the release destination.
+ * @param injectedEnvironment Child environment containing the DPAPI-decrypted credential pair.
+ * @param selected Deployment authorized by the launcher operator.
+ * @param bucket Bucket authorized by the launcher operator.
+ * @param target Packaged target being uploaded.
+ * @returns Release settings with only the selected credential pair replaced.
+ */
+export function resolveCredentialUploadEnvironment(
+  fileEnvironment: NodeJS.ProcessEnv, injectedEnvironment: NodeJS.ProcessEnv,
+  selected: 'test' | 'production', bucket: string,
+  target: DesktopPackageTargetName,
+): NodeJS.ProcessEnv {
+  const platform = target === 'win-x64' ? 'win32' : 'darwin'
+  const arch = target === 'mac-arm64' ? 'arm64' : 'x64'
+  const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch)
+  if (destination.environment !== selected || destination.bucket !== bucket) {
+    throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
+  }
+  if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected
+    || injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) {
+    throw new Error('desktop upload: credential launcher environment differs from its explicit arguments')
+  }
+  return {
+    ...fileEnvironment,
+    [destination.secretIdEnvName]: requiredEnvironmentValue(injectedEnvironment, destination.secretIdEnvName),
+    [destination.secretKeyEnvName]: requiredEnvironmentValue(injectedEnvironment, destination.secretKeyEnvName),
+  }
+}
+
 async function main(): Promise<void> {
-  const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true })
+  const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: {
+    'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
+  } })
   const target = positionals[0]
   if (target === undefined || positionals.length !== 1) {
     throw new Error('desktop upload: expected exactly one target')
   }
   const name = targetName(target)
-  const environment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
+  const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
+  const launcher = values['credential-launcher'] === true
+  if (launcher ? values.environment === undefined || values.bucket === undefined
+    : values.environment !== undefined || values.bucket !== undefined) {
+    throw new Error('desktop upload: credential launcher requires an explicit environment and bucket')
+  }
+  if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') {
+    throw new Error('desktop upload: credential launcher environment must be test or production')
+  }
+  const environment = launcher
+    ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
+    : fileEnvironment
   const plan = await createDesktopUploadPlan(name, { environment })
   const cos = createDesktopCos({
     secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),

+ 2 - 1
apps/desktop/scripts/upload-with-credentials.ps1

@@ -29,6 +29,7 @@ param(
     [string]$Target,
     [Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
     [ValidateNotNullOrEmpty()]
+    [ValidatePattern('^[a-z0-9-]+$')]
     [string]$Bucket,
     [Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
     [switch]$Upload
@@ -85,7 +86,7 @@ try {
     $startInfo.EnvironmentVariables['DSH_DESKTOP_AUTO_UPDATE_ENV'] = $Environment
     if ($Upload) {
         $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
-        $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target"
+        $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
         Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
     } else {
         $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"

+ 6 - 1
apps/desktop/src/main.ts

@@ -717,7 +717,12 @@ async function main(): Promise<void> {
   app.on('before-quit', (event) => {
     shuttingDown = true
     updateJournal?.action('quit-requested')
-    if (shellInstallerOwnsQuit || quitting) return
+    if (shellInstallerOwnsQuit) {
+      updateDialog.dispose()
+      mandatoryUI?.dispose()
+      return
+    }
+    if (quitting) return
     event.preventDefault()
     quitting = true
     mainWindow?.hide()

+ 27 - 1
apps/desktop/tests/main-startup.spec.ts

@@ -39,6 +39,7 @@ const harness = await vi.hoisted(async () => {
   let quitCompleted = deferred()
   let policyBlocked = deferred()
   let embeddedPolicy: unknown
+  let closeWindowsOnQuit = false
   let updateState: DesktopUpdateState = { phase: 'idle' }
   const updateCheck = vi.fn(async (_manual?: boolean): Promise<DesktopUpdateState> => updateState)
   const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
@@ -119,7 +120,10 @@ const harness = await vi.hoisted(async () => {
     quit: vi.fn(() => {
       const event = { preventDefault: vi.fn() }
       app.emit('before-quit', event)
-      if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve()
+      if (event.preventDefault.mock.calls.length === 0) {
+        if (closeWindowsOnQuit) for (const window of [...windows]) if (!window.isDestroyed()) window.close()
+        if (!closeWindowsOnQuit || windows.every(window => window.isDestroyed())) quitCompleted.resolve()
+      }
     }),
   })
   return {
@@ -150,12 +154,14 @@ const harness = await vi.hoisted(async () => {
     nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
     get pluginsEnabled() { return pluginsEnabled },
     set pluginsEnabled(value: boolean) { pluginsEnabled = value },
+    set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
     reset() {
       windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
       powerMonitor.removeAllListeners()
       app.isPackaged = true
       windowFailure = undefined
       pluginsEnabled = false
+      closeWindowsOnQuit = false
       prepareUpdate = undefined
       publishUpdate = undefined
       updateState = { phase: 'idle' }
@@ -924,6 +930,26 @@ describe('desktop main startup', () => {
     await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision)
   }
 
+  it('releases the mandatory modal when the confirmed installer quits Electron', async () => {
+    harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
+    vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
+      show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
+    } })))
+    const host = await readyForUpdate()
+    await harness.policyBlocked.promise
+    const modal = harness.windows.find(window => window.options.modal)!
+    const preparing = harness.prepareUpdate()
+    await answerMandatory('install')
+    await host.stopping.promise
+    host.exited.resolve()
+    await expect(preparing).resolves.toBe(true)
+    harness.closeWindowsOnQuit = true
+    harness.app.quit()
+    await harness.quitCompleted.promise
+    expect(modal.isDestroyed()).toBe(true)
+    expect(harness.app.quit).toHaveBeenCalledOnce()
+  })
+
   it.each([false, true])('restores a cleanly stopped Host after installer failure and retains mandatory blocking: %s', async (mandatory) => {
     if (mandatory) {
       harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }

+ 31 - 0
apps/desktop/tests/upload-with-credentials.spec.ts

@@ -5,6 +5,7 @@ import { dirname, delimiter, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
 import { afterEach, describe, expect, it } from 'vitest'
+import { resolveCredentialUploadEnvironment } from '../scripts/upload-target.ts'
 
 const execute = promisify(execFile)
 const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', import.meta.url))
@@ -33,6 +34,7 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
       assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
       assert.equal(process.env.NODE_OPTIONS, undefined)
       assert.equal(process.argv[2], 'win-x64')
+      assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'])
       console.log('fixture-id fixture-secret')
       console.error('private service details fixture-secret')
       process.exit(${mode === 'upload-failure' ? 17 : 0})
@@ -82,6 +84,35 @@ afterEach(async () => {
   await Promise.all(roots.splice(0).map(async root => rm(root, { recursive: true, force: true })))
 })
 
+describe('credential launcher destination', () => {
+  const fileEnvironment = {
+    DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com',
+    DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', DOWNLOAD_TEST_COS_SECRET_ID: 'stale-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'stale-key',
+  }
+  const injectedEnvironment = {
+    DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_COS_BUCKET: 'test-bucket',
+    DOWNLOAD_TEST_COS_SECRET_ID: 'decrypted-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'decrypted-key',
+  }
+
+  it('uses decrypted credentials only for the matching packaged deployment and bucket', () => {
+    expect(resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'test', 'test-bucket', 'win-x64'))
+      .toMatchObject({ DOWNLOAD_TEST_COS_SECRET_ID: 'decrypted-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'decrypted-key' })
+    expect(() => resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'production', 'test-bucket', 'win-x64'))
+      .toThrow(/differs from the packaged release destination/u)
+    expect(() => resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'test', 'other-bucket', 'win-x64'))
+      .toThrow(/differs from the packaged release destination/u)
+  })
+
+  it('rejects child credentials that do not match the explicit launcher selection', () => {
+    expect(() => resolveCredentialUploadEnvironment(fileEnvironment, { ...injectedEnvironment,
+      DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
+    }, 'test', 'test-bucket', 'win-x64')).toThrow(/differs from its explicit arguments/u)
+    expect(() => resolveCredentialUploadEnvironment(fileEnvironment, { ...injectedEnvironment,
+      DOWNLOAD_TEST_COS_SECRET_KEY: '',
+    }, 'test', 'test-bucket', 'win-x64')).toThrow(/DOWNLOAD_TEST_COS_SECRET_KEY/u)
+  })
+})
+
 // DPAPI's user-and-machine encryption is Windows-only.
 describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () => {
   it.each(['production', 'test'])('checks %s credentials without uploading or changing the parent', async (deployment) => {