dsh-mobile 0.6.3 → 0.6.5

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.
@@ -12,6 +12,14 @@
12
12
 
13
13
  本版采用现有公共插槽能实现的紧凑与溢出修正,保留原稿作者提交并追加维护者调整。若上游以后提供父级工具栏能力,可再评估统一滚动方案,不预先登记一个不会渲染的控件。
14
14
 
15
+ ## 附加控件的紧凑边界
16
+
17
+ 布局层保留原生父组,只调整换行、间距和可达性,不通过猜测第三方类名隐藏标签、错误状态或任务恢复入口。控件的图标、额度摘要和紧凑偏好由提供控件的插件负责;没有公开紧凑接口时保留其现有呈现,不新增一个无效设置。视觉较小的图标也需要可达的触摸目标。
18
+
19
+ 公共插槽测试另覆盖自动继续开关、Fast 图标、两行额度摘要及已有任务恢复入口的紧凑形态。它们是无服务行为的展示夹具,不代表特定社区插件的实际接口、任务操作或配额请求已通过验证。
20
+
21
+ 小屏下,公共 left/right 插槽中直接包含普通按钮的 span/div 包装层会为 44px 点击区域保留尺寸,避免按钮越过原先 30px 包装层而挤到相邻控件。此规则不按第三方类名识别控件;不匹配纯 switch、弹窗包装或只在更深层含按钮的包装,且不在宽屏生效。
22
+
15
23
  ## 验证范围
16
24
 
17
25
  真实 DSH 输入栏与通过公共 left/right/activity 插槽注册的测试扩展共同运行,检查小屏、横屏、较大字号、明暗主题、模型紧凑状态、弹窗、开关以及发送/停止可达性。编辑器、草稿和活跃 activity 的节点保持,不因此重载页面。
package/docs/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
  | 复用已经运行的 frps,不自动修改服务器 | [接入既有 frps](ATTACH_EXISTING_FRPS.md) | [Existing frps](ATTACH_EXISTING_FRPS.en.md) |
13
13
  | 复用 Lucky、Nginx 或 Caddy 的公开 HTTPS 入口 | [自有反向代理](SELF_HOSTED_ORIGIN.md) | [Own reverse proxy](SELF_HOSTED_ORIGIN.en.md) |
14
14
  | 页面加载慢、实时连接反复重连或长操作超时 | [慢链路与重连](SLOW_CONNECTIONS.md) | [Slow links and reconnection](SLOW_CONNECTIONS.en.md) |
15
+ | 保留电脑皮肤,排查移动页面的背景与可读性 | [皮肤与移动布局](SKIN_COMPATIBILITY.md) | [Skins and mobile layout](SKIN_COMPATIBILITY.en.md) |
15
16
 
16
17
  在 Android App 中连接公开隧道或自建入口时请选择 **远程访问**。公开受信任证书也可用手机浏览器配对;既有 frps 的自签入口需要 **0.4.6 或更高版本 App** 固定远程 CA。各通道仍要求 DSH Mobile 配对,不能直接暴露普通 DSH 或私有 HTTP 后端。
17
18
 
@@ -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
 
@@ -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
 
@@ -0,0 +1,25 @@
1
+ # Skins and mobile layout
2
+
3
+ [简体中文](SKIN_COMPATIBILITY.md) · [Third-party plugins](../README.en.md#third-party-plugin-compatibility)
4
+
5
+ DSH Mobile uses a dedicated root layout on the authenticated mobile page while retaining DSH conversations, sidebars, settings and public slots. The computer page is unchanged. Components can still consume public theme variables, but backgrounds, characters and sidebar decorations tied to stock CSS-module classes or specific parents may not apply.
6
+
7
+ ## Keep the computer skin, change only the phone
8
+
9
+ Open **Settings → General → Mobile page modules → Manage** on the phone and find the skin's client module. If optional, deselect it and save. This choice affects only the current paired device: it does not uninstall the computer plugin, change other devices or reload a conversation automatically. Manually reopen or refresh the page to apply it.
10
+
11
+ If saving reports a dependency conflict, retain the required module or also deselect its unwanted consumers. Bootstrap-required modules cannot be excluded. The phone's Restore defaults action returns to computer defaults, not necessarily to loading every module. See [module management](CLIENT_MODULES.en.md).
12
+
13
+ ## Diagnose a missing background
14
+
15
+ - Identify the running plugin, App and skin versions; plugin and App versions need not match.
16
+ - Omit the skin only on this device, reopen manually and compare the conversation, composer and sidebar. Recovery points to styles or mounting, not necessarily a network or pairing failure.
17
+ - First compare the normal computer layout. Developers can also open `/?frontend=stock` on the authenticated mobile Origin for layout diagnosis. Gateway authentication and HTTPS checks remain, but mobile enhancements and module selection are bypassed; some API operations may be rejected. It is not a replacement for the normal mobile entry.
18
+ - When stock has a background but mobile does not, inspect the skin's mounting container and fallback for other layouts. WebSocket approval, APK reinstallation or making every container transparent cannot guarantee a fix.
19
+ - A see-through sidebar, unreadable text shadows and an empty trace need separate diagnosis, not a single missing-background explanation. For an empty trace, inspect the first client exception, actual layout dimensions and whether its module loaded. A static screenshot does not distinguish rendering failure, occlusion and connection failure.
20
+
21
+ For example, the published `maid-atelier` 0.1.7 package mounts its background in the stock `centerCol` container. Its repository's fallback for other layouts does not establish that an installed package contains the same correction. DSH Mobile does not imitate that class or move the skin's nodes. A skin supporting mobile can locate its decoration with the existing `[data-pane="conversation"]` and `[data-pane="sidebar"]` markers, or use DSH public theme variables. The skin must still own sizing, stacking, cleanup and light/dark behavior without obscuring text or hit targets. This guide does not establish full skin acceptance. See [#202](https://github.com/saya-ch/dsh-mobile/issues/202).
22
+
23
+ An isolated DSH 0.2.0-rc.2 combination with Mobile 0.6.4 and the published npm `maid-atelier` 0.1.7 reproduces the see-through navy sidebar, unreadable dark labels, missing background and a blank populated Trajectory view. The trace data remains present: a broad skin rule changes the mobile root to a column Flex layout and collapses the content region to zero height. Plugin 0.6.5 protects the plugin-owned root's Grid geometry and fixes this blank trace, without changing the skin's palette, shadows or decoration mounting.
24
+
25
+ This skin binds light sidebar labels to the stock `sidebarCol`, absent from the mobile layout. Missing backgrounds and some text-contrast problems still need skin adaptation; the trace fix does not resolve them. Device-level exclusion restores default mobile presentation while preserving the computer skin. Plugin 0.6.4 and earlier do not contain the layout protection.
@@ -0,0 +1,25 @@
1
+ # 皮肤与移动布局
2
+
3
+ [English](SKIN_COMPATIBILITY.en.md) · [第三方插件适配](../README.md#第三方插件适配)
4
+
5
+ DSH Mobile 在已认证的移动页面中使用专用根布局,继续渲染 DSH 的会话、侧栏、设置和公共插槽;电脑页面不变。皮肤的公共主题变量可继续被组件使用,但依赖原生 CSS 模块类名或特定父节点的背景、人物及侧栏装饰不一定适用。
6
+
7
+ ## 保留电脑皮肤,只调整手机
8
+
9
+ 在手机 **设置 → 通用设置 → 移动页面模块 → 管理** 中找到皮肤对应的客户端模块;若它是可选项,可取消勾选并保存。选择只作用于当前配对设备,不卸载电脑插件、不修改其他设备,也不会自动刷新会话。下次手动重新打开或刷新页面才生效。
10
+
11
+ 保存时若提示依赖冲突,保留所需模块,或同时取消不需要的依赖方;不能排除启动必需模块。手机的“恢复默认”会重新使用电脑默认选择,不表示恢复为“加载全部”。完整规则见[模块管理](CLIENT_MODULES.md)。
12
+
13
+ ## 背景不显示时如何判断
14
+
15
+ - 先核对正在运行的插件、App 和皮肤版本;插件与 App 版本不必相同。
16
+ - 只在本设备不加载皮肤,手动重新打开后比较会话、输入栏和侧栏。恢复正常说明需要检查双方样式与挂载位置,不表示网络或配对失效。
17
+ - 先与电脑上的原生布局对照。开发者还可在已认证移动 Origin 打开 `/?frontend=stock` 作布局诊断;它保留 Gateway 认证与 HTTPS 检查,但绕过移动增强和模块选择,部分 API 操作可能被拒绝,不能代替正常移动入口。
18
+ - 若原生布局有背景而移动布局没有,检查皮肤背景的挂载容器及其对非原生布局的回退逻辑;单纯放行 WebSocket、重装 APK 或把全部容器设为透明不能保证修复。
19
+ - 侧栏透底、文字阴影难读和轨迹空白应分别排查,不能都归为“少了一张背景图”。轨迹空白时还需检查首个客户端异常、实际布局尺寸和是否加载了相应模块;仅从静态截图不能判断是渲染异常、遮挡还是连接问题。
20
+
21
+ 例如 `maid-atelier` 0.1.7 发布包的背景依赖原生 `centerCol` 容器;皮肤仓库源码中的非原生布局回退不代表已安装包包含相同修正。DSH Mobile 不伪造这个类名或移动皮肤节点。支持移动布局的皮肤可以利用现有 `[data-pane="conversation"]`、`[data-pane="sidebar"]` 标记定位自己的装饰,或使用 DSH 公共主题变量;皮肤仍需自行处理尺寸、层叠、关闭清理和明暗主题,装饰不得遮挡文字或点击区域。本指南不表示整套皮肤已完成兼容验收。参见 [#202](https://github.com/saya-ch/dsh-mobile/issues/202)。
22
+
23
+ 在 DSH 0.2.0-rc.2、Mobile 0.6.4 与正式 npm `maid-atelier` 0.1.7 的隔离组合中,已复现深蓝侧栏透底、深色文字难读、背景缺失和有内容会话的轨迹空白。轨迹数据没有丢失:皮肤的宽泛根布局规则把移动容器改为纵向 Flex,内容区域高度缩为 0。插件 0.6.5 保护自有根容器的 Grid 几何规则,修复该轨迹空白,不改皮肤配色、阴影或装饰挂载。
24
+
25
+ 该皮肤把浅色侧栏文字绑定到原生 `sidebarCol`;移动布局没有该节点。缺失背景及部分文字对比度仍需皮肤适配,不能视为已随轨迹修复解决。按上面的设备模块选择排除皮肤后,移动页面可恢复默认样式,电脑皮肤保持。插件 0.6.4 及更早版本不包含这项布局保护。
@@ -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) 记录了这一情况。
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) {