From 750bf4988ee39aecfba8f0eb043e622979dfdf13 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:36:40 +0800 Subject: [PATCH] fix(desktop): initialize Windows cache under stage ownership --- ...26-09-17-windows-runtime-signature-cache.i18n.yaml | 4 ++-- .../2026-09-17-windows-runtime-signature-cache.md | 2 +- .../2026-09-17-windows-runtime-signature-cache.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 4 ++-- apps/desktop/README.zh.md | 4 ++-- apps/desktop/scripts/package-target.ts | 10 ++++++---- .../scripts/windows-signature-cache-directory.mjs | 2 +- apps/desktop/scripts/windows-signature-cache.d.mts | 2 +- apps/desktop/scripts/windows-signing-stage.mjs | 3 ++- apps/desktop/tests/package-target-stages.spec.ts | 11 +++++++++++ .../tests/windows-signature-cache-directory.spec.ts | 11 ++++++++++- apps/desktop/tests/windows-signing-stage.spec.ts | 9 +++++++++ 13 files changed, 50 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml index b2cef5b014..b2f71a9f4d 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.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/process/2026-09-17-windows-runtime-signature-cache.md -2026-09-17-windows-runtime-signature-cache.md: 3182f46680fd862e123a54a7703b0b0dcad8fd79 -2026-09-17-windows-runtime-signature-cache.zh.md: 72b3599b28244d390a00ac5aa1eea0f5f8834c86 +2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9 +2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md index 3182f46680..911e027719 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -38,7 +38,7 @@ The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files. -Six complete signed Windows x64 builds cover an empty signature cache, repeated inputs, application changes and one controlled native-file content change. Warm builds reduce hardware calls from 379 to 17 and elapsed time from 37:38 to 12:36–12:42 on the measured host, while final runtime checks pass. Download caches remain populated; the controlled file change tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate. +Complete signed Windows x64 builds on one host measure an empty shared cache and reuse from a separate fresh worktree: hardware calls fall from 379 to 17, elapsed time from 37:03 to 13:45, and all 362 runtime entries hit. Cache restoration takes 132 seconds, including 130 seconds of current trust verification. Both builds pass final runtime smoke and signed-artifact checks. Downloads are prepared before timing; each scenario is one complete run. A separate controlled native-file change produces miss/hit/miss with two hardware calls; it tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate. ## Risks diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md index 72b3599b28..bb8cdd6bdc 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -38,7 +38,7 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。 -六轮完整 Windows x64 签名构建覆盖空签名缓存、重复输入、应用变化和单个原生文件的受控内容变化。在测量主机上,热构建将硬件调用从 379 次降至 17 次,耗时从 37:38 降至 12:36–12:42,且最终运行时检查通过。下载缓存保持已填充;受控文件变化验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。 +同一主机上的完整 Windows x64 签名构建测量空共享缓存与另一个全新 worktree 的复用:硬件调用从 379 次降至 17 次,耗时从 37:03 降至 13:45,362 个运行时缓存项全部命中。缓存恢复耗时 132 秒,其中当前信任验证占 130 秒。两轮均通过最终运行时 smoke 及签名产物检查。下载内容在计时前准备好;每种场景均为一次完整运行。另一次受控原生文件变更产生未命中、命中、未命中的结果,合计两次硬件调用;它验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。 ## Risks diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 364ccfffd7..191e1afaf1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/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: 9a20ec4b919c31f9d3427181ae984c30206aed19 -README.zh.md: a5b876ab97c108db51ebbcea45a78322234ffaa0 +README.md: 5de3550de473cb71704025e1d60057611b15987c +README.zh.md: 057094c80e1197e0216f660ea788dabc0d436573 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9a20ec4b91..5de3550de4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -214,9 +214,9 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, cache trust verification and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. -Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. +Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a5b876ab97..057094c80e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -215,9 +215,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、缓存信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 -使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看完整缓存项的字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 +使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index cff4df6006..6988a4edac 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -356,10 +356,12 @@ export async function packageTarget( } if (signPrimaryRuntime) { if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run') - await prepareWindowsSignatureCacheDirectory(resolveWindowsSignatureCacheDirectory(environment)) - await signedStage('preflight', () => run.run('preflight:windows-signing', process.execPath, - ['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')], - { cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 })) + await signedStage('preflight', async () => { + await prepareWindowsSignatureCacheDirectory(resolveWindowsSignatureCacheDirectory(environment)) + await run.run('preflight:windows-signing', process.execPath, + ['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')], + { cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 }) + }) } await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) diff --git a/apps/desktop/scripts/windows-signature-cache-directory.mjs b/apps/desktop/scripts/windows-signature-cache-directory.mjs index 3d4a30b880..69b2064672 100644 --- a/apps/desktop/scripts/windows-signature-cache-directory.mjs +++ b/apps/desktop/scripts/windows-signature-cache-directory.mjs @@ -28,7 +28,7 @@ export function resolveWindowsSignatureCacheDirectory(environment) { */ export async function prepareWindowsSignatureCacheDirectory(path, existingSource = false) { if (process.platform !== 'win32') throw new Error('Windows signature cache directory validation requires Windows') - await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-File', + await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(import.meta.dirname, 'windows-signature-cache-directory.ps1'), '-CachePath', resolve(path), ...(existingSource ? ['-ExistingSource'] : []), ], { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000 }) diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts index 4c90d5bd8a..e68abdf345 100644 --- a/apps/desktop/scripts/windows-signature-cache.d.mts +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions { */ export function signatureCacheIdentity(files: readonly string[]): Promise -/** Counters include failed attempts; restore time includes trust verification and staging cleanup. */ +/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */ export interface WindowsSignatureCacheSummary { root: string identity: string diff --git a/apps/desktop/scripts/windows-signing-stage.mjs b/apps/desktop/scripts/windows-signing-stage.mjs index 65e0043fac..d81ef8bdc3 100644 --- a/apps/desktop/scripts/windows-signing-stage.mjs +++ b/apps/desktop/scripts/windows-signing-stage.mjs @@ -30,7 +30,8 @@ export async function withWindowsSigningStage(options, operation) { const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing') await mkdir(root, { recursive: true }) const stat = await lstat(root) - if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(root) !== resolve(root)) { + const normalize = value => toNamespacedPath(resolve(value)).toLowerCase() + if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(root)) !== normalize(root)) { throw new Error('Windows signing stage requires an unlinked local state directory') } const path = join(root, 'stage.lock') diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index e74bedc215..bdf636ef57 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs' import { afterEach, expect, it, vi } from 'vitest' import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs' +import { prepareWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs' vi.mock('../scripts/windows-signing-stage.mjs', () => ({ withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise) => operation()), @@ -52,6 +53,16 @@ it('requires one signing preflight before building, then records only the comple expect(writeFileSync).toHaveBeenCalledOnce() }) +it('initializes shared storage only after acquiring the preflight stage lock', async () => { + const { run } = supervisor() + vi.mocked(withWindowsSigningStage).mockImplementationOnce(async (_options, operation) => { + expect(prepareWindowsSignatureCacheDirectory).not.toHaveBeenCalled() + await operation() + expect(prepareWindowsSignatureCacheDirectory).toHaveBeenCalledOnce() + }) + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) +}) + it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh --defer-runtime-smoke', 'run sign:primary-runtime --dsh', 'exec tsx scripts/smoke-packaged-runtime.ts', 'exec electron-builder --config electron-builder.config.mjs --win --x64 --publish never']) diff --git a/apps/desktop/tests/windows-signature-cache-directory.spec.ts b/apps/desktop/tests/windows-signature-cache-directory.spec.ts index f5ffe76e44..d910624f18 100644 --- a/apps/desktop/tests/windows-signature-cache-directory.spec.ts +++ b/apps/desktop/tests/windows-signature-cache-directory.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, symlink } from 'node:fs/promises' import { join } from 'node:path' import { homedir } from 'node:os' import { promisify } from 'node:util' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs' it.skipIf(process.platform !== 'win32')('resolves an account cache independently of checkout and LocalAppData virtualization', () => { @@ -16,6 +16,15 @@ it('accepts an explicit local cache directory', () => { .toBe('D:\\private-cache') }) +it.skipIf(process.platform !== 'win32')('prepares storage when the inherited process execution policy is Restricted', async (t) => { + const root = await mkdtemp(join(import.meta.dirname, 'directory-test-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + vi.stubEnv('PSExecutionPolicyPreference', 'Restricted') + t.onTestFinished(() => { vi.unstubAllEnvs() }) + await prepareWindowsSignatureCacheDirectory(join(root, 'private')) + expect(process.env.PSExecutionPolicyPreference).toBe('Restricted') +}) + it.each(['', 'cache', 'C:cache', '\\cache', '\\\\server\\share', '\\\\?\\C:\\cache', 'C:\\cache:stream']) ('rejects unsupported cache path %j', (path) => { expect(() => resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: path })).toThrow(/absolute local/u) diff --git a/apps/desktop/tests/windows-signing-stage.spec.ts b/apps/desktop/tests/windows-signing-stage.spec.ts index 5c1e1b1865..a205681781 100644 --- a/apps/desktop/tests/windows-signing-stage.spec.ts +++ b/apps/desktop/tests/windows-signing-stage.spec.ts @@ -94,6 +94,15 @@ test('a failed stage releases its handle before another process enters', async ( expect(await next.closed).toEqual({ code: 0, signal: null }) }) +test('accepts a state directory whose spelling differs only in Windows path case', async (t) => { + const f = await fixture(t) + let entered = false + await withWindowsSigningStage({ stateDirectory: join(f.root, 'state').toUpperCase(), stage: 'case', record: () => {} }, async () => { + entered = true + }) + expect(entered).toBe(true) +}) + test('a cancelled waiter never enters and does not release another process lock', async (t) => { const f = await fixture(t) const holder = f.child(f.first)