Merge remote-tracking branch 'origin/master' into worktree/web-desktop-startup-commands-a97723

This commit is contained in:
Yichen Jiang committed 2026-09-20 16:27:07 +08:00
commit 7d56ca5cee
28 files changed
+269 -30

No files matched your search

@@ -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: 911e0277199bac55165fbc4adebb22e2599043b9
2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad
2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d
2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2
@@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex
Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses.
The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
## Alternatives considered
@@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
## Alternatives considered
+3
View File
@@ -20,6 +20,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1.
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Optional upload configuration; packaging does not require these credentials.
# DOWNLOAD_TEST_COS_BUCKET=
# DOWNLOAD_TEST_COS_SECRET_ID=
+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: e34440a7d5eaa2315c20bca53050350d910e0636
README.zh.md: da719830ac04766d1e1a95041f0a3d7da92b980c
README.md: 0fc43998495f43b5b8586a21e88e13e7764be73e
README.zh.md: 8eadd2fc96199cc2a3f58bb3d746851e3e8b5816
+3 -1
View File
@@ -232,7 +232,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, 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.
Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required.
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. Concurrent timings sum per-file work rather than stage wall time.
Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from <absolute-old-cache>` 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 <absolute-cache>` 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.
+3 -1
View File
@@ -232,7 +232,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。
缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。
使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from <absolute-old-cache>` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory <absolute-cache>` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。
@@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
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|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
@@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options =
resolveDesktopAppId(environment)
resolveDesktopPolicyEnvironment(environment)
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
else resolveWindowsPackageSettings(environment)
if (options.unsigned) return
if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
if (target.platform === 'win32') {
+1
View File
@@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
+4 -1
View File
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
@@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
async function main(): Promise<void> {
if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64')
const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env)
const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run')
await readFile(join(runDirectory, 'run.json'))
@@ -67,7 +69,8 @@ async function main(): Promise<void> {
await prepareWindowsSignatureCacheDirectory(cacheRoot)
const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint,
sign, inspect: inspectWindowsRuntimeSignature, record })
const options = { thumbprint, sign: cachedSign, record }
const options = { thumbprint, sign: cachedSign, record,
cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } }
record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity })
try {
if (process.argv.includes('--dsh')) {
@@ -0,0 +1,6 @@
/**
* Validate the file-owned Windows cache worker limit 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.
*/
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number }
@@ -0,0 +1,14 @@
/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */
/**
* Validate the cache worker limit without accessing signing hardware.
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
* @returns {{signatureCacheConcurrency: number}} Worker limit, 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) }
}
@@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions {
sign: ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>
inspect?: typeof inspectWindowsRuntimeSignature
record: (event: object) => void
/** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */
cache?: {
restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore']
concurrency: number
}
}
/**
@@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise<string[]>
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param root Materialized directory to sign.
* @param options Signer, certificate identity and audit sink.
* @returns Resolves after verified sequential signing; rejects without retries.
* @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries.
*/
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
@@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) {
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param {string} root - Owned final runtime directory.
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
* @returns {Promise<void>} Resolves only after sequential signatures and verification; no retries.
* @returns {Promise<void>} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting.
*/
export async function signWindowsCode(root, options) {
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
@@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) {
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
}
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
for (const path of unsigned) {
await options.sign({ path, hash: 'sha256', isNest: false })
async function verifySigned(path) {
const signature = await inspect(path)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error(`Windows code: signing verification failed: ${path}`)
}
options.record({ type: 'windows-code-signature-verified', path, ...signature })
}
const restored = new Set()
if (options.cache) {
let next = 0
let failure
const worker = async () => {
while (failure === undefined && next < unsigned.length) {
const path = unsigned[next++]
try {
if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) {
await verifySigned(path)
restored.add(path)
}
} catch (error) { failure ??= { error } }
}
}
await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker))
if (failure !== undefined) throw failure.error
}
for (const path of unsigned) {
if (restored.has(path)) continue
await options.sign({ path, hash: 'sha256', isNest: false })
await verifySigned(path)
}
}
/**
@@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions {
*/
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */
/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */
export interface WindowsSignatureCacheSummary {
root: string
identity: string
@@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary {
signingMs: number
}
/** Serial cache callback with a snapshot of this instance's completed and failed operations. */
/** Serial signing callback with a separate hardware-free restore operation and shared counters. */
export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> & {
/**
* Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock.
* @param configuration Unsigned target owned exclusively by the caller.
* @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure.
*/
restore(configuration: Parameters<ReturnType<typeof createWindowsTokenSigner>>[0]): Promise<boolean>
/** @returns Current counters and elapsed times; does not wait for queued operations. */
summary(): WindowsSignatureCacheSummary
}
@@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> &
/**
* Reuse complete signed files only after input, integrity, identity and timestamp checks.
* @param options Cache policy, supervised signer, public-key verifier and audit sink.
* @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries.
* @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired.
*/
export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner
@@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) {
/**
* Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy.
* @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp.
*/
export function createCachedSigner(options) {
let pending = Promise.resolve()
@@ -142,7 +142,7 @@ export function createCachedSigner(options) {
if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record')
return record
}
async function sign(configuration) {
async function sign(configuration, restoreOnly = false) {
if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported')
const inputDigest = digest(await regularFile(configuration.path))
const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity }))
@@ -173,8 +173,9 @@ export function createCachedSigner(options) {
} finally { statistics.restoreMs += performance.now() - start }
statistics.hits++
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
return
return true
}
if (restoreOnly) return false
statistics.misses++
options.record({ type: 'signature-cache-miss', key, path: configuration.path })
const start = performance.now()
@@ -210,9 +211,10 @@ export function createCachedSigner(options) {
} finally { await rm(staging, { recursive: true, force: true }) }
}
const signer = configuration => {
pending = pending.then(() => sign(configuration))
pending = pending.then(async () => { await sign(configuration) })
return pending
}
signer.restore = configuration => sign(configuration, true)
signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits })
return signer
}
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs'
const WINDOWS = { platform: 'win32', arch: 'x64' } as const
const MACOS = { platform: 'darwin', arch: 'arm64' } as const
@@ -21,6 +22,30 @@ async function withDirectory(action: (directory: string) => Promise<void>): Prom
}
describe('Desktop local packaging configuration', () => {
it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => {
await withDirectory(async (directory) => {
const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' }
await writeFile(join(directory, '.env.windows'), '')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4)
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2)
})
})
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))
})
it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => {
expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8')
for (const options of [{ unsigned: true }, { prepareOnly: true }]) {
expect(() => {
validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options)
}).toThrow('integer from 1 to 8')
}
})
it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => {
await withDirectory(async (directory) => {
await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n')
@@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() })
const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' }
function supervisor(failure?: string) {
vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs')
@@ -56,6 +57,10 @@ it('requires one signing preflight before building, then records only the comple
for (const call of run.run.mock.calls) {
if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) {
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY')
}
if (call[0].startsWith('run sign:primary-runtime')) {
expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2')
}
}
expect(writeFileSync).toHaveBeenCalledOnce()
@@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise<string
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const signed = new Set<string>()
const verified = new Set<string>()
let active = 0
let peak = 0
let hits = 0
const sign = vi.fn(async ({ path }: { path: string }) => {
expect(active).toBe(0)
expect(hits).toBe(2)
expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true)
signed.add(path)
})
const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign,
inspect: async (path) => {
if (!signed.has(path)) return unsigned
verified.add(path)
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
active++
peak = Math.max(peak, active)
if (active === 2) entered.resolve(undefined)
await release.promise
active--
if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true }
return false
} },
})
try {
await Promise.race([entered.promise, operation])
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(peak).toBe(2)
expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name)))
})
it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const failed = Promise.withResolvers<undefined>()
const restored = new Set<string>()
const calls: string[] = []
const sign = vi.fn(async () => {})
let settled = false
const operation = signWindowsCode(root, { thumbprint, sign,
record: (event) => {
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') {
failed.resolve(undefined)
throw new Error('audit failed')
}
},
inspect: async (path) => {
if (!restored.has(path)) return unsigned
if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') }
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
calls.push(path)
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false }
await entered.promise
if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') }
restored.add(path)
return true
} },
}).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
try {
await Promise.race([failed.promise, operation])
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(await operation).toEqual(new Error(`${failure} failed`))
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
expect(sign).not.toHaveBeenCalled()
})
it('selects real PE code, including .node, without signing foreign native modules or data', async () => {
const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary'])
await mkdir(join(root, 'nested'))
@@ -23,6 +23,22 @@ async function fixture(t: TestContext) {
return { root, path, cache, state, options, request }
}
test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => {
const f = await fixture(t)
const signer = createCachedSigner(f.options)
assert.equal(await signer.restore(f.request), false)
assert.equal(f.state.hardwareCalls, 0)
assert.equal(signer.summary().misses, 0)
assert.equal(await readFile(f.path, 'utf8'), 'original')
await signer(f.request)
const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')]
await Promise.all(targets.map(path => writeFile(path, 'original')))
assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true])
assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original'])
assert.equal(f.state.hardwareCalls, 1)
assert.equal(signer.summary().hits, 2)
})
test('unchanged bytes restored in another file avoid another hardware call', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
@@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => {
assert.equal(f.state.hardwareCalls, 3)
})
for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => {
for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
@@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops
assert(entry)
await writeFile(join(f.cache, entry, corrupt), 'corrupt')
const signer = createCachedSigner(f.options)
await assert.rejects(signer(f.request))
await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request))
assert.equal(signer.summary().validationFailures, 1)
assert.equal(signer.summary().avoidedSigningCalls, 0)
assert.equal(f.state.hardwareCalls, 1)
@@ -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 packages/client/ui-settings-general/README.md
README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711
README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6
README.md: ebdb22adc071b5df435fddd6620dd982d866aa36
README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7
@@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request.
These limits define what the shell itself provides versus what features must supply; they are current package constraints.
- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences.
- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688).
<a id="dev-note"></a>
### Dev Note
@@ -103,6 +103,7 @@ kind: "package-reference"
这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。
- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。
- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。
<a id="dev-note"></a>
### 开发备注
+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 packages/client/ui-sidebar/README.md
README.md: 1c5306b8b3efa5d7908cfd33206acefef20fc3a8
README.zh.md: d72da040252f391bcc1468c9e29adf6a5ef85ef0
README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e
README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5
+1 -1
View File
@@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge`
During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation.
On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region.
On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; an occupying `sidebar.toggle.badge` chooses its own bubble side.
### macOS desktop
+1 -1
View File
@@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。
在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件自行决定气泡展开方向。
### macOS 桌面
@@ -107,6 +107,10 @@ export function SidebarRoot({
}, [collapsed])
const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar')
const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
// The Windows caption menus occupy the strip to the right of these controls
// (that is what --dsh-windows-menu-start reserves), so a right-side bubble
// lands under their text. Below the caption is the only clear side.
const captionTooltipSide = windowsTitlebar ? 'bottom' : 'right'
// Freeze the content at its expanded width while it fades out (collapsed
// && wide): the sliding column then clips it instead of reflowing it. The
@@ -169,7 +173,7 @@ export function SidebarRoot({
// (the expand affordance, figma sidebar-hover flow). Expanded it is a plain
// panel icon.
const toggle = (
<Tooltip label={collapsed ? t('toggle.open') : t('toggle.collapse')} delayMs={500}>
<Tooltip label={collapsed ? t('toggle.open') : t('toggle.collapse')} delayMs={500} side={captionTooltipSide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
@@ -247,7 +251,7 @@ export function SidebarRoot({
</div>
{/* Expanded, the button carries its own label — tooltip only on the rail. */}
<Tooltip label={t('session.new.label')} delayMs={500} disabled={wide}>
<Tooltip label={t('session.new.label')} delayMs={500} disabled={wide} side={captionTooltipSide}>
<button
type="button"
className={css.newSession}
@@ -245,3 +245,38 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
expect(toggleSidebar).toHaveBeenCalledOnce()
expect(startSession).toHaveBeenCalledOnce()
})
describe('Windows caption tooltips', () => {
afterEach(() => { document.documentElement.removeAttribute('data-windows-titlebar') })
const hover = (button: HTMLElement): void => {
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
}
it.each([false, true])(
'drops the sidebar toggle bubble below the caption (collapsed=%s)',
(collapsed) => {
vi.useFakeTimers()
document.documentElement.setAttribute('data-windows-titlebar', '')
mountShell({ collapsed, width: collapsed ? 0 : 300 })
hover(screen.getByRole('button', { name: collapsed ? 'Open sidebar' : 'Collapse sidebar' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
},
)
it('drops the collapsed New Session bubble below the caption as well', () => {
vi.useFakeTimers()
document.documentElement.setAttribute('data-windows-titlebar', '')
mountShell({ collapsed: true, width: 0 })
hover(screen.getByRole('button', { name: 'New session' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
})
it('keeps the ordinary Web bubble beside its anchor', () => {
vi.useFakeTimers()
mountShell({ collapsed: true, width: 0 })
hover(screen.getByRole('button', { name: 'Open sidebar' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('right')
})
})