mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge remote-tracking branch 'origin/master' into feat/workspace-dependencies-package
This commit is contained in:
commit
9d66716e50
58 files changed
+1789
-120
No files matched your search
@@ -4,12 +4,22 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
|
||||
# Workspace tarball workers; omit to use 4. Must be a positive integer.
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
# Optional independent proxy origins; empty values preserve inherited networking.
|
||||
# Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads.
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
# Apple tools: HTTP proxy temporarily applied to the active macOS network service.
|
||||
# Requires permission to change system proxies; affects other applications during notarization.
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
|
||||
|
||||
# The selected mandatory-update origin is required, including unsigned and preparation builds.
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
|
||||
# Optional polling and fallback-page options; origin and authentication are selected automatically.
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
# Set approved login origins for test builds; omit allowedAuthOrigins for production.
|
||||
# Optional polling and fallback-page options belong in the same JSON value.
|
||||
# The fallback-page allowlist defaults to the selected service origin.
|
||||
# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
|
||||
|
||||
DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
|
||||
DSH_DESKTOP_MACOS_TEAM_ID=
|
||||
|
||||
@@ -5,11 +5,12 @@ DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
|
||||
# The selected mandatory-update origin is required, including unsigned and preparation builds.
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
|
||||
# Optional polling and fallback-page options; origin and authentication are selected automatically.
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
# Set approved login origins for test builds; omit allowedAuthOrigins for production.
|
||||
# Optional polling and fallback-page options belong in the same JSON value.
|
||||
# The fallback-page allowlist defaults to the selected service origin.
|
||||
# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}'
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
|
||||
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE=
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL=
|
||||
|
||||
@@ -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: 9eda54fba2c7cb7965397b93e9c1dfb8a4d4351e
|
||||
README.zh.md: 17d5851c903d11f10a1bb54f568690cac36fd041
|
||||
README.md: bdff31ddf76e32d5bb4c2145c6dd79227e8f79b0
|
||||
README.zh.md: e7bef665510d04311434b93e31788b63912e73c3
|
||||
+20
-3
@@ -186,7 +186,23 @@ The macOS configuration uses the required release environment instead of accepti
|
||||
|
||||
macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
|
||||
|
||||
Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration.
|
||||
macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets/<target>/signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts.
|
||||
|
||||
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
|
||||
|
||||
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
|
||||
|
||||
Mac packaging reads three tuning fields from `.env.macos`:
|
||||
|
||||
| Setting | Default | Scope |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. |
|
||||
|
||||
The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses.
|
||||
|
||||
Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy.
|
||||
|
||||
### Unsigned Windows test installer
|
||||
|
||||
@@ -281,15 +297,16 @@ Confirmed Host exit without successful task teardown displays localized recovery
|
||||
|
||||
### Mandatory update policy
|
||||
|
||||
The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. Templates use `https://harness-test.deepseek.com` and `https://harness.deepseek.com`, respectively. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides.
|
||||
The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. The templates leave both origins blank; fill in the selected deployment origin in the ignored local dotenv file. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides.
|
||||
|
||||
Optional `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test`, and production selects `anonymous`. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result.
|
||||
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies test login origins and optional polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test` and require `allowedAuthOrigins` in `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG`; production selects `anonymous` and rejects that field. Each login origin must be an HTTPS origin without credentials, path, query, or fragment. The login window permits document navigation only to the selected policy origin and these configured origins. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result.
|
||||
|
||||
| Resolved policy field | Meaning and default |
|
||||
|---|---|
|
||||
| `origin` | Required HTTPS API origin, without credentials, path, query, or fragment; requests use `/api/v0/check_client_update` |
|
||||
| `allowedPageOrigins` | Nonempty array of exact HTTPS origins; packaging defaults to the selected API origin; subdomains and alternate ports are not implied |
|
||||
| `authentication` | Packaging selects `feishu-test` for test and `anonymous` for production; unpackaged development defaults to `anonymous` |
|
||||
| `allowedAuthOrigins` | Nonempty array of exact HTTPS login document origins for test authentication; forbidden for production |
|
||||
| `intervalMs` | Polling interval; default `600000` |
|
||||
| `timeoutMs` | Request deadline; default `15000` |
|
||||
| `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` |
|
||||
|
||||
@@ -187,7 +187,23 @@ macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的
|
||||
|
||||
macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
|
||||
|
||||
可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
|
||||
macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets/<target>/signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。
|
||||
|
||||
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
|
||||
|
||||
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
|
||||
|
||||
Mac 打包从 `.env.macos` 读取三个调优字段:
|
||||
|
||||
| 设置 | 默认值 | 作用范围 |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 |
|
||||
|
||||
两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。
|
||||
|
||||
Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。
|
||||
|
||||
### 未签名 Windows 测试安装包
|
||||
|
||||
@@ -282,15 +298,16 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
|
||||
|
||||
### 强制更新策略
|
||||
|
||||
[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板分别使用 `https://harness-test.deepseek.com` 和 `https://harness.deepseek.com`。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。
|
||||
[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板将两个源站留空;在 Git 忽略的目标 dotenv 文件中填写所选部署的源站。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。
|
||||
|
||||
可选的 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,正式包选择 `anonymous`。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。
|
||||
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供测试登录源站,以及可选的轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,且必须在 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` 中配置 `allowedAuthOrigins`;正式包选择 `anonymous`,并拒绝该字段。每个登录源站必须是没有凭据、路径、查询或片段的 HTTPS origin。登录窗口仅允许文档导航到所选策略源站和这些已配置源站。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。
|
||||
|
||||
| 解析后的策略字段 | 含义与默认值 |
|
||||
|---|---|
|
||||
| `origin` | 必填 HTTPS API 源站,不含凭据、路径、查询或片段;请求使用 `/api/v0/check_client_update` |
|
||||
| `allowedPageOrigins` | 非空的精确 HTTPS 源站数组;打包时默认只包含所选 API 源站;不隐含子域名或其他端口 |
|
||||
| `authentication` | 打包时测试环境选择 `feishu-test`,正式环境选择 `anonymous`;未打包开发模式默认为 `anonymous` |
|
||||
| `allowedAuthOrigins` | 测试鉴权必填的非空 HTTPS 登录文档源站数组;正式环境禁止配置 |
|
||||
| `intervalMs` | 轮询间隔;默认 `600000` |
|
||||
| `timeoutMs` | 请求截止时间;默认 `15000` |
|
||||
| `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` |
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"verify:mac-signature": "node scripts/verify-macos-signature.mjs",
|
||||
"package": "tsx scripts/package-target.ts",
|
||||
"check:package": "tsx scripts/package-target.ts --check",
|
||||
"restore:mac-proxy": "tsx scripts/macos-notarization-proxy.ts",
|
||||
"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",
|
||||
@@ -41,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/node-addon-system": "workspace:^",
|
||||
"@electron/get": "^5.1.0",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -8,12 +8,13 @@ import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSS
|
||||
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
|
||||
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
|
||||
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|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|SIGNATURE_CACHE_DIR)$/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 MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|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|MANDATORY_UPDATE_.*|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', 'CSC_LINK']
|
||||
|
||||
@@ -77,6 +78,7 @@ function requireReadableFile(environment, name) {
|
||||
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
|
||||
resolveDesktopAppId(environment)
|
||||
resolveDesktopPolicyEnvironment(environment)
|
||||
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
|
||||
if (options.unsigned) return
|
||||
if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
|
||||
if (target.platform === 'win32') {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export interface DesktopPolicyEnvironment {
|
||||
origin: string
|
||||
allowedPageOrigins: string[]
|
||||
allowedAuthOrigins?: string[]
|
||||
authentication: 'anonymous' | 'feishu-test'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -30,7 +30,15 @@ export function resolveDesktopPolicyEnvironment(environment) {
|
||||
}
|
||||
const pages = settings.allowedPageOrigins ?? [selected]
|
||||
if (!Array.isArray(pages) || pages.length === 0) throw new Error('desktop package: allowedPageOrigins must be a nonempty array')
|
||||
const authOrigins = settings.allowedAuthOrigins
|
||||
if (deployment === 'test' && (!Array.isArray(authOrigins) || authOrigins.length === 0)) {
|
||||
throw new Error('desktop package: test policy requires nonempty allowedAuthOrigins')
|
||||
}
|
||||
if (deployment === 'production' && authOrigins !== undefined) {
|
||||
throw new Error('desktop package: production policy must not configure allowedAuthOrigins')
|
||||
}
|
||||
return { ...settings, origin: selected,
|
||||
allowedPageOrigins: pages.map(value => origin(value, 'allowedPageOrigins')),
|
||||
...(deployment === 'test' ? { allowedAuthOrigins: authOrigins.map(value => origin(value, 'allowedAuthOrigins')) } : {}),
|
||||
authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Separate upload and Apple processing timings without replacing electron-notarize's qualification. */
|
||||
/**
|
||||
* Run the requested Apple command, splitting submit --wait into submit --no-wait and wait.
|
||||
* @param args Arguments from the pinned electron-notarize library.
|
||||
* @param directory Existing packaging journal.
|
||||
* @param invoke Apple command executor.
|
||||
* @returns Final tool output consumed by electron-notarize; upload failure prevents waiting.
|
||||
*/
|
||||
export function runLoggedNotarytool(args: readonly string[], directory?: string, invoke?: (args: readonly string[]) => Promise<string>): Promise<string>
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
/** Adapt electron-notarize's combined submit/wait into separately timed Apple operations. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile'])
|
||||
|
||||
async function execute(args) {
|
||||
try {
|
||||
const { stdout } = await exec('/usr/bin/xcrun', ['notarytool', ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
return stdout
|
||||
} catch (error) {
|
||||
// Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics.
|
||||
if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the final JSON to electron-notarize, which owns acceptance checks and stapling retries.
|
||||
* @param {readonly string[]} args Arguments supplied by the pinned electron-notarize library.
|
||||
* @param {string | undefined} directory Existing packaging journal.
|
||||
* @param {(args: readonly string[]) => Promise<string>} invoke Apple command executor.
|
||||
* @returns {Promise<string>} Tool output; a failed upload never starts waiting.
|
||||
*/
|
||||
export async function runLoggedNotarytool(args, directory, invoke = execute) {
|
||||
const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1]))
|
||||
if (args[0] !== 'submit' || !args.includes('--wait')) {
|
||||
return packagingStep(directory, `notarytool:${args[0]}`, async () => {
|
||||
const output = await invoke(args)
|
||||
if (directory && args[0] === 'log') {
|
||||
let text = ''
|
||||
const redactor = packagingOutputRedactor(credentials, chunk => { text += chunk })
|
||||
redactor.write(Buffer.from(output))
|
||||
redactor.end()
|
||||
recordPackagingEvent(directory, { type: 'notary-diagnostics', submissionId: args[1], text })
|
||||
}
|
||||
return output
|
||||
}, credentials)
|
||||
}
|
||||
const artifact = basename(args[1])
|
||||
const uploaded = await packagingStep(directory, `notarytool:upload:${artifact}`,
|
||||
() => invoke(args.filter(arg => arg !== '--wait').concat('--no-wait')), credentials)
|
||||
const submission = JSON.parse(uploaded)
|
||||
if (typeof submission.id !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu.test(submission.id)) {
|
||||
throw new Error('desktop notarization: submit returned no valid submission ID')
|
||||
}
|
||||
if (directory) recordPackagingEvent(directory, { type: 'notary-submission', artifact, submissionId: submission.id })
|
||||
const authorization = args.flatMap((arg, index) => AUTH_FLAGS.has(arg) ? [arg, args[index + 1]] : [])
|
||||
const rejected = new Error('desktop notarization: Apple did not accept the submission')
|
||||
let rejectedOutput
|
||||
try {
|
||||
return await packagingStep(directory, `notarytool:wait:${artifact}`, async () => {
|
||||
const output = await invoke(['wait', submission.id, ...authorization, '--output-format', 'json'])
|
||||
const result = JSON.parse(output)
|
||||
if (directory) recordPackagingEvent(directory, { type: 'notary-result', artifact, submissionId: submission.id, status: result.status })
|
||||
if (result.id !== submission.id) throw new Error('desktop notarization: wait returned a different submission ID')
|
||||
if (result.status !== 'Accepted') {
|
||||
rejectedOutput = output
|
||||
throw rejected
|
||||
}
|
||||
return output
|
||||
}, credentials)
|
||||
} catch (error) {
|
||||
// Preserve rejected JSON for electron-notarize's diagnostic retrieval without marking the wait successful.
|
||||
if (error === rejected) return rejectedOutput
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) }
|
||||
catch {
|
||||
// execFile errors include credential-bearing argv; detailed failures are redacted in the journal.
|
||||
process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Bind reusable thin Mach-O signatures to the actual certificate, tools, identifier, and entitlements. */
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir, release } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
|
||||
import { assertMacOSRuntimeSignatureDetails, signMacOSRuntimeCode } from './verify-macos-signature.mjs'
|
||||
import type { MacOSCachedSigner } from './macos-signature-cache.ts'
|
||||
|
||||
function apple(command: string, args: string[], input?: string): { stdout: string; stderr: string } {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8', input, timeout: 120_000 })
|
||||
if (result.error || result.status !== 0 || result.signal) throw new Error(`macOS signature cache: ${command} verification failed`)
|
||||
return result
|
||||
}
|
||||
function digest(bytes: Buffer): string { return createHash('sha256').update(bytes).digest('hex') }
|
||||
function plist(xml: string): unknown {
|
||||
if (xml.trim() === '') return {}
|
||||
return JSON.parse(apple('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], xml).stdout) as unknown
|
||||
}
|
||||
function inspect(path: string): { certificate: string; details: string; entitlements: unknown } {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dsh-signature-policy-'))
|
||||
try {
|
||||
const prefix = join(directory, 'certificate')
|
||||
const result = apple('/usr/bin/codesign', ['--display', '--verbose=4', `--extract-certificates=${prefix}`, '--entitlements', '-', '--xml', path])
|
||||
return { certificate: digest(readFileSync(`${prefix}0`)), details: result.stderr, entitlements: plist(result.stdout) }
|
||||
} finally { rmSync(directory, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a policy from the keychain's signed probe and current implementation; no credential enters the key.
|
||||
* @param probe Verified thin executable signed by this invocation's temporary keychain.
|
||||
* @returns Factory for per-file signing and verification policies.
|
||||
*/
|
||||
export function macOSCachePolicy(probe: string):
|
||||
(identifier: string, expected: MacOSSigningEnvironment, entitlements?: string) => MacOSCachedSigner {
|
||||
const certificate = inspect(probe).certificate
|
||||
const tools = ['/usr/bin/codesign', join(import.meta.dirname, 'verify-macos-signature.mjs'),
|
||||
join(import.meta.dirname, 'macos-cache-policy.ts'), join(import.meta.dirname, 'macos-signature-cache.ts')].map(path => digest(readFileSync(path)))
|
||||
return (identifier, expected, entitlements) => {
|
||||
const entitlementBytes = entitlements === undefined ? undefined : readFileSync(entitlements)
|
||||
const desired = entitlementBytes === undefined ? {} : plist(entitlementBytes.toString('utf8'))
|
||||
return {
|
||||
policy: JSON.stringify({ certificate, identifier, expected, entitlements: entitlementBytes?.toString('base64') ?? null, tools, os: release() }),
|
||||
sign: path => signMacOSRuntimeCode(path, identifier, expected, entitlements),
|
||||
verify: (path) => {
|
||||
if (!['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(readFileSync(path).subarray(0, 4).toString('hex'))) {
|
||||
throw new Error('macOS signature cache: only thin Mach-O files can be reused')
|
||||
}
|
||||
apple('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', path])
|
||||
const actual = inspect(path)
|
||||
assertMacOSRuntimeSignatureDetails(actual.details, expected)
|
||||
if (actual.certificate !== certificate || !actual.details.split(/\r?\n/u).includes(`Identifier=${identifier}`)
|
||||
|| !isDeepStrictEqual(actual.entitlements, desired)) {
|
||||
throw new Error('macOS signature cache: signature does not match certificate, identifier, or entitlements')
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/** Temporarily route Apple tools through the active macOS service; retain recovery data before mutation. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { connect } from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'
|
||||
|
||||
interface ProxyState {
|
||||
readonly enabled: boolean
|
||||
readonly server: string
|
||||
readonly port: number
|
||||
}
|
||||
interface ProxyRecord {
|
||||
readonly pid: number
|
||||
readonly service: string
|
||||
readonly http: ProxyState
|
||||
readonly https: ProxyState
|
||||
}
|
||||
interface ProxyOperations {
|
||||
readonly command: (executable: string, args: readonly string[]) => string
|
||||
readonly reachable: (url: URL) => Promise<void>
|
||||
readonly ownerAlive: (pid: number) => boolean
|
||||
}
|
||||
|
||||
const LOCK = join(homedir(), 'Library', 'Caches', 'com.deepseek.harness', 'notarization-proxy')
|
||||
const RECOVERY = 'pnpm --dir apps/desktop run restore:mac-proxy'
|
||||
const NETWORKSETUP = '/usr/sbin/networksetup'
|
||||
const operations: ProxyOperations = {
|
||||
command(executable, args) {
|
||||
return execFileSync(executable, [...args], { encoding: 'utf8', env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C' }, timeout: 15_000 })
|
||||
},
|
||||
reachable(url) {
|
||||
return new Promise((resolveConnection, reject) => {
|
||||
const socket = connect({ host: url.hostname.replace(/^\[|\]$/gu, ''), port: Number(url.port || '80') })
|
||||
let failure: Error | undefined
|
||||
socket.setTimeout(5000, () => socket.destroy(new Error('desktop package: notarization proxy connection timed out')))
|
||||
socket.once('error', () => { failure = new Error('desktop package: notarization proxy is unreachable') })
|
||||
socket.once('connect', () => socket.destroy())
|
||||
socket.once('close', () => { if (failure) reject(failure); else resolveConnection() })
|
||||
})
|
||||
},
|
||||
ownerAlive(pid) {
|
||||
try { process.kill(pid, 0); return true } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async function withProxyLock<T>(lock: string, action: () => Promise<T>): Promise<T> {
|
||||
mkdirSync(dirname(lock), { recursive: true, mode: 0o700 })
|
||||
// Keep this inode across transactions: unlinking it would allow two independent locks.
|
||||
const fd = openSync(`${lock}.flock`, 'a', 0o600)
|
||||
try {
|
||||
try { await tryLockExclusive(fd) } catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') throw error
|
||||
throw new Error('desktop package: another run or proxy recovery is active; wait for it to finish')
|
||||
}
|
||||
return await action()
|
||||
} finally { closeSync(fd) }
|
||||
}
|
||||
|
||||
function readProxy(service: string, secure: boolean, ops: ProxyOperations): ProxyState {
|
||||
const output = ops.command(NETWORKSETUP, [secure ? '-getsecurewebproxy' : '-getwebproxy', service])
|
||||
const enabled = /^Enabled: (Yes|No)$/mu.exec(output)?.[1]
|
||||
const server = /^Server:[ \t]?(.*)$/mu.exec(output)?.[1]
|
||||
const port = /^Port: (\d+)$/mu.exec(output)?.[1]
|
||||
if (enabled === undefined || server === undefined || port === undefined
|
||||
|| !/^Authenticated Proxy Enabled: 0$/mu.test(output) || Number(port) > 65535) {
|
||||
throw new Error('desktop package: cannot preserve system proxy settings; authenticated proxies are unsupported')
|
||||
}
|
||||
return { enabled: enabled === 'Yes', server, port: Number(port) }
|
||||
}
|
||||
|
||||
function activeService(ops: ProxyOperations): string {
|
||||
const route = ops.command('/sbin/route', ['-n', 'get', 'default'])
|
||||
const device = /^\s*interface: (\S+)$/mu.exec(route)?.[1]
|
||||
const services = ops.command(NETWORKSETUP, ['-listnetworkserviceorder'])
|
||||
const matches = [...services.matchAll(/^\(\d+\) (.+)\n\(Hardware Port: .+, Device: ([^)]+)\)$/gmu)]
|
||||
.filter(match => match[2] === device)
|
||||
if (!device || matches.length !== 1) throw new Error('desktop package: cannot identify one active network service for notarization proxy')
|
||||
return matches[0]![1]!
|
||||
}
|
||||
|
||||
function setProxy(service: string, secure: boolean, state: ProxyState, ops: ProxyOperations): void {
|
||||
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxy' : '-setwebproxy', service, state.server, String(state.port), 'off'])
|
||||
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxystate' : '-setwebproxystate', service, state.enabled ? 'on' : 'off'])
|
||||
const actual = readProxy(service, secure, ops)
|
||||
if (JSON.stringify(actual) !== JSON.stringify(state)) throw new Error('desktop package: system proxy change did not take effect')
|
||||
}
|
||||
|
||||
function restore(record: ProxyRecord, ops: ProxyOperations): void {
|
||||
const errors: unknown[] = []
|
||||
for (const [secure, state] of [[false, record.http], [true, record.https]] as const) {
|
||||
try {
|
||||
if (!state.enabled && state.server === '' && state.port === 0) {
|
||||
ops.command(NETWORKSETUP, [secure ? '-setsecurewebproxystate' : '-setwebproxystate', record.service, 'off'])
|
||||
if (readProxy(record.service, secure, ops).enabled) throw new Error('desktop package: system proxy disable did not take effect')
|
||||
} else setProxy(record.service, secure, state, ops)
|
||||
} catch (error) { errors.push(error) }
|
||||
}
|
||||
if (errors.length) throw new AggregateError(errors, `desktop package: proxy restoration failed; retry ${RECOVERY}`)
|
||||
}
|
||||
|
||||
function readRecord(lock: string): ProxyRecord {
|
||||
const value: unknown = JSON.parse(readFileSync(join(lock, 'original.json'), 'utf8'))
|
||||
function isState(state: unknown): state is ProxyState {
|
||||
if (state === null || typeof state !== 'object') return false
|
||||
const s = state as Partial<ProxyState>
|
||||
return typeof s.enabled === 'boolean' && typeof s.server === 'string' && !/[\r\n\0]/u.test(s.server)
|
||||
&& typeof s.port === 'number' && Number.isInteger(s.port) && s.port >= 0 && s.port <= 65535
|
||||
}
|
||||
if (value === null || typeof value !== 'object') throw new Error('desktop package: invalid proxy recovery record')
|
||||
const record = value as Partial<ProxyRecord>
|
||||
if (!Number.isSafeInteger(record.pid) || record.pid! <= 0 || typeof record.service !== 'string'
|
||||
|| !record.service || /[\r\n\0]/u.test(record.service) || !isState(record.http) || !isState(record.https)) {
|
||||
throw new Error('desktop package: invalid proxy recovery record')
|
||||
}
|
||||
return record as ProxyRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an interrupted run only after its owner exits; active or incomplete records fail closed.
|
||||
* Originally empty proxies are disabled; their temporary server and port may remain stored.
|
||||
* @param lock Per-user lock shared across checkouts; tests supply a private directory.
|
||||
* @param ops Host operations; tests supply isolated system settings.
|
||||
* @returns Resolves after restoration under the shared file lock; retains the record on failure.
|
||||
*/
|
||||
export async function restoreMacOSNotarizationProxy(lock: string = LOCK, ops: ProxyOperations = operations): Promise<void> {
|
||||
await withProxyLock(lock, async () => {
|
||||
const record = readRecord(lock)
|
||||
if (ops.ownerAlive(record.pid)) throw new Error('desktop package: proxy owner is still running; wait for packaging to finish')
|
||||
restore(record, ops)
|
||||
rmSync(lock, { recursive: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the system HTTP/HTTPS proxy until all callback work settles, including after SIGINT/SIGTERM.
|
||||
* @param proxyUrl Validated HTTP proxy origin, or undefined for unchanged system networking.
|
||||
* @param action Work that drains every notarization lane before resolving or rejecting.
|
||||
* @param lock Per-user exclusive recovery directory, shared across checkouts.
|
||||
* @param ops Host operations; tests supply isolated system settings.
|
||||
* @param report Reports restoration state to the packaging journal without exposing proxy addresses.
|
||||
* @returns Callback result after verified restoration; forced termination leaves a recovery record.
|
||||
*/
|
||||
export async function withMacOSNotarizationProxy<T>(
|
||||
proxyUrl: string | undefined,
|
||||
action: () => Promise<T>,
|
||||
lock: string = LOCK,
|
||||
ops: ProxyOperations = operations,
|
||||
report: (status: 'not-used' | 'restoration-pending' | 'enabled' | 'restored' | 'restore-failed') => void = () => {},
|
||||
): Promise<T> {
|
||||
if (proxyUrl === undefined) { report('not-used'); return action() }
|
||||
const url = new URL(proxyUrl)
|
||||
await ops.reachable(url)
|
||||
return withProxyLock(lock, async () => {
|
||||
try { mkdirSync(lock, { mode: 0o700 }) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
throw new Error(`desktop package: another run or interrupted proxy record exists; after its owner exits, run ${RECOVERY}`)
|
||||
}
|
||||
let original: ProxyRecord | undefined
|
||||
let saved = false
|
||||
let workError: unknown
|
||||
let signal: string | undefined
|
||||
const interrupted = (received: string) => {
|
||||
signal = received
|
||||
process.stderr.write('desktop package: waiting for notarization work to stop before restoring the system proxy\n')
|
||||
}
|
||||
process.on('SIGINT', interrupted)
|
||||
process.on('SIGTERM', interrupted)
|
||||
try {
|
||||
const service = activeService(ops)
|
||||
// PAC, discovery and SOCKS can override explicit proxies; do not silently change those policies.
|
||||
const pac = ops.command(NETWORKSETUP, ['-getautoproxyurl', service])
|
||||
const discovery = ops.command(NETWORKSETUP, ['-getproxyautodiscovery', service])
|
||||
const socks = ops.command(NETWORKSETUP, ['-getsocksfirewallproxy', service])
|
||||
if (!/^Enabled: No$/mu.test(pac) || !/^Auto Proxy Discovery: Off$/mu.test(discovery) || !/^Enabled: No$/mu.test(socks)) {
|
||||
throw new Error('desktop package: disable PAC, proxy discovery and SOCKS on the active service before configuring a notarization proxy')
|
||||
}
|
||||
original = { pid: process.pid, service, http: readProxy(service, false, ops), https: readProxy(service, true, ops) }
|
||||
writeFileSync(join(lock, 'original.json'), `${JSON.stringify(original)}\n`, { flag: 'wx', mode: 0o600, flush: true })
|
||||
saved = true
|
||||
report('restoration-pending')
|
||||
const state = { enabled: true, server: url.hostname.replace(/^\[|\]$/gu, ''), port: Number(url.port || '80') }
|
||||
setProxy(service, false, state, ops)
|
||||
setProxy(service, true, state, ops)
|
||||
report('enabled')
|
||||
const result = await action()
|
||||
if (signal) throw new Error(`desktop package: interrupted by ${signal}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
workError = error
|
||||
throw error
|
||||
} finally {
|
||||
try {
|
||||
if (saved && original) {
|
||||
try { restore(original, ops) } catch (error) { report('restore-failed'); throw error }
|
||||
report('restored')
|
||||
}
|
||||
rmSync(lock, { recursive: true })
|
||||
} catch (error) {
|
||||
if (workError !== undefined) {
|
||||
throw new AggregateError([workError, error], `desktop package: work and proxy cleanup failed; run ${RECOVERY}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
process.off('SIGINT', interrupted)
|
||||
process.off('SIGTERM', interrupted)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
if (process.platform !== 'darwin') throw new Error('desktop package: proxy recovery requires macOS')
|
||||
await restoreMacOSNotarizationProxy()
|
||||
console.log('desktop package: original system proxy restored')
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** File-owned macOS packaging concurrency and stage-specific proxies. */
|
||||
|
||||
/** Validated settings shared by preflight and stage execution. */
|
||||
export interface MacOSPackageSettings {
|
||||
readonly packConcurrency: number
|
||||
readonly downloadProxy?: string
|
||||
readonly notarizationProxy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tuning before any build or network operation.
|
||||
* @param environment File-owned packaging settings.
|
||||
* @returns Resolved settings; empty proxy fields preserve inherited networking.
|
||||
*/
|
||||
export function resolveMacOSPackageSettings(environment: NodeJS.ProcessEnv): MacOSPackageSettings
|
||||
|
||||
/**
|
||||
* Override routing only in download-capable subprocesses without mutating the parent.
|
||||
* @param environment Parent environment.
|
||||
* @param proxyUrl Validated proxy, or undefined to preserve inherited settings.
|
||||
* @returns Child environment; explicit routing bypasses only local hosts.
|
||||
*/
|
||||
export function macOSDownloadEnvironment(environment: NodeJS.ProcessEnv, proxyUrl?: string): NodeJS.ProcessEnv
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Resolve file-owned macOS packaging concurrency and stage-specific proxies. */
|
||||
|
||||
function proxy(environment, name, protocols) {
|
||||
const value = environment[name]?.trim()
|
||||
if (!value) return undefined
|
||||
let parsed
|
||||
try { parsed = new URL(value) } catch { throw new Error(`desktop package: ${name} must be a proxy URL`) }
|
||||
if (!protocols.includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password
|
||||
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||
throw new Error(`desktop package: ${name} requires an unauthenticated ${protocols.join('/')} proxy origin`)
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tuning before any build or network operation.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned packaging settings.
|
||||
* @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
|
||||
*/
|
||||
export function resolveMacOSPackageSettings(environment) {
|
||||
const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4'
|
||||
if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer')
|
||||
}
|
||||
return {
|
||||
packConcurrency: Number(concurrency),
|
||||
downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']),
|
||||
notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override proxy routing only in download-capable subprocesses, including Electron and pnpm.
|
||||
* @param {NodeJS.ProcessEnv} environment Parent environment, never mutated.
|
||||
* @param {string | undefined} proxyUrl Validated download proxy, or undefined to preserve inherited settings.
|
||||
* @returns {NodeJS.ProcessEnv} Child environment; explicit routing bypasses only local hosts.
|
||||
*/
|
||||
export function macOSDownloadEnvironment(environment, proxyUrl) {
|
||||
if (proxyUrl === undefined) return { ...environment }
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(environment).filter(([name]) =>
|
||||
!/^(?:(?:https?|all|no)_proxy|npm_config_(?:https?_proxy|proxy|noproxy)|ELECTRON_GET_USE_PROXY)$/iu.test(name))),
|
||||
HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, http_proxy: proxyUrl, https_proxy: proxyUrl,
|
||||
NO_PROXY: 'localhost,127.0.0.1,::1', no_proxy: 'localhost,127.0.0.1,::1',
|
||||
ELECTRON_GET_USE_PROXY: '1',
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import { closeSync, openSync, readSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
|
||||
import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts'
|
||||
import { macOSCachePolicy } from './macos-cache-policy.ts'
|
||||
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
|
||||
|
||||
const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca'])
|
||||
|
||||
function isMachO(path: string): boolean {
|
||||
function magic(path: string): string {
|
||||
const descriptor = openSync(path, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(4)
|
||||
return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex'))
|
||||
return readSync(descriptor, header, 0, 4, 0) === 4 ? header.toString('hex') : ''
|
||||
} finally { closeSync(descriptor) }
|
||||
}
|
||||
|
||||
@@ -22,10 +24,16 @@ function isMachO(path: string): boolean {
|
||||
* @param root - Self-contained production runtime without symlinks.
|
||||
* @param appId - Release application identifier.
|
||||
* @param expected - Required signing identity.
|
||||
* @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe.
|
||||
* @returns Number of signed native files.
|
||||
*/
|
||||
export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise<number> {
|
||||
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path)))
|
||||
export async function signMacOSRuntime(
|
||||
root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string,
|
||||
): Promise<number> {
|
||||
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
|
||||
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
|
||||
let hits = 0
|
||||
let misses = 0
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(4, files.length) }, async () => {
|
||||
for (;;) {
|
||||
@@ -34,12 +42,23 @@ export async function signMacOSRuntime(root: string, appId: string, expected: Ma
|
||||
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
|
||||
const entitlements = path === 'dependencies/node/bin/node'
|
||||
? join(import.meta.dirname, 'node-entitlements.plist') : undefined
|
||||
await signMacOSRuntimeCode(join(root, path), identifier, expected, entitlements)
|
||||
verifyMacOSRuntimeCode(join(root, path), expected)
|
||||
const file = join(root, path)
|
||||
const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file))
|
||||
if (cacheDirectory !== undefined && policy !== undefined && thin) {
|
||||
if (await cachedMacOSSignature(file, cacheDirectory, policy(identifier, expected, entitlements))) hits++
|
||||
else misses++
|
||||
} else {
|
||||
await signMacOSRuntimeCode(file, identifier, expected, entitlements)
|
||||
verifyMacOSRuntimeCode(file, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
const results = await Promise.allSettled(workers)
|
||||
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
|
||||
if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed')
|
||||
if (cacheDirectory !== undefined) {
|
||||
pruneMacOSSignatureCache(cacheDirectory)
|
||||
console.info(`desktop macOS signing cache: ${hits} hits, ${misses} misses, ${files.length - hits - misses} uncached`)
|
||||
}
|
||||
return files.length
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Reuse verified standalone signatures by file content and complete signing policy. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
function digest(bytes: Buffer | string): string { return createHash('sha256').update(bytes).digest('hex') }
|
||||
function regular(path: string): void {
|
||||
if (!lstatSync(path).isFile()) throw new Error(`macOS signature cache: expected regular file ${path}`)
|
||||
}
|
||||
function directory(path: string): void {
|
||||
if (!lstatSync(path).isDirectory()) throw new Error(`macOS signature cache: expected directory ${path}`)
|
||||
}
|
||||
function ensureDirectory(path: string): void {
|
||||
if (dirname(path) !== path) ensureDirectory(dirname(path))
|
||||
if (!existsSync(path)) {
|
||||
try { mkdirSync(path, { mode: 0o700 }) }
|
||||
catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error }
|
||||
}
|
||||
directory(path)
|
||||
}
|
||||
|
||||
function readEntry(entry: string, key: string): { payload: string; sha256: string } {
|
||||
directory(entry)
|
||||
const record = join(entry, 'record.json')
|
||||
const payload = join(entry, 'code')
|
||||
regular(record); regular(payload)
|
||||
const metadata = JSON.parse(readFileSync(record, 'utf8')) as { key?: unknown; sha256?: unknown }
|
||||
if (metadata.key !== key || typeof metadata.sha256 !== 'string') throw new Error(`macOS signature cache: invalid record ${entry}`)
|
||||
if (digest(readFileSync(payload)) !== metadata.sha256) throw new Error(`macOS signature cache: damaged payload ${entry}`)
|
||||
return { payload, sha256: metadata.sha256 }
|
||||
}
|
||||
|
||||
/** A signer whose verifier checks all policy fields represented by its stable key. */
|
||||
export interface MacOSCachedSigner {
|
||||
readonly policy: string
|
||||
/** @param path Private executable copy to sign. @returns Completion after all owned signing activity settles. */
|
||||
sign: (path: string) => Promise<void>
|
||||
/** @param path Executable whose signature must satisfy the complete policy. */
|
||||
verify: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a private copy or validate a cached copy, then atomically replace an unchanged input.
|
||||
* @param path Regular standalone executable owned by this build.
|
||||
* @param cacheRoot Private content-addressed signature storage.
|
||||
* @param signer Complete signing policy and its signing/verification operations.
|
||||
* @returns Whether a previously verified signature was reused.
|
||||
*/
|
||||
export async function cachedMacOSSignature(path: string, cacheRoot: string, signer: MacOSCachedSigner): Promise<boolean> {
|
||||
regular(path)
|
||||
const mode = lstatSync(path).mode & 0o777
|
||||
const input = digest(readFileSync(path))
|
||||
const key = digest(JSON.stringify({ schema: 1, input, mode, policy: signer.policy }))
|
||||
ensureDirectory(cacheRoot)
|
||||
const entry = join(cacheRoot, key)
|
||||
const work = mkdtempSync(join(dirname(path), '.dsh-sign-'))
|
||||
const candidate = join(work, 'code')
|
||||
const unchanged = (): void => {
|
||||
regular(path)
|
||||
if (digest(readFileSync(path)) !== input || (lstatSync(path).mode & 0o777) !== mode) {
|
||||
throw new Error('macOS signature cache: input changed during signing')
|
||||
}
|
||||
}
|
||||
try {
|
||||
const hit = existsSync(entry)
|
||||
if (hit) {
|
||||
const { payload, sha256 } = readEntry(entry, key)
|
||||
copyFileSync(payload, candidate)
|
||||
if (digest(readFileSync(candidate)) !== sha256) throw new Error(`macOS signature cache: damaged payload ${entry}`)
|
||||
} else {
|
||||
copyFileSync(path, candidate)
|
||||
if (digest(readFileSync(candidate)) !== input) throw new Error('macOS signature cache: input changed while copying')
|
||||
await signer.sign(candidate)
|
||||
}
|
||||
chmodSync(candidate, mode)
|
||||
const signed = digest(readFileSync(candidate))
|
||||
signer.verify(candidate)
|
||||
if (digest(readFileSync(candidate)) !== signed) throw new Error('macOS signature cache: candidate changed during verification')
|
||||
unchanged()
|
||||
if (!hit) {
|
||||
const staging = mkdtempSync(join(cacheRoot, '.pending-'))
|
||||
try {
|
||||
copyFileSync(candidate, join(staging, 'code'))
|
||||
if (digest(readFileSync(join(staging, 'code'))) !== signed) throw new Error('macOS signature cache: staged payload changed')
|
||||
writeFileSync(join(staging, 'record.json'), `${JSON.stringify({ key, sha256: signed })}\n`, { mode: 0o600, flag: 'wx', flush: true })
|
||||
try { renameSync(staging, entry) }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && ['ENOTEMPTY', 'EEXIST'].includes(String(error.code)))) throw error
|
||||
const winner = readEntry(entry, key)
|
||||
signer.verify(winner.payload)
|
||||
}
|
||||
} finally { rmSync(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
unchanged()
|
||||
renameSync(candidate, path)
|
||||
return hit
|
||||
} finally { rmSync(work, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain newest complete cache entries within a one-GiB payload budget; active readers fail closed on eviction races.
|
||||
* @param root Cache directory owned by packaging.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
export function pruneMacOSSignatureCache(root: string): void {
|
||||
if (!existsSync(root)) return
|
||||
directory(root)
|
||||
const entries = readdirSync(root).filter(name => /^[a-f0-9]{64}$/u.test(name)).map((name) => {
|
||||
const path = join(root, name)
|
||||
directory(path)
|
||||
const payload = join(path, 'code')
|
||||
regular(payload)
|
||||
const stat = lstatSync(payload)
|
||||
return { path, bytes: stat.size, time: stat.mtimeMs }
|
||||
}).sort((a, b) => b.time - a.time)
|
||||
let bytes = 0
|
||||
for (const entry of entries) {
|
||||
bytes += entry.bytes
|
||||
if (bytes > 1024 ** 3) rmSync(entry.path, { recursive: true })
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function withMacOSSigningKeychain(environment, action, run = execut
|
||||
run('/bin/cp', ['/usr/bin/true', probe])
|
||||
run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe])
|
||||
run('/usr/bin/codesign', ['--verify', '--strict', probe])
|
||||
const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain }
|
||||
const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain, DSH_DESKTOP_MACOS_SIGNING_PROBE: probe }
|
||||
delete childEnvironment.CSC_LINK
|
||||
delete childEnvironment.CSC_KEY_PASSWORD
|
||||
await action(childEnvironment)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Notarize and qualify macOS disk images after electron-builder creates them. */
|
||||
|
||||
import { notarize } from '@electron/notarize'
|
||||
import { notarizeMacOS } from './notarize-macos.mjs'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
|
||||
import { verifyMacOSDiskImage } from './verify-macos-signature.mjs'
|
||||
@@ -18,7 +18,7 @@ export async function notarizeMacOSDiskImageArtifact(
|
||||
artifact,
|
||||
env,
|
||||
expected,
|
||||
submit = notarize,
|
||||
submit = notarizeMacOS,
|
||||
verify = verifyMacOSDiskImage,
|
||||
) {
|
||||
if (!artifact.file.endsWith('.dmg')) return
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Notarize with separate upload and wait timings while preserving signature checks and stapling. */
|
||||
import type { MacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
|
||||
/**
|
||||
* Notarize and staple an App or DMG through the measured notarytool adapter.
|
||||
* @param options Validated Apple credentials and artifact path.
|
||||
* @returns Resolves after acceptance and stapling; failures remain in the packaging journal.
|
||||
*/
|
||||
export function notarizeMacOS(options: MacOSNotarizationEnvironment & { appPath: string }): Promise<void>
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Preserve electron-notarize's signature checks and stapling while recording separate Apple phases. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { notarize } from '@electron/notarize'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
|
||||
/**
|
||||
* Notarize and staple one App or DMG through the measured notarytool adapter.
|
||||
* @param {object} options Validated credentials and appPath accepted by electron-notarize.
|
||||
* @returns {Promise<void>} Resolves only after Apple acceptance and stapling.
|
||||
*/
|
||||
export async function notarizeMacOS(options) {
|
||||
const secrets = Object.entries(options).filter(([name]) => /password|key|appleId/iu.test(name)).map(([, value]) => String(value))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'notarize-and-staple',
|
||||
() => notarize({ ...options, notarytoolPath: fileURLToPath(new URL('./logged-notarytool.mjs', import.meta.url)) }), secrets)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { notarize } from '@electron/notarize'
|
||||
import { notarizeMacOS } from './notarize-macos.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
import {
|
||||
resolveMacOSNotarizationEnvironment,
|
||||
resolveMacOSSigningEnvironment,
|
||||
@@ -45,15 +46,15 @@ const operations: MacOSArtifactOperations = {
|
||||
async copyApp(source, destination) {
|
||||
await execute('/usr/bin/ditto', [source, destination])
|
||||
},
|
||||
notarize,
|
||||
notarize: notarizeMacOS,
|
||||
verifySignature: verifyMacOSSignature,
|
||||
verifyNotarization: verifyMacOSNotarizedApplication,
|
||||
}
|
||||
|
||||
async function timed(label: string, action: () => Promise<void>): Promise<void> {
|
||||
async function timed(label: string, action: () => Promise<void>, secrets: readonly string[]): Promise<void> {
|
||||
const start = performance.now()
|
||||
process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`)
|
||||
await action()
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, label, action, secrets)
|
||||
process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`)
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ export async function packageMacOSArtifacts(
|
||||
apple: MacOSArtifactOperations = operations,
|
||||
): Promise<void> {
|
||||
const { arch, version, artifactsRoot, environment } = request
|
||||
const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')
|
||||
const expected = resolveMacOSSigningEnvironment(environment)
|
||||
const credentials = resolveMacOSNotarizationEnvironment(environment)
|
||||
const update = resolveDesktopAutoUpdateConfig(environment, 'darwin', arch)
|
||||
@@ -95,10 +97,10 @@ export async function packageMacOSArtifacts(
|
||||
await apple.notarize({ appPath: zipApp, ...credentials })
|
||||
apple.verifyNotarization(zipApp, expected)
|
||||
await build({ format: 'zip', appPath: zipApp, output: zipOutput })
|
||||
}),
|
||||
}, secrets),
|
||||
timed('DMG creation and notarization', async () => {
|
||||
await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
|
||||
}),
|
||||
}, secrets),
|
||||
])
|
||||
const failures = results.filter(result => result.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Build one release target with matching Electron and dsh architecture. */
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -15,6 +15,11 @@ import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
|
||||
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-package-settings.mjs'
|
||||
import { packagingErrorDetails, packagingStep } from './packaging-step.mjs'
|
||||
import { notarizeMacOS } from './notarize-macos.mjs'
|
||||
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
|
||||
import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
|
||||
@@ -283,32 +288,51 @@ 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) {
|
||||
validateDesktopPackageEnvironment(environment, target, invocation)
|
||||
process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
|
||||
return
|
||||
}
|
||||
const run = target.platform === 'win32'
|
||||
? createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
|
||||
target: target.name, unsigned: invocation.unsigned, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
|
||||
}) : undefined
|
||||
if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
|
||||
const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')
|
||||
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
|
||||
target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly,
|
||||
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), node: process.version,
|
||||
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim(),
|
||||
dirty: execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim() !== '',
|
||||
}, { parallel: target.platform === 'darwin', secrets })
|
||||
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
|
||||
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory
|
||||
let success = false
|
||||
try {
|
||||
await packagingStep(run.directory, 'configuration', async () => { validateDesktopPackageEnvironment(environment, target, invocation) }, secrets)
|
||||
if (target.platform === 'darwin') {
|
||||
await withMacOSSigningKeychain(environment, signingEnvironment => packageTarget(invocation, signingEnvironment, run))
|
||||
const settings = resolveMacOSPackageSettings(environment)
|
||||
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
|
||||
downloadProxyConfigured: settings.downloadProxy !== undefined,
|
||||
notarizationProxyConfigured: settings.notarizationProxy !== undefined })
|
||||
await packagingStep(run.directory, 'macos-package', () => withMacOSSigningKeychain(environment,
|
||||
signingEnvironment => packageTarget(invocation, signingEnvironment, run)), secrets)
|
||||
} else {
|
||||
await packageTarget(invocation, environment, run)
|
||||
await packagingStep(run.directory, 'windows-package', () => packageTarget(invocation, environment, run), secrets)
|
||||
}
|
||||
success = true
|
||||
} finally { run?.finish(success) }
|
||||
} catch (error) {
|
||||
process.stderr.write(`${packagingErrorDetails(error, secrets)}\n`)
|
||||
process.stderr.write(`desktop package: failed; see ${run.directory}/events.jsonl\n`)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (previousDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = previousDirectory
|
||||
run.finish(success)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare one release only after its signing preflight, without publishing from the builder.
|
||||
* @param invocation Validated host, target and packaging mode.
|
||||
* @param environment File-owned release configuration.
|
||||
* @param run Windows stage supervisor; required for signed Windows packaging.
|
||||
* @param run Persistent stage supervisor; required for signed Windows packaging and enabled for all release commands.
|
||||
* @returns Resolves after preparation or complete packaging; any failed stage prevents a release record.
|
||||
*/
|
||||
export async function packageTarget(
|
||||
@@ -318,6 +342,10 @@ export async function packageTarget(
|
||||
): Promise<void> {
|
||||
const { target } = invocation
|
||||
const execute = (args: readonly string[], env: NodeJS.ProcessEnv, cwd: string = APP_ROOT) => runPnpm(args, env, cwd, run)
|
||||
const journal = target.platform === 'darwin' ? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR : undefined
|
||||
const proxyEvent = (status: string) => { if (journal) recordPackagingEvent(journal, { type: 'notarization-proxy', status }) }
|
||||
const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined
|
||||
const packArguments = mac === undefined ? [] : ['--concurrency', String(mac.packConcurrency)]
|
||||
const buildPaths = desktopTargetBuildPaths(target.name)
|
||||
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
|
||||
if (!invocation.prepareOnly && !invocation.unsigned) {
|
||||
@@ -330,7 +358,8 @@ export async function packageTarget(
|
||||
DSH_DESKTOP_TARGET_PLATFORM: target.platform,
|
||||
DSH_DESKTOP_TARGET_ARCH: target.arch,
|
||||
}
|
||||
const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
|
||||
const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy)
|
||||
const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned)
|
||||
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
|
||||
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
|
||||
}
|
||||
@@ -364,7 +393,7 @@ export async function packageTarget(
|
||||
})
|
||||
}
|
||||
await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
'--dir',
|
||||
'apps/desktop-host',
|
||||
@@ -372,7 +401,7 @@ export async function packageTarget(
|
||||
'--pack-destination',
|
||||
buildPaths.packedDsh,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
|
||||
mkdirSync(buildPaths.packedLandlock, { recursive: true })
|
||||
await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
|
||||
@@ -383,10 +412,10 @@ export async function packageTarget(
|
||||
'--pack-destination',
|
||||
buildPaths.packedLandlock,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv)
|
||||
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
|
||||
await execute(['run', 'prepare:packages'], targetEnv)
|
||||
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], targetEnv)
|
||||
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv)
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
|
||||
if (invocation.prepareOnly) return
|
||||
if (target.platform === 'darwin' && !invocation.directory) {
|
||||
@@ -394,17 +423,23 @@ export async function packageTarget(
|
||||
...desktopElectronBuilderArguments(target, true),
|
||||
'--config.mac.notarize=false',
|
||||
], electronBuilderEnv)
|
||||
await packageMacOSArtifacts({
|
||||
await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({
|
||||
arch: target.arch,
|
||||
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
|
||||
artifactsRoot: buildPaths.artifacts,
|
||||
environment: electronBuilderEnv,
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
|
||||
} else if (target.platform === 'darwin') {
|
||||
await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv)
|
||||
const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
|
||||
await withMacOSNotarizationProxy(mac?.notarizationProxy,
|
||||
() => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent)
|
||||
} else {
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
}
|
||||
if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
|
||||
if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
|
||||
if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: buildPaths.artifacts })
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
|
||||
@@ -26,9 +26,10 @@ export function packagingOutputRedactor(secrets: readonly string[], emit: (text:
|
||||
* Allocate retained evidence and supervise sequential child-process stages.
|
||||
* @param root Parent directory for retained records.
|
||||
* @param metadata Public target/version metadata only.
|
||||
* @param settings Opt-in parallel stages and additional credentials to redact.
|
||||
* @returns A run that blocks later stages after failure and awaits owned process termination.
|
||||
*/
|
||||
export function createPackagingRun(root: string, metadata: object): {
|
||||
export function createPackagingRun(root: string, metadata: object, settings?: { parallel?: boolean; secrets?: readonly string[] }): {
|
||||
directory: string
|
||||
run(stage: string, executable: string, args: readonly string[], options: {
|
||||
cwd: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Persist redacted packaging evidence and terminate the owned stage tree on fatal signing failures. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
|
||||
@@ -59,22 +60,26 @@ export function packagingOutputRedactor(secrets, emit) {
|
||||
* Allocate a run whose failures never become release completion records.
|
||||
* @param {string} root Parent for retained packaging records.
|
||||
* @param {object} metadata Public target/version metadata only.
|
||||
* @param {{parallel?: boolean, secrets?: readonly string[]}} settings Parallel stages are opt-in; secrets include credentials removed from child environments.
|
||||
* @returns {{directory: string, run: (stage: string, executable: string, args: readonly string[], options: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}) => Promise<void>, finish: (success: boolean) => void}} Owned run supervisor; an optional stage deadline records timeout independently of exit status and awaits termination.
|
||||
*/
|
||||
export function createPackagingRun(root, metadata) {
|
||||
export function createPackagingRun(root, metadata, settings = {}) {
|
||||
const started = performance.now()
|
||||
mkdirSync(root, { recursive: true })
|
||||
const directory = realpathSync(mkdtempSync(join(resolve(root), `${new Date().toISOString().replaceAll(':', '-')}-`)))
|
||||
for (const name of ['events.jsonl', 'stdout.log', 'stderr.log']) {
|
||||
writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', flush: true })
|
||||
writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', mode: 0o600, flush: true })
|
||||
let failed = false
|
||||
let active = false
|
||||
let active = 0
|
||||
const fatal = join(directory, 'fatal.json')
|
||||
async function run(stage, executable, args, options) {
|
||||
if (failed || existsSync(fatal)) throw new Error(`desktop package: run is blocked; see ${directory}`)
|
||||
if (active) throw new Error('desktop package: supervised stages must run sequentially')
|
||||
active = true
|
||||
if (active && !settings.parallel) throw new Error('desktop package: supervised stages must run sequentially')
|
||||
active++
|
||||
const stageId = randomUUID()
|
||||
const started = performance.now()
|
||||
let child
|
||||
let fatalObserved = false
|
||||
let launchError = false
|
||||
@@ -112,22 +117,23 @@ export function createPackagingRun(root, metadata) {
|
||||
process.once('SIGINT', interrupted)
|
||||
process.once('SIGTERM', interrupted)
|
||||
try {
|
||||
recordPackagingEvent(directory, { type: 'stage-start', stage })
|
||||
recordPackagingEvent(directory, { type: 'stage-start', stage, stageId })
|
||||
child = spawn(executable, [...args], { cwd: options.cwd, env: { ...options.env, DSH_DESKTOP_PACKAGING_RUN_DIR: directory },
|
||||
windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
closed = new Promise(resolveClose => {
|
||||
child.once('error', () => { launchError = true })
|
||||
child.once('close', (code, signal) => { stageClosed = true; resolveClose({ code, signal }) })
|
||||
})
|
||||
recordPackagingEvent(directory, { type: 'stage-spawn', stage, childPid: child.pid })
|
||||
recordPackagingEvent(directory, { type: 'stage-spawn', stage, stageId, childPid: child.pid })
|
||||
if (options.timeoutMs !== undefined) deadline = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs)
|
||||
const secrets = Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? '')
|
||||
const secrets = [...(settings.secrets ?? []), ...Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')]
|
||||
const streams = [['stdout', child.stdout, process.stdout], ['stderr', child.stderr, process.stderr]]
|
||||
for (const [name, stream, consoleStream] of streams) {
|
||||
let notification = ''
|
||||
const redactor = packagingOutputRedactor(secrets, text => {
|
||||
try {
|
||||
appendFileSync(join(directory, `${name}.log`), text, { flush: true })
|
||||
if (settings.parallel) recordPackagingEvent(directory, { type: 'output', stage, stageId, stream: name, text })
|
||||
consoleStream.write(text)
|
||||
} catch { outputError = true; stop() }
|
||||
notification += text
|
||||
@@ -142,7 +148,7 @@ export function createPackagingRun(root, metadata) {
|
||||
const result = await closed
|
||||
await termination
|
||||
fatalObserved ||= existsSync(fatal)
|
||||
recordPackagingEvent(directory, { type: 'stage-end', stage, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
|
||||
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
|
||||
if (result.code !== 0 || result.signal !== null || fatalObserved || launchError || outputError || terminationError) {
|
||||
failed = true
|
||||
throw new Error(`desktop package: ${stage} failed; evidence: ${directory}`)
|
||||
@@ -157,7 +163,7 @@ export function createPackagingRun(root, metadata) {
|
||||
clearTimeout(deadline)
|
||||
process.removeListener('SIGINT', interrupted)
|
||||
process.removeListener('SIGTERM', interrupted)
|
||||
active = false
|
||||
active--
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -165,7 +171,10 @@ export function createPackagingRun(root, metadata) {
|
||||
run,
|
||||
finish(success) {
|
||||
if (active) throw new Error('desktop package: cannot finish an active run')
|
||||
writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), success: success && !failed && !existsSync(fatal) })}\n`, { flag: 'wx', flush: true })
|
||||
const events = readFileSync(join(directory, 'events.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
|
||||
const stages = events.filter(event => event.type === 'stage-end')
|
||||
const proxy = events.filter(event => event.type === 'notarization-proxy').at(-1)?.status ?? 'not-used'
|
||||
writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), elapsedMs: performance.now() - started, success: success && !failed && !existsSync(fatal), proxy, artifacts: events.filter(event => event.type === 'artifacts').at(-1)?.directory, stages })}\n`, { flag: 'wx', mode: 0o600, flush: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Persist in-process and Apple-tool phases in the packaging journal. */
|
||||
/**
|
||||
* Record a phase outcome and redacted nested failure details.
|
||||
* @param directory Existing run directory; undefined retains unrecorded callers.
|
||||
* @param stage Public phase label, never credentials.
|
||||
* @param action Work that settles all owned activity before returning.
|
||||
* @param secrets Credential values removed from errors.
|
||||
* @returns Action result; logging failures also reject the phase.
|
||||
*/
|
||||
export function packagingStep<T>(directory: string | undefined, stage: string, action: () => Promise<T>, secrets?: readonly string[]): Promise<T>
|
||||
|
||||
/**
|
||||
* Format nested errors with credential values removed for journal and terminal output.
|
||||
* @param error Failure including optional causes or aggregate errors.
|
||||
* @param secrets Credential values removed from output.
|
||||
* @returns Redacted diagnostic including nested recovery instructions.
|
||||
*/
|
||||
export function packagingErrorDetails(error: unknown, secrets: readonly string[]): string
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Record in-process and Apple-tool phases in the same journal as packaging subprocesses. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs'
|
||||
|
||||
/**
|
||||
* Persist a phase outcome, including nested failure details, without logging arguments or environments.
|
||||
* @param {string | undefined} directory Existing run directory; undefined retains unrecorded callers.
|
||||
* @param {string} stage Public phase label, never credentials.
|
||||
* @param {() => Promise<unknown>} action Work that settles all owned activity before returning.
|
||||
* @param {readonly string[]} secrets Credential values removed from errors.
|
||||
* @returns {Promise<unknown>} Action result; logging failures also reject the phase.
|
||||
*/
|
||||
export async function packagingStep(directory, stage, action, secrets = Object.entries(process.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')) {
|
||||
if (directory === undefined) return action()
|
||||
const stageId = randomUUID()
|
||||
const started = performance.now()
|
||||
recordPackagingEvent(directory, { type: 'stage-start', stage, stageId })
|
||||
try {
|
||||
const result = await action()
|
||||
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, success: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = packagingErrorDetails(error, secrets)
|
||||
recordPackagingEvent(directory, { type: 'stage-end', stage, stageId, elapsedMs: performance.now() - started, success: false, error: message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format nested errors with credential values removed for journal and terminal output.
|
||||
* @param {unknown} error Failure including optional causes or aggregate errors.
|
||||
* @param {readonly string[]} secrets Credential values removed from output.
|
||||
* @returns {string} Redacted diagnostic including nested recovery instructions.
|
||||
*/
|
||||
export function packagingErrorDetails(error, secrets) {
|
||||
const seen = new Set()
|
||||
function details(value) {
|
||||
if (!(value instanceof Error)) return String(value)
|
||||
if (seen.has(value)) return '[circular error]'
|
||||
seen.add(value)
|
||||
return [value.stack ?? value.message, ...(value instanceof AggregateError ? value.errors.map(details) : []),
|
||||
...(value.cause === undefined ? [] : [details(value.cause)])].join('\n')
|
||||
}
|
||||
let message = ''
|
||||
const redactor = packagingOutputRedactor(secrets, text => { message += text })
|
||||
redactor.write(Buffer.from(details(error)))
|
||||
redactor.end()
|
||||
return message
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Materialize the complete production runtime before publishing Desktop resources. */
|
||||
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -107,20 +108,24 @@ function runPnpm(args: readonly string[]): Promise<void> {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
|
||||
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
|
||||
mkdirSync(STORE_ROOT, { recursive: true })
|
||||
try {
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => {
|
||||
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
|
||||
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
|
||||
mkdirSync(STORE_ROOT, { recursive: true })
|
||||
})
|
||||
const release = desktopRelease()
|
||||
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
|
||||
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
|
||||
createRuntimeProjectMetadata(BUILD_ROOT, release)
|
||||
await runPnpm(['install', '--lockfile-only'])
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
|
||||
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
|
||||
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
|
||||
createRuntimeProjectMetadata(BUILD_ROOT, release)
|
||||
})
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
|
||||
verifyDesktopCoreLockfile(
|
||||
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
|
||||
readDesktopCorePackageSet(BUILD_ROOT, release.version),
|
||||
)
|
||||
await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
|
||||
const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
|
||||
const targetName = resolveDesktopBuildTarget()
|
||||
const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
|
||||
@@ -128,9 +133,11 @@ async function main(): Promise<void> {
|
||||
const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8'))
|
||||
const officeEngine = selectOfficeEngine(officeManifest, target)
|
||||
mkdirSync(DSH_OUTPUT_ROOT, { recursive: true })
|
||||
cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
|
||||
recursive: true, dereference: true,
|
||||
filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined,
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:materialize-modules', async () => {
|
||||
cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
|
||||
recursive: true, dereference: true,
|
||||
filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined,
|
||||
})
|
||||
})
|
||||
writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module',
|
||||
@@ -145,23 +152,29 @@ async function main(): Promise<void> {
|
||||
throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
|
||||
await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
|
||||
}
|
||||
await prepareRuntimeManifests(DSH_OUTPUT_ROOT)
|
||||
smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
|
||||
writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
|
||||
const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target))
|
||||
const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
|
||||
if (!process.argv.includes('--defer-runtime-smoke')) {
|
||||
await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)
|
||||
await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-after-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
|
||||
}
|
||||
} catch (error) {
|
||||
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
|
||||
throw error
|
||||
} finally {
|
||||
rmSync(BUILD_ROOT, { recursive: true, force: true })
|
||||
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
|
||||
let cleaned = false
|
||||
const cleanup = (): void => {
|
||||
try { rmSync(BUILD_ROOT, { recursive: true, force: true }) }
|
||||
finally { rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) }
|
||||
cleaned = true
|
||||
}
|
||||
try { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:cleanup', async () => cleanup()) }
|
||||
finally { if (!cleaned) cleanup() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Prepare the target Electron distribution and pinned pnpm CLI. */
|
||||
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
@@ -32,9 +33,10 @@ async function main(): Promise<void> {
|
||||
const arch = target.endsWith('arm64') ? 'arm64' : 'x64'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { version } = require('electron/package.json') as { version: string }
|
||||
const archive = await downloadArtifact({ version, platform, arch, artifactName: 'electron', cacheRoot: BUILD_PATHS.downloads })
|
||||
const archive = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'download:electron',
|
||||
() => downloadArtifact({ version, platform, arch, artifactName: 'electron', cacheRoot: BUILD_PATHS.downloads }))
|
||||
rmSync(BUILD_PATHS.electron, { recursive: true, force: true })
|
||||
await extractZip(archive, { dir: BUILD_PATHS.electron })
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'extract:electron', () => extractZip(archive, { dir: BUILD_PATHS.electron }))
|
||||
const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron')
|
||||
const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], {
|
||||
encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
@@ -49,7 +51,8 @@ async function main(): Promise<void> {
|
||||
node: nodeVersion,
|
||||
pnpm: pnpmVersion,
|
||||
}, undefined, 2)}\n`)
|
||||
await preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -44,7 +44,9 @@ try {
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64',
|
||||
DSH_DESKTOP_UNSIGNED: '1', CSC_IDENTITY_AUTO_DISCOVERY: 'false', ELECTRON_BUILDER_7Z_FILTER: 'BCJ',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: signingEnvironment.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN
|
||||
?? 'https://harness-test.deepseek.com',
|
||||
?? 'https://test.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: signingEnvironment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
|
||||
?? JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
...signingRun ? { DSH_DESKTOP_PACKAGING_RUN_DIR: signingRun.directory } : {},
|
||||
})
|
||||
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
|
||||
|
||||
@@ -735,7 +735,8 @@ async function main(): Promise<void> {
|
||||
const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged)
|
||||
if (policyConfig !== undefined) {
|
||||
if (policyConfig.authentication === 'feishu-test') {
|
||||
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, locale, () => mandatoryUI?.confirmationWindow ?? mainWindow,
|
||||
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, policyConfig.allowedAuthOrigins, locale,
|
||||
() => mandatoryUI?.confirmationWindow ?? mainWindow,
|
||||
(event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
|
||||
}
|
||||
const bundleId = app.isPackaged
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DesktopPolicyIdentity {
|
||||
export interface DesktopPolicyConfig {
|
||||
readonly origin: string
|
||||
readonly allowedPageOrigins: readonly string[]
|
||||
readonly allowedAuthOrigins: readonly string[]
|
||||
readonly intervalMs: number
|
||||
readonly timeoutMs: number
|
||||
readonly maxBackoffMs: number
|
||||
@@ -74,12 +75,20 @@ export function resolveDesktopPolicyConfig(input: unknown, allowLoopback = false
|
||||
if (authentication !== 'anonymous' && authentication !== 'feishu-test') {
|
||||
throw new Error('desktop policy: authentication must be anonymous or feishu-test')
|
||||
}
|
||||
const authOrigins = value.allowedAuthOrigins
|
||||
if (authentication === 'feishu-test' && (!Array.isArray(authOrigins) || authOrigins.length === 0)) {
|
||||
throw new Error('desktop policy: test authentication requires nonempty allowedAuthOrigins')
|
||||
}
|
||||
if (authentication === 'anonymous' && authOrigins !== undefined) {
|
||||
throw new Error('desktop policy: anonymous policy must not configure allowedAuthOrigins')
|
||||
}
|
||||
if (typeof jitter !== 'number' || !Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) {
|
||||
throw new Error('desktop policy: jitter must be in [0, 1] and maxBackoffMs must cover intervalMs')
|
||||
}
|
||||
return {
|
||||
origin: origin(value.origin, authentication === 'anonymous' && allowLoopback),
|
||||
allowedPageOrigins: value.allowedPageOrigins.map(item => origin(item, false)),
|
||||
allowedAuthOrigins: authentication === 'feishu-test' ? (authOrigins as unknown[]).map(item => origin(item, false)) : [],
|
||||
intervalMs, timeoutMs: duration('timeoutMs', 15_000), maxBackoffMs, jitter, authentication,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import { randomUUID } from 'node:crypto'
|
||||
import { BrowserWindow, session } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
const FEISHU_ORIGINS = ['https://open.feishu.cn', 'https://accounts.feishu.cn',
|
||||
'https://passport.feishu.cn', 'https://login.feishu.cn']
|
||||
|
||||
/** Packaged placeholder document; it is the window's first document and needs no network. */
|
||||
const LOGIN_LOADING_PAGE = 'renderer/policy-login-loading.html'
|
||||
|
||||
@@ -28,11 +25,13 @@ export class DesktopPolicyTestAuth {
|
||||
|
||||
/**
|
||||
* @param origin Validated HTTPS policy origin; login always starts at its root with fresh gateway state.
|
||||
* @param allowedAuthOrigins Validated HTTPS origins for login document navigation.
|
||||
* @param locale Shell-owned login title.
|
||||
* @param parent Current application or mandatory-update window.
|
||||
* @param record Fixed, nonsecret login outcomes for diagnostic evidence.
|
||||
*/
|
||||
constructor(private readonly origin: string, private readonly locale: DesktopLocale,
|
||||
constructor(private readonly origin: string, private readonly allowedAuthOrigins: readonly string[],
|
||||
private readonly locale: DesktopLocale,
|
||||
private readonly parent: () => BrowserWindow | undefined,
|
||||
private readonly record: (event: 'opened' | DesktopPolicyLoginResult) => void) {
|
||||
this.browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
|
||||
@@ -144,7 +143,7 @@ export class DesktopPolicyTestAuth {
|
||||
let url: URL
|
||||
try { url = new URL(value) } catch { return false }
|
||||
return url.protocol === 'https:' && url.username === '' && url.password === ''
|
||||
&& (url.origin === this.origin || FEISHU_ORIGINS.includes(url.origin))
|
||||
&& (url.origin === this.origin || this.allowedAuthOrigins.includes(url.origin))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,8 @@ import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from
|
||||
|
||||
const WINDOWS = { platform: 'win32', arch: 'x64' } as const
|
||||
const MACOS = { platform: 'darwin', arch: 'arm64' } as const
|
||||
const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com' }
|
||||
const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) }
|
||||
const RELEASE = { ...POLICY, 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' }
|
||||
|
||||
@@ -134,3 +135,14 @@ describe('Desktop local packaging configuration', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('owns macOS tuning in the local file and validates it before signing credentials', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\nDSH_DESKTOP_MACOS_DOWNLOAD_PROXY=http://proxy.example:8080\nDSH_DESKTOP_MACOS_NOTARIZATION_PROXY=\n')
|
||||
const env = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '99' }, directory)
|
||||
expect(env.DSH_DESKTOP_MACOS_PACK_CONCURRENCY).toBe('2')
|
||||
expect(env.DSH_DESKTOP_MACOS_NOTARIZATION_PROXY).toBe('')
|
||||
expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '' }, MACOS) }).toThrow('PACK_CONCURRENCY')
|
||||
expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'socks5://localhost:8080' }, MACOS) }).toThrow('NOTARIZATION_PROXY')
|
||||
})
|
||||
})
|
||||
@@ -3,28 +3,32 @@ import { resolveDesktopPolicyEnvironment } from '../scripts/desktop-policy-envir
|
||||
import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts'
|
||||
|
||||
const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://harness.deepseek.com' }
|
||||
const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://prod.example.com' }
|
||||
const auth = { DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) }
|
||||
|
||||
it.each(['test', 'production'] as const)('selects the %s policy and authentication together', (deployment) => {
|
||||
const policy = resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: deployment })
|
||||
const policy = resolveDesktopPolicyEnvironment({ ...origins, ...(deployment === 'test' ? auth : {}), DSH_DESKTOP_AUTO_UPDATE_ENV: deployment })
|
||||
const origin = deployment === 'test' ? origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN : origins.DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN
|
||||
expect(policy).toEqual({ origin, allowedPageOrigins: [origin], authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' })
|
||||
expect(policy).toEqual({ origin, allowedPageOrigins: [origin],
|
||||
...(deployment === 'test' ? { allowedAuthOrigins: ['https://login.example.com'] } : {}),
|
||||
authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' })
|
||||
expect(resolveDesktopPolicyConfig(policy)).toMatchObject(policy)
|
||||
})
|
||||
|
||||
it('requires only the selected origin, defaults to test, and accepts explicit page restrictions', () => {
|
||||
const policy = resolveDesktopPolicyEnvironment({
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN,
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedPageOrigins: ['https://download.deepseek.com'], intervalMs: 5000 }) })
|
||||
expect(policy).toMatchObject({ authentication: 'feishu-test', intervalMs: 5000, allowedPageOrigins: ['https://download.deepseek.com'] })
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://download.example.com'], intervalMs: 5000 }) })
|
||||
expect(policy).toMatchObject({ authentication: 'feishu-test', intervalMs: 5000, allowedPageOrigins: ['https://download.example.com'] })
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' })).toThrow('production')
|
||||
})
|
||||
|
||||
it.each([undefined, '', 'http://harness-test.deepseek.com', 'https://user:secret@harness-test.deepseek.com',
|
||||
'https://harness-test.deepseek.com/api', 'https://harness-test.deepseek.com/?secret=value'])('rejects invalid selected origin %s', (origin) => {
|
||||
expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).toThrow('HTTPS origin')
|
||||
expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).not.toThrow('secret=value')
|
||||
it.each([undefined, '', 'http://test.example.com', 'https://user:secret@test.example.com',
|
||||
'https://test.example.com/api', 'https://test.example.com/?secret=value'])('rejects invalid selected origin %s', (origin) => {
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...auth, DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).toThrow('HTTPS origin')
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...auth, DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).not.toThrow('secret=value')
|
||||
})
|
||||
|
||||
it.each(['{', 'null', '[]', '{"origin":"https://old.example.com"}', '{"authentication":"anonymous"}',
|
||||
@@ -32,6 +36,17 @@ it.each(['{', 'null', '[]', '{"origin":"https://old.example.com"}', '{"authentic
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: options })).toThrow()
|
||||
})
|
||||
|
||||
it.each([undefined, '[]', '["http://login.example.com"]', '["https://login.example.com/path"]',
|
||||
'["https://user:secret@login.example.com"]'])('rejects missing or invalid test login origins %s', (value) => {
|
||||
const settings = value === undefined ? {} : { allowedAuthOrigins: JSON.parse(value) as unknown }
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(settings) })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects login origins in production', () => {
|
||||
expect(() => resolveDesktopPolicyEnvironment({ ...origins, ...auth, DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' }))
|
||||
.toThrow('must not configure allowedAuthOrigins')
|
||||
})
|
||||
|
||||
it.each([{ unsigned: true }, { prepareOnly: true }, {}])('fails before signing/preparation when policy is absent in %j', (options) => {
|
||||
for (const platform of ['win32', 'darwin'] as const) {
|
||||
expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'com.example.test' }, { platform, arch: 'x64' }, options) })
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Exercise one independent signature-cache writer through native TypeScript stripping. */
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { cachedMacOSSignature } from '../../scripts/macos-signature-cache.ts'
|
||||
const [file, cache] = process.argv.slice(2)
|
||||
await cachedMacOSSignature(file, cache, {
|
||||
policy: 'cross-process-fixture',
|
||||
sign: async path => {
|
||||
const ready = new Promise(resolve => process.once('message', resolve))
|
||||
process.send('ready')
|
||||
await ready
|
||||
writeFileSync(path, `${readFileSync(path, 'utf8')}:signed`)
|
||||
},
|
||||
verify: path => { if (readFileSync(path, 'utf8') !== 'original:signed') throw Error('invalid signature') },
|
||||
})
|
||||
process.disconnect()
|
||||
@@ -20,6 +20,7 @@ vi.mock('../scripts/windows-sign.mjs', () => ({
|
||||
const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const
|
||||
const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' }
|
||||
const require = createRequire(import.meta.url)
|
||||
const { validateConfiguration } = require('app-builder-lib/out/util/config/config.js') as {
|
||||
|
||||
@@ -61,7 +61,7 @@ async function fixture(body: (context: {
|
||||
await writeFile(join(source, 'package.json'), JSON.stringify({ name: `dsh-update-test-${run.id}`, version,
|
||||
dshDesktopAppId: run.appId, main: 'qualification-bootstrap.mjs', type: 'module',
|
||||
dshMandatoryUpdatePolicy: { origin: 'https://policy.example.com', allowedPageOrigins: ['https://policy.example.com'],
|
||||
authentication: 'feishu-test' } }))
|
||||
authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'] } }))
|
||||
for (const name of ['electron-updater', 'semver']) {
|
||||
await mkdir(join(source, 'node_modules', name), { recursive: true })
|
||||
await cp(require.resolve(`${name}/package.json`), join(source, 'node_modules', name, 'package.json'))
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
|
||||
expect(() => createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: 'com.example.installer',
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com',
|
||||
}, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN')
|
||||
})
|
||||
it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => {
|
||||
@@ -24,6 +24,7 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
const env = {
|
||||
DSH_DESKTOP_APP_ID: 'com.example.installer',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: platform,
|
||||
DSH_DESKTOP_TARGET_ARCH: 'x64',
|
||||
DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs'
|
||||
|
||||
const command = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>())
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const { promisify } = await import('node:util')
|
||||
return {
|
||||
...await importOriginal<typeof import('node:child_process')>(),
|
||||
execFile: Object.assign(() => {}, { [promisify.custom]: command }),
|
||||
}
|
||||
})
|
||||
|
||||
const id = '11111111-2222-3333-4444-555555555555'
|
||||
const args = ['submit', '/fixture/app.zip', '--keychain-profile', 'fixture', '--wait', '--output-format', 'json']
|
||||
|
||||
it('passes Apple rejection JSON from a nonzero wait exit back to electron-notarize for diagnostic retrieval', async () => {
|
||||
command.mockReset()
|
||||
command.mockResolvedValueOnce({ stdout: JSON.stringify({ id }), stderr: '' })
|
||||
command.mockRejectedValueOnce(Object.assign(new Error('notarytool exited 65'), {
|
||||
code: 65, stdout: JSON.stringify({ id, status: 'Invalid' }), stderr: '',
|
||||
}))
|
||||
expect(JSON.parse(await runLoggedNotarytool(args))).toEqual({ id, status: 'Invalid' })
|
||||
expect(command).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not treat failed upload output as a completed submission', async () => {
|
||||
command.mockReset()
|
||||
command.mockRejectedValueOnce(Object.assign(new Error('upload failed'), {
|
||||
code: 1, stdout: JSON.stringify({ id }), stderr: '',
|
||||
}))
|
||||
await expect(runLoggedNotarytool(args)).rejects.toThrow('upload failed')
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs'
|
||||
import { createPackagingRun } from '../scripts/packaging-run.mjs'
|
||||
|
||||
const id = '11111111-2222-3333-4444-555555555555'
|
||||
const args = ['submit', '/fixture/app.zip', '--apple-id', 'private@example.test', '--password', 'secret-value', '--team-id', 'TEAM', '--wait', '--output-format', 'json']
|
||||
async function fixture(action: (directory: string) => Promise<void>) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'notary-log-'))
|
||||
try { await action(createPackagingRun(root, {}).directory) } finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
it('finishes submission before starting wait and records the ID and separate timings', async () => {
|
||||
await fixture(async (directory) => {
|
||||
const uploaded = Promise.withResolvers<string>()
|
||||
const execute = vi.fn(async (command: readonly string[]) => command[0] === 'submit' ? uploaded.promise : JSON.stringify({ id, status: 'Accepted' }))
|
||||
const work = runLoggedNotarytool(args, directory, execute)
|
||||
try {
|
||||
expect(execute).toHaveBeenCalledExactlyOnceWith(args.filter(arg => arg !== '--wait').concat('--no-wait'))
|
||||
expect(await readFile(join(directory, 'events.jsonl'), 'utf8')).not.toContain('notarytool:wait')
|
||||
} finally { uploaded.resolve(JSON.stringify({ id })) }
|
||||
expect(JSON.parse(await work)).toEqual({ id, status: 'Accepted' })
|
||||
expect(execute.mock.calls[1]![0]).toEqual(['wait', id, '--apple-id', 'private@example.test', '--password', 'secret-value', '--team-id', 'TEAM', '--output-format', 'json'])
|
||||
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
|
||||
expect(log).toContain('notary-submission')
|
||||
expect(log).toContain('notarytool:upload:app.zip')
|
||||
expect(log).toContain('notarytool:wait:app.zip')
|
||||
expect(log).toContain('elapsedMs')
|
||||
expect(log).not.toContain('private@example.test')
|
||||
expect(log).not.toContain('secret-value')
|
||||
})
|
||||
})
|
||||
|
||||
it('records a redacted upload error without starting wait', async () => {
|
||||
await fixture(async (directory) => {
|
||||
const execute = vi.fn(async () => { throw new Error('rejected secret-value private@example.test') })
|
||||
await expect(runLoggedNotarytool(args, directory, execute)).rejects.toThrow('rejected')
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
|
||||
expect(log).toContain('"success":false')
|
||||
expect(log).not.toContain('secret-value')
|
||||
expect(log).not.toContain('private@example.test')
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['Invalid', 'Rejected'])('preserves %s for electron-notarize rejection handling', async (status) => {
|
||||
const execute = vi.fn(async (command: readonly string[]) => JSON.stringify(command[0] === 'submit' ? { id } : { id, status }))
|
||||
await fixture(async (directory) => {
|
||||
expect(JSON.parse(await runLoggedNotarytool(args, directory, execute))).toEqual({ id, status })
|
||||
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
|
||||
expect(log).toContain('\"success\":false')
|
||||
expect(log).toContain(status)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing submission IDs and mismatched wait results', async () => {
|
||||
await expect(runLoggedNotarytool(args, undefined, async () => '{}')).rejects.toThrow('submission ID')
|
||||
await expect(runLoggedNotarytool(args, undefined, async command => JSON.stringify(command[0] === 'submit' ? { id } : { id: 'other', status: 'Accepted' }))).rejects.toThrow('different submission ID')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['--key', '/private/AuthKey.p8', '--key-id', 'KEY123', '--issuer', id],
|
||||
['--keychain-profile', 'release', '--keychain', '/private/release.keychain'],
|
||||
])('preserves API-key and keychain authentication when waiting: %j', async (...auth) => {
|
||||
const execute = vi.fn(async () => JSON.stringify({ id, status: 'Accepted' }))
|
||||
await runLoggedNotarytool(['submit', '/fixture/release.dmg', ...auth, '--wait', '--output-format', 'json'], undefined, execute)
|
||||
expect(execute.mock.calls[1]).toEqual([['wait', id, ...auth, '--output-format', 'json']])
|
||||
})
|
||||
|
||||
it('retains redacted Apple diagnostics and passes the original result to electron-notarize', async () => {
|
||||
await fixture(async (directory) => {
|
||||
const output = 'diagnostic for private@example.test'
|
||||
const command = ['log', id, '--apple-id', 'private@example.test']
|
||||
expect(await runLoggedNotarytool(command, directory, async () => output)).toBe(output)
|
||||
const log = await readFile(join(directory, 'events.jsonl'), 'utf8')
|
||||
expect(log).toContain('notary-diagnostics')
|
||||
expect(log).toContain('[REDACTED]')
|
||||
expect(log).not.toContain('private@example.test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { macOSCachePolicy } from '../scripts/macos-cache-policy.ts'
|
||||
|
||||
vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal<typeof import('node:child_process')>(), spawnSync: vi.fn() }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof import('node:fs')>()
|
||||
return { ...fs, readFileSync: (...args: Parameters<typeof fs.readFileSync>) => args[0] === '/usr/bin/codesign' ? Buffer.from('codesign-tool') : args[0] === 'candidate' ? Buffer.from(header, 'hex') : fs.readFileSync(...args) }
|
||||
})
|
||||
const identity = { signingIdentity: 'Example (TEAM123456)', teamId: 'TEAM123456' }
|
||||
const expectedDetails = 'Authority=Developer ID Application: Example (TEAM123456)\nTeamIdentifier=TEAM123456\nTimestamp=today\nCodeDirectory v=20500 flags=0x10000(runtime)\nIdentifier=com.example.file\n'
|
||||
let header = 'cffaedfe'
|
||||
let details = expectedDetails
|
||||
let certificate = 'leaf-certificate'
|
||||
let entitlements = ''
|
||||
let rejectVerify = false
|
||||
beforeEach(() => {
|
||||
header = 'cffaedfe'; details = expectedDetails; certificate = 'leaf-certificate'; entitlements = ''; rejectVerify = false
|
||||
vi.mocked(spawnSync).mockImplementation((command, args, options) => {
|
||||
const argv = args as string[]
|
||||
if (command === '/usr/bin/plutil') {
|
||||
const input = (options as { input: string }).input
|
||||
return { pid: 1, output: [], status: 0, signal: null, stdout: JSON.stringify(input.includes('allow-jit') ? { 'com.apple.security.cs.allow-jit': true } : {}), stderr: '' }
|
||||
}
|
||||
const extraction = argv.find(arg => arg.startsWith('--extract-certificates='))
|
||||
if (extraction) writeFileSync(`${extraction.slice('--extract-certificates='.length)}0`, certificate)
|
||||
return { pid: 1, output: [], status: rejectVerify && argv.includes('--verify') ? 1 : 0, signal: null, stdout: entitlements, stderr: details }
|
||||
})
|
||||
})
|
||||
afterEach(() => { vi.resetAllMocks() })
|
||||
it('accepts only a signature matching the resolved policy', () => {
|
||||
const signer = macOSCachePolicy('probe')('com.example.file', identity)
|
||||
expect(() => { signer.verify('candidate') }).not.toThrow()
|
||||
})
|
||||
it.each(['certificate', 'identifier', 'team', 'authority', 'timestamp', 'runtime', 'entitlements', 'strict', 'universal'])('rejects mismatched %s', (field) => {
|
||||
const signer = macOSCachePolicy('probe')('com.example.file', identity)
|
||||
if (field === 'certificate') certificate = 'another-certificate'
|
||||
if (field === 'identifier') details = details.replace('com.example.file', 'com.other.file')
|
||||
if (field === 'team') details = details.replace('TeamIdentifier=TEAM123456', 'TeamIdentifier=OTHER12345')
|
||||
if (field === 'authority') details = details.replace('Authority=Developer', 'Authority=Other')
|
||||
if (field === 'timestamp') details = details.replace('Timestamp=today', '')
|
||||
if (field === 'runtime') details = details.replace('flags=0x10000(runtime)', 'flags=0x0(none)')
|
||||
if (field === 'entitlements') entitlements = '<key>com.apple.security.cs.allow-jit</key>'
|
||||
if (field === 'strict') rejectVerify = true
|
||||
if (field === 'universal') header = 'cafebabe'
|
||||
expect(() => { signer.verify('candidate') }).toThrow()
|
||||
})
|
||||
it('includes certificate, identifier and entitlement contents in the cache policy', () => {
|
||||
const create = macOSCachePolicy('probe')
|
||||
const original = create('com.example.file', identity).policy
|
||||
expect(create('com.other.file', identity).policy).not.toBe(original)
|
||||
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-entitlements.plist')).policy).not.toBe(original)
|
||||
certificate = 'renewed-certificate'
|
||||
expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original)
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it as test, vi } from 'vitest'
|
||||
import { restoreMacOSNotarizationProxy, withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
|
||||
|
||||
// System proxy transactions use POSIX flock, which is unavailable on Windows.
|
||||
const it = test.skipIf(process.platform === 'win32')
|
||||
|
||||
async function fixture(action: (fixture: ReturnType<typeof fakeSystem>, lock: string) => Promise<void>): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mac-proxy-test-'))
|
||||
try { await action(fakeSystem(), join(root, 'lock')) } finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
function fakeSystem() {
|
||||
const states = {
|
||||
http: { enabled: false, server: 'old-http', port: 3128 },
|
||||
https: { enabled: true, server: 'old-https', port: 8080 },
|
||||
}
|
||||
const original = structuredClone(states)
|
||||
const control = { failSet: 0, setCount: 0, pac: false, authenticated: false, serverSpace: true, ignoreDisable: false }
|
||||
const ops = {
|
||||
reachable: vi.fn(async (_url: URL) => {}),
|
||||
ownerAlive: vi.fn((_pid: number) => false),
|
||||
command: vi.fn((executable: string, args: readonly string[]) => {
|
||||
if (executable === '/sbin/route') return ' interface: en9\n'
|
||||
if (args[0] === '-listnetworkserviceorder') return '(1) Ethernet\n(Hardware Port: USB LAN, Device: en9)\n\n(2) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n'
|
||||
expect(args[1]).toBe('Ethernet')
|
||||
if (args[0] === '-getautoproxyurl') return `URL: http://pac\nEnabled: ${control.pac ? 'Yes' : 'No'}\n`
|
||||
if (args[0] === '-getproxyautodiscovery') return 'Auto Proxy Discovery: Off\n'
|
||||
if (args[0] === '-getsocksfirewallproxy') return 'Enabled: No\n'
|
||||
const state = args[0]!.includes('secure') ? states.https : states.http
|
||||
if (args[0]!.startsWith('-get')) return `Enabled: ${state.enabled ? 'Yes' : 'No'}\nServer:${control.serverSpace ? ' ' : ''}${state.server}\nPort: ${state.port}\nAuthenticated Proxy Enabled: ${control.authenticated ? '1' : '0'}\n`
|
||||
control.setCount++
|
||||
if (control.setCount === control.failSet) throw new Error('system write failed')
|
||||
if (args[0]!.endsWith('state')) {
|
||||
if (!(control.ignoreDisable && args[2] === 'off')) state.enabled = args[2] === 'on'
|
||||
}
|
||||
else { state.server = args[2]!; state.port = Number(args[3]); state.enabled = true }
|
||||
return ''
|
||||
}),
|
||||
}
|
||||
return { ops, states, original, control }
|
||||
}
|
||||
|
||||
it('preserves the network when no Apple proxy is configured', async () => {
|
||||
await fixture(async ({ ops }, lock) => {
|
||||
await expect(withMacOSNotarizationProxy(undefined, async () => 42, lock, ops)).resolves.toBe(42)
|
||||
expect(ops.command).not.toHaveBeenCalled()
|
||||
expect(ops.reachable).not.toHaveBeenCalled()
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('restores both original proxies after settled work (failure=%s)', async (fail) => {
|
||||
await fixture(async ({ ops, states, original }, lock) => {
|
||||
const statuses: string[] = []
|
||||
const work = withMacOSNotarizationProxy('http://localhost:8888', async () => {
|
||||
expect(states.http).toEqual({ enabled: true, server: 'localhost', port: 8888 })
|
||||
expect(states.https).toEqual(states.http)
|
||||
const record = JSON.parse(readFileSync(join(lock, 'original.json'), 'utf8')) as { service: string }
|
||||
expect(record.service).toBe('Ethernet')
|
||||
if (fail) throw new Error('Apple refused')
|
||||
return 42
|
||||
}, lock, ops, (status) => { statuses.push(status) })
|
||||
if (fail) await expect(work).rejects.toThrow('Apple refused')
|
||||
else await expect(work).resolves.toBe(42)
|
||||
expect(statuses).toEqual(['restoration-pending', 'enabled', 'restored'])
|
||||
expect(states).toEqual(original)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('disables originally empty proxies without writing empty endpoints (space=%s)', async (space) => {
|
||||
await fixture(async ({ ops, states, control }, lock) => {
|
||||
control.serverSpace = space
|
||||
states.http = { enabled: false, server: '', port: 0 }
|
||||
states.https = { enabled: false, server: '', port: 0 }
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {
|
||||
expect(states.http.enabled).toBe(true)
|
||||
expect(states.https.enabled).toBe(true)
|
||||
throw new Error('Apple refused')
|
||||
}, lock, ops)).rejects.toThrow('Apple refused')
|
||||
expect(states.http).toEqual({ enabled: false, server: 'localhost', port: 8888 })
|
||||
expect(states.https).toEqual(states.http)
|
||||
const setters = ops.command.mock.calls.filter(([, args]) => ['-setwebproxy', '-setsecurewebproxy'].includes(args[0]!))
|
||||
expect(setters).toHaveLength(2)
|
||||
expect(setters.every(([, args]) => args[2] === 'localhost')).toBe(true)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('retains an empty-proxy recovery record after failed disabling and retries it', async () => {
|
||||
await fixture(async ({ ops, states, control }, lock) => {
|
||||
states.http = { enabled: false, server: '', port: 0 }
|
||||
states.https = { enabled: false, server: '', port: 0 }
|
||||
control.failSet = 5
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
|
||||
expect(existsSync(lock)).toBe(true)
|
||||
expect(states.http.enabled).toBe(true)
|
||||
expect(states.https.enabled).toBe(false)
|
||||
await restoreMacOSNotarizationProxy(lock, ops)
|
||||
expect(states.http.enabled).toBe(false)
|
||||
expect(states.https.enabled).toBe(false)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the recovery record when disabling an originally empty proxy has no effect', async () => {
|
||||
await fixture(async ({ ops, states, control }, lock) => {
|
||||
states.http = { enabled: false, server: '', port: 0 }
|
||||
control.ignoreDisable = true
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
|
||||
expect(states.http.enabled).toBe(true)
|
||||
expect(existsSync(lock)).toBe(true)
|
||||
control.ignoreDisable = false
|
||||
await restoreMacOSNotarizationProxy(lock, ops)
|
||||
expect(states.http.enabled).toBe(false)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not steal a live lock and holds the proxy until concurrent work drains', async () => {
|
||||
await fixture(async ({ ops, states, original }, lock) => {
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const finish = Promise.withResolvers<undefined>()
|
||||
const running = withMacOSNotarizationProxy('http://localhost:8888', async () => { entered.resolve(undefined); await finish.promise }, lock, ops)
|
||||
try {
|
||||
await entered.promise
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)).rejects.toThrow('another run')
|
||||
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('another run')
|
||||
expect(ops.ownerAlive).not.toHaveBeenCalled()
|
||||
expect(states.http.port).toBe(8888)
|
||||
expect(existsSync(lock)).toBe(true)
|
||||
} finally { finish.resolve(undefined); await running }
|
||||
expect(states).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes both operations across processes and releases ownership after forced exit', async () => {
|
||||
await fixture(async ({ ops, states, original, control }, lock) => {
|
||||
control.failSet = 5
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
|
||||
const saved = readFileSync(join(lock, 'original.json'), 'utf8')
|
||||
// Coverage builds the addon, but does not emit the package's JavaScript entry.
|
||||
const flockSource = new URL('../../../native/system/packages/entry/src/flock.ts', import.meta.url).href
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', `
|
||||
import { openSync } from 'node:fs';
|
||||
import { tryLockExclusive } from ${JSON.stringify(flockSource)};
|
||||
const fd = openSync(process.argv[1], 'a', 0o600);
|
||||
await tryLockExclusive(fd);
|
||||
process.send('locked');
|
||||
process.stdin.resume();
|
||||
`, `${lock}.flock`], { stdio: ['pipe', 'ignore', 'pipe', 'ipc'], env: { PATH: process.env.PATH } })
|
||||
const exited = once(child, 'exit')
|
||||
const closed = once(child, 'close')
|
||||
let stderr = ''
|
||||
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-8192) })
|
||||
try {
|
||||
await Promise.race([
|
||||
once(child, 'message').then(([message]) => { expect(message).toBe('locked') }),
|
||||
closed.then(([code, signal]) => { throw new Error(`lock holder exited before readiness: ${code}/${signal}\n${stderr}`) }),
|
||||
])
|
||||
ops.command.mockClear()
|
||||
ops.ownerAlive.mockClear()
|
||||
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('another run')
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)).rejects.toThrow('another run')
|
||||
expect(ops.command).not.toHaveBeenCalled()
|
||||
expect(ops.ownerAlive).not.toHaveBeenCalled()
|
||||
expect(readFileSync(join(lock, 'original.json'), 'utf8')).toBe(saved)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
await closed
|
||||
}
|
||||
expect(await exited).toEqual([null, 'SIGKILL'])
|
||||
await restoreMacOSNotarizationProxy(lock, ops)
|
||||
expect(states).toEqual(original)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
expect(existsSync(`${lock}.flock`)).toBe(true)
|
||||
await withMacOSNotarizationProxy('http://localhost:8889', async () => {}, lock, ops)
|
||||
expect(states).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores partial setup and never calls Apple after a system write failure', async () => {
|
||||
await fixture(async ({ ops, states, original, control }, lock) => {
|
||||
control.failSet = 3
|
||||
const action = vi.fn(async () => {})
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', action, lock, ops)).rejects.toThrow('system write failed')
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(states).toEqual(original)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['pac', 'authenticated'] as const)('rejects unpreservable %s settings before mutation', async (kind) => {
|
||||
await fixture(async ({ ops, states, original, control }, lock) => {
|
||||
control[kind] = true
|
||||
const action = vi.fn(async () => {})
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', action, lock, ops)).rejects.toThrow(/proxy|proxies/u)
|
||||
expect(control.setCount).toBe(0)
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(states).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails unreachable proxies before acquiring a lock or changing settings', async () => {
|
||||
await fixture(async ({ ops }, lock) => {
|
||||
ops.reachable.mockRejectedValueOnce(new Error('unreachable'))
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('unreachable')
|
||||
expect(ops.command).not.toHaveBeenCalled()
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('retains recovery data after failed restoration, rejects live owners, then recovers after owner exit', async () => {
|
||||
await fixture(async ({ ops, states, original, control }, lock) => {
|
||||
control.failSet = 5
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
|
||||
expect(existsSync(join(lock, 'original.json'))).toBe(true)
|
||||
ops.ownerAlive.mockReturnValueOnce(true)
|
||||
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('still running')
|
||||
await restoreMacOSNotarizationProxy(lock, ops)
|
||||
expect(states).toEqual(original)
|
||||
expect(existsSync(lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed saved state without invoking host commands', async () => {
|
||||
await fixture(async ({ ops, control }, lock) => {
|
||||
control.failSet = 5
|
||||
await expect(withMacOSNotarizationProxy('http://localhost:8888', async () => {}, lock, ops)).rejects.toThrow('restoration failed')
|
||||
await writeFile(join(lock, 'original.json'), '{"pid":-1}')
|
||||
ops.command.mockClear()
|
||||
await expect(restoreMacOSNotarizationProxy(lock, ops)).rejects.toThrow('invalid proxy recovery record')
|
||||
expect(ops.command).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('defers interruption until work settles and removes its signal listeners after restoration', async () => {
|
||||
await fixture(async ({ ops, states, original }, lock) => {
|
||||
const before = process.listenerCount('SIGTERM')
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const finish = Promise.withResolvers<undefined>()
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
const running = withMacOSNotarizationProxy('http://localhost:8888', async () => { entered.resolve(undefined); await finish.promise }, lock, ops)
|
||||
const result = expect(running).rejects.toThrow('interrupted by SIGTERM')
|
||||
try {
|
||||
await entered.promise
|
||||
// Deliver only the listener owned by this invocation; never signal the test runner or other suites.
|
||||
const listener = process.listeners('SIGTERM').at(-1)!
|
||||
listener('SIGTERM')
|
||||
expect(states.http.port).toBe(8888)
|
||||
expect(existsSync(lock)).toBe(true)
|
||||
} finally { finish.resolve(undefined); await result; stderr.mockRestore() }
|
||||
expect(states).toEqual(original)
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('retains both the Apple failure and restoration failure', async () => {
|
||||
await fixture(async ({ ops, control }, lock) => {
|
||||
control.failSet = 5
|
||||
const appleError = new Error('Apple refused')
|
||||
const result = withMacOSNotarizationProxy('http://localhost:8888', async () => { throw appleError }, lock, ops)
|
||||
await expect(result).rejects.toMatchObject({ errors: [appleError, expect.any(AggregateError)] })
|
||||
expect(existsSync(join(lock, 'original.json'))).toBe(true)
|
||||
await restoreMacOSNotarizationProxy(lock, ops)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from '../scripts/macos-package-settings.mjs'
|
||||
|
||||
it('defaults to four workers and keeps download and Apple proxies independent', () => {
|
||||
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4 })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '2',
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'https://download.example:8080',
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
|
||||
})).toEqual({ packConcurrency: 2, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4 })
|
||||
})
|
||||
|
||||
it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => {
|
||||
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: value })).toThrow('PACK_CONCURRENCY')
|
||||
})
|
||||
|
||||
it.each(['socks5://host:8080', 'http://user:secret@host', 'http://host/path', 'http://host?query', 'http://host#fragment', 'invalid'])
|
||||
('rejects unsupported proxy input without echoing it: %s', (value) => {
|
||||
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: value })).toThrow('NOTARIZATION_PROXY')
|
||||
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: value })).not.toThrow(value)
|
||||
})
|
||||
|
||||
it('overrides conflicting child proxy variables while preserving the parent and unrelated settings', () => {
|
||||
const parent = { PATH: 'tools', HTTPS_PROXY: 'http://old', http_proxy: 'http://lower', ALL_PROXY: 'socks5://old',
|
||||
NO_PROXY: '*', npm_config_proxy: 'http://npm', ELECTRON_GET_USE_PROXY: '0' }
|
||||
expect(macOSDownloadEnvironment(parent)).toEqual(parent)
|
||||
const child = macOSDownloadEnvironment(parent, 'http://new:8080')
|
||||
expect(child).toMatchObject({ PATH: 'tools', HTTP_PROXY: 'http://new:8080', HTTPS_PROXY: 'http://new:8080',
|
||||
http_proxy: 'http://new:8080', https_proxy: 'http://new:8080', ELECTRON_GET_USE_PROXY: '1', NO_PROXY: 'localhost,127.0.0.1,::1' })
|
||||
expect(child).not.toHaveProperty('ALL_PROXY')
|
||||
expect(child).not.toHaveProperty('npm_config_proxy')
|
||||
expect(parent.HTTPS_PROXY).toBe('http://old')
|
||||
expect(parent.NO_PROXY).toBe('*')
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { cachedMacOSSignature, type MacOSCachedSigner } from '../scripts/macos-signature-cache.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
function fixture(): { root: string; file: string; cache: string; signer: MacOSCachedSigner } {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-signature-cache-')))
|
||||
roots.push(root)
|
||||
const file = join(root, 'code')
|
||||
writeFileSync(file, 'original', { mode: 0o755 })
|
||||
return { root, file, cache: join(root, 'cache'), signer: {
|
||||
policy: 'certificate+identifier+entitlements+tools',
|
||||
sign: vi.fn(async (path: string) => { writeFileSync(path, `${readFileSync(path, 'utf8')}:signed`) }),
|
||||
verify: vi.fn((path: string) => { if (!readFileSync(path, 'utf8').endsWith(':signed')) throw Error('invalid signature') }),
|
||||
} }
|
||||
}
|
||||
it('reuses verified bytes without sharing mutable cache storage', async () => {
|
||||
const { file, cache, signer } = fixture()
|
||||
expect(await cachedMacOSSignature(file, cache, signer)).toBe(false)
|
||||
writeFileSync(file, 'original')
|
||||
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
|
||||
expect(signer.sign).toHaveBeenCalledTimes(1)
|
||||
expect(signer.verify).toHaveBeenCalledTimes(2)
|
||||
expect(readFileSync(file, 'utf8')).toBe('original:signed')
|
||||
if (process.platform !== 'win32') expect(lstatSync(file).mode & 0o777).toBe(0o755)
|
||||
writeFileSync(file, 'later mutation')
|
||||
writeFileSync(file, 'original')
|
||||
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
|
||||
})
|
||||
it.each(['input', 'policy', 'mode'])('invalidates changed %s without consulting git', async (change) => {
|
||||
const { file, cache, signer } = fixture()
|
||||
await cachedMacOSSignature(file, cache, signer)
|
||||
writeFileSync(file, change === 'input' ? 'uncommitted' : 'original')
|
||||
if (change === 'mode') {
|
||||
if (process.platform === 'win32') return
|
||||
chmodSync(file, 0o644)
|
||||
}
|
||||
expect(await cachedMacOSSignature(file, cache, change === 'policy' ? { ...signer, policy: 'new-policy' } : signer)).toBe(false)
|
||||
expect(signer.sign).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it.each(['payload', 'record', 'signature'])('rejects corrupted %s without replacing the input', async (damage) => {
|
||||
const { file, cache, signer } = fixture()
|
||||
await cachedMacOSSignature(file, cache, signer)
|
||||
writeFileSync(file, 'original')
|
||||
const entry = join(cache, readdirSync(cache)[0]!)
|
||||
if (damage === 'payload') writeFileSync(join(entry, 'code'), 'truncated')
|
||||
if (damage === 'record') writeFileSync(join(entry, 'record.json'), '{"key":"wrong"}')
|
||||
if (damage === 'signature') signer.verify = () => { throw Error('invalid signature') }
|
||||
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow()
|
||||
expect(readFileSync(file, 'utf8')).toBe('original')
|
||||
})
|
||||
it('never publishes or replaces input after signing fails', async () => {
|
||||
const { file, cache, signer } = fixture()
|
||||
signer.sign = async () => { throw Error('interrupted signing') }
|
||||
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('interrupted')
|
||||
expect(readFileSync(file, 'utf8')).toBe('original')
|
||||
expect(readdirSync(cache)).toEqual([])
|
||||
})
|
||||
it('rejects a concurrently changed target', async () => {
|
||||
const { file, cache, signer } = fixture()
|
||||
const sign = signer.sign
|
||||
signer.sign = async (candidate) => { await sign(candidate); writeFileSync(file, 'concurrent edit') }
|
||||
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('input changed')
|
||||
expect(readFileSync(file, 'utf8')).toBe('concurrent edit')
|
||||
expect(readdirSync(cache)).toEqual([])
|
||||
})
|
||||
it.skipIf(process.platform === 'win32')('allows competing writers to publish only complete entries', async () => {
|
||||
const { root, file, cache, signer } = fixture()
|
||||
const second = join(root, 'other')
|
||||
writeFileSync(second, 'original', { mode: 0o755 })
|
||||
let ready!: () => void
|
||||
const both = new Promise<void>((resolve) => { ready = resolve })
|
||||
let count = 0
|
||||
const sign = signer.sign
|
||||
signer.sign = async (path) => { if (++count === 2) ready(); await both; await sign(path) }
|
||||
await Promise.all([cachedMacOSSignature(file, cache, signer), cachedMacOSSignature(second, cache, signer)])
|
||||
expect(readdirSync(cache)).toHaveLength(1)
|
||||
expect(readFileSync(file, 'utf8')).toBe('original:signed')
|
||||
expect(readFileSync(second, 'utf8')).toBe('original:signed')
|
||||
writeFileSync(file, 'original')
|
||||
expect(await cachedMacOSSignature(file, cache, signer)).toBe(true)
|
||||
})
|
||||
it.skipIf(process.platform === 'win32')('rejects links in the target and cache ancestry', async () => {
|
||||
const { root, file, cache, signer } = fixture()
|
||||
const link = join(root, 'link')
|
||||
symlinkSync(file, link)
|
||||
await expect(cachedMacOSSignature(link, cache, signer)).rejects.toThrow('regular file')
|
||||
const real = join(root, 'real')
|
||||
mkdirSync(real)
|
||||
symlinkSync(real, cache)
|
||||
await expect(cachedMacOSSignature(file, join(cache, 'nested'), signer)).rejects.toThrow('directory')
|
||||
expect(existsSync(join(real, 'nested'))).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('publishes reusable entries across independent processes', async () => {
|
||||
const { root, file, cache } = fixture()
|
||||
const second = join(root, 'second')
|
||||
writeFileSync(second, 'original', { mode: 0o755 })
|
||||
// Windows directory replacement uses different errors; this cache is owned by macOS packaging.
|
||||
const children: ReturnType<typeof fork>[] = []
|
||||
const exits: Promise<[number | null, NodeJS.Signals | null]>[] = []
|
||||
try {
|
||||
for (const path of [file, second]) {
|
||||
const child = fork(join(import.meta.dirname, 'fixtures/macos-cache-writer.mjs'), [path, cache], {
|
||||
execArgv: [], stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
children.push(child)
|
||||
exits.push(new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => { resolve([code, signal]) })
|
||||
}))
|
||||
}
|
||||
await Promise.all(children.map((child, index) => Promise.race([once(child, 'message'), exits[index]!.then(() => { throw Error('cache writer exited before ready') })])))
|
||||
for (const child of children) child.send('continue')
|
||||
for (const [code, signal] of await Promise.all(exits)) {
|
||||
expect(signal).toBeNull()
|
||||
expect(code).toBe(0)
|
||||
}
|
||||
expect(readdirSync(cache)).toHaveLength(1)
|
||||
expect(readFileSync(file, 'utf8')).toBe('original:signed')
|
||||
expect(readFileSync(second, 'utf8')).toBe('original:signed')
|
||||
} finally {
|
||||
for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill()
|
||||
await Promise.allSettled(exits)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects candidate mutation during verification', async () => {
|
||||
const { file, cache, signer } = fixture()
|
||||
signer.verify = (candidate) => { writeFileSync(candidate, 'mutated') }
|
||||
await expect(cachedMacOSSignature(file, cache, signer)).rejects.toThrow('candidate changed')
|
||||
expect(readFileSync(file, 'utf8')).toBe('original')
|
||||
expect(readdirSync(cache)).toEqual([])
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
const RELEASE_ENVIRONMENT = {
|
||||
DSH_DESKTOP_APP_ID: 'com.example.desktop',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
|
||||
DSH_DESKTOP_TARGET_ARCH: 'arm64',
|
||||
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
|
||||
@@ -97,6 +98,7 @@ describe('desktop macOS release signature', () => {
|
||||
expect(() => createElectronBuilderConfig({
|
||||
DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
|
||||
}, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
|
||||
})
|
||||
@@ -106,6 +108,7 @@ describe('desktop macOS release signature', () => {
|
||||
const config = createElectronBuilderConfig({
|
||||
DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
|
||||
DSH_DESKTOP_UNSIGNED: '1',
|
||||
}, 'win32', 'x64')
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
||||
@@ -383,7 +383,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
|
||||
const request = vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
|
||||
vi.stubGlobal('fetch', request)
|
||||
@@ -410,7 +410,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it('does not open Feishu when the user declines test login', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'] }
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
||||
await readyForUpdate()
|
||||
@@ -420,7 +420,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it('does not require gateway login to download an already available ordinary update', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'] }
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
|
||||
@@ -433,7 +433,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it('retains the same blocking window and running Host after expired test login is cancelled', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
|
||||
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
|
||||
@@ -937,7 +937,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
it('queues policy authentication until the ordinary result dialog closes', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
|
||||
const request = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(Response.json({ code: 0, data: { biz_code: 0, biz_data: null } }))
|
||||
|
||||
@@ -13,7 +13,7 @@ const instances: DesktopMandatoryUpdatePolicy[] = []
|
||||
function fixture(authentication: 'anonymous' | 'feishu-test' = 'anonymous') {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json(clear))
|
||||
const publish = vi.fn<(state: DesktopPolicyState) => void>()
|
||||
const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication })!,
|
||||
const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication, ...(authentication === 'feishu-test' ? { allowedAuthOrigins: ['https://login.example.com'] } : {}) })!,
|
||||
identity, publish, request)
|
||||
instances.push(policy)
|
||||
return { policy, request, publish }
|
||||
@@ -162,6 +162,15 @@ describe('mandatory update policy', () => {
|
||||
})
|
||||
|
||||
describe('policy deployment and page validation', () => {
|
||||
it('requires validated login origins only for test authentication', () => {
|
||||
expect(() => resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test' })).toThrow('allowedAuthOrigins')
|
||||
expect(() => resolveDesktopPolicyConfig({ ...deployment, allowedAuthOrigins: ['https://login.example.com'] })).toThrow('anonymous')
|
||||
expect(() => resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test',
|
||||
allowedAuthOrigins: ['https://login.example.com/path'] })).toThrow('HTTPS origin')
|
||||
expect(resolveDesktopPolicyConfig({ ...deployment, authentication: 'feishu-test',
|
||||
allowedAuthOrigins: ['https://login.example.com'] })?.allowedAuthOrigins).toEqual(['https://login.example.com'])
|
||||
})
|
||||
|
||||
it('requires explicit production origins and confines HTTP to an explicit local policy fixture', () => {
|
||||
expect(resolveDesktopPolicyConfig(undefined)).toBeUndefined()
|
||||
expect(() => resolveDesktopPolicyConfig({ ...deployment, origin: 'http://127.0.0.1:19001' })).toThrow('HTTPS')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({ root: '', directory: '' }))
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({
|
||||
loadDesktopPackageEnvironment: () => ({ DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'credential-sentinel' }),
|
||||
validateDesktopPackageEnvironment: () => {},
|
||||
}))
|
||||
vi.mock('../scripts/macos-signing-keychain.mjs', () => ({
|
||||
withMacOSSigningKeychain: async (_environment: object, action: (environment: object) => Promise<unknown>) => action({}),
|
||||
}))
|
||||
vi.mock('node:fs', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:fs')>(),
|
||||
rmSync: () => { throw new AggregateError([new Error('credential-sentinel')], 'restore:mac-proxy recovery required') },
|
||||
}))
|
||||
vi.mock('../scripts/packaging-run.mjs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../scripts/packaging-run.mjs')>()
|
||||
return { ...original, createPackagingRun: (...args: Parameters<typeof original.createPackagingRun>) => {
|
||||
const run = original.createPackagingRun(state.root, args[1], args[2])
|
||||
state.directory = run.directory
|
||||
return run
|
||||
} }
|
||||
})
|
||||
|
||||
it.each(['win32', 'darwin'] as const)('records and prints redacted parent failures on %s', async (platform) => {
|
||||
state.root = await mkdtemp(join(tmpdir(), 'package-errors-'))
|
||||
const savedPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
const savedArch = Object.getOwnPropertyDescriptor(process, 'arch')!
|
||||
const savedArgv = process.argv
|
||||
const savedExit = process.exitCode
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const savedDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { ...savedPlatform, value: platform })
|
||||
Object.defineProperty(process, 'arch', { ...savedArch, value: 'x64' })
|
||||
process.argv = [process.execPath, fileURLToPath(new URL('../scripts/package-target.ts', import.meta.url)), platform === 'win32' ? 'win-x64' : 'mac-x64']
|
||||
vi.resetModules()
|
||||
await import('../scripts/package-target.ts')
|
||||
expect(process.exitCode).toBe(1)
|
||||
const events = await readFile(join(state.directory, 'events.jsonl'), 'utf8')
|
||||
const output = stderr.mock.calls.map(([text]) => String(text)).join('')
|
||||
for (const diagnostic of [events, output]) {
|
||||
expect(diagnostic).toContain('restore:mac-proxy recovery required')
|
||||
expect(diagnostic).toContain('[REDACTED]')
|
||||
expect(diagnostic).not.toContain('credential-sentinel')
|
||||
}
|
||||
expect(JSON.parse(await readFile(join(state.directory, 'result.json'), 'utf8'))).toMatchObject({
|
||||
success: false, stages: [
|
||||
{ stage: 'configuration', success: true },
|
||||
{ stage: platform === 'win32' ? 'windows-package' : 'macos-package', success: false },
|
||||
],
|
||||
})
|
||||
expect(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR).toBe(savedDirectory)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', savedPlatform)
|
||||
Object.defineProperty(process, 'arch', savedArch)
|
||||
process.argv = savedArgv
|
||||
process.exitCode = savedExit
|
||||
if (savedDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = savedDirectory
|
||||
stderr.mockRestore()
|
||||
consoleLog.mockRestore()
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,17 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
|
||||
import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
|
||||
import { packageMacOSArtifacts } from '../scripts/package-macos.ts'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs'
|
||||
|
||||
vi.mock('../scripts/macos-notarization-proxy.ts', () => ({
|
||||
withMacOSNotarizationProxy: vi.fn(async (_proxy: string | undefined, action: () => Promise<void>) => action()),
|
||||
}))
|
||||
vi.mock('../scripts/notarize-macos.mjs', () => ({ notarizeMacOS: vi.fn(async () => {}) }))
|
||||
vi.mock('../scripts/package-macos.ts', () => ({ packageMacOSArtifacts: vi.fn(async () => {}) }))
|
||||
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
@@ -85,3 +93,42 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no
|
||||
for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, '2'])('passes macOS pack concurrency %s only to workspace packing and download routing only to download stages', async (concurrency) => {
|
||||
const { run } = supervisor()
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--prepare-only'], 'darwin', 'arm64'), {
|
||||
...environment, DSH_DESKTOP_MACOS_PACK_CONCURRENCY: concurrency,
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'http://downloads.example:8080',
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
|
||||
}, run)
|
||||
const calls = run.run.mock.calls
|
||||
const packs = calls.filter(call => call[0].startsWith('run release:pack'))
|
||||
expect(packs).toHaveLength(2)
|
||||
for (const call of packs) expect(call[2].slice(-2)).toEqual(['--concurrency', concurrency ?? '4'])
|
||||
for (const call of calls) {
|
||||
expect(call[3].env.HTTP_PROXY).toBe(/^run prepare:(?:runtime|dsh)$/u.test(call[0]) ? 'http://downloads.example:8080' : undefined)
|
||||
}
|
||||
expect(withMacOSNotarizationProxy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])('scopes the Apple proxy around notarization (directory=%s)', async (directory) => {
|
||||
const { run } = supervisor()
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), {
|
||||
...environment, APPLE_KEYCHAIN_PROFILE: 'fixture', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
|
||||
}, run)
|
||||
expect(withMacOSNotarizationProxy).toHaveBeenCalledExactlyOnceWith('http://apple.example:8081', expect.any(Function), undefined, undefined, expect.any(Function))
|
||||
expect(packageMacOSArtifacts).toHaveBeenCalledTimes(directory ? 0 : 1)
|
||||
if (!directory) {
|
||||
const signing = run.run.mock.calls.findIndex(call => call[0].includes('--config.mac.notarize=false'))
|
||||
expect(signing).toBeGreaterThan(-1)
|
||||
expect(run.run.mock.invocationCallOrder[signing]).toBeLessThan(vi.mocked(withMacOSNotarizationProxy).mock.invocationCallOrder[0]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not write a release completion record when Apple proxy cleanup fails', async () => {
|
||||
const { run } = supervisor()
|
||||
vi.mocked(withMacOSNotarizationProxy).mockRejectedValueOnce(new Error('proxy restoration failed'))
|
||||
await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), environment, run))
|
||||
.rejects.toThrow('proxy restoration failed')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -94,3 +94,29 @@ describe('packaging run records', () => {
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
})
|
||||
|
||||
it('allows parallel Mac stages, attributes their output, and refuses finish until both settle', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-parallel-log-'))
|
||||
try {
|
||||
const run = createPackagingRun(root, { target: 'mac-fixture' }, { parallel: true, secrets: ['removed-p12-password'] })
|
||||
const script = "process.stdout.write(process.argv[1]+' removed-p12-password')"
|
||||
const first = run.run('app', process.execPath, ['-e', script, 'app'], { cwd: root, env: environment })
|
||||
const second = run.run('dmg', process.execPath, ['-e', script, 'dmg'], { cwd: root, env: environment })
|
||||
const settled = Promise.allSettled([first, second])
|
||||
try { expect(() => { run.finish(true) }).toThrow('active run') } finally { await settled }
|
||||
const results = await settled
|
||||
expect(results.map(result => result.status)).toEqual(['fulfilled', 'fulfilled'])
|
||||
run.finish(true)
|
||||
const log = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
|
||||
const events = log.trim().split('\n').map(line => JSON.parse(line) as { type: string; stage: string; text?: string })
|
||||
expect(events.filter(event => event.type === 'stage-start' || event.type === 'stage-end').slice(0, 2).map(event => event.type))
|
||||
.toEqual(['stage-start', 'stage-start'])
|
||||
expect(events.filter(event => event.type === 'output').map(event => event.stage).sort()).toEqual(['app', 'dmg'])
|
||||
expect(log).not.toContain('removed-p12-password')
|
||||
expect(events.filter(event => event.type === 'output').every(event => event.text?.includes('[REDACTED]'))).toBe(true)
|
||||
const summary = JSON.parse(await readFile(join(run.directory, 'result.json'), 'utf8')) as { stages: unknown[]; elapsedMs: number }
|
||||
expect(summary).toMatchObject({ success: true, proxy: 'not-used' })
|
||||
expect(typeof summary.elapsedMs).toBe('number')
|
||||
expect(summary.stages).toHaveLength(2)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs'
|
||||
import { packagingStep } from '../scripts/packaging-step.mjs'
|
||||
|
||||
it('retains nested failures without credentials and includes the restored proxy in the final summary', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'packaging-step-'))
|
||||
try {
|
||||
const run = createPackagingRun(root, { target: 'fixture' })
|
||||
const work = packagingStep(run.directory, 'notarization', async () => {
|
||||
throw new AggregateError([new Error('secret-sentinel'), new Error('outer', { cause: new Error('secret-sentinel') })], 'Apple refused')
|
||||
}, ['secret-sentinel'])
|
||||
await expect(work).rejects.toThrow('Apple refused')
|
||||
recordPackagingEvent(run.directory, { type: 'notarization-proxy', status: 'restored' })
|
||||
run.finish(false)
|
||||
const events = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
|
||||
const summary = await readFile(join(run.directory, 'result.json'), 'utf8')
|
||||
expect(events).toContain('[REDACTED]')
|
||||
expect(events).toContain('outer')
|
||||
expect(events).not.toContain('secret-sentinel')
|
||||
expect(summary).not.toContain('secret-sentinel')
|
||||
expect(JSON.parse(summary)).toMatchObject({ success: false, proxy: 'restored', stages: [{ stage: 'notarization', success: false }] })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('preserves unrecorded callers and records successful return values without persisting them', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'packaging-step-result-'))
|
||||
try {
|
||||
const run = createPackagingRun(root, {})
|
||||
expect(await packagingStep(undefined, 'plain', async () => 42)).toBe(42)
|
||||
expect(await packagingStep(run.directory, 'logged', async () => 'private-return-value')).toBe('private-return-value')
|
||||
run.finish(true)
|
||||
const events = await readFile(join(run.directory, 'events.jsonl'), 'utf8')
|
||||
expect(events).toContain('"success":true')
|
||||
expect(events).not.toContain('private-return-value')
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -35,7 +35,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
browserSession = makeSession(); window = makeWindow()
|
||||
native.partition.mockReturnValue(browserSession); native.create.mockReturnValue(window)
|
||||
auth = new DesktopPolicyTestAuth('https://policy.example.com', resolveDesktopLocale('zh'), () => undefined, record)
|
||||
auth = new DesktopPolicyTestAuth('https://policy.example.com', ['https://login.example.com'], resolveDesktopLocale('zh'), () => undefined, record)
|
||||
})
|
||||
afterEach(async () => { await auth.dispose() })
|
||||
|
||||
@@ -64,7 +64,7 @@ it('opens a sandboxed window on explicit action and coalesces logins without aut
|
||||
expect(window.loadURL).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(window.loadURL).toHaveBeenCalledWith('https://policy.example.com/') })
|
||||
expect(window.loadFile).toHaveBeenCalledTimes(1)
|
||||
window.webContents.emit('did-navigate', {}, 'https://accounts.feishu.cn/open-apis/authen/v1/index?state=secret')
|
||||
window.webContents.emit('did-navigate', {}, 'https://login.example.com/open-apis/authen/v1/index?state=secret')
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
window.webContents.emit('did-navigate', {}, 'https://policy.example.com/')
|
||||
expect(await pending).toBe('returned')
|
||||
@@ -121,7 +121,7 @@ it('cancels a login closed while the placeholder is still loading, without start
|
||||
it.each(['will-navigate', 'will-redirect'])('refuses unapproved %s targets without logging OAuth data', async (eventName) => {
|
||||
const pending = auth.login()
|
||||
const event = { preventDefault: vi.fn() }
|
||||
window.webContents.emit(eventName, event, 'https://accounts.feishu.cn.evil.example/?code=secret')
|
||||
window.webContents.emit(eventName, event, 'https://login.example.com.evil.example/?code=secret')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(await pending).toBe('failed')
|
||||
expect(record.mock.calls).toEqual([['opened'], ['failed']])
|
||||
@@ -144,8 +144,8 @@ it('rejects child-frame escapes, permissions, downloads, new windows and HTTP au
|
||||
expect(await pending).toBe('failed')
|
||||
})
|
||||
|
||||
it.each(['https://open.feishu.cn/', 'https://accounts.feishu.cn/', 'https://passport.feishu.cn/',
|
||||
'https://login.feishu.cn/'])('allows reviewed login documents: %s', (url) => {
|
||||
it.each(['https://login.example.com/', 'https://login.example.com/', 'https://login.example.com/',
|
||||
'https://login.example.com/'])('allows reviewed login documents: %s', (url) => {
|
||||
const callback = vi.fn()
|
||||
browserSession.webRequest.onBeforeRequest.mock.calls[0]
|
||||
expect(callback).toHaveBeenCalledWith({ cancel: false })
|
||||
@@ -157,7 +157,7 @@ it('keeps manual login open until close, reports cancellation once, and permits
|
||||
expect(await pending).toBe('cancelled')
|
||||
window = makeWindow(); native.create.mockReturnValue(window)
|
||||
const retry = auth.login()
|
||||
window.webContents.emit('did-fail-load', {}, -105, 'raw URL error', 'https://accounts.feishu.cn/?state=secret', true)
|
||||
window.webContents.emit('did-fail-load', {}, -105, 'raw URL error', 'https://login.example.com/?state=secret', true)
|
||||
expect(await retry).toBe('failed')
|
||||
expect(record.mock.calls).toEqual([['opened'], ['cancelled'], ['opened'], ['failed']])
|
||||
})
|
||||
|
||||
@@ -166,6 +166,7 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async
|
||||
await writeFile(certificate, 'fixture public certificate')
|
||||
const config = createElectronBuilderConfig({
|
||||
DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0',
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
|
||||
}, 'win32', 'x64', input.source)
|
||||
|
||||
@@ -161,6 +161,7 @@ describe('Windows token signing', () => {
|
||||
validateDesktopPackageEnvironment({
|
||||
DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.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',
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'C:\\fixture\\signature-cache',
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('Windows update publisher', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('DSH_DESKTOP_APP_ID', 'com.example.publisher-test')
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'https://policy.example.com')
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }))
|
||||
vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'win32')
|
||||
vi.stubEnv('DSH_DESKTOP_UNSIGNED', '1')
|
||||
})
|
||||
|
||||
Generated
+3
@@ -560,6 +560,9 @@ importers:
|
||||
'@deepseek-ai/dsh-home-paths':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/util/home-paths
|
||||
'@deepseek-ai/node-addon-system':
|
||||
specifier: workspace:^
|
||||
version: link:../../native/system/packages/entry
|
||||
'@electron/get':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(supports-color@9.4.0)
|
||||
|
||||
@@ -32,6 +32,7 @@ const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
|
||||
/** Every JavaScript executable in an application or packaging workspace has one explicit role. */
|
||||
const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
|
||||
['apps/cli/src/bin.ts', 'supported dsh application launcher'],
|
||||
['apps/desktop/scripts/logged-notarytool.mjs', 'build-only notarization logging wrapper'],
|
||||
['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'],
|
||||
['packages/experimental/webworker-packer/bin.js', 'build-only wrapper'],
|
||||
['packages/experimental/webworker-packer/src/bin.ts', 'build-only implementation'],
|
||||
|
||||
Reference in new issue
Block a user