From e826d5bf331cf84a0aeafcb66e5fefb13bfa4ff1 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:08:14 +0800 Subject: [PATCH] perf(desktop): batch Windows workspace tarball packing --- apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/desktop-package-environment.mjs | 4 ++-- apps/desktop/scripts/package-target.ts | 4 +++- .../desktop/scripts/windows-package-settings.d.mts | 6 +++--- apps/desktop/scripts/windows-package-settings.mjs | 12 ++++++++---- .../tests/desktop-package-environment.spec.ts | 14 ++++++++++++++ apps/desktop/tests/package-target-stages.spec.ts | 12 ++++++++++++ apps/desktop/tests/windows-package-preset.spec.ts | 13 +++++++++++++ 10 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index a2cdac0936..5a80e930af 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -36,8 +36,8 @@ en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: 62c125856aa03c26 - zh: 9fc00a43c8b1e8e2 + en: cb5621e5f8c286b3 + zh: c45301f7c74bb057 /desktop-packaging/directory-builds-and-preparation: en: 30d58517c9c2044f zh: 16d26bdc923fc4ba diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index b29bc9dcc2..271b81aa5a 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -185,7 +185,7 @@ Mac packaging reads three tuning fields from `.env.macos`: | `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. +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 packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Both platforms batch first-party and vendor tarballs with bounded workers, then validate every payload and record publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`. 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. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. 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. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index e97f072a2f..48dbef9fa4 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -185,7 +185,7 @@ Mac 打包从 `.env.macos` 读取三个调优字段: | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | -两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 +两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。两个平台都使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 8116dea245..32f58ce874 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|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|SIGNATURE_CACHE_CONCURRENCY)$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|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'] @@ -53,7 +53,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING const publicPlatformSetting = platform === 'win32' - ? /^DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY)$/u + ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u const settings = readSettings(path, name => preset === undefined ? SHARED_SETTING.test(name) || platformSetting.test(name) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index be2f577e1b..d5fa2b69d1 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -16,6 +16,7 @@ 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 { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' import { notarizeMacOS } from './notarize-macos.mjs' import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' @@ -423,7 +424,8 @@ export async function packageTarget( 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 packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency + const packArguments = ['--concurrency', String(packConcurrency)] const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts index a7627e3b7d..cef8b8aa85 100644 --- a/apps/desktop/scripts/windows-package-settings.d.mts +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -1,6 +1,6 @@ /** - * Validate the file-owned Windows cache worker limit before packaging starts. + * Validate file-owned Windows packaging and cache worker limits before packaging starts. * @param environment Windows packaging settings loaded from .env.windows. - * @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw. + * @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw. */ -export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number } +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs index 847849af97..cd78e49756 100644 --- a/apps/desktop/scripts/windows-package-settings.mjs +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -1,14 +1,18 @@ -/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */ +/** Resolve file-owned Windows packaging and signature-cache concurrency. */ /** - * Validate the cache worker limit without accessing signing hardware. + * Validate packaging and cache worker limits without accessing signing hardware. * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. - * @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted. + * @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted. */ export function resolveWindowsPackageSettings(environment) { const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' if (!/^[1-8]$/u.test(value)) { throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8') } - return { signatureCacheConcurrency: Number(value) } + const pack = environment.DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY ?? '4' + if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer') + } + return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) } } diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 1c3eb02fc0..db0f2ea516 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -52,6 +52,20 @@ describe('Desktop local packaging configuration', () => { }) }) + it('takes pack concurrency from the Windows file without inheriting ambient tuning', async () => { + await withDirectory(async (directory) => { + const parent = { DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: '8' } + await writeFile(join(directory, '.env.windows'), '') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).packConcurrency).toBe(4) + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY=2\n') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).packConcurrency).toBe(2) + }) + }) + + it.each(['', '0', '-1', '1.5', 'Infinity', '01', '9007199254740992'])('rejects invalid pack concurrency %s', (value) => { + expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: value })).toThrow('PACK_CONCURRENCY') + }) + it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => { const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }) expect(settings.signatureCacheConcurrency).toBe(Number(value)) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 1d8b067390..299351fdb4 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -43,6 +43,18 @@ function supervisor(failure?: string) { return { run, stages } } +it.each([undefined, '2'])('uses bounded Windows packing while retaining signing and smoke (workers=%s)', async (workers) => { + const { run, stages } = supervisor() + const settings = workers === undefined ? environment : { ...environment, DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: workers } + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), settings, run) + const packs = stages.filter(stage => stage.startsWith('run release:pack')) + expect(packs).toHaveLength(2) + for (const stage of packs) expect(stage).toContain(`--concurrency ${workers ?? '4'}`) + expect(stages).toContain('preflight:windows-signing') + expect(stages).toContain('run sign:primary-runtime --dsh') + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') +}) + it('requires one signing preflight before building, then records only the complete release', async () => { const { run, stages } = supervisor() await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts index 84cb72921c..bbf205cf45 100644 --- a/apps/desktop/tests/windows-package-preset.spec.ts +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs' async function withPreset(run: (root: string) => Promise) { const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-')) @@ -34,6 +35,18 @@ it('loads unsigned public settings without requiring the signing secret file or }) }) +it('loads pack concurrency from the public preset and rejects an invalid worker count', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: '8' } + const load = () => resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', env, root, { unsigned: true })) + expect(load().packConcurrency).toBe(4) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_PACK_CONCURRENCY=2\n') + expect(load().packConcurrency).toBe(2) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_PACK_CONCURRENCY=0\n') + expect(load).toThrow('PACK_CONCURRENCY') + }) +}) + it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => { await withPreset(async (root) => { const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }