dsh-mobile 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CONTRIBUTORS.md +19 -4
  3. package/README.en.md +16 -8
  4. package/README.md +16 -8
  5. package/SECURITY.md +3 -2
  6. package/THIRD_PARTY_NOTICES.md +29 -1
  7. package/apps/mobile/README.md +25 -5
  8. package/apps/mobile/release.json +3 -3
  9. package/cordis.patch.yml +7 -3
  10. package/docs/ATTACH_EXISTING_FRPS.en.md +1 -1
  11. package/docs/ATTACH_EXISTING_FRPS.md +2 -2
  12. package/docs/CADDY_MANAGED.en.md +15 -1
  13. package/docs/CADDY_MANAGED.md +15 -1
  14. package/docs/CLOUDFLARE_TUNNEL.en.md +2 -2
  15. package/docs/CLOUDFLARE_TUNNEL.md +3 -3
  16. package/docs/EXTENSION_WORKERS.en.md +49 -0
  17. package/docs/EXTENSION_WORKERS.md +49 -0
  18. package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +27 -0
  19. package/docs/MOBILE_COMPOSER_TOOLBAR.md +27 -0
  20. package/docs/README.md +2 -0
  21. package/docs/SELF_HOSTED_FRP.en.md +4 -2
  22. package/docs/SELF_HOSTED_FRP.md +4 -2
  23. package/docs/SLOW_CONNECTIONS.en.md +6 -0
  24. package/docs/SLOW_CONNECTIONS.md +6 -0
  25. package/docs/TRUSTED_NETWORKS.en.md +1 -1
  26. package/docs/TRUSTED_NETWORKS.md +1 -1
  27. package/lib/cli.js +140 -19
  28. package/lib/cli.js.map +1 -1
  29. package/lib/client.js +1264 -106
  30. package/lib/client.js.map +1 -1
  31. package/lib/extension-worker-runtime.mjs +1646 -0
  32. package/lib/extension-worker-runtime.mjs.map +1 -0
  33. package/lib/index.d.mts +209 -12
  34. package/lib/index.mjs +2152 -811
  35. package/lib/index.mjs.map +1 -1
  36. package/lib/mobile-layout.js.map +1 -1
  37. package/package.json +17 -6
  38. package/packages/question-fixes/LICENSE +201 -0
  39. package/packages/question-fixes/package.json +9 -2
  40. package/node_modules/dsh-mobile-question-fixes/lib/client.js +0 -78
  41. package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +0 -1
  42. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +0 -14
  43. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +0 -1
  44. package/node_modules/dsh-mobile-question-fixes/package.json +0 -25
@@ -22,7 +22,7 @@ Android App / 浏览器 → 公网 HTTPS 域名(Caddy 终止 TLS)
22
22
 
23
23
  ## 安装、启动与就绪
24
24
 
25
- 固定组件正式分发后,安装需要你明确确认下载。插件检查完整大小、SHA-256、Caddy 核心版本和 DNS 模块版本;来源不可用或验证失败时不执行文件。组件已安装不等于连接就绪:本地配置完成解析后启动代理,只有公网 HTTPS 证书通过验证,并确认 discovery 属于当前 DSH 实例,才显示已就绪。
25
+ 官方组件分发本身不启用安装:还需独立验证实际资产,并通过后续独立审核的生产目录 PR 固定来源。完成第二阶段后,安装仍需要你明确确认下载。插件检查完整大小、SHA-256、Caddy 核心版本和 DNS 模块版本;来源不可用或验证失败时不执行文件。组件已安装不等于连接就绪:本地配置完成解析后启动代理,只有公网 HTTPS 证书通过验证,并确认 discovery 属于当前 DSH 实例,才显示已就绪。
26
26
 
27
27
  首次 DNS-01 签发需要等待 DNS 生效和证书签发。期间电脑保持在线,避免反复重启触发签发限流。托管模式不安装系统服务、不修改 PATH、不设置开机启动、不导入系统证书;Caddy 的管理 API 和自动配置落盘均关闭。
28
28
 
@@ -58,4 +58,18 @@ gh workflow run caddy-component.yml --repo abworks-dev/dsh-mobile --ref caddy-co
58
58
 
59
59
  公开定制下载端点曾在请求 v2.11.6 时返回 v2.11.7,动态最新版不能成为信任来源。版本化 GitHub URL 仍可被仓库所有者替换;信任边界是审核过的精确大小/哈希,而非 URL 名称或远端自报校验和。fork 的目录只是审核候选,不会自动写入[生产组件表](<../src/caddy-component.ts>);正式安装仍等待维护者发布并审核官方分发。
60
60
 
61
+ ### 官方分发需要独立的两阶段授权
62
+
63
+ 本次仅准备发布通道,不执行发布。第一阶段要求维护者审核工作流 PR,并合入经过审核、受保护的 `main`。维护者必须预先配置 `caddy-component-release`:必需审核者规则、非空审核者列表和 **禁止自我审核**;管理员不得绕过环境批准。GitHub Actions 在唯一发布任务开始前提供真实人工批准;脚本另外读取实际环境策略,在首次修改 Release 前拒绝缺失、格式错误或未保护的环境。环境名称、main 祖先比较、合成策略测试都不能证明人工审核或批准。
64
+
65
+ 官方发布仅允许 **公开** 的 `saya-ch/dsh-mobile` 仓库和已有的固定非 `v` 稳定标签 `caddy-component-2.11.6-tencentcloud-0.4.3`。两个发布输入均默认 false;同时启用会在原生构建前明确失败。错误事件、分支、仓库或通道/标签身份不能发布。发布任务先检查 `/commits/TAG` 的 SHA 与 Actions `GITHUB_SHA` 完全一致,且 `/compare/main...SHA` 状态必须为 `identical` 或 `behind`(只证明属于 main/祖先关系,**不证明代码经过审核**)。准备资产和源码元数据使用同一个 SHA、同一次运行中的两平台原生构建,不复用旧运行的产物。
66
+
67
+ 发布任务仍仅有 `contents: write`,API 使用 `GITHUB_TOKEN`,公开资产不带令牌或 API 请求头。[GitHub Get an environment 文档](<https://docs.github.com/en/rest/deployments/environments?apiVersion=2022-11-28#get-an-environment>) 通常要求 Actions read,但明确允许公开资源在没有该权限时读取。此处只采用公开资源例外作为前提,不声称已验证真实令牌能力:带认证请求的 401/403/404/5xx 或网络失败均在创建 Release 前关闭路径;不重试未认证请求、不提升权限、不增加发布密钥。维护者需确认仓库公开、端点可读以及环境保护配置;认证读取失败应报告缺少读取能力,不放宽策略。fork 审查通道使用 `caddy-component-review`,不要求官方环境策略。
68
+
69
+ 满足以上前提并得到明确发布授权后(不是本次准备任务),维护者才可在已有稳定标签上手动启用 `publish_official_release=true`,保持 review 输入 false。仍只使用现有 Windows/Linux x64 原生矩阵:各独立构建两次,验证原生安装/TLS/API/WSS/不导入信任,并运行完整测试与构建。通用发布器使用 `--draft --prerelease=false --latest=false`,拒绝已占用 Release,不更新、覆盖、删除或破坏性重试;上传并下载 **全部 16 个资产**、逐字节比较,再以 `--draft=false --prerelease=false --latest=false` 公开。fork 仍保留预发布且非 latest 的标志。官方验证允许 `/releases/latest` 的 404(确实没有 latest),但拒绝该组件成为 latest,以及其他错误或格式错误的成功响应。组件不替换 latest 插件,不自动发布 npm/APK。公开后验证失败保留公开 Release、任务失败、不产生候选,不进行破坏性回滚。
70
+
71
+ prepare/verify CLI 原有严格签名默认 review;只有可选尾参数 `--channel official`(或 `--channel review`)选择共享的精确身份校验。官方 `COMPONENT-RELEASE.json` 使用 `managed-caddy-component-official`;全部资产的公开元数据/大小/哈希及实际 HTTPS/受限重定向检查通过后,才生成 `official-candidate-not-production`。两通道索引与候选均保持 `productionCatalogEnabled: false`,输出有界、独占创建且不覆盖。
72
+
73
+ 第二阶段在 **以后** 进行:独立验证实际发布的官方字节,再提交单独审核的目录 PR,将精确来源/大小/哈希硬编码到 `CADDY_COMPONENT_RELEASES`。本次准备中它仍严格为 `Object.freeze({})`。远端候选 JSON 不是运行时信任或配置。仅支持 Windows/Linux x64、腾讯云 DNS;不要把私有预览替换为目录为空的官方插件包后期待托管重启正常工作。人工真实网络证据与最终候选 CI 分开记录;隔离测试或该人工证据都不证明证书续期通过。
74
+
61
75
  隔离测试仅用回环随机端口、私有内部 CA 和 `skip_install_trust`,不修改系统信任、生产 DNS 或现有代理。**不等于真实公网 DNS-01/ACME 签发或公网路由验收**;后者需要另行授权专用测试域名、最小权限 DNS 凭据和可达入口。
@@ -53,7 +53,7 @@ Once saved, leaving the token field blank keeps the stored token, and the field
53
53
 
54
54
  ## Connect the phone to a named tunnel
55
55
 
56
- Moving to a fixed hostname means **an already paired phone must pair again**: a DSH Mobile device credential is only ever sent to the exact origin that first received it, which is the design that stops a swapped-in domain from harvesting it. A credential issued for the old address therefore never authenticates at the new one.
56
+ Moving to a fixed hostname means **an already paired phone must pair again**: the App does not send a saved device token to a changed Origin without new pairing. This client-side exact-address policy does not mean the server token automatically expires by hostname; remote channels share the device registry. Use a trusted entry you control and revoke a device on the computer if credential theft is suspected.
57
57
 
58
58
  1. On the computer, open **Remote** in the panel and choose **Create remote pairing QR code**. That is what opens the pairing window, which is time-limited; while it is closed nothing can pair.
59
59
  2. In the app, **open the Remote entry first** (the remote access setup page in the connection center), then scan the code.
@@ -96,7 +96,7 @@ The old address may show **Address may have changed** or temporarily unreachable
96
96
  | Connection stopped or exited | `cloudflared_stopped` / `cloudflared_exited` | The channel dropped. Reconnect. |
97
97
  | Unrecognized output | `cloudflared_invalid_output` / `cloudflared_invalid_origin` | cloudflared output, or the public address it printed, failed validation. Reconnect and copy the diagnostic report. |
98
98
  | Gateway start failed | `gateway_start_failed` | The authentication gateway behind the tunnel did not start; this is not a port conflict. Check the local logs. |
99
- | Download redirect problem | `cloudflared_download_redirect_missing` / `_invalid` / `_rejected` | The official download page did not redirect to a release asset, or the redirect did not point at a GitHub release asset. Retry later, or install from the official page manually. |
99
+ | Download redirect problem | `cloudflared_download_redirect_missing` / `_invalid` / `_rejected` | The official page did not redirect to the fixed release asset, or validation rejected the target. Retry later or update the plugin; a global cloudflared installation does not replace its private pinned component. |
100
100
 
101
101
  ## Troubleshooting
102
102
 
@@ -53,9 +53,9 @@ Cloudflare 提供公网域名与 TLS,插件在本机运行 `cloudflared`,把
53
53
 
54
54
  ## 用手机连上命名隧道
55
55
 
56
- 切到固定域名后,**已经配对过的手机必须重新配对**:DSH Mobile 的设备凭据只发给它当初保存的那个精确 Origin(这是防止换域名骗走凭据的安全设计),所以旧地址上的凭据在新域名上一律无效。
56
+ 切到固定域名后,**已经配对过的手机必须重新配对**:App 不会把已存设备令牌发送给未经新配对验证的 Origin。这是客户端的精确地址发送策略,不代表服务端令牌自动按域名失效;远程通道共用设备注册表。请使用可信且由你控制的入口,怀疑凭据泄漏时在电脑端撤销设备。
57
57
 
58
- 1. 电脑面板 → **远程** → 点 **生成远程配对二维码**。这一步才会打开配对窗口,窗口有时限;没打开时任何设备都进不来。
58
+ 1. 电脑面板 → **远程** → 点 **生成远程配对二维码**。这一步打开有时限的配对窗口;未打开时不能新增配对,已有配对不受影响。
59
59
  2. 手机 App → **先进入「远程」入口**(连接中心的远程访问设置页)→ 再扫描二维码。
60
60
 
61
61
  > **命名隧道必须在「远程」入口里扫码。** App 只把 `.ts.net`、cpolar、`.trycloudflare.com` 这类平台后缀当作「无需询问即为远程」;你自己的域名同样会被识别为可远程的候选地址,但「局域网」流程只接受非候选地址,所以在局域网界面扫码会被判为无效并提示**无效二维码 / Invalid QR code**,看起来就像「连不上」。先进入「远程」流程再扫即可。
@@ -96,7 +96,7 @@ Cloudflare 提供公网域名与 TLS,插件在本机运行 `cloudflared`,把
96
96
  | 连接已停止 / 意外退出 | `cloudflared_stopped` / `cloudflared_exited` | 通道已断开,点重新连接。 |
97
97
  | 返回内容无法识别 | `cloudflared_invalid_output` / `cloudflared_invalid_origin` | cloudflared 输出或公网地址未通过校验。重新连接并复制诊断报告。 |
98
98
  | 网关启动失败 | `gateway_start_failed` | 隧道背后的认证网关没能启动(不是端口冲突)。检查本地日志。 |
99
- | 下载跳转异常 | `cloudflared_download_redirect_missing` / `_invalid` / `_rejected` | 官方下载页没有跳到发布资源,或跳转目标不是 GitHub 发布资源。稍后重试,或按官方页面手动安装。 |
99
+ | 下载跳转异常 | `cloudflared_download_redirect_missing` / `_invalid` / `_rejected` | 官方下载页没有跳到固定发布资源,或目标未通过校验。稍后重试或更新插件;全局安装的 cloudflared 不会替代插件的私有固定组件。 |
100
100
 
101
101
  ## 排错
102
102
 
@@ -0,0 +1,49 @@
1
+ # Computer-side extension Workers
2
+
3
+ [简体中文](EXTENSION_WORKERS.md) · [Customization](../README.en.md#extend-and-customize)
4
+
5
+ > Added in plugin 0.6.3. This computer-side feature does not require a matching App version or new pairing.
6
+
7
+ Worker mode runs selected local `host.mjs` files in separate threads so synchronous JavaScript does not occupy the gateway's event loop. The default remains `in-process`; existing extensions and ordinary Cordis plugins are not automatically migrated.
8
+
9
+ ## Which extensions fit
10
+
11
+ Use trusted local-directory extensions created through `/mobile` or directly, using Node.js and the extension API. Workers retain `api.manifest`, `api.schema`, `api.action`, `api.route` and `api.effect`; `api.context.logger` provides only `debug`, `info`, `warn` and `error`, not the full Cordis Context or Logger API. Extensions needing live Cordis services stay in-process. Incompatible hosts do not silently fall back.
12
+
13
+ Workers are **not a security sandbox**. Code retains local-user access to files, networks and processes. V8 limits do not cover all native/ArrayBuffer memory or prevent every process-wide OOM. Termination does not guarantee cleanup callbacks or undo completed file/remote operations. [Node's limitations](https://nodejs.org/download/release/v22.19.0/docs/api/worker_threads.html#new-workerfilename-options).
14
+
15
+ ## Enable selectively
16
+
17
+ Add these fields to the current DSH profile's `mobile-access` configuration, then normally restart or reload the plugin; do not modify `extension.json`:
18
+
19
+ ```yaml
20
+ hostExecution:
21
+ mode: worker
22
+ extensions:
23
+ - media-remote
24
+ maxWorkers: 8
25
+ ```
26
+
27
+ `extensions` selects only these local IDs; other extensions remain in-process. Omitting the list selects all local hosts, while an empty list selects none. Configure the actual profile that loads the plugin rather than mixing Desktop and Web profiles. Restore `mode: in-process` and reload normally to disable Worker execution.
28
+
29
+ ## When an extension stops
30
+
31
+ Open **Mobile Access** on the computer. An unavailable Worker appears under “Extensions need a restart”; choose its restart action and confirm. The card is shared across LAN, remote and diagnostics views. Ready Workers do not show the action. Recovery is explicit: no automatic restart or meaningless file edits.
32
+
33
+ The invocation that triggered a deadline keeps its timeout result; other pending calls in that Worker receive `extension_host_unavailable`. Other runtime instances are not terminated together. A stream whose headers were already sent loses its connection; it cannot append a new JSON error after a partial body. Interrupted actions are never replayed. Check whether completed side effects require attention before restarting.
34
+
35
+ New calls can still receive `extension_busy` while abandoned work remains unfinished. Accounting clears only on actual completion or confirmed thread exit. Starting, retained-generation and terminating Workers all occupy the thread budget; exhaustion does not silently switch execution to in-process.
36
+
37
+ ## Adjustable budgets
38
+
39
+ | Budget | `hostExecution` fields |
40
+ | --- | --- |
41
+ | Threads and registrations | `maxWorkers`, `maxRegistrations` |
42
+ | V8 heap and stack | `maxOldGenerationSizeMb`, `maxYoungGenerationSizeMb`, `stackSizeMb` |
43
+ | Activation, actions, cancellation and cleanup | `activationTimeoutMs`, `operationTimeoutMs`, `cancelGraceMs`, `streamCancelGraceMs`, `disposeGraceMs` |
44
+ | Action JSON and streams | `resultMaxBytes`, `streamWindowBytes`, `streamChunkBytes`, `streamAggregateBytes` |
45
+ | Logging | `logWindowMs`, `logMessagesPerWindow`, `logMessageBytes` |
46
+
47
+ Values must be integers within the accepted ranges. Chunks cannot exceed the per-stream window, and a window cannot exceed the aggregate budget. Start with the defaults. `src/extension-worker-config.ts` owns their definitions and validation. Global Node heap flags can override Worker settings, so detected overriding parent launch flags reject startup before importing the extension with `extension_worker_heap_override`. Check DSH's launch options; keep in-process execution when those flags are required rather than silently bypassing the limits.
48
+
49
+ Consumption refills stream credit, and unread EOF bytes retain their budget. Logs and results are converted to bounded data inside the Worker before reaching the gateway. This contains transport and JavaScript-blocking risks, not all operating-system or process resources.
@@ -0,0 +1,49 @@
1
+ # 电脑端扩展 Worker 模式
2
+
3
+ [English](EXTENSION_WORKERS.en.md) · [返回定制功能](../README.md#扩展与自定义)
4
+
5
+ > 插件 0.6.3 新增功能,由电脑端插件实现,不要求 App 同步版本或重新配对。
6
+
7
+ Worker 模式把选中的本地 `host.mjs` 放到独立工作线程,使同步 JavaScript 循环不再占住网关主线程。默认仍使用 `in-process`;已有扩展和普通 Cordis 插件不会被自动迁移。
8
+
9
+ ## 适合哪些扩展
10
+
11
+ 适合通过 `/mobile` 或本地目录创建、使用 Node.js 与扩展 API 的可信代码。Worker 内仍可使用 `api.manifest`、`api.schema`、`api.action`、`api.route`、`api.effect`;`api.context.logger` 只提供 `debug`、`info`、`warn`、`error`,不是完整的 Cordis Context 或 Logger。需要直接使用 Cordis 服务的扩展保留进程内模式,不会静默回退。
12
+
13
+ Worker **不是安全沙箱**:代码仍有本机用户权限,可以访问文件、网络和进程。V8 内存限制不覆盖全部原生/ArrayBuffer 内存,也不能保证全进程不会 OOM。停止线程不保证执行清理回调,不撤销已经发生的文件或远程操作。[Node 的限制说明](https://nodejs.org/download/release/v22.19.0/docs/api/worker_threads.html#new-workerfilename-options)。
14
+
15
+ ## 按需启用
16
+
17
+ 在当前 DSH profile 的 `mobile-access` 配置中添加以下字段,再正常重启或重新加载插件;不要修改 `extension.json`:
18
+
19
+ ```yaml
20
+ hostExecution:
21
+ mode: worker
22
+ extensions:
23
+ - media-remote
24
+ maxWorkers: 8
25
+ ```
26
+
27
+ `extensions` 只选择这些本地扩展 ID,其他扩展继续进程内执行;省略列表选择所有本地 Host,空列表不选择任何 Host。配置放在实际承载插件的 profile,Desktop 与 Web 的 profile 不应混用。恢复 `mode: in-process` 后正常重新加载插件即可关闭 Worker 模式。
28
+
29
+ ## 扩展停止后
30
+
31
+ 电脑端打开 **移动访问**。停止的 Worker 会显示“扩展需要重启”;点击目标扩展的“重启扩展”并确认。此卡片在局域网、远程和诊断视图共用。只有不可用的 Worker 显示该入口,不自动重启,也不要求改文件来触发恢复。
32
+
33
+ 触发截止的调用保留超时结果;同一 Worker 中其他未完成调用会收到 `extension_host_unavailable`。其他运行实例不会被一起终止。已发出响应头的流会结束连接,不能在半截内容后补一份 JSON 错误。未完成动作不自动重放;恢复前请确认已经产生的副作用是否需要处理。
34
+
35
+ 同一扩展存在尚未结束的废弃工作时,新请求仍可能收到 `extension_busy`;线程退出确认或对应工作真正结束后才释放记账。正在退出、保留旧版本及启动中的 Worker 都占线程预算;预算耗尽时不会偷偷使用进程内模式绕过。
36
+
37
+ ## 可调整的预算
38
+
39
+ | 预算 | `hostExecution` 字段 |
40
+ | --- | --- |
41
+ | 线程与注册数量 | `maxWorkers`、`maxRegistrations` |
42
+ | V8 堆与栈 | `maxOldGenerationSizeMb`、`maxYoungGenerationSizeMb`、`stackSizeMb` |
43
+ | 激活、动作、取消与清理 | `activationTimeoutMs`、`operationTimeoutMs`、`cancelGraceMs`、`streamCancelGraceMs`、`disposeGraceMs` |
44
+ | 动作 JSON 与流 | `resultMaxBytes`、`streamWindowBytes`、`streamChunkBytes`、`streamAggregateBytes` |
45
+ | 日志 | `logWindowMs`、`logMessagesPerWindow`、`logMessageBytes` |
46
+
47
+ 参数必须是范围内的整数;流块不能大于单流窗口,单流窗口不能大于聚合预算。初次启用建议保留默认值。配置定义与校验由 `src/extension-worker-config.ts` 管理。Node 的全局堆参数可能覆盖 Worker 资源配置,因此检测到会覆盖限制的父进程启动参数时,Worker 会在导入扩展前拒绝启动并报告 `extension_worker_heap_override`。请检查 DSH 的启动参数;需要保留这些参数时可继续使用进程内模式,不会静默绕过。
48
+
49
+ 流量按消费确认回填,未读完的 EOF 数据继续占预算;日志和结果在 Worker 内先转换为受限数据,再交给网关。该机制约束传输与 JavaScript 阻塞风险,不是整个操作系统或进程的资源沙箱。
@@ -0,0 +1,27 @@
1
+ # Mobile composer layout
2
+
3
+ [简体中文](MOBILE_COMPOSER_TOOLBAR.md) · [Third-party compatibility](../README.en.md#third-party-plugin-compatibility)
4
+
5
+ > Layout changes in plugin 0.6.3. Android and mobile browsers use the same plugin-served page; this change does not require reinstalling the App.
6
+
7
+ The narrow-screen composer preserves DSH's native structure and handlers. Model names truncate when space is limited, additional controls wrap, and Send/Stop retain touch dimensions. Entire native groups wrap according to their actual width. Switches are not stretched into action buttons, and inline dialogs, menus and lists do not receive compact toolbar sizing.
8
+
9
+ ## Difference from the original PR
10
+
11
+ The original proposal combined plugins into one upper horizontal scrollport with native actions fixed below. Current DSH exposes no parent `conversation.input.toolbar` entry; left and right plugins belong to different React parents, and the core width observer measures those two native groups. This version does not provide a shared upper scrollport, move DOM, copy core callbacks or flatten the tree with `display:contents`.
12
+
13
+ It implements compact/overflow fixes through available public seams, retaining the original author commit with maintainer corrections. A future upstream parent-toolbar capability can be evaluated separately rather than registering a presenter that never renders.
14
+
15
+ ## Compact extension boundaries
16
+
17
+ The layout preserves native parent groups and adjusts wrapping, spacing and reachability. It does not guess third-party classes to hide labels, errors or task-recovery exits. Icons, quota summaries and compact preferences belong to the plugin providing the control; without a public compact interface, keep its existing presentation instead of adding an ineffective setting. Small visual icons still need reachable touch targets.
18
+
19
+ Public-slot checks also cover compact shapes representing an auto-continue switch, Fast icon, quota summary and existing-task recovery entry. These are presentation fixtures without service behavior, not evidence that a particular community plugin's interfaces, task operations or quota requests work.
20
+
21
+ On small screens, direct span/div wrappers with a non-switch button in the public left/right slots reserve room for the 44px target, preventing it from escaping a 30px wrapper into adjacent controls. The rule does not identify third-party classes; pure switches, popup wrappers and wrappers containing buttons only deeper in their trees are excluded, as are wide viewports.
22
+
23
+ ## Verification scope
24
+
25
+ The real DSH composer runs with test extensions genuinely registered through the public left/right/activity slots. Checks cover small viewports, landscape, larger text, both themes, model compactness, popups, switches and reachable Send/Stop. Editor, draft and active activity nodes remain mounted without a page reload.
26
+
27
+ The activity fixture verifies slot/mount continuity, not a physical microphone or recognition provider. Arbitrary community combinations can still have their own layout issues; [Mobile page modules](CLIENT_MODULES.en.md) can exclude unneeded optional modules.
@@ -0,0 +1,27 @@
1
+ # 移动输入栏布局
2
+
3
+ [English](MOBILE_COMPOSER_TOOLBAR.en.md) · [第三方插件适配](../README.md#第三方插件适配)
4
+
5
+ > 0.6.3 的布局调整。Android App 与手机浏览器共用插件提供的页面,不需要为此重装 App。
6
+
7
+ 窄屏输入栏保留 DSH 原生结构和事件处理。模型名称在空间不足时截断,附加控件正常换行,发送/停止保持触摸尺寸,整组放不下时按实际宽度换行。插件开关不会被拉成操作按钮;内联弹窗、菜单和列表不会套用工具栏的紧凑尺寸。
8
+
9
+ ## 与 PR 原稿的区别
10
+
11
+ 原稿提出把所有插件合入一个上层横向滚动条,并把原生操作固定在下层。当前 DSH 没有公开父级 `conversation.input.toolbar` 入口,左右插件属于不同 React 父节点;核心宽度观察器还要测量这两个父组。因此本版不提供共同滚动上栏,也不搬动 DOM、复制原生回调或用 `display:contents` 绕过核心布局。
12
+
13
+ 本版采用现有公共插槽能实现的紧凑与溢出修正,保留原稿作者提交并追加维护者调整。若上游以后提供父级工具栏能力,可再评估统一滚动方案,不预先登记一个不会渲染的控件。
14
+
15
+ ## 附加控件的紧凑边界
16
+
17
+ 布局层保留原生父组,只调整换行、间距和可达性,不通过猜测第三方类名隐藏标签、错误状态或任务恢复入口。控件的图标、额度摘要和紧凑偏好由提供控件的插件负责;没有公开紧凑接口时保留其现有呈现,不新增一个无效设置。视觉较小的图标也需要可达的触摸目标。
18
+
19
+ 公共插槽测试另覆盖自动继续开关、Fast 图标、两行额度摘要及已有任务恢复入口的紧凑形态。它们是无服务行为的展示夹具,不代表特定社区插件的实际接口、任务操作或配额请求已通过验证。
20
+
21
+ 小屏下,公共 left/right 插槽中直接包含普通按钮的 span/div 包装层会为 44px 点击区域保留尺寸,避免按钮越过原先 30px 包装层而挤到相邻控件。此规则不按第三方类名识别控件;不匹配纯 switch、弹窗包装或只在更深层含按钮的包装,且不在宽屏生效。
22
+
23
+ ## 验证范围
24
+
25
+ 真实 DSH 输入栏与通过公共 left/right/activity 插槽注册的测试扩展共同运行,检查小屏、横屏、较大字号、明暗主题、模型紧凑状态、弹窗、开关以及发送/停止可达性。编辑器、草稿和活跃 activity 的节点保持,不因此重载页面。
26
+
27
+ activity 夹具只验证插槽和挂载连续性,不代表真实麦克风或语音识别。任意社区插件组合仍可能有自己的布局问题;可使用[移动页面模块](CLIENT_MODULES.md)关闭不需要的可选模块。
package/docs/README.md CHANGED
@@ -27,6 +27,8 @@
27
27
 
28
28
  ## 开发与安全 / Development and security
29
29
 
30
+ 0.6.3 的 [扩展 Worker 指南](EXTENSION_WORKERS.md)/[Worker guide](EXTENSION_WORKERS.en.md) 介绍按扩展启用、预算与显式恢复;此功能不迁移普通 Cordis 插件,也不是权限沙箱。
31
+
30
32
  - [贡献指南](../CONTRIBUTING.md):本地检查、浏览器与 Android 验证、发布要求。
31
33
  - [安全说明](../SECURITY.md):配对授权、TLS、组件清理与私密漏洞反馈。
32
34
  - [贡献记录](../CONTRIBUTORS.md)与[更新记录](../CHANGELOG.md):分别查看社区参与和版本变化。
@@ -27,7 +27,9 @@ The publicly trusted entry in this guide requires Android app 0.3.3 or later for
27
27
  - **Manual deployment**: install frps and Caddy and configure services and firewalls yourself, then copy the panel's restricted template. Save its frps section as `/etc/dsh-mobile/frps.toml` and Caddy snippet as `/etc/caddy/dsh-mobile-dsh.caddy`. Add `import /etc/caddy/dsh-mobile-dsh.caddy` once to the main Caddyfile without overwriting other sites. Copying or saving a template does not install or start services. This project's public-IPv4 template uses Certbot and requires operator-managed renewal and certificate reload; Caddy manages domain certificates.
28
28
  - **Automatic deployment**: enter an SSH user, port, and local private-key path (leave the path empty to use ssh-agent or SSH configuration), then select the one-click deployment action. The VPS must run Ubuntu or Debian with systemd. Only key-based login is accepted; passwords are not supported.
29
29
 
30
- Before deployment, the panel lists every VPS change. It installs Caddy from its official APT source; public IPv4 mode additionally installs a Python virtual environment, Certbot and the renewal timer. It creates the frps service/configuration, Caddy snippet and one import line, and allows the FRP control port plus 80/tcp and 443/tcp when UFW is active. An existing `dsh-mobile` system user is reused and retained during cleanup; a user created by this deployment is removed during cleanup. **Your existing Caddy content is never merged or overwritten**: an existing DSH Mobile import is only normalized to one line; another non-empty configuration stops deployment and asks you to add the import manually.
30
+ Before deployment, the panel lists every VPS change. It installs Caddy from its official APT source; public IPv4 mode additionally installs a Python virtual environment, Certbot and the renewal timer. It creates the frps service/configuration, Caddy snippet and one import line, and allows the FRP control port plus 80/tcp and 443/tcp when UFW is active. An existing `dsh-mobile` system user is reused and retained during cleanup; a user created by this deployment is removed during cleanup. An existing DSH Mobile import is normalized to one line. Merge custom sites manually; the version-specific behavior is described below.
31
+
32
+ The unreleased correction recognizes a replaceable default Caddyfile by its complete file hash. A default site with additional sites, or a file carrying only the legacy management marker, requires manual merging. The currently published plugin can misidentify such files during automatic deployment; use manual deployment and retain a backup when custom sites exist. Remove legacy whole-file DSH Mobile sites manually during cleanup while preserving other content.
31
33
 
32
34
  ## Verify the host keys every time
33
35
 
@@ -61,7 +63,7 @@ curl -v https://PUBLIC_HOST/mobile-access/discovery
61
63
 
62
64
  Only public IPv4 mode installs `dsh-mobile-cert-renew.timer`; in that mode, also run `sudo systemctl status dsh-mobile-cert-renew.timer`. Replace `PUBLIC_HOST` with your actual domain or public IPv4 address. Do not add `-k`: verify the public certificate as well as the response. Check the same address from an independent external network and confirm discovery reports this computer's installation identifier; success on the VPS alone does not prove the phone's network can reach it.
63
65
 
64
- On the computer, start with the plugin log at `$DSH_HOME/mobile-access/logs/dsh-mobile.log` (JSONL, 5 MB rotation, tokens and keys redacted) and the panel diagnostic report. Windows antivirus software can quarantine frpc; if it does, create the smallest possible exception for the verified component directory only.
66
+ On the computer, start with the plugin log at `$DSH_HOME/mobile-access/logs/dsh-mobile.log` (JSONL; existing files of at least 5 MB rotate at startup) and the panel diagnostic report. Custom extensions must not log tokens or keys; inspect and remove sensitive content before sharing logs because this is not a generic secret redactor. Windows antivirus software can quarantine frpc; if it does, create the smallest possible exception for the verified component directory only.
65
67
 
66
68
  When several computers share one frps, proxy names are unique across the server and entry ports cannot overlap. The plugin uses `dsh-mobile-<first 12 characters of the installation identity>` as its name. A name conflict requires checking another computer or an unexpired old connection; a port conflict requires a different entry port. A running frpc process does not mean the public entry was published: verify the public route.
67
69
 
@@ -31,7 +31,9 @@
31
31
  - **手动部署**:先自行安装 frps、Caddy,配置服务和防火墙,再复制面板的受限模板。frps 部分存为 `/etc/dsh-mobile/frps.toml`,Caddy 片段存为 `/etc/caddy/dsh-mobile-dsh.caddy`;主 Caddyfile 只需添加一次 `import /etc/caddy/dsh-mobile-dsh.caddy`,不要覆盖其他站点。复制或保存模板不会自动安装、启动服务。公网 IPv4 按本项目模板使用 Certbot,并需自行维护续期和证书重载;域名证书由 Caddy 管理。
32
32
  - **自动部署**:填写 SSH 用户、端口和本机私钥路径(留空则用 ssh-agent 或 SSH 配置),点击一键部署。要求 Ubuntu/Debian + systemd,只接受密钥登录,不接受密码。
33
33
 
34
- 点部署按钮前,面板会列出自动部署将在 VPS 上做的全部改动:从官方 APT 源安装 Caddy;仅在公网 IPv4 模式安装 Python venv、Certbot 和证书续期定时器;创建 `dsh-mobile` 系统用户、frps 配置与服务、Caddy 片段和主文件里的一行 import;如果 UFW 已启用,则放行 FRP 控制端口与 80/443。已存在的系统用户会复用并在清理时保留,本次部署创建的用户则会在清理时删除。**主 Caddyfile 里你自己的内容不会被改写或合并**:没有 Caddyfile、空文件或官方默认文件时才写入这一行 import;已有 DSH Mobile import 时只整理为一行,其他非空配置会停止部署并告诉你手动加这一行。
34
+ 点部署按钮前,面板会列出自动部署将在 VPS 上做的全部改动:从官方 APT 源安装 Caddy;仅在公网 IPv4 模式安装 Python venv、Certbot 和证书续期定时器;创建 `dsh-mobile` 系统用户、frps 配置与服务、Caddy 片段和主文件里的一行 import;如果 UFW 已启用,则放行 FRP 控制端口与 80/443。已存在的系统用户会复用并在清理时保留,本次部署创建的用户则会在清理时删除。主 Caddyfile 已有 DSH Mobile import 时,只整理该行;已有自定义站点时请手动合并,版本差异见下方说明。
35
+
36
+ 待发布修正使用完整默认文件哈希识别可替换的 Caddyfile;保留默认站点后追加其他站点的文件,以及只有旧版管理标记的文件,都需要手动合并。当前已发布插件的自动部署尚有误判覆盖风险,已有自定义站点时请使用手动部署并保留备份。清理旧版整文件配置时也应自行移除 DSH Mobile 站点,保留其他内容。
35
37
 
36
38
  ## 主机指纹核对(每次必做)
37
39
 
@@ -65,7 +67,7 @@ curl -v https://PUBLIC_HOST/mobile-access/discovery
65
67
 
66
68
  只有公网 IPv4 模式会安装 `dsh-mobile-cert-renew.timer`,该模式另查 `sudo systemctl status dsh-mobile-cert-renew.timer`。将 `PUBLIC_HOST` 换成实际域名或公网 IPv4;不加 `-k`,以便同时验证公开证书。还应从独立外部网络检查同一地址,并确认 discovery 返回当前电脑的安装标识;只在 VPS 本机成功不代表手机网络可达。
67
69
 
68
- 本机优先看插件日志(`$DSH_HOME/mobile-access/logs/dsh-mobile.log`,JSONL,超 5 MB 轮转,自动脱敏 Token 与密钥)与面板诊断报告。Windows 杀毒软件可能隔离 frpc;如确有拦截,只为已校验的组件目录设置最小范围例外。
70
+ 本机优先看插件日志(`$DSH_HOME/mobile-access/logs/dsh-mobile.log`,JSONL,启动时检查并轮转达到 5 MB 的已有日志)与面板诊断报告。自定义扩展不要输出令牌或密钥;分享日志前检查并移除敏感内容,文件日志不是通用秘密脱敏器。Windows 杀毒软件可能隔离 frpc;如确有拦截,只为已校验的组件目录设置最小范围例外。
69
71
 
70
72
  一台 frps 被多台电脑共用时,代理名在整台服务器内唯一,公网入口端口也不能重复。插件使用 `dsh-mobile-<安装标识前 12 位>` 作为代理名。报代理名已使用时,检查另一台电脑或尚未过期的旧连接;报入口端口已占用时,为本机选择另一个入口端口。frpc 仍在运行并不表示入口已发布,应以公网验证结果为准。
71
73
 
@@ -76,6 +76,12 @@ These optional fields sit alongside `paths`; their defaults normally suffice:
76
76
 
77
77
  Restart DSH and reopen the phone page after a change. Developer tools can show whether the WebSocket handshake negotiated `permessage-deflate`; a client that does not negotiate it can still connect without compression. Compare actual channel transfer volume and CPU use for the same Session, rather than judging savings from the decompressed message lengths shown in developer tools. Set `paths` back to `[]` to disable it. Compression does not raise provider quotas or replace connection troubleshooting.
78
78
 
79
+ ## Static-resource caching and repeated downloads
80
+
81
+ Gateway-generated resources use quoted ETags and process conditional GET/HEAD requests only after device authentication and resource checks. When the gateway applies streaming gzip to an upstream static resource, a valid upstream ETag can remain usable as a weak validator with `Vary: Accept-Encoding`. Identity lengths and range metadata are not reused as compressed-representation metadata. Failed responses are not cached, and API requests receive no new caching policy.
82
+
83
+ Resources without upstream validators do not automatically acquire a reliable one. Compare the same complete resource URL during ordinary navigation, reload and app cold start: inspect `200`/`304`, cache hits, `If-None-Match`, encoding and actual transfer volume. Do not clear site data first and treat the result as normal cache behavior. Improved HTTP validation does not guarantee cache reuse in every WebView, and these headers alone do not establish a complete fix for [#107](https://github.com/saya-ch/dsh-mobile/issues/107).
84
+
79
85
  ## Manual compaction and long-running API requests (0.6.0)
80
86
 
81
87
  Commands such as `/compact` sent from the phone traverse the Mobile gateway's HTTP API. Automatic compaction inside DSH does not traverse that gateway request. Older gateways used the default 30-second `upstreamTimeoutMs` for every proxied request, so manual compaction could return `502 upstream_unavailable` while the model was still producing the summary. [#171](https://github.com/saya-ch/dsh-mobile/issues/171) records this case.
@@ -76,6 +76,12 @@ websocketCompression:
76
76
 
77
77
  修改后重启 DSH 并重新打开手机页面。可在开发者工具中查看 WebSocket 握手是否协商了 `permessage-deflate`;客户端没有协商时仍可无压缩连接。对比同一会话的实际通道传输量与 CPU 使用,不能仅以开发者工具显示的解压后消息长度判断节省比例。需要关闭时把 `paths` 改回 `[]`;压缩不能提高服务商限额,也不能替代连接故障排查。
78
78
 
79
+ ## 静态资源缓存与重复下载
80
+
81
+ 网关自身生成的资源使用带引号的 ETag,并在设备鉴权和资源检查之后处理 GET/HEAD 的条件请求。网关对上游静态资源做流式 gzip 时,可保留合法上游 ETag 的弱校验形式,并维护 `Vary: Accept-Encoding`;不会把原始内容的长度或范围元信息当作压缩内容的元信息,也不会缓存失败响应或为 API 请求新增缓存。
82
+
83
+ 上游未提供校验标识的资源不会因此自动得到一个可靠标识。检查重复下载时,比较同一完整资源 URL 在普通打开、重新加载和 App 冷启动中的 `200`/`304`、缓存命中、`If-None-Match`、编码与实际传输量;不要先清除站点数据再把结果当作正常缓存行为。HTTP 校验语义改善不保证每个 WebView 都会复用缓存,也不能仅凭这些响应头认定 [#107](https://github.com/saya-ch/dsh-mobile/issues/107) 已完全解决。
84
+
79
85
  ## 手动压缩会话与长时间 API 请求(0.6.0)
80
86
 
81
87
  手机发出的 `/compact` 等命令要通过移动网关的 HTTP API;DSH 内部触发的自动压缩不经过这次网关请求。旧版网关对所有上游代理请求都使用默认 30 秒的 `upstreamTimeoutMs`,因此模型仍在生成摘要时,手动压缩可能先收到 `502 upstream_unavailable`。[#171](https://github.com/saya-ch/dsh-mobile/issues/171) 记录了这一情况。
@@ -2,7 +2,7 @@
2
2
 
3
3
  [中文](TRUSTED_NETWORKS.md) · [Back to the LAN guide](../README.en.md#local-network)
4
4
 
5
- > DSH Mobile 0.6.0 provides this computer-side advanced control. Update the plugin and app together.
5
+ > Plugin 0.6.0 and later provide this computer-side control. The App does not need a matching version or an update solely for this setting.
6
6
 
7
7
  Default LAN setup permits the LAN source subnet of the selected interface. Explicitly append source CIDRs when an existing routed network you control, such as WireGuard, needs access. This does not create a VPN, route, IPv6 listener, or public entry.
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](TRUSTED_NETWORKS.en.md) · [返回局域网指南](../README.md#局域网访问)
4
4
 
5
- > DSH Mobile 0.6.0 提供电脑端高级设置;请同步更新插件与 App。
5
+ > 插件 0.6.0 起提供此电脑端高级设置,不要求 App 使用相同版本或为此同步升级。
6
6
 
7
7
  默认局域网设置只允许当前所选网卡的 LAN 来源网段。已有受控的路由网络(例如 WireGuard 网段)需要连接时,可明确追加来源 CIDR;这不会自动建立 VPN、路由、IPv6 监听或公网入口。
8
8
 
package/lib/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir, networkInterfaces } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
5
5
  import { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
6
6
  import { execFile } from "node:child_process";
7
7
  import { generate } from "selfsigned";
8
+ import { performance } from "node:perf_hooks";
8
9
  import "@deepseek-ai/cordis";
9
10
  import "@deepseek-ai/schemastery";
10
11
  //#region src/exec-file.ts
@@ -34,24 +35,75 @@ function execFileText(file, args, options = {}) {
34
35
  //#endregion
35
36
  //#region src/private-file.ts
36
37
  let userSidTask;
38
+ function commandFailureDetails(error) {
39
+ if (error === null || typeof error !== "object") return {};
40
+ let descriptors;
41
+ try {
42
+ descriptors = Object.getOwnPropertyDescriptors(error);
43
+ } catch (_error) {
44
+ return {};
45
+ }
46
+ const code = descriptors.code?.value;
47
+ const signal = descriptors.signal?.value;
48
+ const killed = descriptors.killed?.value;
49
+ return {
50
+ ...code === null || typeof code === "number" && Number.isSafeInteger(code) || typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/u.test(code) ? { code } : {},
51
+ ...signal === null || typeof signal === "string" && /^SIG[A-Z0-9]{1,16}$/u.test(signal) ? { signal } : {},
52
+ ...typeof killed === "boolean" ? { killed } : {}
53
+ };
54
+ }
55
+ /** Phase and full await duration, including subprocess creation, without arguments, output or private paths. */
56
+ var PrivateFilePermissionError = class extends Error {
57
+ stage;
58
+ elapsedMs;
59
+ code;
60
+ signal;
61
+ killed;
62
+ constructor(stage, elapsedMs, error) {
63
+ const details = commandFailureDetails(error);
64
+ const fields = [
65
+ ...details.code === void 0 ? [] : [`code=${String(details.code)}`],
66
+ ...details.signal === void 0 ? [] : [`signal=${String(details.signal)}`],
67
+ ...details.killed === void 0 ? [] : [`killed=${String(details.killed)}`]
68
+ ];
69
+ const summary = `Windows private-file ${stage} failed after ${String(elapsedMs)} ms${fields.length === 0 ? "" : ` (${fields.join(", ")})`}`;
70
+ const cause = Object.assign(/* @__PURE__ */ new Error("Windows permission operation failed"), details);
71
+ super(summary, { cause });
72
+ this.stage = stage;
73
+ this.elapsedMs = elapsedMs;
74
+ this.name = "PrivateFilePermissionError";
75
+ this.code = details.code;
76
+ this.signal = details.signal;
77
+ this.killed = details.killed;
78
+ }
79
+ };
80
+ async function permissionOperation(stage, operation) {
81
+ const started = performance.now();
82
+ try {
83
+ return await operation();
84
+ } catch (error) {
85
+ throw new PrivateFilePermissionError(stage, Math.max(0, Math.round(performance.now() - started)), error);
86
+ }
87
+ }
37
88
  function windowsSystemTool(name) {
38
89
  const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
39
- if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
90
+ if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw Object.assign(/* @__PURE__ */ new Error("Windows system root is unavailable"), { code: "WINDOWS_SYSTEM_ROOT_UNAVAILABLE" });
40
91
  return win32.join(systemRoot, "System32", name);
41
92
  }
42
93
  async function currentWindowsUserSid() {
43
- userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
44
- "/user",
45
- "/fo",
46
- "csv",
47
- "/nh"
48
- ], {
49
- encoding: "utf8",
50
- windowsHide: true,
51
- timeout: 1e4
52
- }).then(({ stdout }) => {
94
+ userSidTask ??= permissionOperation("sid-query", async () => {
95
+ const { stdout } = await execFileText(windowsSystemTool("whoami.exe"), [
96
+ "/user",
97
+ "/fo",
98
+ "csv",
99
+ "/nh"
100
+ ], {
101
+ encoding: "utf8",
102
+ windowsHide: true,
103
+ timeout: 1e4
104
+ });
53
105
  const match = /,"(S-\d(?:-\d+)+)"\s*$/u.exec(stdout.trim());
54
- if (match?.[1] === void 0) throw new Error("unable to resolve the current Windows user SID");
106
+ if (match?.[1] === void 0) throw Object.assign(/* @__PURE__ */ new Error("unable to resolve the current Windows user SID"), { code: "WINDOWS_USER_SID_INVALID" });
55
107
  return match[1];
56
108
  }).catch((error) => {
57
109
  userSidTask = void 0;
@@ -59,12 +111,12 @@ async function currentWindowsUserSid() {
59
111
  });
60
112
  return userSidTask;
61
113
  }
62
- /** Restrict a sensitive regular file to the current user and Windows administrators. */
114
+ /** Restrict a sensitive regular file to the current user and Windows administrators; reject Windows permission failures with redacted phase and duration. */
63
115
  async function restrictPrivateFile(file, mode = 384) {
64
116
  await chmod(file, mode);
65
117
  if (process.platform !== "win32") return;
66
118
  const userSid = await currentWindowsUserSid();
67
- await execFileText(windowsSystemTool("icacls.exe"), [
119
+ await permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
68
120
  file,
69
121
  "/inheritance:r",
70
122
  "/grant:r",
@@ -79,7 +131,7 @@ async function restrictPrivateFile(file, mode = 384) {
79
131
  encoding: "utf8",
80
132
  windowsHide: true,
81
133
  timeout: 1e4
82
- });
134
+ }));
83
135
  }
84
136
  Object.freeze([
85
137
  [0n, 8],
@@ -243,7 +295,7 @@ function assertMatchingCa(certPem, keyPem) {
243
295
  if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
244
296
  return certificate;
245
297
  }
246
- async function atomicWrite(file, contents) {
298
+ async function stagePrivateFile(file, contents) {
247
299
  const directory = dirname(file);
248
300
  await mkdir(directory, {
249
301
  recursive: true,
@@ -251,17 +303,85 @@ async function atomicWrite(file, contents) {
251
303
  });
252
304
  const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
253
305
  try {
254
- await writeFile(temporary, contents, {
306
+ await writeFile(temporary, "", {
255
307
  mode: 384,
256
308
  flag: "wx"
257
309
  });
258
310
  await restrictPrivateFile(temporary);
311
+ await writeFile(temporary, contents);
312
+ return temporary;
313
+ } catch (error) {
314
+ await rm(temporary, { force: true });
315
+ throw error;
316
+ }
317
+ }
318
+ async function atomicWrite(file, contents) {
319
+ const temporary = await stagePrivateFile(file, contents);
320
+ try {
259
321
  await rename(temporary, file);
260
322
  await restrictPrivateFile(file);
261
323
  } finally {
262
324
  await rm(temporary, { force: true });
263
325
  }
264
326
  }
327
+ const certificatePublications = /* @__PURE__ */ new Map();
328
+ /** Stage both files before promotion and retain a private rollback copy until the pair is installed. */
329
+ async function installServerCertificatePair(output, cert, key) {
330
+ const identity = `${output.certFile}\0${output.keyFile}`;
331
+ const publication = (certificatePublications.get(identity) ?? Promise.resolve()).catch(() => void 0).then(async () => {
332
+ const files = [output.certFile, output.keyFile];
333
+ const contents = [cert, key];
334
+ const staged = [];
335
+ const backups = [];
336
+ let promoted = 0;
337
+ let restored = true;
338
+ const failures = [];
339
+ try {
340
+ for (let index = 0; index < files.length; index += 1) {
341
+ const file = files[index];
342
+ staged.push(await stagePrivateFile(file, contents[index]));
343
+ let old;
344
+ try {
345
+ const metadata = await lstat(file);
346
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("managed TLS output is not a regular file");
347
+ old = await readFile(file);
348
+ } catch (error) {
349
+ if (error.code !== "ENOENT") throw error;
350
+ }
351
+ backups.push(old === void 0 ? void 0 : await stagePrivateFile(file, old));
352
+ }
353
+ for (let index = 0; index < files.length; index += 1) {
354
+ await rename(staged[index], files[index]);
355
+ promoted += 1;
356
+ }
357
+ } catch (error) {
358
+ failures.push(error);
359
+ for (let index = promoted - 1; index >= 0; index -= 1) try {
360
+ const backup = backups[index];
361
+ if (backup === void 0) await rm(files[index], { force: true });
362
+ else await rename(backup, files[index]);
363
+ } catch (restoreError) {
364
+ restored = false;
365
+ failures.push(restoreError);
366
+ }
367
+ } finally {
368
+ const cleanup = await Promise.allSettled(staged.map((file) => rm(file, { force: true })));
369
+ for (const result of cleanup) if (result.status === "rejected") failures.push(result.reason);
370
+ if (restored) {
371
+ const backupCleanup = await Promise.allSettled(backups.filter((file) => file !== void 0).map((file) => rm(file, { force: true })));
372
+ for (const result of backupCleanup) if (result.status === "rejected") failures.push(result.reason);
373
+ }
374
+ }
375
+ if (failures.length > 1) throw new AggregateError(failures, "managed TLS certificate replacement, rollback or cleanup failed");
376
+ if (failures.length === 1) throw failures[0];
377
+ });
378
+ certificatePublications.set(identity, publication);
379
+ try {
380
+ await publication;
381
+ } finally {
382
+ if (certificatePublications.get(identity) === publication) certificatePublications.delete(identity);
383
+ }
384
+ }
265
385
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
266
386
  async function ensureManagedCa(setup, legacy) {
267
387
  let certPem;
@@ -376,7 +496,7 @@ async function issueServerCertificate(authority, target, output, lifetimeDays =
376
496
  }
377
497
  ]
378
498
  });
379
- await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
499
+ await installServerCertificatePair(output, server.cert, server.private);
380
500
  }
381
501
  /** Sign and atomically install a server leaf for the interface's current address. */
382
502
  async function refreshManagedServerCertificate(setup, address) {
@@ -473,6 +593,7 @@ async function prepareManagedLanSetup(options) {
473
593
  androidCertificate
474
594
  });
475
595
  }
596
+ process.env.NODE_OPTIONS;
476
597
  Object.freeze({
477
598
  manifest: 65536,
478
599
  script: 1048576,