mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge master and retain desktop file actions dependency
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 输入增加受控求值开销时,翻倍断言仍须失败。
|
||||
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
|
||||
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
|
||||
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -56,3 +56,7 @@ APPLE_API_ISSUER=
|
||||
# DOWNLOAD_PROD_COS_BUCKET=
|
||||
# DOWNLOAD_PROD_COS_SECRET_ID=
|
||||
# DOWNLOAD_PROD_COS_SECRET_KEY=
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -41,3 +41,7 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
# DOWNLOAD_PROD_COS_BUCKET=
|
||||
# DOWNLOAD_PROD_COS_SECRET_ID=
|
||||
# DOWNLOAD_PROD_COS_SECRET_KEY=
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -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",
|
||||
@@ -87,6 +88,7 @@
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
|
||||
"zod": "4.4.3",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-file-actions": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-ui-file-actions": "workspace:*",
|
||||
"tencentcloud-sdk-nodejs-teo": "4.1.330"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
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: 38054a5f89590cfa
|
||||
zh: 9e115f9bdeeab213
|
||||
en: ce8296f8f1ee881e
|
||||
zh: 749ab63bd6ed4248
|
||||
/desktop-publication/upload-credentials:
|
||||
en: 1c5689525753b08d
|
||||
zh: 93089de765806733
|
||||
en: 47442fb912fa4d5d
|
||||
zh: a21a0d67cd01724b
|
||||
/desktop-publication/upload-audit-and-recovery:
|
||||
en: ec85f43ece09a5e3
|
||||
zh: 2de5bf801c9c9e39
|
||||
|
||||
@@ -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/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. 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 |
|
||||
|---|---|---|---|
|
||||
@@ -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 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 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.
|
||||
|
||||
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 <target>` 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.
|
||||
|
||||
<a id="credentials"></a>
|
||||
|
||||
## 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 <target> -Bucket <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 <target> -Bucket <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.
|
||||
|
||||
<a id="audit-and-recovery"></a>
|
||||
|
||||
|
||||
@@ -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/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
|
||||
|
||||
更新目标与上传凭据都与所选环境对应:
|
||||
更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads):
|
||||
|
||||
| 环境 | 公开 origin | COS bucket | COS 凭据 |
|
||||
|---|---|---|---|
|
||||
@@ -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 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
|
||||
|
||||
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。
|
||||
|
||||
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。
|
||||
|
||||
<a id="credentials"></a>
|
||||
|
||||
## 上传凭据
|
||||
|
||||
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 <target> -Bucket <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 <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
|
||||
<a id="audit-and-recovery"></a>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -98,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
|
||||
|
||||
@@ -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.
|
||||
@@ -99,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`)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** 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'
|
||||
|
||||
/** Validated production destination and credentials; never serialize this value. */
|
||||
export interface DesktopPurgeConfig {
|
||||
readonly zoneId: string
|
||||
readonly publicUrl: string
|
||||
readonly secretId: string
|
||||
readonly secretKey: string
|
||||
}
|
||||
|
||||
/** The single SDK operation needed to submit a purge. */
|
||||
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'CreatePurgeTask'>
|
||||
|
||||
/**
|
||||
* 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 URL for a cache-only submission.
|
||||
* @returns Validated destination and credentials.
|
||||
*/
|
||||
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')
|
||||
return {
|
||||
zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
|
||||
secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|| value.includes(config.secretId) || value.includes(config.secretKey)) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit once and report the accepted task ID, without querying cache completion.
|
||||
* @param config Validated production destination and credentials.
|
||||
* @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, client?: DesktopPurgeClient): Promise<void> {
|
||||
let response: Awaited<ReturnType<DesktopPurgeClient['CreatePurgeTask']>>
|
||||
try {
|
||||
client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
|
||||
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
|
||||
profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } },
|
||||
})
|
||||
response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
|
||||
} catch (error) {
|
||||
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 ((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`)
|
||||
}
|
||||
@@ -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|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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
/** 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'
|
||||
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'
|
||||
import { uploadDesktopRelease } from './desktop-upload-run.ts'
|
||||
import { DesktopPurgeError, purgeDesktopInstaller, resolveDesktopPurgeConfig } from './desktop-edgeone.ts'
|
||||
|
||||
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
|
||||
|
||||
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.
|
||||
@@ -41,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,
|
||||
@@ -58,40 +54,74 @@ 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 and record a release tag only for production update uploads.
|
||||
* 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 and any release-tag attempt complete.
|
||||
* @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<void> {
|
||||
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
|
||||
latest: { type: 'boolean' },
|
||||
'purge-only': { type: 'boolean' },
|
||||
'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)
|
||||
const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
|
||||
if (values['purge-only'] && !values.latest) {
|
||||
throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest')
|
||||
}
|
||||
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 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(purgeConfig(environment, name))
|
||||
return
|
||||
}
|
||||
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
|
||||
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),
|
||||
})
|
||||
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) {
|
||||
process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n')
|
||||
await purgeDesktopInstaller(purge)
|
||||
}
|
||||
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
|
||||
}
|
||||
|
||||
@@ -121,9 +151,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(() => {
|
||||
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
|
||||
/**
|
||||
* 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<void> {
|
||||
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
|
||||
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--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
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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
|
||||
Submit a purge for the production fixed download without uploading. Requires Upload and Latest.
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Check')]
|
||||
param(
|
||||
@@ -36,7 +38,9 @@ param(
|
||||
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
|
||||
[switch]$Upload,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$Latest
|
||||
[switch]$Latest,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$PurgeOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -49,6 +53,11 @@ $credentials = $null
|
||||
$startInfo = $null
|
||||
$stage = 'decrypt-file'
|
||||
|
||||
if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) {
|
||||
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
|
||||
throw 'Windows DPAPI is required.'
|
||||
@@ -92,7 +101,12 @@ 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 ($PurgeOnly) {
|
||||
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."
|
||||
}
|
||||
} 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`""
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
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',
|
||||
}
|
||||
const config = resolveDesktopPurgeConfig(environment, 'mac-arm64')
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
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],
|
||||
] 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 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<typeof teo.v20220901.Client>) {
|
||||
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('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(['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, '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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
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) => {
|
||||
assert.equal(config.target, 'mac-arm64')
|
||||
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']
|
||||
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-submit: mac-arm64')
|
||||
})
|
||||
@@ -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<typeof import('../scripts/desktop-edgeone.ts')>(),
|
||||
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(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,77 @@ 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 submission command', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment)
|
||||
})
|
||||
|
||||
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).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg',
|
||||
}))
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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: 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()
|
||||
})
|
||||
|
||||
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' }))
|
||||
} 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' | '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' || 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' ? ", '--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-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]@{
|
||||
@@ -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'] 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]')
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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<SamplePair & {
|
||||
/** Alternate scaling sizes within every warm-up and measured round to limit drift between them. */
|
||||
async function measure(workloads: readonly Workload[], attempts: number): Promise<readonly (SamplePair & {
|
||||
readonly workload: Workload
|
||||
readonly samples: readonly UserTextTimingPair[]
|
||||
}> {
|
||||
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<SamplePair>() }))
|
||||
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<UserTextAllocationReport> {
|
||||
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<UserTextAllocationReport> {
|
||||
|
||||
async function timings(attempts: number): Promise<UserTextTimeReport> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 串行写入,最新写入被拒时重新加载持久值。
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={rowCss.row}>
|
||||
<div className={clsx(rowCss.row, rowCss.stacked)}>
|
||||
<div className={rowCss.rowText}>
|
||||
<div className={rowCss.title}>{title}</div>
|
||||
<div className={rowCss.desc}>{t(`fontFamily.${role}.description`)}</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+97
@@ -813,6 +813,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
|
||||
@@ -21344,6 +21347,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'}
|
||||
@@ -22612,6 +22619,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
|
||||
engines: {node: '>= 0.12'}
|
||||
|
||||
form-data@3.0.5:
|
||||
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'}
|
||||
@@ -22715,6 +22726,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'}
|
||||
@@ -22890,6 +22905,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'}
|
||||
@@ -22954,6 +22973,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==}
|
||||
|
||||
@@ -23017,6 +23040,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'}
|
||||
@@ -24854,6 +24881,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==}
|
||||
|
||||
@@ -24992,6 +25027,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==}
|
||||
|
||||
@@ -25162,6 +25200,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'}
|
||||
@@ -28791,6 +28834,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: {}
|
||||
@@ -30333,6 +30382,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
|
||||
@@ -30456,6 +30513,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
|
||||
@@ -30709,6 +30768,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
|
||||
@@ -30766,6 +30832,8 @@ snapshots:
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
ini@2.0.0: {}
|
||||
|
||||
internmap@1.0.1: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
@@ -30804,6 +30872,8 @@ snapshots:
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-stream@4.0.1: {}
|
||||
|
||||
is-typedarray@1.0.0: {}
|
||||
@@ -32875,6 +32945,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
|
||||
@@ -32984,6 +33077,8 @@ snapshots:
|
||||
- oxc-resolver
|
||||
- vue-tsc
|
||||
|
||||
tslib@1.13.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
@@ -33151,6 +33246,8 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
verror@1.10.0:
|
||||
|
||||
Reference in New Issue
Block a user