From 8e89080ba5d861c60747aa9f784183ba543fcc0e Mon Sep 17 00:00:00 2001 From: std4453 Date: Mon, 26 Oct 2026 13:16:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(desktop):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=AE=89=E8=A3=85=E5=8C=85=E5=90=8E=E5=88=B7?= =?UTF-8?q?=E6=96=B0=20EdgeOne=20=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/.env.macos.example | 6 + apps/desktop/.env.windows.example | 6 + apps/desktop/package.json | 6 +- apps/desktop/publishing/README.i18n.yaml | 8 +- apps/desktop/publishing/README.md | 8 +- apps/desktop/publishing/README.zh.md | 8 +- .../desktop-auto-update-environment.d.mts | 7 + .../desktop-auto-update-environment.mjs | 11 ++ apps/desktop/scripts/desktop-edgeone.ts | 162 ++++++++++++++++++ .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/desktop-upload-plan.ts | 3 +- apps/desktop/scripts/upload-target.ts | 37 +++- .../scripts/upload-with-credentials.ps1 | 22 ++- package.json | 1 + pnpm-lock.yaml | 104 +++++++++++ 15 files changed, 370 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/scripts/desktop-edgeone.ts diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index cc3d21f3e8..672071d922 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -56,3 +56,9 @@ APPLE_API_ISSUER= # DOWNLOAD_PROD_COS_BUCKET= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= + +# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Required for upload:latest:* and purge:latest; configure the download domain's EO zone. +# DOWNLOAD_PROD_EO_ZONE_ID= +# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 +# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9d1a976d5d..efd679b245 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -41,3 +41,9 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 # DOWNLOAD_PROD_COS_BUCKET= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= + +# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Required for upload:latest:* and purge:latest; configure the download domain's EO zone. +# DOWNLOAD_PROD_EO_ZONE_ID= +# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 +# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b626dab3f9..a39070a78c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -43,7 +43,8 @@ "upload:win:x64": "tsx scripts/upload-target.ts win-x64", "upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest", "upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest", - "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest" + "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest", + "purge:latest": "tsx scripts/upload-target.ts --latest --purge-only" }, "dependencies": { "electron-updater": "^6.8.9", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "zod": "4.4.3", - "@deepseek-ai/dsh-util-file-drag": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "tencentcloud-sdk-nodejs-teo": "4.1.330" } } diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 9483eaff54..cee11ae495 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -24,11 +24,11 @@ en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: 38054a5f89590cfa - zh: 9e115f9bdeeab213 + en: 2a4c72e884502909 + zh: 29b112db69971cc7 /desktop-publication/upload-credentials: - en: 1c5689525753b08d - zh: 93089de765806733 + en: 3a14a53c7eca80f7 + zh: bb122276f6648f74 /desktop-publication/upload-audit-and-recovery: en: ec85f43ece09a5e3 zh: 2de5bf801c9c9e39 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 71c52c1d75..0b5f40901f 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -69,13 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the ## Fixed installer downloads -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. + +An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including URL, task ID, status events and final result. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; EOF, timeout or access denial does not imply EO canceled the task. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. ## Upload credentials -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For cache-only recovery, add `-PurgeOnly` to `-Upload -Latest -Environment production`; optionally add `-JobId ` to resume. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 22952efae4..a610c72f02 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -69,13 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo ## 固定安装包下载 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限,无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 + +缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含 URL、任务 ID、状态事件和最终结果。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接结束、超时或权限拒绝不代表 EO 已取消任务。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 ## 上传凭据 -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅恢复缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`;可选的 `-JobId ` 用于继续等待。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index f656f9baaf..a331c88388 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,6 +7,13 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' +/** + * Name the fixed installer object shared by uploading and cache refresh. + * @param target Supported Desktop target. + * @returns Fixed download filename. + */ +export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string + /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index a1973a9c64..d8d2dac198 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -35,6 +35,17 @@ const UPDATE_ENVIRONMENTS = { const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) +/** + * Name the fixed installer object shared by uploading and cache refresh. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target. + * @returns {string} Fixed download filename. + */ +export function desktopLatestInstallerFilename(target) { + if (!UPDATE_TARGETS.has(target)) throw new Error(`desktop upload: unsupported target ${target}`) + const windows = target === 'win-x64' + return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}` +} + /** * Resolve the update deployment, defaulting local release work to test. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts new file mode 100644 index 0000000000..ce7ddf6940 --- /dev/null +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -0,0 +1,162 @@ +/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { DesktopPackageTargetName } from './package-target.ts' +import { desktopLatestInstallerFilename, resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { recordPackagingEvent } from './packaging-run.mjs' + +/** Validated production destination and credentials; never serialize this value. */ +export interface DesktopPurgeConfig { + readonly target: DesktopPackageTargetName + readonly zoneId: string + readonly publicUrl: string + readonly secretId: string + readonly secretKey: string + readonly timeoutMs: number + readonly pollIntervalMs: number +} + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]?.trim() + if (!value) throw new DesktopPurgeError(`desktop purge: ${name} must be set in the target dotenv`) + return value +} + +function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + if (env[name] === undefined) return fallback + const value = Number(env[name]) + if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) { + throw new DesktopPurgeError(`desktop purge: ${name} must be a positive timer duration in milliseconds`) + } + return value +} + +/** + * Resolve the production URL, EO zone and existing COS credentials before any upload. + * @param env Target dotenv settings, with any credential-launcher replacement already applied. + * @param target Fixed installer selected by the operator. + * @returns Required EO settings; test deployments and invalid settings throw before network writes. + */ +export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName): DesktopPurgeConfig { + if (env.DSH_DESKTOP_AUTO_UPDATE_ENV?.trim() !== 'production') throw new DesktopPurgeError('desktop purge: production is required') + const update = resolveDesktopAutoUpdateConfig(env, target === 'win-x64' ? 'win32' : 'darwin', target === 'mac-arm64' ? 'arm64' : 'x64') + const zoneId = required(env, 'DOWNLOAD_PROD_EO_ZONE_ID') + if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') + const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000) + const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000) + if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout') + return { + target, zoneId, publicUrl: `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + secretId: required(env, 'DOWNLOAD_PROD_COS_SECRET_ID'), secretKey: required(env, 'DOWNLOAD_PROD_COS_SECRET_KEY'), + timeoutMs, pollIntervalMs, + } +} + +/** A failed or incomplete purge, with only safe diagnostics available to CLI callers. */ +export class DesktopPurgeError extends Error { + constructor(message: string) { + super(message) + this.name = 'DesktopPurgeError' + } +} + +function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined { + if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value) + || value.includes(config.secretId) || value.includes(config.secretKey)) return undefined + return value +} + +/** + * Submit one URL purge, or wait for an existing task without submitting again. + * @param config Validated production destination and credentials. + * @param recordsRoot Parent directory for separate purge records; upload records remain intact. + * @param existingJobId EO task to resume; omitted submits once, without automatic write retries. + * @returns Resolves only after EO reports success for the exact target URL and task ID. + * @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged. + */ +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, existingJobId?: string): Promise { + if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) { + throw new DesktopPurgeError('desktop purge: invalid --job-id') + } + let directory: string | undefined + let jobId = existingJobId + let stage = 'prepare' + let success = false + let failure: string | undefined + let rejected = false + let report: DesktopPurgeError | undefined + try { + await mkdir(recordsRoot, { recursive: true }) + directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`)) + process.stdout.write(`desktop purge: record ${directory}\n`) + await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ zoneId: config.zoneId, publicUrl: config.publicUrl, + target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) } + const client = new teo.v20220901.Client({ + credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', + profile: { httpProfile }, + }) + const deadline = performance.now() + config.timeoutMs + if (jobId === undefined) { + stage = 'CreatePurgeTask' + recordPackagingEvent(directory, { type: 'purge-intent', url: config.publicUrl }) + const response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + jobId = safeField(response.JobId, config) + if (jobId) process.stdout.write(`desktop purge: task ${jobId}\n`) + recordPackagingEvent(directory, { type: 'purge-submitted', jobId, requestId: safeField(response.RequestId, config) }) + if ((response.FailedList?.length ?? 0) > 0) { + rejected = true + throw new DesktopPurgeError('EO rejected the URL purge') + } + if (!jobId) throw new DesktopPurgeError('EO returned no task ID; submission outcome is unknown') + } + stage = 'DescribePurgeTasks' + while (performance.now() < deadline) { + client.profile.httpProfile = { ...httpProfile, reqTimeout: Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) } + const response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, + Filters: [{ Name: 'job-id', Values: [jobId] }, { Name: 'target', Values: [config.publicUrl] }] }) + if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit') + const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? [] + const task = tasks[0] + if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID') + const status = task?.Status + recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) }) + if (status === 'success') { + success = true + stage = 'complete' + break + } + rejected = status === 'failed' || status === 'timeout' || status === 'canceled' + if (task !== undefined && status !== 'processing') throw new DesktopPurgeError(`EO task did not succeed (${safeField(status, config) ?? 'unknown status'})`) + const remaining = deadline - performance.now() + if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) + } + if (!success) throw new DesktopPurgeError('timed out waiting for EO; task completion is unknown') + } catch (error) { + const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})` + const recovery = rejected + ? `Resolve the EO failure, then retry with pnpm run purge:latest ${config.target}.` + : jobId ? `Resume with pnpm run purge:latest ${config.target} --job-id ${jobId}.` + : 'Inspect EO task history before submitting another purge.' + const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation') + ? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : '' + report = new DesktopPurgeError(`desktop purge: ${failure}.${permission} ${recovery}`) + } finally { + if (directory !== undefined) { + try { + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + } catch (error) { + // An unavailable journal cannot establish completion for a subsequent process. + report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`) + } + } + } + if (report !== undefined) throw report + process.stdout.write(`desktop purge: completed ${config.publicUrl}\n`) +} diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 9877b25d09..fa2bdaf3b8 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' import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.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 SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_(?:ZONE_ID|TIMEOUT_MS|POLL_INTERVAL_MS)|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|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|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 diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index aa05966c4a..1461444fc6 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -8,6 +8,7 @@ import { dump, load } from 'js-yaml' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, + desktopLatestInstallerFilename, desktopUpdateMetadataFilename, resolveDesktopUploadConfig, } from './desktop-auto-update-environment.mjs' @@ -277,7 +278,7 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` + const latestFilename = desktopLatestInstallerFilename(targetName) const latestKey = `desktop/${latestFilename}` return { environment: update.environment, diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index 2285512014..f94e0fdb72 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -1,4 +1,4 @@ -/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */ +/** Upload Desktop artifacts to COS and refresh production fixed downloads through EdgeOne. */ import { resolve } from 'node:path' import { parseArgs } from 'node:util' @@ -9,6 +9,7 @@ import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts' import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts' import { uploadDesktopRelease } from './desktop-upload-run.ts' +import { DesktopPurgeError, purgeDesktopInstaller, resolveDesktopPurgeConfig } from './desktop-edgeone.ts' const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) @@ -59,13 +60,15 @@ export function resolveCredentialUploadEnvironment( } /** - * Upload the selected target and record a release tag only for production update uploads. + * Upload the selected target, refresh production fixed downloads, or resume only their cache refresh. * @param args Target and upload options from the command line. - * @returns Resolves after upload and any release-tag attempt complete. + * @returns Resolves after upload, any required EO refresh, and any release-tag attempt complete. + * @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { - const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { + const { positionals, values } = parseArgs({ args: args.filter(arg => arg !== '--'), allowPositionals: true, options: { latest: { type: 'boolean' }, + 'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] @@ -73,6 +76,9 @@ export async function uploadDesktopTarget(args: string[]): Promise { throw new Error('desktop upload: expected exactly one target') } const name = targetName(target) + if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) { + throw new Error('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + } const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') const launcher = values['credential-launcher'] === true if (launcher ? values.environment === undefined || values.bucket === undefined @@ -85,13 +91,26 @@ export async function uploadDesktopTarget(args: string[]): Promise { const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment + const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') + if (values['purge-only']) { + await purgeDesktopInstaller(resolveDesktopPurgeConfig(environment, name), recordsRoot, values['job-id']) + return + } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) + const purge = plan.environment === 'production' && values.latest ? resolveDesktopPurgeConfig(environment, name) : undefined const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), }) process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) - await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) + await uploadDesktopRelease(plan, cos, recordsRoot) + if (purge !== undefined) { + try { + await purgeDesktopInstaller(purge, recordsRoot) + } catch (error) { + throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`) + } + } if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } @@ -122,8 +141,12 @@ function recordProductionRelease(plan: DesktopUploadPlan): void { } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - uploadDesktopTarget(process.argv.slice(2)).catch(() => { - process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n') + uploadDesktopTarget(process.argv.slice(2)).catch((error) => { + const message = error instanceof DesktopPurgeError ? error.message + : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' + // The DPAPI launcher suppresses raw stderr; this error contains only public purge diagnostics. + const output = error instanceof DesktopPurgeError && process.argv.includes('--credential-launcher') ? process.stdout : process.stderr + output.write(`${message}\n`) process.exitCode = 1 }) } diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index 4f146d59b8..410ff31384 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -18,6 +18,10 @@ COS bucket for an explicit upload. Not needed for the local credential check. Publish only the installer at its fixed download URL. Requires Upload. .PARAMETER Upload Authorize the existing target upload entry. Omit to check credentials locally. +.PARAMETER PurgeOnly +Refresh the production fixed download without uploading. Requires Upload and Latest. +.PARAMETER JobId +Resume an existing EO task without submitting again. Requires PurgeOnly. #> [CmdletBinding(DefaultParameterSetName = 'Check')] param( @@ -36,7 +40,12 @@ param( [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] [switch]$Upload, [Parameter(ParameterSetName = 'Publish')] - [switch]$Latest + [switch]$Latest, + [Parameter(ParameterSetName = 'Publish')] + [switch]$PurgeOnly, + [Parameter(ParameterSetName = 'Publish')] + [ValidatePattern('^[a-zA-Z0-9-]{1,128}$')] + [string]$JobId ) $ErrorActionPreference = 'Stop' @@ -50,6 +59,9 @@ $startInfo = $null $stage = 'decrypt-file' try { + if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { + throw 'PurgeOnly requires Latest and production; JobId requires PurgeOnly.' + } if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows DPAPI is required.' } @@ -92,7 +104,13 @@ try { $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" if ($Latest) { $startInfo.Arguments += " --latest" } - Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." + if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" } + if ($JobId) { $startInfo.Arguments += " --job-id $JobId" } + if ($PurgeOnly) { + Write-Output "desktop credentials: refreshing $Target in $Environment; no installer upload." + } else { + Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." + } } else { $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)" $startInfo.Arguments = "-e `"$probe`"" diff --git a/package.json b/package.json index 368901f21a..76a0364be1 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64", "upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64", "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", + "purge:latest": "pnpm --filter @deepseek-ai/dsh-desktop run purge:latest", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsx scripts/build.ts --scope typecheck", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98230d746f..a4743039c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: sharp: specifier: 0.35.5 version: 0.35.5(@types/node@22.20.1) + tencentcloud-sdk-nodejs-teo: + specifier: 4.1.330 + version: 4.1.330(supports-color@9.4.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -21128,6 +21131,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -22396,6 +22403,17 @@ packages: resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} engines: {node: '>= 0.12'} + form-data@3.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==} + engines: {node: '>= 6'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -22499,6 +22517,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -22674,6 +22696,10 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -22738,6 +22764,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -22801,6 +22831,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -24638,6 +24672,14 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + tencentcloud-sdk-nodejs-common@4.1.220: + resolution: {integrity: sha512-FlRJzgFYqSz1blKyN39Jhy/WdK3VuyFkn2+naKJfpwWcSpLF0F9AqSzPAtgorb5/Ezs8sLmFSCuaqFcCw+uIWw==} + engines: {node: '>=10'} + + tencentcloud-sdk-nodejs-teo@4.1.330: + resolution: {integrity: sha512-t23eGzL646ldJMWH/zfdyjsnjDvphYj5ccoKu5nr8ToI1O9u6VVuTHF1fSTlqSZF227qILh8pl/f894BTHIp3w==} + engines: {node: '>=10'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -24776,6 +24818,9 @@ packages: unrun: optional: true + tslib@1.13.0: + resolution: {integrity: sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -24946,6 +24991,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -28575,6 +28625,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2(supports-color@9.4.0): + dependencies: + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agent-base@9.0.0: {} @@ -30117,6 +30173,14 @@ snapshots: mime-types: 2.1.35 safe-buffer: 5.2.1 + form-data@3.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -30240,6 +30304,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@6.0.1: {} + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -30493,6 +30559,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1(supports-color@9.4.0): + dependencies: + agent-base: 6.0.2(supports-color@9.4.0) + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6(supports-color@9.4.0): dependencies: agent-base: 7.1.4 @@ -30550,6 +30623,8 @@ snapshots: ini@1.3.8: {} + ini@2.0.0: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -30588,6 +30663,8 @@ snapshots: is-promise@4.0.0: {} + is-stream@2.0.1: {} + is-stream@4.0.1: {} is-typedarray@1.0.0: {} @@ -32659,6 +32736,29 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + tencentcloud-sdk-nodejs-common@4.1.220(supports-color@9.4.0): + dependencies: + form-data: 3.0.5 + get-stream: 6.0.1 + https-proxy-agent: 5.0.1(supports-color@9.4.0) + ini: 2.0.0 + is-stream: 2.0.1 + json-bigint: 1.0.0 + node-fetch: 2.7.0 + tslib: 1.13.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + tencentcloud-sdk-nodejs-teo@4.1.330(supports-color@9.4.0): + dependencies: + tencentcloud-sdk-nodejs-common: 4.1.220(supports-color@9.4.0) + tslib: 1.13.0 + transitivePeerDependencies: + - encoding + - supports-color + text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -32768,6 +32868,8 @@ snapshots: - oxc-resolver - vue-tsc + tslib@1.13.0: {} + tslib@2.8.1: {} tsx@4.23.13: @@ -32935,6 +33037,8 @@ snapshots: uuid@8.3.2: {} + uuid@9.0.1: {} + vary@1.1.2: {} verror@1.10.0: From ad5dfe7658df8b998da7dacd0274a068eca4f0d1 Mon Sep 17 00:00:00 2001 From: zhc Date: Mon, 26 Oct 2026 14:01:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D=20EdgeOn?= =?UTF-8?q?e=20=E8=BD=AE=E8=AF=A2=E5=92=8C=E5=8F=91=E5=B8=83=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THIRD_PARTY_NOTICES.md | 1 + apps/desktop/publishing/README.i18n.yaml | 8 +- apps/desktop/publishing/README.md | 6 +- apps/desktop/publishing/README.zh.md | 6 +- .../desktop-auto-update-environment.d.mts | 8 + .../desktop-auto-update-environment.mjs | 2 +- apps/desktop/scripts/desktop-build-paths.mjs | 10 +- apps/desktop/scripts/desktop-edgeone.ts | 104 ++++++--- apps/desktop/scripts/desktop-upload-run.ts | 2 +- apps/desktop/scripts/upload-target.ts | 84 ++++--- .../scripts/upload-with-credentials.ps1 | 8 +- apps/desktop/tests/desktop-edgeone.spec.ts | 220 ++++++++++++++++++ apps/desktop/tests/purge-command.spec.ts | 64 +++++ apps/desktop/tests/upload-target.spec.ts | 92 +++++++- .../tests/upload-with-credentials.spec.ts | 17 +- pnpm-lock.yaml | 13 +- 16 files changed, 554 insertions(+), 91 deletions(-) create mode 100644 apps/desktop/tests/desktop-edgeone.spec.ts create mode 100644 apps/desktop/tests/purge-command.spec.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1295efff31..11d99ff8aa 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -267,6 +267,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT | | [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | | [`vite`](https://github.com/vitejs/vite) | MIT | diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index cee11ae495..4cbef48dc5 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -18,14 +18,14 @@ en: b407de63118c9bf9 zh: de19f1ba9feacbbf /desktop-publication/deployment-and-test-release-identity: - en: 405588e021bcfb60 - zh: d9f9700b0b6db6f8 + en: eed7c4ed0d4e2758 + zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: 2a4c72e884502909 - zh: 29b112db69971cc7 + en: fc2d126835d00f12 + zh: fceda915f9ce5558 /desktop-publication/upload-credentials: en: 3a14a53c7eca80f7 zh: bb122276f6648f74 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 0b5f40901f..5559cd0177 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. -The update destination and upload credentials follow the selected deployment: +The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads): | Environment | Public origin | COS bucket | COS credentials | |---|---|---|---| @@ -71,9 +71,9 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. -Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. -An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including URL, task ID, status events and final result. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; EOF, timeout or access denial does not imply EO canceled the task. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. +An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including versioned plans/results, script fingerprints, URL, task ID and status events. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index a610c72f02..6dd23e1cda 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 -更新目标与上传凭据都与所选环境对应: +更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads): | 环境 | 公开 origin | COS bucket | COS 凭据 | |---|---|---|---| @@ -71,9 +71,9 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo 固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 -生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限,无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 -缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含 URL、任务 ID、状态事件和最终结果。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接结束、超时或权限拒绝不代表 EO 已取消任务。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 +缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index a331c88388..4d42b3dd1e 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -105,3 +105,11 @@ export function resolveDesktopUploadConfig( * @returns Validated channel. */ export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' + +/** + * Read a required nonempty deployment setting. + * @param env Packaging or upload settings. + * @param name Setting name included in missing-value diagnostics. + * @returns Trimmed setting value. + */ +export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index d8d2dac198..835beadd6d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -110,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh * @param {string} name - Environment variable to read. * @returns {string} Trimmed setting. */ -function requiredEnvironmentValue(env, name) { +export function requiredEnvironmentValue(env, name) { const value = env[name]?.trim() if (value === undefined || value === '') { throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`) diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index bf2361a695..edfd9cdf78 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) { */ export function desktopTargetPlatform(target) { assertSupportedTarget(target) - return { - platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'), - arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'), - } + const targets = /** @type {const} */ ({ + 'mac-arm64': { platform: 'darwin', arch: 'arm64' }, + 'mac-x64': { platform: 'darwin', arch: 'x64' }, + 'win-x64': { platform: 'win32', arch: 'x64' }, + }) + return targets[target] } /** diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts index ce7ddf6940..30b530d854 100644 --- a/apps/desktop/scripts/desktop-edgeone.ts +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -1,10 +1,12 @@ /** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' -import { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { teo } from 'tencentcloud-sdk-nodejs-teo' import type { DesktopPackageTargetName } from './package-target.ts' -import { desktopLatestInstallerFilename, resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' import { recordPackagingEvent } from './packaging-run.mjs' /** Validated production destination and credentials; never serialize this value. */ @@ -18,10 +20,14 @@ export interface DesktopPurgeConfig { readonly pollIntervalMs: number } -function required(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]?.trim() - if (!value) throw new DesktopPurgeError(`desktop purge: ${name} must be set in the target dotenv`) - return value +/** SDK operations used by one purge; callers may supply an isolated client. */ +export type DesktopPurgeClient = Pick, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'> + +/** Optional existing task, credential-launcher context, and caller-owned SDK client. */ +export interface DesktopPurgeOptions { + readonly jobId?: string + readonly credentialLauncher?: boolean + readonly client?: DesktopPurgeClient } function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number { @@ -37,19 +43,23 @@ function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): n * Resolve the production URL, EO zone and existing COS credentials before any upload. * @param env Target dotenv settings, with any credential-launcher replacement already applied. * @param target Fixed installer selected by the operator. + * @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery. * @returns Required EO settings; test deployments and invalid settings throw before network writes. */ -export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName): DesktopPurgeConfig { - if (env.DSH_DESKTOP_AUTO_UPDATE_ENV?.trim() !== 'production') throw new DesktopPurgeError('desktop purge: production is required') - const update = resolveDesktopAutoUpdateConfig(env, target === 'win-x64' ? 'win32' : 'darwin', target === 'mac-arm64' ? 'arm64' : 'x64') - const zoneId = required(env, 'DOWNLOAD_PROD_EO_ZONE_ID') +export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, + publicUrl?: string, +): DesktopPurgeConfig { + if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required') + const { platform, arch } = desktopTargetPlatform(target) + const update = resolveDesktopUploadConfig(env, platform, arch) + const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID') if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000) const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000) if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout') return { - target, zoneId, publicUrl: `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, - secretId: required(env, 'DOWNLOAD_PROD_COS_SECRET_ID'), secretKey: required(env, 'DOWNLOAD_PROD_COS_SECRET_KEY'), + target, zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName), timeoutMs, pollIntervalMs, } } @@ -68,15 +78,33 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin return value } +function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined { + return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) +} + +function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean { + const code = errorCode(error, config) + // The pinned SDK wraps transport failures without preserving their original code. + if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) { + return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500)) + } + return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!) + || ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError', + 'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code)) +} + /** * Submit one URL purge, or wait for an existing task without submitting again. * @param config Validated production destination and credentials. * @param recordsRoot Parent directory for separate purge records; upload records remain intact. - * @param existingJobId EO task to resume; omitted submits once, without automatic write retries. + * @param options Existing task, launcher context, and optional SDK client; submission is never retried. * @returns Resolves only after EO reports success for the exact target URL and task ID. * @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged. */ -export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, existingJobId?: string): Promise { +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, + options: DesktopPurgeOptions = {}, +): Promise { + const existingJobId = options.jobId if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) { throw new DesktopPurgeError('desktop purge: invalid --job-id') } @@ -84,6 +112,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR let jobId = existingJobId let stage = 'prepare' let success = false + let observedTask = false + let lastQueryFailed = false let failure: string | undefined let rejected = false let report: DesktopPurgeError | undefined @@ -92,11 +122,12 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`)) process.stdout.write(`desktop purge: record ${directory}\n`) await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) - await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ zoneId: config.zoneId, publicUrl: config.publicUrl, + const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') } + await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, zoneId: config.zoneId, publicUrl: config.publicUrl, target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`, { flag: 'wx', mode: 0o600, flush: true }) const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) } - const client = new teo.v20220901.Client({ + const client = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', profile: { httpProfile }, }) @@ -116,12 +147,28 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR } stage = 'DescribePurgeTasks' while (performance.now() < deadline) { - client.profile.httpProfile = { ...httpProfile, reqTimeout: Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) } - const response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, - Filters: [{ Name: 'job-id', Values: [jobId] }, { Name: 'target', Values: [config.publicUrl] }] }) + client.profile.httpProfile!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) + let response: Awaited> + try { + response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, + Filters: [{ Name: 'job-id', Values: [jobId] }] }) + } catch (error) { + const code = errorCode(error, config) + if (!transientQueryError(error, config)) throw error + lastQueryFailed = true + recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' }) + const remaining = deadline - performance.now() + if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) + continue + } + lastQueryFailed = false if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit') const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? [] + if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) { + throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target') + } const task = tasks[0] + observedTask ||= task !== undefined if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID') const status = task?.Status recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) }) @@ -135,13 +182,18 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR const remaining = deadline - performance.now() if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) } - if (!success) throw new DesktopPurgeError('timed out waiting for EO; task completion is unknown') + if (!success) throw new DesktopPurgeError(observedTask || lastQueryFailed + ? 'timed out waiting for EO; task completion is unknown' + : 'EO task was not found before the deadline; check the zone, job ID and EO task history') } catch (error) { - const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + const code = errorCode(error, config) failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})` + const command = options.credentialLauncher + ? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}` + : `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}` const recovery = rejected - ? `Resolve the EO failure, then retry with pnpm run purge:latest ${config.target}.` - : jobId ? `Resume with pnpm run purge:latest ${config.target} --job-id ${jobId}.` + ? `Resolve the EO failure, then ${command}.` + : jobId ? `Resume with ${command}.` : 'Inspect EO task history before submitting another purge.' const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation') ? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : '' @@ -149,8 +201,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR } finally { if (directory !== undefined) { try { - await writeFile(join(directory, 'result.json'), `${JSON.stringify({ success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) } catch (error) { // An unavailable journal cannot establish completion for a subsequent process. report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`) diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index aa3cd49aa5..6f40c974c7 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) /** Object keys fingerprinted into every upload record to bind evidence to this uploader. */ -const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts'] +const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts'] async function fingerprint(artifact: DesktopUploadArtifact) { const sha512 = createHash('sha512') diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index f94e0fdb72..f95f3cd6e5 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -4,7 +4,8 @@ import { resolve } from 'node:path' import { parseArgs } from 'node:util' import type { DesktopPackageTargetName } from './package-target.ts' import { createDesktopCos } from './desktop-cos.ts' -import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts' import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts' @@ -15,18 +16,13 @@ const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x function targetName(value: string): DesktopPackageTargetName { if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) { - throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) + throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) } return value as DesktopPackageTargetName } -function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string { - const value = environment[name]?.trim() - if (value === undefined || value === '') { - throw new Error(`desktop upload: ${name} must be set to a non-empty value`) - } - return value -} +/** Invalid command arguments or release configuration, reported before network writes. */ +class DesktopUploadUsageError extends Error {} /** * Use the credential launcher's selected deployment only when it matches the packaged release destination. @@ -42,15 +38,14 @@ export function resolveCredentialUploadEnvironment( selected: 'test' | 'production', bucket: string, target: DesktopPackageTargetName, ): NodeJS.ProcessEnv { - const platform = target === 'win-x64' ? 'win32' : 'darwin' - const arch = target === 'mac-arm64' ? 'arm64' : 'x64' + const { platform, arch } = desktopTargetPlatform(target) const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch) if (destination.environment !== selected || destination.bucket !== bucket) { - throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') + throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') } if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected || injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) { - throw new Error('desktop upload: credential launcher environment differs from its explicit arguments') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments') } return { ...fileEnvironment, @@ -59,6 +54,15 @@ export function resolveCredentialUploadEnvironment( } } +function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) { + try { + return resolveDesktopPurgeConfig(environment, target, publicUrl) + } catch (error) { + if (error instanceof DesktopPurgeError) throw error + throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.') + } +} + /** * Upload the selected target, refresh production fixed downloads, or resume only their cache refresh. * @param args Target and upload options from the command line. @@ -66,38 +70,48 @@ export function resolveCredentialUploadEnvironment( * @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { - const { positionals, values } = parseArgs({ args: args.filter(arg => arg !== '--'), allowPositionals: true, options: { + const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { latest: { type: 'boolean' }, 'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] if (target === undefined || positionals.length !== 1) { - throw new Error('desktop upload: expected exactly one target') + throw new DesktopUploadUsageError('desktop upload: expected exactly one target') } const name = targetName(target) if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) { - throw new Error('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + } + let fileEnvironment: NodeJS.ProcessEnv + try { + fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform) + } catch (error) { + throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.') } - const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') const launcher = values['credential-launcher'] === true if (launcher ? values.environment === undefined || values.bucket === undefined : values.environment !== undefined || values.bucket !== undefined) { - throw new Error('desktop upload: credential launcher requires an explicit environment and bucket') + throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket') } if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') { - throw new Error('desktop upload: credential launcher environment must be test or production') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production') } const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment + try { + resolveDesktopAutoUpdateEnvironment(environment) + } catch (error) { + throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".') + } const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') if (values['purge-only']) { - await purgeDesktopInstaller(resolveDesktopPurgeConfig(environment, name), recordsRoot, values['job-id']) + await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher }) return } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) - const purge = plan.environment === 'production' && values.latest ? resolveDesktopPurgeConfig(environment, name) : undefined + const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), @@ -106,7 +120,7 @@ export async function uploadDesktopTarget(args: string[]): Promise { await uploadDesktopRelease(plan, cos, recordsRoot) if (purge !== undefined) { try { - await purgeDesktopInstaller(purge, recordsRoot) + await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher }) } catch (error) { throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`) } @@ -140,13 +154,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void { process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`) } -if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - uploadDesktopTarget(process.argv.slice(2)).catch((error) => { +/** + * Report command failures without exposing raw storage or SDK exceptions. + * @param args Target and options forwarded by the package command or credential launcher. + * @returns Resolves after reporting any failure and setting the process exit status. + */ +export async function runDesktopUploadCommand(args: string[]): Promise { + try { + await uploadDesktopTarget(args) + } catch (error) { + const usage = error instanceof DesktopUploadUsageError + || (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_')) const message = error instanceof DesktopPurgeError ? error.message - : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' - // The DPAPI launcher suppresses raw stderr; this error contains only public purge diagnostics. - const output = error instanceof DesktopPurgeError && process.argv.includes('--credential-launcher') ? process.stdout : process.stderr + : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only [--job-id ]]]; targets: mac-arm64, mac-x64, win-x64.` + : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' + // The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details. + const output = args.includes('--credential-launcher') ? process.stdout : process.stderr output.write(`${message}\n`) process.exitCode = 1 - }) + } +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + void runDesktopUploadCommand(process.argv.slice(2)) } diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index 410ff31384..eb94f61bde 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -58,10 +58,12 @@ $credentials = $null $startInfo = $null $stage = 'decrypt-file' +if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { + Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.' + exit 1 +} + try { - if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { - throw 'PurgeOnly requires Latest and production; JobId requires PurgeOnly.' - } if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows DPAPI is required.' } diff --git a/apps/desktop/tests/desktop-edgeone.spec.ts b/apps/desktop/tests/desktop-edgeone.spec.ts new file mode 100644 index 0000000000..2578b2327b --- /dev/null +++ b/apps/desktop/tests/desktop-edgeone.spec.ts @@ -0,0 +1,220 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts' + +const environment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10', +} +const config = resolveDesktopPurgeConfig(environment, 'mac-arm64') +const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' } +const roots: string[] = [] +let time = 0 + +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) })) + +beforeEach(() => { + time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => time) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) +}) +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-purge-')) + roots.push(root) + // Real SDK initialization, with both network operations replaced before use. + const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } }) + const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' }) + const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] }) + const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) => + purgeDesktopInstaller(config, root, { ...options, client }) + const directory = async () => join(root, (await readdir(root))[0]!) + const result = async (): Promise => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8')) + return { root, client, create, describe, run, directory, result } +} + +function sdkError(code: string) { + return Object.assign(new Error('private service details fixture-secret-key'), { code }) +} + +describe('production EdgeOne configuration', () => { + it.each([ + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u], + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u], + [{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u], + [{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u], + [{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u], + ] as const)('rejects invalid settings %j', (patch, message) => { + expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message) + }) + + it('uses the upload plan URL verbatim and derives only cache-only destinations', () => { + const url = 'https://download.deepseek.com/changed/installer.dmg' + expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url) + expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe') + expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg') + }) +}) + +describe('EdgeOne task tracking', () => { + it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => { + const f = await fixture() + const profile = f.client.profile.httpProfile + f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] }) + await f.run() + expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + expect(f.describe).toHaveBeenCalledTimes(3) + expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] }) + expect(f.client.profile.httpProfile).toBe(profile) + expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 }) + expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' }) + const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8') + const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex') + expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } }) + expect(plan).not.toContain(config.secretId) + expect(plan).not.toContain(config.secretKey) + }) + + it('resumes an existing multi-URL job without resubmission', async () => { + const f = await fixture() + f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] }) + await f.run({ jobId: 'job-1' }) + expect(f.create).not.toHaveBeenCalled() + expect(await f.result()).toMatchObject({ success: true }) + }) + + it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => { + const f = await fixture() + f.describe.mockRejectedValueOnce(sdkError(code)) + await f.run() + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).toHaveBeenCalledTimes(2) + }) + + it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => { + const f = await fixture() + const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '', + ...httpCode === undefined ? {} : { httpCode } }) + f.describe.mockRejectedValue(error) + await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u) + expect(f.describe).toHaveBeenCalledTimes(3) + expect(await f.result()).toMatchObject({ success: false }) + }) + + it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => { + const f = await fixture() + f.describe.mockRejectedValue(sdkError(code)) + await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u')) + expect(f.describe).toHaveBeenCalledOnce() + expect(await f.result()).toMatchObject({ success: false }) + const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8') + expect(evidence).not.toContain('private service details') + expect(evidence).not.toContain(config.secretKey) + }) + + it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => { + const f = await fixture() + f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] }) + await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.') + expect(f.describe).toHaveBeenCalledOnce() + }) + + it('does not poll a rejected submission', async () => { + const f = await fixture() + f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] }) + await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u) + expect(f.describe).not.toHaveBeenCalled() + }) + + it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => { + const f = await fixture() + f.create.mockResolvedValue(response) + await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u) + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).not.toHaveBeenCalled() + }) + + it('never retries a submission with an unknown outcome', async () => { + const f = await fixture() + f.create.mockRejectedValue(sdkError('ECONNRESET')) + await expect(f.run()).rejects.toThrow(/Inspect EO task history/u) + expect(f.create).toHaveBeenCalledOnce() + expect(f.describe).not.toHaveBeenCalled() + }) + + it.each([ + [{ Tasks: [] }, /task was not found/u], + [{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u], + ])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => { + const f = await fixture() + f.describe.mockResolvedValue(response) + await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message) + expect(f.describe).toHaveBeenCalledTimes(3) + }) + + it.each([ + [{ TotalCount: 1001 }, /page limit/u], + [{ Tasks: [task, task] }, /multiple entries/u], + [{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u], + [{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u], + ])('refuses ambiguous or mismatched task responses: %j', async (response, message) => { + const f = await fixture() + f.describe.mockResolvedValue(response) + await expect(f.run()).rejects.toThrow(message) + }) + + it('retains the DPAPI launcher and original identity in recovery instructions', async () => { + const f = await fixture() + f.describe.mockRejectedValue(sdkError('UnauthorizedOperation')) + await expect(f.run({ credentialLauncher: true })).rejects + .toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u) + }) + + it('does not report completion when the final record cannot be saved', async () => { + const f = await fixture() + f.describe.mockImplementation(async () => { + await writeFile(join(await f.directory(), 'result.json'), 'occupied') + return { Tasks: [task] } + }) + await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u) + const output = vi.spyOn(process.stdout, 'write') + expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed')) + }) + + it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => { + const f = await fixture() + await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u) + expect(await readdir(f.root)).toEqual([]) + expect(f.create).not.toHaveBeenCalled() + expect(f.describe).not.toHaveBeenCalled() + }) + + it('lazily initializes the pinned SDK when no client is supplied', async () => { + const f = await fixture() + vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) + vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType) { + expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' }) + return { Tasks: [task] } + }) + await purgeDesktopInstaller(config, f.root) + expect(await f.result()).toMatchObject({ success: true }) + }) + + it('accepts the minimal caller-owned client', async () => { + const f = await fixture() + const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe } + await purgeDesktopInstaller(config, f.root, { client }) + expect(await f.result()).toMatchObject({ success: true }) + }) +}) diff --git a/apps/desktop/tests/purge-command.spec.ts b/apps/desktop/tests/purge-command.spec.ts new file mode 100644 index 0000000000..30a7bbe213 --- /dev/null +++ b/apps/desktop/tests/purge-command.spec.ts @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { z } from 'zod' +import { afterEach, expect, it } from 'vitest' + +const execute = promisify(execFile) +const root = fileURLToPath(new URL('../../..', import.meta.url)) +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +it('forwards the documented pnpm purge command through both package scripts to the real argument parser', async () => { + const directory = await mkdtemp(join(tmpdir(), 'desktop-purge-command-')) + roots.push(directory) + const scripts = join(directory, 'apps/desktop/scripts') + await mkdir(scripts, { recursive: true }) + const manifest = z.object({ name: z.string(), scripts: z.object({ 'purge:latest': z.string() }) }) + const repository = manifest.parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))) + const desktop = manifest.parse(JSON.parse(await readFile(join(root, 'apps/desktop/package.json'), 'utf8'))) + await writeFile(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { 'purge:latest': repository.scripts['purge:latest'] } })) + await writeFile(join(directory, 'pnpm-workspace.yaml'), 'packages:\n - apps/desktop\n') + await writeFile(join(directory, 'apps/desktop/package.json'), JSON.stringify({ name: desktop.name, type: 'module', scripts: { 'purge:latest': desktop.scripts['purge:latest'] } })) + await mkdir(join(directory, 'node_modules')) + for (const dependency of ['.bin', 'pnpm', 'tsx', 'semver']) { + await symlink(join(root, 'node_modules', dependency), join(directory, 'node_modules', dependency), 'junction') + } + await copyFile(join(root, 'apps/desktop/scripts/upload-target.ts'), join(scripts, 'upload-target.ts')) + for (const filename of ['desktop-auto-update-environment.mjs', 'desktop-build-paths.mjs']) { + await copyFile(join(root, 'apps/desktop/scripts', filename), join(scripts, filename)) + } + // Only command forwarding and parsing are under test; all external work fails closed. + const stubs = { + 'desktop-package-environment.mjs': "export const loadDesktopPackageEnvironment = () => ({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })", + 'desktop-cos.ts': "export const createDesktopCos = () => { throw new Error('unexpected COS') }", + 'desktop-upload-plan.ts': "export const createDesktopUploadPlan = () => { throw new Error('unexpected installer read') }", + 'desktop-upload-run.ts': "export const uploadDesktopRelease = () => { throw new Error('unexpected upload') }", + 'desktop-release-tag.ts': "export const desktopReleaseTag = () => { throw new Error('unexpected tag') }; export const tagDesktopRelease = desktopReleaseTag", + 'desktop-edgeone.ts': ` + import assert from 'node:assert/strict' + export class DesktopPurgeError extends Error {} + export const resolveDesktopPurgeConfig = (_env, target) => ({ target }) + export const purgeDesktopInstaller = async (config, _root, options) => { + assert.equal(config.target, 'mac-arm64') + assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false }) + console.log('purge-resume: mac-arm64 job-1') + } + `, + } + await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents))) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name))) + const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1'] + const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => { + expect(error).not.toMatchObject({ killed: true }) + throw error + }) + expect(result.stdout).toContain('purge-resume: mac-arm64 job-1') +}) diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts index 860d7867c2..ce15d7d54a 100644 --- a/apps/desktop/tests/upload-target.spec.ts +++ b/apps/desktop/tests/upload-target.spec.ts @@ -1,9 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopPurgeError, purgeDesktopInstaller } from '../scripts/desktop-edgeone.ts' import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts' import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' -import { uploadDesktopTarget } from '../scripts/upload-target.ts' +import { runDesktopUploadCommand, uploadDesktopTarget } from '../scripts/upload-target.ts' + +vi.mock('../scripts/desktop-edgeone.ts', async importOriginal => ({ + ...await importOriginal(), + purgeDesktopInstaller: vi.fn(), +})) vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() })) vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() })) @@ -27,7 +33,7 @@ describe('desktop upload command', () => { { environment: 'test', latest: false, tagged: false }, ] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => { vi.spyOn(process.stdout, 'write').mockReturnValue(true) - const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } + const credentials = { ...productionEnvironment, DSH_DESKTOP_AUTO_UPDATE_ENV: environment, DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } const plan: DesktopUploadPlan = { environment, target: 'win-x64', version: '1.2.3-alpha.4', publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe', @@ -44,6 +50,15 @@ describe('desktop upload command', () => { expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest }) expect(uploadDesktopRelease).toHaveBeenCalledOnce() expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) + if (environment === 'production' && latest) { + expect(purgeDesktopInstaller).toHaveBeenCalledWith( + expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false }, + ) + expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0]) + .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) + } else { + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + } if (tagged) { expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit })) expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0]) @@ -54,3 +69,74 @@ describe('desktop upload command', () => { } }) }) + +const productionEnvironment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', +} + +describe('cache-only recovery command', () => { + beforeEach(() => { + vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment) + }) + + it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => { + await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []]) + expect(createDesktopUploadPlan).not.toHaveBeenCalled() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(tagDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false }) + }) + + it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { + await expect(uploadDesktopTarget(args)).rejects.toThrow() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + }) + + it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.mocked(createDesktopUploadPlan).mockResolvedValue({ + environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg', + bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [], + }) + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1')) + await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u) + expect(uploadDesktopRelease).toHaveBeenCalledOnce() + expect(purgeDesktopInstaller).toHaveBeenCalledOnce() + expect(tagDesktopRelease).not.toHaveBeenCalled() + }) + + it.each([false, true])('reports safe purge diagnostics to the launcher-visible stream: launcher=%s', async (launcher) => { + const previousExitCode = process.exitCode + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + vi.stubEnv('DSH_DESKTOP_AUTO_UPDATE_ENV', 'production') + vi.stubEnv('DOWNLOAD_PROD_COS_BUCKET', 'fixture-bucket') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_ID', 'injected-id') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_KEY', 'injected-key') + try { + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: safe recovery message')) + await runDesktopUploadCommand(['mac-arm64', '--latest', '--purge-only', ...launcher ? ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'] : []]) + expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n') + expect(launcher ? stderr : stdout).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher })) + } finally { + process.exitCode = previousExitCode + vi.unstubAllEnvs() + } + }) + + it('shows usage without referring to a nonexistent record for invalid arguments', async () => { + const previousExitCode = process.exitCode + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + await runDesktopUploadCommand([]) + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Usage:')) + expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('record directory')) + } finally { + process.exitCode = previousExitCode + } + }) +}) diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 8951f4a01b..4346689ff8 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}]) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}` + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -134,7 +134,14 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('child environment verified') }) - it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it('reports invalid purge arguments before decrypting credentials', async () => { + const result = await check('invalid-purge') + expect(result.code).toBe(1) + expect(result.output).toContain('invalid arguments; PurgeOnly requires Latest and production') + expect(result.output).not.toContain('stage=decrypt-file') + }) + + it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4743039c1..dd99015765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,12 +747,12 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../packages/util/home-paths - '@deepseek-ai/dsh-plugin-manager': - specifier: workspace:* - version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-official-bundles': specifier: workspace:* version: link:../../packages/util/official-bundles + '@deepseek-ai/dsh-plugin-manager': + specifier: workspace:* + version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-util-file-drag': specifier: workspace:* version: link:../../packages/util/file-drag @@ -22404,13 +22404,6 @@ packages: engines: {node: '>= 0.12'} form-data@3.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==} engines: {node: '>= 6'} From 3969c29fa5be89e5f5bd3f524c278bfc06b030c2 Mon Sep 17 00:00:00 2001 From: zhc Date: Mon, 26 Oct 2026 18:00:09 +0800 Subject: [PATCH 3/4] refactor(desktop): simplify EdgeOne purge to submission only --- apps/desktop/.env.macos.example | 4 +- apps/desktop/.env.windows.example | 4 +- apps/desktop/publishing/README.i18n.yaml | 8 +- apps/desktop/publishing/README.md | 8 +- apps/desktop/publishing/README.zh.md | 8 +- apps/desktop/scripts/desktop-edgeone.ts | 199 +++------------ .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/desktop-upload-run.ts | 2 +- apps/desktop/scripts/upload-target.ts | 23 +- .../scripts/upload-with-credentials.ps1 | 16 +- apps/desktop/tests/desktop-edgeone.spec.ts | 227 ++++-------------- apps/desktop/tests/purge-command.spec.ts | 9 +- apps/desktop/tests/upload-target.spec.ts | 23 +- .../tests/upload-with-credentials.spec.ts | 10 +- 14 files changed, 125 insertions(+), 418 deletions(-) diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index 672071d922..7d2336d546 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -57,8 +57,6 @@ APPLE_API_ISSUER= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= -# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. # Required for upload:latest:* and purge:latest; configure the download domain's EO zone. # DOWNLOAD_PROD_EO_ZONE_ID= -# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 -# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index efd679b245..fd45f0cb32 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -42,8 +42,6 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= -# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. # Required for upload:latest:* and purge:latest; configure the download domain's EO zone. # DOWNLOAD_PROD_EO_ZONE_ID= -# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 -# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 4cbef48dc5..99b4b44aef 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -24,11 +24,11 @@ en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: fc2d126835d00f12 - zh: fceda915f9ce5558 + en: ce8296f8f1ee881e + zh: 749ab63bd6ed4248 /desktop-publication/upload-credentials: - en: 3a14a53c7eca80f7 - zh: bb122276f6648f74 + en: 47442fb912fa4d5d + zh: a21a0d67cd01724b /desktop-publication/upload-audit-and-recovery: en: ec85f43ece09a5e3 zh: 2de5bf801c9c9e39 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 5559cd0177..b9bb98af2b 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -69,17 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the ## Fixed installer downloads -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. -Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically. -An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including versioned plans/results, script fingerprints, URL, task ID and status events. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. +The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest ` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh. ## Upload credentials -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For cache-only recovery, add `-PurgeOnly` to `-Upload -Latest -Environment production`; optionally add `-JobId ` to resume. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 6dd23e1cda..0f64fb31ee 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -69,17 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo ## 固定安装包下载 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 -生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。 -缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 +命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest ` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。 ## 上传凭据 -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅恢复缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`;可选的 `-JobId ` 用于继续等待。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts index 30b530d854..e2faf122a0 100644 --- a/apps/desktop/scripts/desktop-edgeone.ts +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -1,76 +1,43 @@ -/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +/** Submit one production fixed-installer cache purge to EdgeOne. */ import type { teo } from 'tencentcloud-sdk-nodejs-teo' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' import { desktopTargetPlatform } from './desktop-build-paths.mjs' -import { recordPackagingEvent } from './packaging-run.mjs' /** Validated production destination and credentials; never serialize this value. */ export interface DesktopPurgeConfig { - readonly target: DesktopPackageTargetName readonly zoneId: string readonly publicUrl: string readonly secretId: string readonly secretKey: string - readonly timeoutMs: number - readonly pollIntervalMs: number } -/** SDK operations used by one purge; callers may supply an isolated client. */ -export type DesktopPurgeClient = Pick, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'> - -/** Optional existing task, credential-launcher context, and caller-owned SDK client. */ -export interface DesktopPurgeOptions { - readonly jobId?: string - readonly credentialLauncher?: boolean - readonly client?: DesktopPurgeClient -} - -function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number { - if (env[name] === undefined) return fallback - const value = Number(env[name]) - if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) { - throw new DesktopPurgeError(`desktop purge: ${name} must be a positive timer duration in milliseconds`) - } - return value -} +/** The single SDK operation needed to submit a purge. */ +export type DesktopPurgeClient = Pick, 'CreatePurgeTask'> /** - * Resolve the production URL, EO zone and existing COS credentials before any upload. - * @param env Target dotenv settings, with any credential-launcher replacement already applied. + * Resolve production purge settings before uploading. + * @param env Target dotenv settings, including any credential-launcher replacement. * @param target Fixed installer selected by the operator. - * @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery. - * @returns Required EO settings; test deployments and invalid settings throw before network writes. + * @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission. + * @returns Validated destination and credentials. */ -export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, - publicUrl?: string, +export function resolveDesktopPurgeConfig( + env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string, ): DesktopPurgeConfig { if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required') const { platform, arch } = desktopTargetPlatform(target) const update = resolveDesktopUploadConfig(env, platform, arch) const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID') if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') - const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000) - const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000) - if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout') return { - target, zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName), - timeoutMs, pollIntervalMs, } } -/** A failed or incomplete purge, with only safe diagnostics available to CLI callers. */ -export class DesktopPurgeError extends Error { - constructor(message: string) { - super(message) - this.name = 'DesktopPurgeError' - } -} +/** A purge submission failure containing only safe CLI diagnostics. */ +export class DesktopPurgeError extends Error {} function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined { if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value) @@ -78,137 +45,29 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin return value } -function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined { - return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) -} - -function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean { - const code = errorCode(error, config) - // The pinned SDK wraps transport failures without preserving their original code. - if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) { - return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500)) - } - return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!) - || ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError', - 'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code)) -} - /** - * Submit one URL purge, or wait for an existing task without submitting again. + * Submit once and report the accepted task ID, without querying cache completion. * @param config Validated production destination and credentials. - * @param recordsRoot Parent directory for separate purge records; upload records remain intact. - * @param options Existing task, launcher context, and optional SDK client; submission is never retried. - * @returns Resolves only after EO reports success for the exact target URL and task ID. - * @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged. + * @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout. + * @returns Resolves when EO accepts the submission; this does not confirm cache completion. + * @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes. */ -export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, - options: DesktopPurgeOptions = {}, -): Promise { - const existingJobId = options.jobId - if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) { - throw new DesktopPurgeError('desktop purge: invalid --job-id') - } - let directory: string | undefined - let jobId = existingJobId - let stage = 'prepare' - let success = false - let observedTask = false - let lastQueryFailed = false - let failure: string | undefined - let rejected = false - let report: DesktopPurgeError | undefined +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise { + let response: Awaited> try { - await mkdir(recordsRoot, { recursive: true }) - directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`)) - process.stdout.write(`desktop purge: record ${directory}\n`) - await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) - const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') } - await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, zoneId: config.zoneId, publicUrl: config.publicUrl, - target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) - const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) } - const client = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ + client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', - profile: { httpProfile }, + profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } }, }) - const deadline = performance.now() + config.timeoutMs - if (jobId === undefined) { - stage = 'CreatePurgeTask' - recordPackagingEvent(directory, { type: 'purge-intent', url: config.publicUrl }) - const response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) - jobId = safeField(response.JobId, config) - if (jobId) process.stdout.write(`desktop purge: task ${jobId}\n`) - recordPackagingEvent(directory, { type: 'purge-submitted', jobId, requestId: safeField(response.RequestId, config) }) - if ((response.FailedList?.length ?? 0) > 0) { - rejected = true - throw new DesktopPurgeError('EO rejected the URL purge') - } - if (!jobId) throw new DesktopPurgeError('EO returned no task ID; submission outcome is unknown') - } - stage = 'DescribePurgeTasks' - while (performance.now() < deadline) { - client.profile.httpProfile!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) - let response: Awaited> - try { - response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, - Filters: [{ Name: 'job-id', Values: [jobId] }] }) - } catch (error) { - const code = errorCode(error, config) - if (!transientQueryError(error, config)) throw error - lastQueryFailed = true - recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' }) - const remaining = deadline - performance.now() - if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) - continue - } - lastQueryFailed = false - if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit') - const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? [] - if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) { - throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target') - } - const task = tasks[0] - observedTask ||= task !== undefined - if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID') - const status = task?.Status - recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) }) - if (status === 'success') { - success = true - stage = 'complete' - break - } - rejected = status === 'failed' || status === 'timeout' || status === 'canceled' - if (task !== undefined && status !== 'processing') throw new DesktopPurgeError(`EO task did not succeed (${safeField(status, config) ?? 'unknown status'})`) - const remaining = deadline - performance.now() - if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) - } - if (!success) throw new DesktopPurgeError(observedTask || lastQueryFailed - ? 'timed out waiting for EO; task completion is unknown' - : 'EO task was not found before the deadline; check the zone, job ID and EO task history') + response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) } catch (error) { - const code = errorCode(error, config) - failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})` - const command = options.credentialLauncher - ? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}` - : `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}` - const recovery = rejected - ? `Resolve the EO failure, then ${command}.` - : jobId ? `Resume with ${command}.` - : 'Inspect EO task history before submitting another purge.' - const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation') - ? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : '' - report = new DesktopPurgeError(`desktop purge: ${failure}.${permission} ${recovery}`) - } finally { - if (directory !== undefined) { - try { - await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) - } catch (error) { - // An unavailable journal cannot establish completion for a subsequent process. - report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`) - } - } + const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`) } - if (report !== undefined) throw report - process.stdout.write(`desktop purge: completed ${config.publicUrl}\n`) + if ((response.FailedList?.length ?? 0) > 0) { + throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.') + } + const jobId = safeField(response.JobId, config) + if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.') + process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\n`) } diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index fa2bdaf3b8..27a7f92c7f 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' import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.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_PROD_EO_(?:ZONE_ID|TIMEOUT_MS|POLL_INTERVAL_MS)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_ZONE_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|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|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 diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index 6f40c974c7..aa3cd49aa5 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) /** Object keys fingerprinted into every upload record to bind evidence to this uploader. */ -const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts'] +const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts'] async function fingerprint(artifact: DesktopUploadArtifact) { const sha512 = createHash('sha512') diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index f95f3cd6e5..a1afdb3241 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -64,15 +64,15 @@ function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTarge } /** - * Upload the selected target, refresh production fixed downloads, or resume only their cache refresh. + * Upload the selected target, refresh production fixed downloads, or submit only their cache purge. * @param args Target and upload options from the command line. - * @returns Resolves after upload, any required EO refresh, and any release-tag attempt complete. - * @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back. + * @returns Resolves after upload, any required EO submission, and any release-tag attempt complete. + * @throws DesktopPurgeError when purge submission fails; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { latest: { type: 'boolean' }, - 'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' }, + 'purge-only': { type: 'boolean' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] @@ -80,8 +80,8 @@ export async function uploadDesktopTarget(args: string[]): Promise { throw new DesktopUploadUsageError('desktop upload: expected exactly one target') } const name = targetName(target) - if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) { - throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + if (values['purge-only'] && !values.latest) { + throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest') } let fileEnvironment: NodeJS.ProcessEnv try { @@ -107,7 +107,7 @@ export async function uploadDesktopTarget(args: string[]): Promise { } const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') if (values['purge-only']) { - await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher }) + await purgeDesktopInstaller(purgeConfig(environment, name)) return } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) @@ -119,11 +119,8 @@ export async function uploadDesktopTarget(args: string[]): Promise { process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) await uploadDesktopRelease(plan, cos, recordsRoot) if (purge !== undefined) { - try { - await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher }) - } catch (error) { - throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`) - } + process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n') + await purgeDesktopInstaller(purge) } if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } @@ -166,7 +163,7 @@ export async function runDesktopUploadCommand(args: string[]): Promise { const usage = error instanceof DesktopUploadUsageError || (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_')) const message = error instanceof DesktopPurgeError ? error.message - : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only [--job-id ]]]; targets: mac-arm64, mac-x64, win-x64.` + : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.` : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' // The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details. const output = args.includes('--credential-launcher') ? process.stdout : process.stderr diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index eb94f61bde..e6cde5f27e 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -19,9 +19,7 @@ Publish only the installer at its fixed download URL. Requires Upload. .PARAMETER Upload Authorize the existing target upload entry. Omit to check credentials locally. .PARAMETER PurgeOnly -Refresh the production fixed download without uploading. Requires Upload and Latest. -.PARAMETER JobId -Resume an existing EO task without submitting again. Requires PurgeOnly. +Submit a purge for the production fixed download without uploading. Requires Upload and Latest. #> [CmdletBinding(DefaultParameterSetName = 'Check')] param( @@ -42,10 +40,7 @@ param( [Parameter(ParameterSetName = 'Publish')] [switch]$Latest, [Parameter(ParameterSetName = 'Publish')] - [switch]$PurgeOnly, - [Parameter(ParameterSetName = 'Publish')] - [ValidatePattern('^[a-zA-Z0-9-]{1,128}$')] - [string]$JobId + [switch]$PurgeOnly ) $ErrorActionPreference = 'Stop' @@ -58,8 +53,8 @@ $credentials = $null $startInfo = $null $stage = 'decrypt-file' -if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { - Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.' +if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) { + Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.' exit 1 } @@ -107,9 +102,8 @@ try { $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" if ($Latest) { $startInfo.Arguments += " --latest" } if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" } - if ($JobId) { $startInfo.Arguments += " --job-id $JobId" } if ($PurgeOnly) { - Write-Output "desktop credentials: refreshing $Target in $Environment; no installer upload." + Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload." } else { Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." } diff --git a/apps/desktop/tests/desktop-edgeone.spec.ts b/apps/desktop/tests/desktop-edgeone.spec.ts index 2578b2327b..cada969378 100644 --- a/apps/desktop/tests/desktop-edgeone.spec.ts +++ b/apps/desktop/tests/desktop-edgeone.spec.ts @@ -1,50 +1,15 @@ -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { teo } from 'tencentcloud-sdk-nodejs-teo' -import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts' +import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts' const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key', - DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', } const config = resolveDesktopPurgeConfig(environment, 'mac-arm64') -const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' } -const roots: string[] = [] -let time = 0 -vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) })) - -beforeEach(() => { - time = 0 - vi.spyOn(performance, 'now').mockImplementation(() => time) - vi.spyOn(process.stdout, 'write').mockReturnValue(true) -}) -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -async function fixture() { - const root = await mkdtemp(join(tmpdir(), 'desktop-purge-')) - roots.push(root) - // Real SDK initialization, with both network operations replaced before use. - const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } }) - const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' }) - const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] }) - const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) => - purgeDesktopInstaller(config, root, { ...options, client }) - const directory = async () => join(root, (await readdir(root))[0]!) - const result = async (): Promise => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8')) - return { root, client, create, describe, run, directory, result } -} - -function sdkError(code: string) { - return Object.assign(new Error('private service details fixture-secret-key'), { code }) -} +afterEach(() => vi.restoreAllMocks()) describe('production EdgeOne configuration', () => { it.each([ @@ -53,8 +18,6 @@ describe('production EdgeOne configuration', () => { [{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u], [{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u], [{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u], - [{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u], - [{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u], ] as const)('rejects invalid settings %j', (patch, message) => { expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message) }) @@ -67,154 +30,50 @@ describe('production EdgeOne configuration', () => { }) }) -describe('EdgeOne task tracking', () => { - it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => { - const f = await fixture() - const profile = f.client.profile.httpProfile - f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] }) - await f.run() - expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) - expect(f.describe).toHaveBeenCalledTimes(3) - expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] }) - expect(f.client.profile.httpProfile).toBe(profile) - expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 }) - expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' }) - const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8') - const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex') - expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } }) - expect(plan).not.toContain(config.secretId) - expect(plan).not.toContain(config.secretKey) +describe('EdgeOne purge submission', () => { + it('submits once with the pinned SDK and reports acceptance without querying completion', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask') + .mockImplementation(async function (this: InstanceType) { + expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 }) + return { JobId: 'job-1' } + }) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query')) + + await purgeDesktopInstaller(config) + + expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + expect(query).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledExactlyOnceWith( + `desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`, + ) }) - it('resumes an existing multi-URL job without resubmission', async () => { - const f = await fixture() - f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] }) - await f.run({ jobId: 'job-1' }) - expect(f.create).not.toHaveBeenCalled() - expect(await f.result()).toMatchObject({ success: true }) + it('reports a rejected URL without printing the raw response or claiming acceptance', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO rejected the URL; inspect EO before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() }) - it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => { - const f = await fixture() - f.describe.mockRejectedValueOnce(sdkError(code)) - await f.run() - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).toHaveBeenCalledTimes(2) + it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => { + const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code })) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + `desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`, + ) + expect(submit).toHaveBeenCalledOnce() }) - it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => { - const f = await fixture() - const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '', - ...httpCode === undefined ? {} : { httpCode } }) - f.describe.mockRejectedValue(error) - await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u) - expect(f.describe).toHaveBeenCalledTimes(3) - expect(await f.result()).toMatchObject({ success: false }) - }) - - it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => { - const f = await fixture() - f.describe.mockRejectedValue(sdkError(code)) - await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u')) - expect(f.describe).toHaveBeenCalledOnce() - expect(await f.result()).toMatchObject({ success: false }) - const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8') - expect(evidence).not.toContain('private service details') - expect(evidence).not.toContain(config.secretKey) - }) - - it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => { - const f = await fixture() - f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] }) - await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.') - expect(f.describe).toHaveBeenCalledOnce() - }) - - it('does not poll a rejected submission', async () => { - const f = await fixture() - f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] }) - await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u) - expect(f.describe).not.toHaveBeenCalled() - }) - - it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => { - const f = await fixture() - f.create.mockResolvedValue(response) - await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u) - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).not.toHaveBeenCalled() - }) - - it('never retries a submission with an unknown outcome', async () => { - const f = await fixture() - f.create.mockRejectedValue(sdkError('ECONNRESET')) - await expect(f.run()).rejects.toThrow(/Inspect EO task history/u) - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).not.toHaveBeenCalled() - }) - - it.each([ - [{ Tasks: [] }, /task was not found/u], - [{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u], - ])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => { - const f = await fixture() - f.describe.mockResolvedValue(response) - await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message) - expect(f.describe).toHaveBeenCalledTimes(3) - }) - - it.each([ - [{ TotalCount: 1001 }, /page limit/u], - [{ Tasks: [task, task] }, /multiple entries/u], - [{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u], - [{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u], - ])('refuses ambiguous or mismatched task responses: %j', async (response, message) => { - const f = await fixture() - f.describe.mockResolvedValue(response) - await expect(f.run()).rejects.toThrow(message) - }) - - it('retains the DPAPI launcher and original identity in recovery instructions', async () => { - const f = await fixture() - f.describe.mockRejectedValue(sdkError('UnauthorizedOperation')) - await expect(f.run({ credentialLauncher: true })).rejects - .toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u) - }) - - it('does not report completion when the final record cannot be saved', async () => { - const f = await fixture() - f.describe.mockImplementation(async () => { - await writeFile(join(await f.directory(), 'result.json'), 'occupied') - return { Tasks: [task] } - }) - await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u) - const output = vi.spyOn(process.stdout, 'write') - expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed')) - }) - - it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => { - const f = await fixture() - await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u) - expect(await readdir(f.root)).toEqual([]) - expect(f.create).not.toHaveBeenCalled() - expect(f.describe).not.toHaveBeenCalled() - }) - - it('lazily initializes the pinned SDK when no client is supplied', async () => { - const f = await fixture() - vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) - vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType) { - expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' }) - return { Tasks: [task] } - }) - await purgeDesktopInstaller(config, f.root) - expect(await f.result()).toMatchObject({ success: true }) - }) - - it('accepts the minimal caller-owned client', async () => { - const f = await fixture() - const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe } - await purgeDesktopInstaller(config, f.root, { client }) - expect(await f.result()).toMatchObject({ success: true }) + it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: jobId }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO returned no task ID; check EO task history before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/tests/purge-command.spec.ts b/apps/desktop/tests/purge-command.spec.ts index 30a7bbe213..a690db7a35 100644 --- a/apps/desktop/tests/purge-command.spec.ts +++ b/apps/desktop/tests/purge-command.spec.ts @@ -45,20 +45,19 @@ it('forwards the documented pnpm purge command through both package scripts to t import assert from 'node:assert/strict' export class DesktopPurgeError extends Error {} export const resolveDesktopPurgeConfig = (_env, target) => ({ target }) - export const purgeDesktopInstaller = async (config, _root, options) => { + export const purgeDesktopInstaller = async (config) => { assert.equal(config.target, 'mac-arm64') - assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false }) - console.log('purge-resume: mac-arm64 job-1') + console.log('purge-submit: mac-arm64') } `, } await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents))) const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name))) - const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1'] + const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64'] const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => { expect(error).not.toMatchObject({ killed: true }) throw error }) - expect(result.stdout).toContain('purge-resume: mac-arm64 job-1') + expect(result.stdout).toContain('purge-submit: mac-arm64') }) diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts index ce15d7d54a..94e9aa5b4f 100644 --- a/apps/desktop/tests/upload-target.spec.ts +++ b/apps/desktop/tests/upload-target.spec.ts @@ -52,7 +52,7 @@ describe('desktop upload command', () => { expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) if (environment === 'production' && latest) { expect(purgeDesktopInstaller).toHaveBeenCalledWith( - expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false }, + expect.objectContaining({ publicUrl: plan.publicUrl }), ) expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0]) .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) @@ -75,33 +75,36 @@ const productionEnvironment = { DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', } -describe('cache-only recovery command', () => { +describe('cache-only submission command', () => { beforeEach(() => { vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment) }) - it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => { - await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []]) + it('submits a purge without reading or uploading an installer', async () => { + await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64']) expect(createDesktopUploadPlan).not.toHaveBeenCalled() expect(uploadDesktopRelease).not.toHaveBeenCalled() expect(tagDesktopRelease).not.toHaveBeenCalled() - expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false }) + expect(purgeDesktopInstaller).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg', + })) }) - it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { + it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--latest', '--purge-only', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { await expect(uploadDesktopTarget(args)).rejects.toThrow() expect(uploadDesktopRelease).not.toHaveBeenCalled() expect(purgeDesktopInstaller).not.toHaveBeenCalled() }) it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => { - vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.mocked(createDesktopUploadPlan).mockResolvedValue({ environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg', bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [], }) - vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1')) - await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u) + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: submission not confirmed')) + await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow('desktop purge: submission not confirmed') + expect(output).toHaveBeenCalledWith('desktop upload: installer uploaded; submitting cache purge.\n') expect(uploadDesktopRelease).toHaveBeenCalledOnce() expect(purgeDesktopInstaller).toHaveBeenCalledOnce() expect(tagDesktopRelease).not.toHaveBeenCalled() @@ -121,7 +124,7 @@ describe('cache-only recovery command', () => { expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n') expect(launcher ? stderr : stdout).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) - expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher })) + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' })) } finally { process.exitCode = previousExitCode vi.unstubAllEnvs() diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 4346689ff8..4bd2bb1336 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge' | 'invalid-purge', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge' || mode === 'invalid-purge') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge' ? ", '--latest', '--purge-only'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge' ? ' -Latest -PurgeOnly' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -141,7 +141,7 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('stage=decrypt-file') }) - it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]') From 3b432c3f403ce1fe818b64fc9fd19d543a057add Mon Sep 17 00:00:00 2001 From: EricMoin <61308504+EricMoin@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:53:18 +0800 Subject: [PATCH 4/4] fix(client): stack mobile font-family settings (#7356) * fix(client): stack mobile font-family settings * fix(client): cover font layout and touch sizing * test(bench): interleave scaling samples --- ...11-user-text-credential-matching.i18n.yaml | 8 +- ...026-10-11-user-text-credential-matching.md | 3 +- ...-10-11-user-text-credential-matching.zh.md | 3 +- apps/web/tests/settings-appearance.e2e.ts | 105 +++++++++++++++++- .../user-text/user-text.worker.client.ts | 102 +++++++++-------- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../src/client/FontFamilyRow.module.css | 15 ++- .../ui-theme/src/client/FontFamilyRow.tsx | 3 +- .../src/client/FontSizeRow.module.css | 15 ++- 11 files changed, 200 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 4d929aec54..0fa00ec1bf 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: fcc52be9892b2bfc - zh: ac259aba407efcbc + en: 37faaffb4662fd54 + zh: df0a4bf016393be9 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 168bda1a4e9e0325 - zh: af9cf7b804db84b7 + en: 559a8037caa9e3aa + zh: 4f3719ed09445cc2 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index e4373eb7dc..c3c361047f 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered +- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. - **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index f081970b1c..7541efa2de 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,6 +54,7 @@ Status: implemented ## 考虑过的替代方案 +- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 - **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 diff --git a/apps/web/tests/settings-appearance.e2e.ts b/apps/web/tests/settings-appearance.e2e.ts index 8f51764f88..fa55a444df 100644 --- a/apps/web/tests/settings-appearance.e2e.ts +++ b/apps/web/tests/settings-appearance.e2e.ts @@ -1,4 +1,4 @@ -/** Built settings controls share radius and card materials across account, model, and plugin pages. */ +/** Built settings controls share materials and adapt their geometry to the viewport and pointer. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { chromium, type Locator } from 'playwright' @@ -23,6 +23,109 @@ function appearance(element: Locator) { }) } +function rowGeometry(control: Locator) { + return control.evaluate((node) => { + const row = node.parentElement! + const text = row.firstElementChild! + const description = text.lastElementChild!.getBoundingClientRect() + const rowBox = row.getBoundingClientRect() + const textBox = text.getBoundingClientRect() + const controlBox = node.getBoundingClientRect() + return { + widthDifference: rowBox.width - controlBox.width, + leftDifference: controlBox.left - textBox.left, + rightGap: rowBox.right - controlBox.right, + afterText: controlBox.left - textBox.right, + belowDescription: controlBox.top - description.bottom, + centerDifference: (controlBox.top + controlBox.bottom - textBox.top - textBox.bottom) / 2, + height: controlBox.height, + } + }) +} + +it('stacks font fields at narrow widths while keeping font-size controls on the right', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + for (const platform of ['web', 'darwin', 'win32'] as const) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: platform === 'web', + }) + try { + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置', exact: true }) + await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click() + await page.evaluate((platform) => { + document.documentElement.toggleAttribute('data-windows-titlebar', platform === 'win32') + if (platform === 'darwin') document.documentElement.setAttribute('data-platform', platform) + }, platform) + + for (const width of platform === 'web' ? [390, 767, 768] : [520, 767, 768]) { + await page.setViewportSize({ width, height: 844 }) + for (const name of ['正文字体', '代码字体', '终端字体']) { + const geometry = await rowGeometry(dialog.getByRole('textbox', { name, exact: true }).locator('..')) + if (width <= 767) { + expect(Math.abs(geometry.widthDifference), `${platform} ${width}px ${name} width`).toBeLessThan(1) + expect(Math.abs(geometry.leftDifference), `${platform} ${width}px ${name} alignment`).toBeLessThan(1) + expect(geometry.belowDescription, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + expect(geometry.height, `${platform} ${width}px ${name} height`).toBeLessThan(60) + } else { + expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + } + } + for (const name of ['增大字号', '增大代码字号', '增大终端字号']) { + const geometry = await rowGeometry(dialog.getByRole('button', { name, exact: true }).locator('../../..')) + expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + expect(Math.abs(geometry.rightGap), `${platform} ${width}px ${name} alignment`).toBeLessThan(1) + expect(Math.abs(geometry.centerDifference), `${platform} ${width}px ${name} centering`).toBeLessThan(1) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) + } + + const field = dialog.getByRole('textbox', { name: '正文字体', exact: true }) + await field.fill('Uncommitted font draft') + const original = await field.elementHandle() + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 844 }) + expect(await original!.evaluate(input => input === document.activeElement && input.isConnected)).toBe(true) + expect(await field.inputValue()).toBe('Uncommitted font draft') + } + } finally { + await context.close() + } + } +}) + +it('uses 16px text for coarse-pointer font fields and preserves compact fine-pointer fields', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + for (const hasTouch of [true, false]) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch }) + try { + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置', exact: true }) + await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click() + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch) + for (const name of ['正文字体', '代码字体', '终端字体']) { + const fontSize = await dialog.getByRole('textbox', { name, exact: true }) + .evaluate(input => getComputedStyle(input).fontSize) + expect(fontSize, name).toBe(hasTouch ? '16px' : '14px') + } + } finally { + await context.close() + } + } +}) + it('shares settings card materials and control sizes in both palettes', async () => { const scaffold = await launchWebScaffold({}) onTestFinished(() => scaffold.close()) diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index 52e10ac919..dbd4f7fd1d 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -164,20 +164,22 @@ interface Workload { readonly evaluationsPerSample: number } -/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ -function workloads(): readonly Workload[] { +/** Scaling sizes share adjacent rounds; standalone workloads occupy one group. */ +function workloadGroups(): readonly (readonly Workload[])[] { return [ - { id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }, + [{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }], ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) - .map(([name, unit]): Workload => ({ + .map(([name, unit]): readonly Workload[] => [{ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, - })), - ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ + }]), + ...Object.entries(CREDENTIALS).map(([name, credential]): readonly Workload[] => [{ id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1, - })), - { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, - { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, - ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ + }]), + [ + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, + ], + ...Object.entries(ADVERSARIAL_UNITS).map(([name, unit]): readonly Workload[] => [ { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS, @@ -256,25 +258,34 @@ async function evaluatePair(texts: readonly string[], index: number, evaluations return { base: await sample(withoutSecret, texts, evaluations), secret } } -async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise { - for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations) - const pairs: SamplePair[] = [] - for (let attempt = 0; attempt < attempts; attempt++) { - pairs.push(await evaluatePair(texts, attempt, evaluations)) - } - const best = pairs.reduce((min, pair) => ({ - base: pair.base.ms < min.base.ms ? pair.base : min.base, - secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, - })) - return { - ...best, - samples: pairs.map(pair => ({ - baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, - withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, - })), +})[]> { + const measured = workloads.map(workload => ({ workload, pairs: new Array() })) + for (let round = 0; round < WARMUP_PAIRS + attempts; round++) { + for (let position = 0; position < measured.length; position++) { + const index = round % 2 === 0 ? position : measured.length - 1 - position + const { workload, pairs } = measured[index]! + const pair = await evaluatePair(workload.texts, round, workload.evaluationsPerSample) + if (round >= WARMUP_PAIRS) pairs.push(pair) + } } + return measured.map(({ workload, pairs }) => { + const best = pairs.reduce((min, pair) => ({ + base: pair.base.ms < min.base.ms ? pair.base : min.base, + secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, + })) + return { + workload, + ...best, + samples: pairs.map(pair => ({ + baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, + withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, + })), + } + }) } declare const gc: (() => void) | undefined @@ -366,7 +377,7 @@ async function allocations(): Promise { if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc') const collect = gc const results: UserTextWorkloadAllocation[] = [] - for (const { id, texts } of workloads()) { + for (const { id, texts } of workloadGroups().flat()) { results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) }) } return { workloads: results } @@ -374,23 +385,24 @@ async function allocations(): Promise { async function timings(attempts: number): Promise { const results: UserTextWorkloadTiming[] = [] - for (const { id, group, texts, evaluationsPerSample } of workloads()) { - const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) - results.push({ - id, - group, - documents: texts.length, - characters: texts.reduce((sum, text) => sum + text.length, 0), - evaluationsPerSample, - secrets: secret.secrets, - maskedCharacters: secret.maskedCharacters, - baseMs: base.ms, - baseCpuMs: base.cpuMs, - withSecretMs: secret.ms, - withSecretCpuMs: secret.cpuMs, - ratio: secret.ms / base.ms, - samples, - }) + for (const workloads of workloadGroups()) { + for (const { workload: { id, group, texts, evaluationsPerSample }, base, secret, samples } of await measure(workloads, attempts)) { + results.push({ + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + evaluationsPerSample, + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + baseCpuMs: base.cpuMs, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + samples, + }) + } } return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results } } diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 1b86d7c1dd..82139793ef 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -18,8 +18,8 @@ en: 5a279269e31713c1 zh: dcd5c31fbdfd63e2 /deepseek-ai-dsh-client-ui-theme/use-this-package/appearance-and-font-size: - en: 7dc280d0e36a3e7d - zh: f7a75164db34cf0a + en: 7dff01929847efd1 + zh: ec04b4c4485aba96 /deepseek-ai-dsh-client-ui-theme/use-this-package/fonts: en: b45d5b48ca799c3a zh: af65514b3759204f diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2e65e8be04..03156c75ed 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -29,7 +29,7 @@ Users switch the color scheme, font sizes, and fonts from rows in Settings (Gene ### Appearance and font size -In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. Windows and macOS desktop windows retain wrapping choices and standard spacing. +In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. At viewport widths up to 767 CSS px, including narrow desktop windows, font-family rows stack each title, description, and full-width field vertically. Font-size steppers stay to the right of their labels. Wider viewports retain horizontal font-family rows. Windows and macOS desktop windows retain wrapping Appearance choices and standard font-size label spacing. Font-family fields use 16 px text on devices with a coarse primary pointer. The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1095fbc1c3..249b7f3315 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -窄屏浏览器设置中,三个外观选项同排显示,字号标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留可换行选项和标准间距。 +窄屏浏览器设置中,三个外观选项同排显示,字号标签使用较小的间距。视口宽度不超过 767 CSS px 时(包括窄桌面窗口),字体输入行按标题、说明、输入框纵向排列,输入框占满可用宽度;字号步进器保持在标签右侧。更宽的视口保留字体输入行的横向排列。Windows 和 macOS 桌面窗口的外观选项保留换行,字号标签保留标准间距。主指针精度较低的设备上,字体输入框使用 16 px 文字。 插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。 diff --git a/packages/client/ui-theme/src/client/FontFamilyRow.module.css b/packages/client/ui-theme/src/client/FontFamilyRow.module.css index c3ca4ff66e..f5498280b8 100644 --- a/packages/client/ui-theme/src/client/FontFamilyRow.module.css +++ b/packages/client/ui-theme/src/client/FontFamilyRow.module.css @@ -1,7 +1,18 @@ -/* Font-family rows reuse the font-size row cell (FontSizeRow.module.css); - * the fields share one basis so the three rows align, and shrink with the row. */ +/* Font-family fields; shared row text and separators live in FontSizeRow.module.css. */ .field { flex: 0 1 240px; min-width: 0; } + +@media (max-width: 767px) { + .field { + flex-basis: auto; + } +} + +@media (pointer: coarse) { + .field input { + font-size: 16px; + } +} diff --git a/packages/client/ui-theme/src/client/FontFamilyRow.tsx b/packages/client/ui-theme/src/client/FontFamilyRow.tsx index b14b9af034..58e76042b5 100644 --- a/packages/client/ui-theme/src/client/FontFamilyRow.tsx +++ b/packages/client/ui-theme/src/client/FontFamilyRow.tsx @@ -5,6 +5,7 @@ * an empty field restores the built-in stack. The field shows the persisted * list and resets whenever that list changes. */ +import clsx from 'clsx' import { Input } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { normalizeFontFamily, type FontRole } from '../theme-settings.ts' @@ -39,7 +40,7 @@ export function FontFamilyRow({ t, role, setFontFamily, useStore }: FontFamilyRo setFontFamily(input.value) } return ( -
+
{title}
{t(`fontFamily.${role}.description`)}
diff --git a/packages/client/ui-theme/src/client/FontSizeRow.module.css b/packages/client/ui-theme/src/client/FontSizeRow.module.css index 4676f34862..e5a26d5472 100644 --- a/packages/client/ui-theme/src/client/FontSizeRow.module.css +++ b/packages/client/ui-theme/src/client/FontSizeRow.module.css @@ -19,12 +19,21 @@ padding-right: 48px; } -/* Narrow settings panel: the 48px gutter separates the label from its control - across a wide row and only squeezes the label here. */ @media (max-width: 767px) { - :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + /* The wide-row gutter squeezes labels beside steppers in narrow browser panels. */ + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .row:not(.stacked) .rowText { padding-right: 16px; } + + .stacked { + flex-direction: column; + align-items: stretch; + } + + .stacked .rowText { + flex: none; + padding-right: 0; + } } .title {