feat: 加速 mac sign

This commit is contained in:
07akioni
2026-09-08 17:44:05 +08:00
parent 8e4d3bab9f
commit 8874a23b9e
12 changed files with 96 additions and 9 deletions
@@ -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: fea2c419d73e4eff66de08483733cbc336b6f9af
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc
2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7
@@ -85,6 +85,8 @@ Core dsh and the private Desktop Host come only from the signed application reso
Electron 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.
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.
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. 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.
Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project.
@@ -85,6 +85,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
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 中。
[固定版本的 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 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。
+2 -2
View File
@@ -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: b2a51010dc37bcca7c7846819b72ababdc227496
README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088
README.md: 6ef18d302531beb80877f23d657d8524ff396b2b
README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f
+2
View File
@@ -117,6 +117,8 @@ Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOW
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>`.
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.
### Windows EV signing
Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
+2
View File
@@ -117,6 +117,8 @@ pnpm run upload:mac:arm64
macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,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 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
### Windows EV 签名
Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
+2 -2
View File
@@ -69,8 +69,8 @@ export function createElectronBuilderConfig(
identity: macOSSigning?.signingIdentity,
forceCodeSigning: true,
hardenedRuntime: true,
// Runtime files are signed before their integrity inventory is sealed.
signIgnore: ['/Contents/Resources/dsh(?:/|$)'],
// Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'],
notarize: true,
target: ['dmg', 'zip'],
},
+16 -1
View File
@@ -63,7 +63,7 @@ describe('desktop macOS release signature', () => {
identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY,
forceCodeSigning: true,
notarize: true,
signIgnore: ['/Contents/Resources/dsh(?:/|$)'],
signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'],
},
dmg: {
sign: true,
@@ -77,6 +77,21 @@ describe('desktop macOS release signature', () => {
expect(typeof config.artifactBuildCompleted).toBe('function')
})
it('seals PAK resources with their enclosing bundle while signing executable code', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path))
expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true)
expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true)
for (const path of [
'/App.app/Contents/Resources/runtime/node/node',
'/App.app/Contents/Resources/runtime/pnpm/addon.node',
'/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib',
'/App.app/Contents/Frameworks/Electron.framework',
'/App.app',
]) expect(ignored(path)).toBe(false)
})
it('copies the complete runtime despite electron-builder excluding root node_modules', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
@@ -0,0 +1,36 @@
/** Exercise the installed macOS signer against Framework file and directory aliases. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { expect, it } from 'vitest'
const builderRequire = createRequire(createRequire(import.meta.url).resolve('app-builder-lib'))
const { walkAsync } = builderRequire(join(dirname(builderRequire.resolve('@electron/osx-sign')), 'util.js')) as {
walkAsync: (path: string) => Promise<string[]>
}
// macOS Framework aliases use POSIX file and directory symlinks, unavailable on unprivileged Windows runners.
it.skipIf(process.platform === 'win32')('signs each real Framework file once and retains nested bundle signing', async () => {
const root = mkdtempSync(join(tmpdir(), 'desktop-signing-walk-'))
try {
const contents = join(root, 'Contents')
const framework = join(contents, 'Frameworks', 'Test.framework')
const version = join(framework, 'Versions', 'A')
const resource = join(version, 'Resources', 'en.lproj', 'locale.pak')
const library = join(version, 'library.dylib')
const helper = join(contents, 'Frameworks', 'Helper.app')
const executable = join(helper, 'Contents', 'MacOS', 'helper')
for (const file of [resource, library, executable]) {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0]))
}
symlinkSync('A', join(framework, 'Versions', 'Current'))
symlinkSync('Versions/Current/Resources', join(framework, 'Resources'))
symlinkSync('Versions/Current/library.dylib', join(framework, 'library.dylib'))
const files = await walkAsync(contents)
expect(files.sort()).toEqual([resource, library, executable, framework, helper].sort())
expect(new Set(files.map(file => realpathSync(file))).size).toBe(files.length)
} finally { rmSync(root, { recursive: true, force: true }) }
})
+26
View File
@@ -0,0 +1,26 @@
diff --git a/dist/cjs/util.js b/dist/cjs/util.js
index 77e64b56872bc65bcb0f60ecfbec84eb3da02c6f..8d5b81eea636c3f1672cfe2c66602078bda1e847 100644
--- a/dist/cjs/util.js
+++ b/dist/cjs/util.js
@@ -153,7 +153,7 @@ async function walkAsync(dirPath) {
const children = await fs.readdir(dirPath);
return await Promise.all(children.map(async (child) => {
const filePath = path.resolve(dirPath, child);
- const stat = await fs.stat(filePath);
+ const stat = await fs.lstat(filePath);
if (stat.isFile()) {
switch (path.extname(filePath)) {
case '.cstemp': // Temporary file generated from past codesign
diff --git a/dist/esm/util.js b/dist/esm/util.js
index d3f66d54b1a88e7410d78affe17de87302db02a3..e86b066d1d21628908ecc848f5bb29a7598605a7 100644
--- a/dist/esm/util.js
+++ b/dist/esm/util.js
@@ -117,7 +117,7 @@ export async function walkAsync(dirPath) {
const children = await fs.readdir(dirPath);
return await Promise.all(children.map(async (child) => {
const filePath = path.resolve(dirPath, child);
- const stat = await fs.stat(filePath);
+ const stat = await fs.lstat(filePath);
if (stat.isFile()) {
switch (path.extname(filePath)) {
case '.cstemp': // Temporary file generated from past codesign
+3 -2
View File
@@ -9,6 +9,7 @@ overrides:
'@deepseek-ai/schemastery': link:vendor/schemastery
patchedDependencies:
'@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
'@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8
node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
@@ -18637,7 +18638,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/osx-sign@1.3.3':
'@electron/osx-sign@1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861)':
dependencies:
compare-version: 0.1.2
debug: 4.4.3(supports-color@9.4.0)
@@ -20811,7 +20812,7 @@ snapshots:
'@electron/fuses': 1.8.0
'@electron/get': 3.1.0
'@electron/notarize': 2.5.0
'@electron/osx-sign': 1.3.3
'@electron/osx-sign': 1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861)
'@electron/rebuild': 4.2.0
'@electron/universal': 2.0.3
'@malept/flatpak-bundler': 0.4.0
+1
View File
@@ -87,5 +87,6 @@ minimumReleaseAgeExclude:
- '@openai/codex@0.149.1||0.149.1-darwin-arm64||0.149.1-darwin-x64||0.149.1-linux-arm64||0.149.1-linux-x64||0.149.1-win32-arm64||0.149.1-win32-x64'
patchedDependencies:
'@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch
'@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch
node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch