Преглед изворни кода

Merge pull request #3860 from deepseek-harness/fix/parallel-macos-notarization

perf(desktop): 并行 macOS 公证并记录上传加速说明
07akioni пре 4 недеља
родитељ
комит
69005cb956

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

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

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

@@ -83,7 +83,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
 
 Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation.
 
-Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
 
 The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
 

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

@@ -83,7 +83,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。
 
-Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md
+2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70
+2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75

+ 37 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md

@@ -0,0 +1,37 @@
+# Agent Note: Parallel macOS notarization from isolated App copies
+
+Status: implemented
+
+English | [中文](2026-09-09-parallel-macos-notarization.zh.md)
+
+## Problem
+
+The Desktop release distributes a DMG for installation and a ZIP for updates. Waiting for App notarization before creating the DMG serializes two Apple submissions. A proxy improves upload throughput but does not overlap the independent service waits. Stapling modifies the App, so concurrent notarization and packaging cannot safely share that writable directory.
+
+## Decision
+
+The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`.
+
+The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App.
+
+Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata.
+
+This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements.
+
+## Alternatives considered
+
+**Share one App between both lanes.** A DMG reader can overlap with `stapler` writes, producing a nondeterministic bundle. Independent copies keep the submitted and distributed bytes stable within each lane.
+
+**Only notarize the DMG.** ZIP updates are distributed independently and need a stapled App. Retaining both submissions keeps that independent qualification explicit.
+
+**Keep serial notarization and only use a proxy.** The same 214.84 MiB DMG uploads in 203.83 seconds directly and 38.59 seconds through the tested system proxy, but neither route removes the serial dependency between Apple submissions. Proxy configuration remains a build-host concern; the packaging script does not change host network settings.
+
+**Run both targets in one electron-builder call before App notarization finishes.** The ZIP must read the stapled copy. Separate prepackaged invocations preserve electron-builder's own archive, blockmap, and metadata implementation without changing its target scheduling or patching the dependency.
+
+## Consequences
+
+On 2026-09-09, full arm64 packaging on the same Mac through the same system proxy took 490.78 seconds with parallel notarization versus 751.33 seconds serially, a 34.7% reduction. The artifact lanes began 8 milliseconds apart and completed in 307.36 seconds for App/ZIP and 268.54 seconds for DMG. Apple accepted both submissions; their uploads completed about one second apart. Each configuration has one full-build sample, so cache and Apple queue variation prevent attributing the entire difference to concurrency.
+
+Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process.
+
+The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work.

+ 37 - 0
.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 基于隔离 App 副本的并行 macOS 公证
+
+Status: implemented
+
+[English](2026-09-09-parallel-macos-notarization.md) | 中文
+
+## 问题
+
+Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 App 公证完成后才创建 DMG,会让两次 Apple 提交串行执行。代理可以提高上传吞吐量,但不能让两个独立的服务等待过程重叠。钉票会修改 App,因此并发公证和打包不能安全地共享同一个可写目录。
+
+## 决策
+
+固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。
+
+ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。
+
+错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。
+
+本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。
+
+## 考虑过的替代方案
+
+**两路共享一个 App。** DMG 读取可能与 `stapler` 写入重叠,使 bundle 内容不确定。独立副本使每条路线中提交与分发的字节保持稳定。
+
+**只公证 DMG。** ZIP 更新独立分发,需要已钉票的 App。保留两次提交可以明确维持这项独立验收。
+
+**保留串行公证,仅使用代理。** 同一个 214.84 MiB DMG 直连上传耗时 203.83 秒,通过所测系统代理上传耗时 38.59 秒,但两种网络路径都不能消除 Apple 提交间的串行依赖。代理配置仍由构建主机负责;打包脚本不修改主机网络设置。
+
+**App 公证结束前,在同一次 electron-builder 调用中运行两个目标。** ZIP 必须读取已钉票副本。独立的 prepackaged 调用可以保留 electron-builder 自己的归档、blockmap 和元数据实现,无需修改目标调度或给依赖打补丁。
+
+## 影响
+
+2026-09-09,在同一台 Mac、同一系统代理下,arm64 完整打包在并行公证时耗时 490.78 秒,串行时耗时 751.33 秒,缩短 34.7%。两条产物路线相隔 8 毫秒启动,App/ZIP 耗时 307.36 秒,DMG 耗时 268.54 秒。Apple 接受了两次提交,两者上传完成时间仅相差约一秒。每种配置只有一次完整构建样本,缓存与 Apple 队列变化使我们不能将全部差值归因于并发。
+
+两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。
+
+[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。

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

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

+ 3 - 1
apps/desktop/README.md

@@ -129,10 +129,12 @@ pnpm run upload:mac:arm64
 
 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
 
-The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
+The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
 
 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.
+
 ### Unsigned Windows test installer
 
 On Windows x64, use the complete unsigned packaging command for local installation testing:

+ 3 - 1
apps/desktop/README.zh.md

@@ -129,10 +129,12 @@ pnpm run upload:mac:arm64
 
 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
 
-macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion 钩子 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有钩子成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
+macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
 
 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
 
+可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
+
 ### 未签名 Windows 测试安装包
 
 在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试:

+ 122 - 0
apps/desktop/scripts/package-macos.ts

@@ -0,0 +1,122 @@
+/** Build the ZIP and DMG from separate signed application copies with overlapping notarization. */
+
+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 { Arch, getArchSuffix } from 'electron-builder'
+import { notarize } from '@electron/notarize'
+import {
+  resolveMacOSNotarizationEnvironment,
+  resolveMacOSSigningEnvironment,
+} from './desktop-release-environment.mjs'
+import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs'
+import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs'
+
+const execute = promisify(execFile)
+
+/** One electron-builder artifact made from an already signed application. */
+export interface DesktopPrepackagedArtifact {
+  readonly format: 'dmg' | 'zip'
+  readonly appPath: string
+  readonly output: string
+}
+
+/** A signed macOS directory build and its final release destination. */
+export interface MacOSArtifactRequest {
+  readonly arch: 'arm64' | 'x64'
+  readonly version: string
+  readonly artifactsRoot: string
+  readonly environment: NodeJS.ProcessEnv
+}
+
+/** Apple-tool operations replaced by deterministic fixtures in orchestration tests. */
+export interface MacOSArtifactOperations {
+  readonly copyApp: (source: string, destination: string) => Promise<void>
+  readonly notarize: (options: ReturnType<typeof resolveMacOSNotarizationEnvironment> & { appPath: string }) => Promise<void>
+  readonly verifySignature: typeof verifyMacOSSignature
+  readonly verifyNotarization: typeof verifyMacOSNotarizedApplication
+}
+
+const operations: MacOSArtifactOperations = {
+  async copyApp(source, destination) {
+    await execute('/usr/bin/ditto', [source, destination])
+  },
+  notarize,
+  verifySignature: verifyMacOSSignature,
+  verifyNotarization: verifyMacOSNotarizedApplication,
+}
+
+async function timed(label: string, action: () => Promise<void>): Promise<void> {
+  const start = performance.now()
+  process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`)
+  await action()
+  process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`)
+}
+
+/**
+ * Notarize independent App/DMG copies concurrently, then promote their completed artifacts.
+ * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG
+ * carries its own ticket and encloses the signed App without an individually stapled ticket.
+ * @param request - Signed directory build, release version, architecture, and credentials.
+ * @param build - Runs electron-builder with publishing disabled; resolves only after its DMG
+ * notarization and verification hook succeeds, and rejects on build or hook failure.
+ * @param apple - Apple signing, copying, and notarization operations.
+ * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory.
+ */
+export async function packageMacOSArtifacts(
+  request: MacOSArtifactRequest,
+  build: (artifact: DesktopPrepackagedArtifact) => Promise<void>,
+  apple: MacOSArtifactOperations = operations,
+): Promise<void> {
+  const { arch, version, artifactsRoot, environment } = request
+  const expected = resolveMacOSSigningEnvironment(environment)
+  const credentials = resolveMacOSNotarizationEnvironment(environment)
+  const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app')
+  const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-'))
+  const zipApp = join(root, 'zip', basename(appPath))
+  const dmgApp = join(root, 'dmg', basename(appPath))
+  const zipOutput = join(root, 'zip-artifacts')
+  const dmgOutput = join(root, 'dmg-artifacts')
+  try {
+    await apple.copyApp(appPath, zipApp)
+    await apple.copyApp(appPath, dmgApp)
+    apple.verifySignature(zipApp, expected)
+    apple.verifySignature(dmgApp, expected)
+    const results = await Promise.allSettled([
+      timed('App notarization and ZIP', async () => {
+        await apple.notarize({ appPath: zipApp, ...credentials })
+        apple.verifyNotarization(zipApp, expected)
+        await build({ format: 'zip', appPath: zipApp, output: zipOutput })
+      }),
+      timed('DMG creation and notarization', async () => {
+        await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
+      }),
+    ])
+    const failures = results.filter(result => result.status === 'rejected')
+    if (failures.length > 0) {
+      throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed')
+    }
+    const base = `deepseek-harness-${version}-mac-${arch}`
+    const artifacts = [
+      [dmgOutput, `${base}.dmg`],
+      [zipOutput, `${base}.zip`],
+      [zipOutput, `${base}.zip.blockmap`],
+      [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')],
+    ] as const
+    for (const [output, filename] of artifacts) {
+      const file = join(output, filename)
+      const details = await stat(file)
+      if (!details.isFile() || details.size === 0) {
+        throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`)
+      }
+    }
+    for (const [output, filename] of artifacts) {
+      await rename(join(output, filename), join(artifactsRoot, filename))
+    }
+    await rm(appPath, { recursive: true })
+    await rename(zipApp, appPath)
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+}

+ 23 - 1
apps/desktop/scripts/package-target.ts

@@ -9,6 +9,7 @@ import {
   resolveDesktopAutoUpdateConfig,
 } from './desktop-auto-update-environment.mjs'
 import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
+import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -217,11 +218,13 @@ export function parseDesktopPackageInvocation(
  * Build the electron-builder command arguments for one validated target.
  * @param target - Supported release target.
  * @param directory - Whether to stop at an unpacked application directory.
+ * @param artifact - Optional single artifact built from an existing signed application.
  * @returns Arguments that keep publishing under the separate validated upload command.
  */
 export function desktopElectronBuilderArguments(
   target: DesktopPackageTarget,
   directory: boolean,
+  artifact?: DesktopPrepackagedArtifact,
 ): readonly string[] {
   return [
     'exec',
@@ -229,10 +232,16 @@ export function desktopElectronBuilderArguments(
     '--config',
     'electron-builder.config.mjs',
     target.builderPlatform,
+    ...(artifact === undefined ? [] : [artifact.format]),
     target.builderArch,
     '--publish',
     'never',
     ...(directory ? ['--dir'] : []),
+    ...(artifact === undefined ? [] : [
+      ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []),
+      '--prepackaged', artifact.appPath,
+      '--config.directories.output', artifact.output,
+    ]),
   ]
 }
 
@@ -302,7 +311,20 @@ async function main(): Promise<void> {
   await runPnpm(['run', 'prepare:packages'], targetEnv)
   await runPnpm(['run', 'prepare:dsh'], targetEnv)
   if (invocation.prepareOnly) return
-  await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
+  if (target.platform === 'darwin' && !invocation.directory) {
+    await runPnpm([
+      ...desktopElectronBuilderArguments(target, true),
+      '--config.mac.notarize=false',
+    ], electronBuilderEnv)
+    await packageMacOSArtifacts({
+      arch: target.arch,
+      version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
+      artifactsRoot: buildPaths.artifacts,
+      environment: electronBuilderEnv,
+    }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
+  } else {
+    await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
+  }
   if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }
 

+ 7 - 0
apps/desktop/scripts/verify-macos-signature.d.mts

@@ -41,6 +41,13 @@ export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvir
  */
 export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void
 
+/**
+ * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance.
+ * @param appPath - Path to the stapled `.app` directory.
+ * @param expected - Public release identity.
+ */
+export function verifyMacOSNotarizedApplication(appPath: string, expected: MacOSSigningEnvironment): void
+
 /**
  * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
  * @param diskImagePath - Path to the packaged `.dmg` file.

+ 12 - 0
apps/desktop/scripts/verify-macos-signature.mjs

@@ -147,6 +147,18 @@ export function verifyMacOSSignature(appPath, expected) {
   assertMacOSSignatureDetails(details, expected)
 }
 
+/**
+ * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance.
+ * @param {string} appPath - Path to the stapled `.app` directory.
+ * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
+ * @returns {void}
+ */
+export function verifyMacOSNotarizedApplication(appPath, expected) {
+  verifyMacOSSignature(appPath, expected)
+  runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', appPath], 'stapler validate')
+  runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'spctl')
+}
+
 /**
  * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
  * @param {string} diskImagePath - Path to the packaged `.dmg` file.

+ 45 - 0
apps/desktop/tests/macos-notarized-application.spec.ts

@@ -0,0 +1,45 @@
+/** Verify application qualification commands without invoking Apple tools. */
+
+import { spawnSync } from 'node:child_process'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { verifyMacOSNotarizedApplication } from '../scripts/verify-macos-signature.mjs'
+
+vi.mock('node:child_process', async importOriginal => ({
+  ...await importOriginal<typeof import('node:child_process')>(),
+  spawnSync: vi.fn(),
+}))
+
+const expected = { signingIdentity: 'Example Company (TEAMID1234)', teamId: 'TEAMID1234' }
+const appPath = '/private build/DeepSeek Harness.app'
+const commands = [
+  ['/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]],
+  ['/usr/bin/codesign', ['--display', '--verbose=4', appPath]],
+  ['/usr/bin/xcrun', ['stapler', 'validate', appPath]],
+  ['/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]],
+] as const
+
+afterEach(() => { vi.resetAllMocks() })
+
+describe('notarized application qualification', () => {
+  it.each([undefined, 0, 1, 2, 3])('stops at failed command %s or verifies every qualification', (failedCommand) => {
+    let index = 0
+    vi.mocked(spawnSync).mockImplementation(() => ({
+      pid: 1,
+      output: [],
+      stdout: '',
+      stderr: `Authority=Developer ID Application: ${expected.signingIdentity}\nTeamIdentifier=${expected.teamId}\n`,
+      status: index++ === failedCommand ? 1 : 0,
+      signal: null,
+    }))
+    if (failedCommand === undefined) {
+      expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).not.toThrow()
+    } else {
+      expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).toThrow('exited with 1')
+    }
+    const calledCommands = commands.slice(0, failedCommand === undefined ? commands.length : failedCommand + 1)
+    expect(spawnSync).toHaveBeenCalledTimes(calledCommands.length)
+    for (const [index, [command, args]] of calledCommands.entries()) {
+      expect(spawnSync).toHaveBeenNthCalledWith(index + 1, command, args, { encoding: 'utf8' })
+    }
+  })
+})

+ 190 - 0
apps/desktop/tests/package-macos.spec.ts

@@ -0,0 +1,190 @@
+/** Exercise notarization overlap and artifact isolation without Apple credentials or network. */
+
+import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import {
+  packageMacOSArtifacts,
+  type DesktopPrepackagedArtifact,
+  type MacOSArtifactOperations,
+} from '../scripts/package-macos.ts'
+import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts'
+
+const environment = {
+  DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
+  DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
+  APPLE_KEYCHAIN_PROFILE: 'fixture-profile',
+}
+
+function barrier() {
+  let release!: () => void
+  const promise = new Promise<void>((resolve) => { release = resolve })
+  return { promise, release }
+}
+
+async function fixture(arch: 'arm64' | 'x64' = 'arm64') {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-'))
+  const artifactsRoot = join(root, 'artifacts')
+  const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
+  await mkdir(appPath, { recursive: true })
+  await writeFile(join(appPath, 'payload'), 'signed content')
+  const version = '1.2.3-alpha.1'
+  const base = `deepseek-harness-${version}-mac-${arch}`
+  const request = { arch, artifactsRoot, version, environment }
+  const apple: MacOSArtifactOperations = {
+    copyApp: async (source, destination) => {
+      await cp(source, destination, { recursive: true, verbatimSymlinks: true })
+    },
+    notarize: async ({ appPath: path }) => { await writeFile(join(path, 'ticket'), 'accepted') },
+    verifySignature: vi.fn(),
+    verifyNotarization: vi.fn((path: string) => {
+      if (!existsSync(join(path, 'ticket'))) throw new Error('missing App ticket')
+    }),
+  }
+  const build = async (artifact: DesktopPrepackagedArtifact) => {
+    await mkdir(artifact.output, { recursive: true })
+    const contents = JSON.stringify({
+      payload: await readFile(join(artifact.appPath, 'payload'), 'utf8'),
+      appTicket: existsSync(join(artifact.appPath, 'ticket')),
+    })
+    await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents)
+    if (artifact.format === 'zip') {
+      await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap')
+      await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata')
+    }
+  }
+  return { root, appPath, request, apple, build, base }
+}
+
+describe('parallel macOS artifacts', () => {
+  it.each(['arm64', 'x64'] as const)('overlaps notarization on isolated %s copies and promotes only completed payloads', async (arch) => {
+    const f = await fixture(arch)
+    const appStarted = barrier()
+    const appAccepted = barrier()
+    const dmgCompleted = barrier()
+    const zipCompleted = barrier()
+    const starts: string[] = []
+    const copies: string[] = []
+    const operation = packageMacOSArtifacts(f.request, async (artifact) => {
+      starts.push(artifact.format)
+      if (artifact.format === 'dmg') await dmgCompleted.promise
+      await f.build(artifact)
+      if (artifact.format === 'zip') zipCompleted.release()
+    }, {
+      ...f.apple,
+      copyApp: async (source, destination) => {
+        copies.push(destination)
+        await f.apple.copyApp(source, destination)
+      },
+      notarize: async (options) => {
+        starts.push('app')
+        appStarted.release()
+        await appAccepted.promise
+        await f.apple.notarize(options)
+      },
+    })
+    try {
+      await appStarted.promise
+      await vi.waitFor(() => { expect([...starts]).toEqual(expect.arrayContaining(['app', 'dmg'])) })
+      expect(new Set(copies).size).toBe(2)
+      expect(copies.every(path => path !== f.appPath)).toBe(true)
+      appAccepted.release()
+      await zipCompleted.promise
+      expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
+      expect(existsSync(join(f.request.artifactsRoot, `${f.base}.zip`))).toBe(false)
+      dmgCompleted.release()
+      await operation
+      expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.zip`), 'utf8')))
+        .toEqual({ payload: 'signed content', appTicket: true })
+      expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8')))
+        .toEqual({ payload: 'signed content', appTicket: false })
+      expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted')
+      expect((await readdir(f.root)).sort()).toEqual(['artifacts'])
+      expect(f.apple.verifySignature).toHaveBeenCalledTimes(2)
+      expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1)
+    } finally {
+      appAccepted.release()
+      dmgCompleted.release()
+      await Promise.allSettled([operation])
+      await rm(f.root, { recursive: true, force: true })
+    }
+  })
+
+  it('collects both failures after both lanes release their copies and publishes neither payload', async () => {
+    const f = await fixture()
+    const appStarted = barrier()
+    const failApp = barrier()
+    const failDmg = barrier()
+    const appError = new Error('App rejected')
+    const dmgError = new Error('DMG rejected')
+    const released: string[] = []
+    const outcome = packageMacOSArtifacts(f.request, async (artifact) => {
+      expect(artifact.format).toBe('dmg')
+      await failDmg.promise
+      expect(await readFile(join(artifact.appPath, 'payload'), 'utf8')).toBe('signed content')
+      released.push('dmg')
+      throw dmgError
+    }, {
+      ...f.apple,
+      notarize: async () => {
+        appStarted.release()
+        await failApp.promise
+        released.push('app')
+        throw appError
+      },
+    }).catch((error: unknown) => error)
+    try {
+      await appStarted.promise
+      failApp.release()
+      failDmg.release()
+      const error = await outcome
+      expect(error).toBeInstanceOf(AggregateError)
+      expect((error as AggregateError).errors).toEqual([appError, dmgError])
+      expect(released.sort()).toEqual(['app', 'dmg'])
+      expect(await readdir(f.root)).toEqual(['artifacts'])
+      expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
+      expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
+    } finally {
+      failApp.release()
+      failDmg.release()
+      await outcome
+      await rm(f.root, { recursive: true, force: true })
+    }
+  })
+
+  it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => {
+    const f = await fixture()
+    try {
+      const apple: MacOSArtifactOperations = {
+        ...f.apple,
+        ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}),
+        ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}),
+        ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}),
+      }
+      await expect(packageMacOSArtifacts(f.request, async (artifact) => {
+        await f.build(artifact)
+        if (failure === 'metadata' && artifact.format === 'zip') {
+          await writeFile(join(artifact.output, 'alpha-mac.yml'), '')
+        }
+      }, apple)).rejects.toThrow()
+      expect(await readdir(f.root)).toEqual(['artifacts'])
+      expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
+    } finally { await rm(f.root, { recursive: true, force: true }) }
+  })
+
+  it('passes the actual App and isolated output directory to each single-target builder', () => {
+    const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')
+    for (const format of ['zip', 'dmg'] as const) {
+      const appPath = join('private build', format, 'DeepSeek Harness.app')
+      const output = join(dirname(appPath), 'artifacts')
+      expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([
+        'exec', 'electron-builder', '--config', 'electron-builder.config.mjs',
+        '--mac', format, '--arm64', '--publish', 'never',
+        '--config.mac.notarize=false',
+        '--prepackaged', appPath, '--config.directories.output', output,
+      ])
+    }
+  })
+})