dsh-remote-mobile 1.8.0 → 1.9.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.0] - 2026-10-09
9
+
10
+ ### Fixed (适配 DSH 0.1.7+ 配置存储变更,修复插件配置无法持久化)
11
+
12
+ - **配置持久化迁移**:DSH 自 **0.1.7** 起(最早见于 v0.1.7-alpha.1 的 release notes,0.2.x 延续)官方已弃用 `~/.dsh/settings.yaml` 作为实时配置源(启动时一次性迁移进当前 profile 的 `cordis.patch.yml` 并改名为 `settings.yaml.imported`)。原实现持续写回该文件,导致免密开关、长期密码哈希、限频/锁定阈值在升级后无法持久化(重启即回落默认):
13
+ - 新增插件自有配置 `~/.dsh/remote-mobile/settings.json`(JSON、临时文件原子替换、权限 `0o600`),由 `SessionStore` 读写;
14
+ - 首次启动自动从旧 `settings.yaml` / `settings.yaml.imported` 一次性迁移,救回既有长期密码与限频配置;
15
+ - 保留旧版 `ctx.settings.register` 集成作为旧版 DSH 的尽力兼容(0.1.7+ 的 `SettingsForms` 无该 API,会安全跳过)。
16
+ - **防止已清空的密码复活**:配置迁移仅在自有 `settings.json` 不存在时执行一次;一旦自有配置存在,旧 `settings.yaml.imported` 不再参与合并,避免用户清空长期密码后重启又被旧值救回。决策逻辑抽为纯函数 `resolveInitialStoreOptions()` 并由单测锁定。
17
+ - **语言偏好读取适配**:`readGlobalLocale()` 改为优先读取当前 profile 的 `cordis.patch.yml`(locale entry),回退旧 `settings.yaml` / `settings.yaml.imported`,修复升级后登录页与接口语言恒为中文的问题。
18
+ - **测试**:新增 `test/settings-persistence.test.js` 覆盖自有配置读写、旧版迁移合并与 locale 解析。
19
+ - **文档**:README(中 / 英)更新配置存储位置与新增 FAQ Q8,说明 DSH 0.1.7+ 的 settings.yaml 迁移行为。
20
+
21
+ ---
22
+
8
23
  ## [1.8.0] - 2026-10-08
9
24
 
10
25
  ### Fixed & Security (适配 NAS / 网关子路径前缀反代,修复静默失败与开放重定向)
package/README.md CHANGED
@@ -166,7 +166,7 @@ dsh web --no-open
166
166
  - 访问审计与锁定状态持久化落盘,服务重启后自动恢复;
167
167
  - 支持管理员在管理面板中一键解锁指定 IP。
168
168
  - **智能静态资源放行机制**:安全门禁内置智能前端静态扩展名识别(放行 `.js`、`.css`、`.png`、`.svg`、`.woff2` 等 20 种合法资源),拦截无扩展名或动态管理 API 请求(如 `/plugins/xxx/admin`),确保第三方生态插件前端正常展示的同时严密保护后台动态接口,无需用户在设置界面手动维护白名单。(DSH 核心静态目录 `/assets/` 属官方资源,单独无条件放行。)
169
- - **高可靠原子持久化与防抖**:采用毫秒级写盘防抖节流保护磁盘 I/O(会话数据 500ms、样式片段 300ms),配合 `beforeExit` 进程退出 Flush 保证数据不丢失;`settings.yaml` 与 `style-snippets.json` 采用临时文件原子替换(`renameSync`),防止异常中断损坏数据。
169
+ - **高可靠原子持久化与防抖**:采用毫秒级写盘防抖节流保护磁盘 I/O(会话数据 500ms、样式片段 300ms),配合 `beforeExit` 进程退出 Flush 保证数据不丢失;`settings.json` 与 `style-snippets.json` 采用临时文件原子替换(`renameSync`),防止异常中断损坏数据。
170
170
  - **真实 IP 安全提取**:仅信任底层 Socket 真实连接地址,防范伪造的 `X-Forwarded-For` 欺骗攻击。
171
171
  - **本机回环 CSRF 防御**:插件的变更类 API 会校验浏览器同源信号(`Origin` / `Sec-Fetch-Site`),恶意网页驱使浏览器向 `127.0.0.1` 发起的跨站写请求将被直接拒绝;curl / 本机脚本等无浏览器信号的客户端不受影响。
172
172
  - **本机回环请求不做上下文改写**:上下文虚拟化仅作用于外部来源流量;本机回环请求保留原始 Host / Origin(不"洗白"),使 DSH 自带的 DNS-Rebinding 与同源校验对外部域名继续生效。SSE 事件流仅限同源连接,不开放跨域读取。
@@ -283,18 +283,21 @@ dsh plugin --profile web remove dsh-remote-mobile
283
283
  <span id="advanced"></span>
284
284
  ## ⚙️ 高级配置
285
285
 
286
- 插件已接入 DSH 官方 Settings 体系,配置项支持在 Web 界面中直观调整,也可在 `~/.dsh/settings.yaml` 的 `dsh-remote-mobile` 命名空间下手动修改:
286
+ 配置持久化于插件自有文件 `~/.dsh/remote-mobile/settings.json`,可在 Web 界面中直观调整,也可直接编辑该文件:
287
287
 
288
- ```yaml
289
- dsh-remote-mobile:
290
- allowTailscale: false # boolean,默认 false:是否允许 Tailscale 虚拟私网免密访问
291
- allowLan: false # boolean,默认 false:是否允许局域网免密访问(高危警示)
292
- secretHash: "" # string,默认空:长期访问密码的 scrypt 加盐哈希值
293
- maxVisitsPerMinute: 60 # number,默认 60:单 IP 每分钟最大访问登录页次数
294
- maxFailedAttempts: 5 # number,默认 5:触发封禁的连续认证失败最大次数
295
- lockDurationMs: 900000 # number,默认 900000 (15分钟):IP 锁定持续时间(毫秒)
288
+ ```jsonc
289
+ {
290
+ "allowTailscale": false, // boolean,默认 false:是否允许 Tailscale 虚拟私网免密访问
291
+ "allowLan": false, // boolean,默认 false:是否允许局域网免密访问(高危警示)
292
+ "secretHash": "", // string,默认空:长期访问密码的 scrypt 加盐哈希值
293
+ "maxVisitsPerMinute": 60, // number,默认 60:单 IP 每分钟最大访问登录页次数
294
+ "maxFailedAttempts": 5, // number,默认 5:触发封禁的连续认证失败最大次数
295
+ "lockDurationMs": 900000 // number,默认 900000 (15分钟):IP 锁定持续时间(毫秒)
296
+ }
296
297
  ```
297
298
 
299
+ > **关于配置存储**:DSH 自 **0.1.7** 起(最早见于 v0.1.7-alpha.1 的 release notes,0.2.x 延续)官方已弃用 `~/.dsh/settings.yaml` 作为**实时配置源**——该文件会在启动时被一次性迁移进当前 profile 的 `cordis.patch.yml`,并改名为 `settings.yaml.imported`(详见 [Q8](#faq))。因此本插件不再写入 settings.yaml,而是持久化到上述自有 `settings.json`;首次启动时会自动从旧 `settings.yaml` / `settings.yaml.imported` 迁移一次(把旧长期密码、限频等配置救回)。
300
+
298
301
  ### 🎨 移动端样式片段(可选)
299
302
 
300
303
  自定义样式片段(样式小插件)与启停状态持久化于 `~/.dsh/remote-mobile/style-snippets.json`,可在设置面板图形化管理,也可直接编辑该文件:
@@ -331,7 +334,7 @@ dsh-remote-mobile:
331
334
 
332
335
  | 文件路径 | 说明 | 安全级别 |
333
336
  |---|---|---|
334
- | `~/.dsh/settings.yaml` | 全局安全策略与免密开关配置 | 用户级读写 |
337
+ | `~/.dsh/remote-mobile/settings.json` | 插件配置:免密开关、长期密码哈希与限频/锁定策略 | 本地落盘,权限 `0o600`(仅当前用户可读写) |
335
338
  | `~/.dsh/remote-mobile/devices.json` | 已授权设备会话、IP 访问计数与安全审计数据(内含长效 Token,权限 `0o600`) | 本地落盘,仅当前用户可读写 |
336
339
  | `~/.dsh/remote-mobile/rsa-keys.json` | 服务端 RSA 密钥对文件(含公钥与私钥) | 本地落盘,权限 `0o600`(仅当前用户可读写) |
337
340
  | `~/.dsh/remote-mobile/style-snippets.json` | 移动端样式片段(内置预设启停状态 + 用户自定义 CSS 小插件) | 本地落盘持久化 |
@@ -435,6 +438,16 @@ dsh-remote-mobile:
435
438
  * 若你希望通过外部统一域名/网关访问时仍强制要求输入本插件的动态配对码或长期访问密码,建议配置反向代理透明透传客户端源 IP,或直接通过 DSH 开放的外部端口(如默认的 `3080` 或自定义映射端口)直连访问。
436
439
  </details>
437
440
 
441
+ <details>
442
+ <summary><b>Q8: 升级 DSH 0.1.7+(含 0.2.x)后 <code>~/.dsh/settings.yaml</code> 不见了 / 我的配置丢了?</b></summary>
443
+
444
+ **答**:这是 **DSH 官方自 0.1.7 起的行为变更**(最早见于 v0.1.7-alpha.1 的 release notes,0.2.x 延续),与本插件无关:
445
+
446
+ - DSH 自 **0.1.7** 起已**不再把 `~/.dsh/settings.yaml` 当作实时配置源**。启动时它由官方 `dsh-settings` 的 `importLegacyDocument()` **一次性导入**到当前 profile 的 `~/.dsh/profiles/<profile>/cordis.patch.yml`,随后原文件被**改名为 `settings.yaml.imported`**(先改名、再导入;若某个 section 找不到对应 entry,则会跳过,仅保留在该文件中,且不会重试)。官方 release notes 原文:「设置改由当前 Profile 的插件配置保存,支持声明过的实时更新字段;旧 settings.yaml 仅尝试导入一次,自定义设置插件需适配。」
447
+ - 因此升级后 `settings.yaml` 消失、只剩 `settings.yaml.imported` 属**正常现象**;模型 provider、locale 等应改为在 profile 的 `cordis.patch.yml` 中查看/编辑。
448
+ - **本插件自 v1.9.0 起**已改为把自身配置持久化到 `~/.dsh/remote-mobile/settings.json`,并在首次启动时**自动从旧 `settings.yaml` / `settings.yaml.imported` 迁移一次**(把长期密码哈希、免密开关、限频阈值等救回),不再依赖 DSH 的 settings.yaml。若你曾发现"长期密码失效/限频重置",升级到 v1.9.0 后会自动恢复。
449
+ </details>
450
+
438
451
  ---
439
452
 
440
453
  ## 📄 开源许可证
package/README_EN.md CHANGED
@@ -165,7 +165,7 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
165
165
  - Audit logs and lockout states persist to disk and restore across restarts;
166
166
  - Administrators can manually unlock blocked IPs with one click in the panel.
167
167
  - **Intelligent Static Asset Whitelisting**: The authentication gate automatically allows verified frontend asset extensions (`.js`, `.css`, `.png`, `.svg`, `.woff2`, etc., 20 types) while blocking extensionless or dynamic API routes (e.g. `/plugins/xxx/admin`), ensuring smooth third-party plugin rendering without manual whitelist configuration. (The DSH core static directory `/assets/` is an official resource and is allowed unconditionally.)
168
- - **Resilient Atomic Persistence & Debounce**: Millisecond-level debounce write-throttling to safeguard disk I/O (500ms for session data, 300ms for style snippets), coupled with `beforeExit` process flush hooks and atomic temporary file replacement (`renameSync`) for `settings.yaml` and `style-snippets.json`.
168
+ - **Resilient Atomic Persistence & Debounce**: Millisecond-level debounce write-throttling to safeguard disk I/O (500ms for session data, 300ms for style snippets), coupled with `beforeExit` process flush hooks and atomic temporary file replacement (`renameSync`) for `settings.json` and `style-snippets.json`.
169
169
  - **Real Socket IP Extraction**: Relies strictly on underlying Socket connection addresses, preventing spoofed `X-Forwarded-For` header attacks.
170
170
  - **Loopback CSRF Defense**: Mutating plugin APIs validate browser same-origin signals (`Origin` / `Sec-Fetch-Site`). Cross-site write requests to `127.0.0.1` driven by malicious webpages are rejected outright; non-browser clients (curl / local scripts) are unaffected.
171
171
  - **No Context Rewriting for Loopback Requests**: Context virtualization applies only to external traffic. Loopback requests keep their original Host / Origin (no "laundering"), so DSH's built-in DNS-rebinding and same-origin checks keep working against external domains. The SSE event stream accepts same-origin connections only, with no cross-origin reads.
@@ -282,18 +282,21 @@ dsh plugin --profile web remove dsh-remote-mobile
282
282
  <span id="advanced"></span>
283
283
  ## ⚙️ Advanced Configuration
284
284
 
285
- Integrated with the DSH official Settings system. Configurations can be adjusted in the Web UI or edited in `~/.dsh/settings.yaml` under the `dsh-remote-mobile` namespace:
285
+ Configurations persist in the plugin-owned file `~/.dsh/remote-mobile/settings.json`; adjust them in the Web UI or edit the file directly:
286
286
 
287
- ```yaml
288
- dsh-remote-mobile:
289
- allowTailscale: false # boolean, default false: allow passwordless access via Tailscale
290
- allowLan: false # boolean, default false: allow passwordless access via LAN (High Risk)
291
- secretHash: "" # string, default empty: scrypt salted hash of the persistent password
292
- maxVisitsPerMinute: 60 # number, default 60: max login page visits per minute per IP
293
- maxFailedAttempts: 5 # number, default 5: max consecutive failed attempts before IP lockout
294
- lockDurationMs: 900000 # number, default 900000 (15 mins): IP lockout duration in ms
287
+ ```jsonc
288
+ {
289
+ "allowTailscale": false, // boolean, default false: allow passwordless access via Tailscale
290
+ "allowLan": false, // boolean, default false: allow passwordless access via LAN (High Risk)
291
+ "secretHash": "", // string, default empty: scrypt salted hash of the persistent password
292
+ "maxVisitsPerMinute": 60, // number, default 60: max login page visits per minute per IP
293
+ "maxFailedAttempts": 5, // number, default 5: max consecutive failed attempts before IP lockout
294
+ "lockDurationMs": 900000 // number, default 900000 (15 mins): IP lockout duration in ms
295
+ }
295
296
  ```
296
297
 
298
+ > **About config storage**: Since DSH **0.1.7** (first noted in the v0.1.7-alpha.1 release notes; continued in 0.2.x), `~/.dsh/settings.yaml` is no longer a live configuration source — on boot it is imported once into the active profile's `cordis.patch.yml` and renamed to `settings.yaml.imported` (see [Q8](#faq)). This plugin therefore no longer writes to settings.yaml and persists to its own `settings.json` above; on first launch it automatically migrates once from the legacy `settings.yaml` / `settings.yaml.imported` (recovering your old persistent password, rate limits, etc.).
299
+
297
300
  ### 🎨 Mobile Style Snippets (optional)
298
301
 
299
302
  Custom snippets (style mini-plugins) and their enabled states are persisted in `~/.dsh/remote-mobile/style-snippets.json`. Manage them from the settings panel or edit the file directly:
@@ -330,7 +333,7 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
330
333
 
331
334
  | Path | Description | Security Level |
332
335
  |---|---|---|
333
- | `~/.dsh/settings.yaml` | Global security policies & bypass toggles | User R/W |
336
+ | `~/.dsh/remote-mobile/settings.json` | Plugin config: bypass toggles, persistent-password hash, rate-limit & lockout policy | Local storage, `0o600` (Restricted to current user) |
334
337
  | `~/.dsh/remote-mobile/devices.json` | Authorized devices, sessions & IP audit statistics (contains long-lived tokens, `0o600`) | Local storage, restricted to current user |
335
338
  | `~/.dsh/remote-mobile/rsa-keys.json` | Server RSA keypair (public & private) | Local storage, `0o600` (Restricted to current user) |
336
339
  | `~/.dsh/remote-mobile/style-snippets.json` | Mobile style snippets (built-in toggles + custom CSS mini-plugins) | Local persistent storage |
@@ -434,6 +437,16 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
434
437
  * If you wish to enforce the plugin's pairing code or persistent password even when entering through an external reverse proxy, configure the proxy to transparently pass client IPs or connect directly via the exposed DSH listening port (e.g. default port `3080` or custom mapped ports).
435
438
  </details>
436
439
 
440
+ <details>
441
+ <summary><b>Q8: After upgrading to DSH 0.1.7+ (including 0.2.x), <code>~/.dsh/settings.yaml</code> is gone / my settings were lost?</b></summary>
442
+
443
+ **Answer**: This is an **official DSH behavior change introduced in 0.1.7** (first noted in the v0.1.7-alpha.1 release notes; continued in 0.2.x), unrelated to this plugin:
444
+
445
+ - Since DSH **0.1.7**, `~/.dsh/settings.yaml` is **no longer treated as a live configuration source**. On boot it is imported **once** by the official `dsh-settings` `importLegacyDocument()` into the active profile's `~/.dsh/profiles/<profile>/cordis.patch.yml`, then the original file is **renamed to `settings.yaml.imported`** (it renames first, then imports; a section with no matching entry is skipped, kept only in that file, and never retried). The official v0.1.7-alpha.1 release notes state that settings are now persisted as the current profile's plugin configuration (with declared live-update fields), and the legacy `settings.yaml` is imported only once — custom settings plugins must adapt.
446
+ - So after upgrading, `settings.yaml` disappearing and only `settings.yaml.imported` remaining is **expected**; model providers, locale, etc. should now be viewed/edited in the profile's `cordis.patch.yml`.
447
+ - **As of v1.9.0**, this plugin persists its own config to `~/.dsh/remote-mobile/settings.json` and, on first launch, **migrates once from the legacy `settings.yaml` / `settings.yaml.imported`** (recovering the persistent-password hash, bypass toggles, rate limits, etc.). It no longer depends on DSH's settings.yaml. If you saw a "persistent password stopped working / limits reset" symptom, upgrading to v1.9.0 restores it automatically.
448
+ </details>
449
+
437
450
  ---
438
451
 
439
452
  ## 📄 License
@@ -1,20 +1,72 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { IncomingMessage } from 'node:http';
3
3
  export declare const AUTH_COOKIE_NAME = "dsh_mobile_token";
4
+ /** DSH_HOME 目录(支持 DSH_HOME 覆盖) */
5
+ export declare const DSH_HOME: string;
6
+ /** 插件自有数据目录 */
7
+ export declare const REMOTE_MOBILE_DIR: string;
4
8
  export declare const DEFAULT_PERSIST_FILE: string;
9
+ /** 插件自有配置持久化文件(DSH 0.1.7+ 起官方弃用 settings.yaml,插件改为自持存储) */
10
+ export declare const SETTINGS_FILE: string;
11
+ /** DSH 旧版全局设置文件(0.1.7+ 起仅作一次性迁移来源,启动时会被改名) */
5
12
  export declare const GLOBAL_SETTINGS_FILE: string;
13
+ /** DSH 迁移后遗留的旧设置存档(legacy settings.yaml → settings.yaml.imported) */
14
+ export declare const LEGACY_SETTINGS_IMPORTED_FILE: string;
6
15
  /**
7
16
  * 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
8
17
  */
9
18
  export declare function readFromSettingsYaml(filePath?: string): Partial<SessionStoreOptions>;
10
19
  /**
11
- * 从 ~/.dsh/settings.yaml 中读取全局语言偏好 (locale.preference)
20
+ * 从旧版 settings.yaml 文本内容中解析 locale.preference(返回 null 表示未命中)
21
+ */
22
+ export declare function parseLocaleFromSettingsYaml(content: string): 'zh' | 'en' | null;
23
+ /**
24
+ * 从 cordis.patch.yml 文本内容中解析 locale entry 的 config.preference(返回 null 表示未命中)
25
+ */
26
+ export declare function parseLocaleFromPatch(content: string): 'zh' | 'en' | null;
27
+ /**
28
+ * 读取 DSH 全局语言偏好。
29
+ *
30
+ * DSH 0.1.7+ 起配置真实落点为当前 profile 的 cordis.patch.yml(旧版 settings.yaml
31
+ * 已被一次性迁移并改名为 settings.yaml.imported),因此优先读取 profile patch,
32
+ * 再回退旧 settings.yaml / settings.yaml.imported。
33
+ *
34
+ * @param filePath 显式传入时按旧版 settings.yaml 格式解析(向后兼容与测试)。
12
35
  */
13
36
  export declare function readGlobalLocale(filePath?: string): 'zh' | 'en';
14
37
  /**
15
- * 将最新的插件配置安全原子写回 ~/.dsh/settings.yaml 中的 dsh-remote-mobile 命名空间
38
+ * 读取插件自有持久化配置文件 ~/.dsh/remote-mobile/settings.json
39
+ *
40
+ * 注:若文件中 secretHash 为空串 ""(表示用户清空了密码),此处会跳过赋值以归一化为 undefined。
41
+ * 上层 resolveInitialStoreOptions 必须根据文件存在性(own !== null)做出决策,严禁在自有配置存在时用 legacy 兜底回退。
42
+ */
43
+ export declare function readPluginSettingsJson(filePath?: string): Partial<SessionStoreOptions>;
44
+ /**
45
+ * 将插件配置原子写回自有配置文件 ~/.dsh/remote-mobile/settings.json(权限 0600)。
46
+ *
47
+ * DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源(启动时一次性迁移为
48
+ * settings.yaml.imported),因此插件不再依赖该文件,改为自持存储,避免升级后配置丢失。
49
+ */
50
+ export declare function writePluginSettingsJson(options: SessionStoreOptions, filePath?: string): boolean;
51
+ /**
52
+ * 兼容读取 DSH 旧版 settings.yaml / 迁移后的 settings.yaml.imported 中的
53
+ * dsh-remote-mobile 段,用于首次启动时把旧配置迁移到自有 settings.json。
54
+ */
55
+ export declare function readLegacyDshSettings(liveFile?: string, importedFile?: string): Partial<SessionStoreOptions>;
56
+ /**
57
+ * 解析 SessionStore 的初始运行配置(纯函数决策逻辑)。
58
+ *
59
+ * 优先级与防反噬规则:
60
+ * 1. 显式传入的 explicit 参数优先级最高;
61
+ * 2. 若自有配置文件已存在(own !== null),则以自有配置 own 为准,此时 legacy 配置被彻底忽略(防止已清空的密码死灰复燃);
62
+ * 3. 仅当自有配置文件尚不存在(own === null,即首次安装或旧版升级)时,才回退读取 legacy 配置进行一次性迁移;
63
+ * 4. 最终缺失项采用系统安全默认值。
64
+ *
65
+ * @param explicit 构造函数传入的显式参数
66
+ * @param own 插件自有 settings.json 内容;null 表示文件尚不存在(首次启动迁移状态)
67
+ * @param legacy 旧版 settings.yaml / settings.yaml.imported 内容
16
68
  */
17
- export declare function writeBackToSettingsYaml(options: SessionStoreOptions, filePath?: string): boolean;
69
+ export declare function resolveInitialStoreOptions(explicit?: SessionStoreOptions, own?: Partial<SessionStoreOptions> | null, legacy?: Partial<SessionStoreOptions>): Required<SessionStoreOptions>;
18
70
  export interface SessionRecord {
19
71
  token: string;
20
72
  createdAt: number;
package/lib/auth/token.js CHANGED
@@ -1,13 +1,22 @@
1
1
  import { randomBytes, randomInt } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { hashSecret, verifySecretHash } from './crypto.js';
7
7
  import { isTailscaleIp, isLanIp } from './tailscale.js';
8
8
  export const AUTH_COOKIE_NAME = 'dsh_mobile_token';
9
- export const DEFAULT_PERSIST_FILE = join(homedir(), '.dsh', 'remote-mobile', 'devices.json');
10
- export const GLOBAL_SETTINGS_FILE = join(homedir(), '.dsh', 'settings.yaml');
9
+ /** DSH_HOME 目录(支持 DSH_HOME 覆盖) */
10
+ export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
11
+ /** 插件自有数据目录 */
12
+ export const REMOTE_MOBILE_DIR = join(DSH_HOME, 'remote-mobile');
13
+ export const DEFAULT_PERSIST_FILE = join(REMOTE_MOBILE_DIR, 'devices.json');
14
+ /** 插件自有配置持久化文件(DSH 0.1.7+ 起官方弃用 settings.yaml,插件改为自持存储) */
15
+ export const SETTINGS_FILE = join(REMOTE_MOBILE_DIR, 'settings.json');
16
+ /** DSH 旧版全局设置文件(0.1.7+ 起仅作一次性迁移来源,启动时会被改名) */
17
+ export const GLOBAL_SETTINGS_FILE = join(DSH_HOME, 'settings.yaml');
18
+ /** DSH 迁移后遗留的旧设置存档(legacy settings.yaml → settings.yaml.imported) */
19
+ export const LEGACY_SETTINGS_IMPORTED_FILE = join(DSH_HOME, 'settings.yaml.imported');
11
20
  /**
12
21
  * 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
13
22
  */
@@ -72,63 +81,143 @@ export function readFromSettingsYaml(filePath = GLOBAL_SETTINGS_FILE) {
72
81
  }
73
82
  }
74
83
  /**
75
- * 从 ~/.dsh/settings.yaml 中读取全局语言偏好 (locale.preference)
84
+ * 从旧版 settings.yaml 文本内容中解析 locale.preference(返回 null 表示未命中)
76
85
  */
77
- export function readGlobalLocale(filePath = GLOBAL_SETTINGS_FILE) {
86
+ export function parseLocaleFromSettingsYaml(content) {
87
+ const match = content.match(/^locale:\s*\r?\n\s+preference:\s*['"]?([a-zA-Z_-]+)['"]?/m);
88
+ if (match && match[1])
89
+ return match[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
90
+ return null;
91
+ }
92
+ /**
93
+ * 从 cordis.patch.yml 文本内容中解析 locale entry 的 config.preference(返回 null 表示未命中)
94
+ */
95
+ export function parseLocaleFromPatch(content) {
96
+ const lines = content.split(/\r?\n/);
97
+ for (let i = 0; i < lines.length; i++) {
98
+ // 精确匹配 `- id: locale`(避免误命中 locale-xxx 等其它 entry)
99
+ if (!/^\s*-\s*id:\s*['"]?locale['"]?\s*$/.test(lines[i]))
100
+ continue;
101
+ for (let j = i + 1; j < Math.min(i + 40, lines.length); j++) {
102
+ // 进入下一个 patch entry,说明本条已结束
103
+ if (/^\s*-\s*id:/.test(lines[j]))
104
+ break;
105
+ const m = lines[j].match(/^\s*preference:\s*['"]?([a-zA-Z_-]+)['"]?/);
106
+ if (m && m[1])
107
+ return m[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ function readLocaleFromFile(filePath, parser) {
78
113
  try {
79
114
  if (!existsSync(filePath))
80
- return 'zh';
81
- const content = readFileSync(filePath, 'utf8');
82
- const match = content.match(/^locale:\s*\r?\n\s+preference:\s*['"]?([a-zA-Z_-]+)['"]?/m);
83
- if (match && match[1]) {
84
- return match[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
85
- }
86
- return 'zh';
115
+ return null;
116
+ return parser(readFileSync(filePath, 'utf8'));
87
117
  }
88
118
  catch {
89
- return 'zh';
119
+ return null;
90
120
  }
91
121
  }
92
122
  /**
93
- * 将最新的插件配置安全原子写回 ~/.dsh/settings.yaml 中的 dsh-remote-mobile 命名空间
123
+ * 读取 DSH 全局语言偏好。
124
+ *
125
+ * DSH 0.1.7+ 起配置真实落点为当前 profile 的 cordis.patch.yml(旧版 settings.yaml
126
+ * 已被一次性迁移并改名为 settings.yaml.imported),因此优先读取 profile patch,
127
+ * 再回退旧 settings.yaml / settings.yaml.imported。
128
+ *
129
+ * @param filePath 显式传入时按旧版 settings.yaml 格式解析(向后兼容与测试)。
94
130
  */
95
- export function writeBackToSettingsYaml(options, filePath = GLOBAL_SETTINGS_FILE) {
96
- const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
131
+ export function readGlobalLocale(filePath) {
132
+ if (filePath) {
133
+ return readLocaleFromFile(filePath, parseLocaleFromSettingsYaml) ?? 'zh';
134
+ }
135
+ // 1. 当前 profile 的 cordis.patch.yml(web 优先,其余按目录名排序兜底)
97
136
  try {
98
- if (!existsSync(filePath))
99
- return false;
100
- const content = readFileSync(filePath, 'utf8');
101
- const ns = 'dsh-remote-mobile:';
102
- const fields = [
103
- 'dsh-remote-mobile:',
104
- ` allowTailscale: ${Boolean(options.allowTailscale)}`,
105
- ` allowLan: ${Boolean(options.allowLan)}`,
106
- ` secretHash: ${options.secretHash ? `'${options.secretHash}'` : `''`}`,
107
- ` maxFailedAttempts: ${options.maxFailedAttempts || 5}`,
108
- ` lockDurationMs: ${options.lockDurationMs || 900000}`,
109
- ` maxVisitsPerMinute: ${options.maxVisitsPerMinute || 60}`,
110
- ];
111
- const newBlock = fields.join('\n');
112
- const lines = content.split(/\r?\n/);
113
- const startIdx = lines.findIndex((l) => l.trim() === ns);
114
- let updatedContent = '';
115
- if (startIdx === -1) {
116
- updatedContent = content.trimEnd() + '\n\n' + newBlock + '\n';
137
+ const profilesDir = join(DSH_HOME, 'profiles');
138
+ let names = [];
139
+ try {
140
+ names = readdirSync(profilesDir, { withFileTypes: true })
141
+ .filter((e) => e.isDirectory())
142
+ .map((e) => e.name)
143
+ .sort((a, b) => (a === 'web' ? -1 : b === 'web' ? 1 : a.localeCompare(b)));
117
144
  }
118
- else {
119
- let endIdx = lines.length;
120
- for (let i = startIdx + 1; i < lines.length; i++) {
121
- const line = lines[i];
122
- if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && line.includes(':')) {
123
- endIdx = i;
124
- break;
125
- }
126
- }
127
- lines.splice(startIdx, endIdx - startIdx, newBlock);
128
- updatedContent = lines.join('\n');
145
+ catch { }
146
+ const candidates = [
147
+ ...names.map((n) => join(profilesDir, n, 'cordis.patch.yml')),
148
+ join(DSH_HOME, 'cordis.patch.yml'),
149
+ ];
150
+ for (const c of candidates) {
151
+ const loc = readLocaleFromFile(c, parseLocaleFromPatch);
152
+ if (loc)
153
+ return loc;
129
154
  }
130
- writeFileSync(tmpPath, updatedContent, 'utf8');
155
+ }
156
+ catch { }
157
+ // 2. 回退旧版 settings.yaml / settings.yaml.imported
158
+ for (const p of [GLOBAL_SETTINGS_FILE, LEGACY_SETTINGS_IMPORTED_FILE]) {
159
+ const loc = readLocaleFromFile(p, parseLocaleFromSettingsYaml);
160
+ if (loc)
161
+ return loc;
162
+ }
163
+ return 'zh';
164
+ }
165
+ /**
166
+ * 读取插件自有持久化配置文件 ~/.dsh/remote-mobile/settings.json
167
+ *
168
+ * 注:若文件中 secretHash 为空串 ""(表示用户清空了密码),此处会跳过赋值以归一化为 undefined。
169
+ * 上层 resolveInitialStoreOptions 必须根据文件存在性(own !== null)做出决策,严禁在自有配置存在时用 legacy 兜底回退。
170
+ */
171
+ export function readPluginSettingsJson(filePath = SETTINGS_FILE) {
172
+ try {
173
+ if (!existsSync(filePath))
174
+ return {};
175
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
176
+ if (!data || typeof data !== 'object' || Array.isArray(data))
177
+ return {};
178
+ const result = {};
179
+ if (typeof data.allowTailscale === 'boolean')
180
+ result.allowTailscale = data.allowTailscale;
181
+ if (typeof data.allowLan === 'boolean')
182
+ result.allowLan = data.allowLan;
183
+ if (typeof data.secretHash === 'string' && data.secretHash)
184
+ result.secretHash = data.secretHash;
185
+ if (typeof data.maxFailedAttempts === 'number' && data.maxFailedAttempts > 0)
186
+ result.maxFailedAttempts = data.maxFailedAttempts;
187
+ if (typeof data.lockDurationMs === 'number' && data.lockDurationMs > 0)
188
+ result.lockDurationMs = data.lockDurationMs;
189
+ if (typeof data.maxVisitsPerMinute === 'number' && data.maxVisitsPerMinute > 0)
190
+ result.maxVisitsPerMinute = data.maxVisitsPerMinute;
191
+ return result;
192
+ }
193
+ catch {
194
+ return {};
195
+ }
196
+ }
197
+ /**
198
+ * 将插件配置原子写回自有配置文件 ~/.dsh/remote-mobile/settings.json(权限 0600)。
199
+ *
200
+ * DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源(启动时一次性迁移为
201
+ * settings.yaml.imported),因此插件不再依赖该文件,改为自持存储,避免升级后配置丢失。
202
+ */
203
+ export function writePluginSettingsJson(options, filePath = SETTINGS_FILE) {
204
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
205
+ try {
206
+ mkdirSync(dirname(filePath), { recursive: true });
207
+ const payload = {
208
+ allowTailscale: Boolean(options.allowTailscale),
209
+ allowLan: Boolean(options.allowLan),
210
+ secretHash: options.secretHash || '',
211
+ maxFailedAttempts: options.maxFailedAttempts || 5,
212
+ lockDurationMs: options.lockDurationMs || 900000,
213
+ maxVisitsPerMinute: options.maxVisitsPerMinute || 60,
214
+ };
215
+ writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
131
216
  renameSync(tmpPath, filePath);
217
+ try {
218
+ chmodSync(filePath, 0o600);
219
+ }
220
+ catch { }
132
221
  return true;
133
222
  }
134
223
  catch {
@@ -140,6 +229,56 @@ export function writeBackToSettingsYaml(options, filePath = GLOBAL_SETTINGS_FILE
140
229
  return false;
141
230
  }
142
231
  }
232
+ /**
233
+ * 兼容读取 DSH 旧版 settings.yaml / 迁移后的 settings.yaml.imported 中的
234
+ * dsh-remote-mobile 段,用于首次启动时把旧配置迁移到自有 settings.json。
235
+ */
236
+ export function readLegacyDshSettings(liveFile = GLOBAL_SETTINGS_FILE, importedFile = LEGACY_SETTINGS_IMPORTED_FILE) {
237
+ return { ...readFromSettingsYaml(importedFile), ...readFromSettingsYaml(liveFile) };
238
+ }
239
+ /**
240
+ * 解析 SessionStore 的初始运行配置(纯函数决策逻辑)。
241
+ *
242
+ * 优先级与防反噬规则:
243
+ * 1. 显式传入的 explicit 参数优先级最高;
244
+ * 2. 若自有配置文件已存在(own !== null),则以自有配置 own 为准,此时 legacy 配置被彻底忽略(防止已清空的密码死灰复燃);
245
+ * 3. 仅当自有配置文件尚不存在(own === null,即首次安装或旧版升级)时,才回退读取 legacy 配置进行一次性迁移;
246
+ * 4. 最终缺失项采用系统安全默认值。
247
+ *
248
+ * @param explicit 构造函数传入的显式参数
249
+ * @param own 插件自有 settings.json 内容;null 表示文件尚不存在(首次启动迁移状态)
250
+ * @param legacy 旧版 settings.yaml / settings.yaml.imported 内容
251
+ */
252
+ export function resolveInitialStoreOptions(explicit = {}, own = null, legacy = {}) {
253
+ // 当自有配置文件已存在时,legacy 彻底失效;仅在 own 为 null 时继承 legacy
254
+ const base = own !== null ? own : legacy;
255
+ const merged = { ...base, ...explicit };
256
+ // 密码优先级:explicit > base(base 即 own 或 legacy,已由上面的门控决定;
257
+ // 自有配置存在但未设密码时 base 无密码,保持为空,绝不回退 legacy)
258
+ let secretHash = '';
259
+ if (typeof explicit.secretHash === 'string' && explicit.secretHash) {
260
+ secretHash = explicit.secretHash;
261
+ }
262
+ else if (typeof explicit.secret === 'string' && explicit.secret) {
263
+ secretHash = hashSecret(explicit.secret);
264
+ }
265
+ else if (typeof base.secretHash === 'string' && base.secretHash) {
266
+ secretHash = base.secretHash;
267
+ }
268
+ else if (typeof base.secret === 'string' && base.secret) {
269
+ secretHash = hashSecret(base.secret);
270
+ }
271
+ return {
272
+ allowTailscale: merged.allowTailscale ?? false,
273
+ allowLan: merged.allowLan ?? false,
274
+ secret: '',
275
+ secretHash,
276
+ devicesFile: merged.devicesFile ?? DEFAULT_PERSIST_FILE,
277
+ maxFailedAttempts: merged.maxFailedAttempts ?? 5,
278
+ lockDurationMs: merged.lockDurationMs ?? 15 * 60 * 1000,
279
+ maxVisitsPerMinute: merged.maxVisitsPerMinute ?? 60,
280
+ };
281
+ }
143
282
  /**
144
283
  * 将 User-Agent 字符串解析为人类友好的设备名称
145
284
  */
@@ -198,37 +337,23 @@ export class SessionStore extends EventEmitter {
198
337
  constructor(options = {}) {
199
338
  super();
200
339
  this.setMaxListeners(100);
201
- // 严密隔离单测环境:当处于 node --test 环境或使用了自定义设备文件时,严禁读写全局 settings.yaml
340
+ // 严密隔离单测环境:当处于 node --test 环境或使用了自定义设备文件时,严禁读写全局配置文件
202
341
  const isTestEnv = Boolean(process.env.NODE_TEST_CONTEXT ||
203
342
  process.argv.some((arg) => arg.includes('test')) ||
204
343
  (options.devicesFile && options.devicesFile !== DEFAULT_PERSIST_FILE));
205
- this.settingsFilePath = isTestEnv ? null : GLOBAL_SETTINGS_FILE;
206
- const yamlOpts = isTestEnv ? {} : readFromSettingsYaml(GLOBAL_SETTINGS_FILE);
207
- const mergedOpts = { ...yamlOpts, ...options };
208
- let secretHash = '';
209
- if (typeof options.secretHash === 'string' && options.secretHash) {
210
- secretHash = options.secretHash;
211
- }
212
- else if (typeof options.secret === 'string' && options.secret) {
213
- secretHash = hashSecret(options.secret);
214
- }
215
- else if (typeof yamlOpts.secretHash === 'string' && yamlOpts.secretHash) {
216
- secretHash = yamlOpts.secretHash;
217
- }
218
- else if (typeof yamlOpts.secret === 'string' && yamlOpts.secret) {
219
- secretHash = hashSecret(yamlOpts.secret);
220
- }
221
- this.options = {
222
- allowTailscale: mergedOpts.allowTailscale ?? false,
223
- allowLan: mergedOpts.allowLan ?? false,
224
- secret: '',
225
- secretHash,
226
- devicesFile: mergedOpts.devicesFile ?? DEFAULT_PERSIST_FILE,
227
- maxFailedAttempts: mergedOpts.maxFailedAttempts ?? 5,
228
- lockDurationMs: mergedOpts.lockDurationMs ?? 15 * 60 * 1000,
229
- maxVisitsPerMinute: mergedOpts.maxVisitsPerMinute ?? 60,
230
- };
344
+ this.settingsFilePath = isTestEnv ? null : SETTINGS_FILE;
345
+ // 仅在自有 settings.json 尚不存在时,才尝试读取旧版 settings.yaml / settings.yaml.imported 进行一次性兼容迁移;
346
+ // 一旦 settings.json 存在,即以自有配置为准,杜绝旧文件中已废弃的密码或参数在重启后反向覆盖当前配置。
347
+ const hasOwnSettings = !isTestEnv && existsSync(SETTINGS_FILE);
348
+ const ownOpts = isTestEnv ? {} : (hasOwnSettings ? readPluginSettingsJson(SETTINGS_FILE) : null);
349
+ const legacyOpts = isTestEnv || hasOwnSettings ? {} : readLegacyDshSettings();
350
+ this.options = resolveInitialStoreOptions(options, ownOpts, legacyOpts);
231
351
  this.persistPath = this.options.devicesFile;
352
+ // 首次启动迁移:自有配置不存在但旧 settings.yaml / settings.yaml.imported 中仍有配置时,
353
+ // 落盘一次到自有 settings.json,把旧配置(如长期密码哈希、限频)救回。
354
+ if (!isTestEnv && !hasOwnSettings && Object.keys(legacyOpts).length > 0) {
355
+ writePluginSettingsJson(this.options, SETTINGS_FILE);
356
+ }
232
357
  this.loadPersistedData();
233
358
  activeStores.add(this);
234
359
  }
@@ -275,9 +400,10 @@ export class SessionStore extends EventEmitter {
275
400
  }
276
401
  catch { }
277
402
  }
278
- // 仅在非测试环境或指定了 settingsFilePath 时,同步持久化至 ~/.dsh/settings.yaml
403
+ // 同步持久化到插件自有配置文件 ~/.dsh/remote-mobile/settings.json
404
+ // (DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源)
279
405
  if (this.settingsFilePath) {
280
- writeBackToSettingsYaml(this.options, this.settingsFilePath);
406
+ writePluginSettingsJson(this.options, this.settingsFilePath);
281
407
  }
282
408
  }
283
409
  // 1. 如果关闭了 Tailscale 免密直连,自动清理所有 Tailscale 免密设备,并将审计状态重置为 ⚪ 待认证
package/lib/client.js CHANGED
@@ -782,8 +782,8 @@ var translations = {
782
782
  storageStylesFile: "\u79FB\u52A8\u7AEF\u6837\u5F0F\u7247\u6BB5\u6587\u4EF6 (\u53EF\u72EC\u7ACB\u542F\u505C\u7684 CSS \u5C0F\u63D2\u4EF6)\uFF1A",
783
783
  // 卡片 7: 全局高级配置
784
784
  configCardTitle: "\u5168\u5C40\u9AD8\u7EA7\u5B89\u5168\u53C2\u6570\u914D\u7F6E",
785
- configCardDesc: "\u8C03\u6574\u5355 IP \u8BBF\u95EE\u9891\u7387\u9650\u5236\u3001\u8F93\u9519\u5C01\u7981\u9608\u503C\u4E0E\u9501\u5B9A\u6301\u7EED\u65F6\u95F4\u3002\u70B9\u51FB\u4FDD\u5B58\u5C06\u7ACB\u5373\u751F\u6548\u5E76\u81EA\u52A8\u540C\u6B65\u5199\u56DE ~/.dsh/settings.yaml \u914D\u7F6E\u6587\u4EF6\u3002",
786
- copySettingsPathBtn: "\u{1F4CB} \u590D\u5236 settings.yaml \u8DEF\u5F84",
785
+ configCardDesc: "\u8C03\u6574\u5355 IP \u8BBF\u95EE\u9891\u7387\u9650\u5236\u3001\u8F93\u9519\u5C01\u7981\u9608\u503C\u4E0E\u9501\u5B9A\u6301\u7EED\u65F6\u95F4\u3002\u70B9\u51FB\u4FDD\u5B58\u5C06\u7ACB\u5373\u751F\u6548\u5E76\u4FDD\u5B58\u5230 ~/.dsh/remote-mobile/settings.json\u3002",
786
+ copySettingsPathBtn: "\u{1F4CB} \u590D\u5236 settings.json \u8DEF\u5F84",
787
787
  fieldVisitsLimit: "\u23F1\uFE0F \u6BCF\u5206\u949F\u8BBF\u95EE\u4E0A\u9650 (\u6B21/\u5206)",
788
788
  fieldVisitsLimitHint: "\u5355 IP 1\u5206\u949F\u5185\u6253\u5F00\u767B\u5F55\u9875\u8D85\u8FC7\u6B64\u503C\u5C06\u89E6\u53D1\u9650\u9891",
789
789
  fieldFailedLimit: "\u{1F6E1}\uFE0F \u8F93\u9519\u5BC6\u7801\u5C01\u7981\u9608\u503C (\u6B21)",
@@ -793,9 +793,9 @@ var translations = {
793
793
  restoreDefaultsBtn: "\u{1F504} \u6062\u590D\u9ED8\u8BA4\u53C2\u6570",
794
794
  saveConfigBtn: "\u{1F4BE} \u4FDD\u5B58\u53C2\u6570\u914D\u7F6E",
795
795
  savingConfigBtn: "\u6B63\u5728\u4FDD\u5B58...",
796
- saveConfigConfirm: "\u786E\u5B9A\u8981\u4FDD\u5B58\u5F53\u524D\u9AD8\u7EA7\u5B89\u5168\u53C2\u6570\u914D\u7F6E\u5417\uFF1F\u4FDD\u5B58\u540E\u5C06\u7ACB\u5373\u751F\u6548\u5E76\u81EA\u52A8\u540C\u6B65\u5199\u56DE ~/.dsh/settings.yaml\u3002",
796
+ saveConfigConfirm: "\u786E\u5B9A\u8981\u4FDD\u5B58\u5F53\u524D\u9AD8\u7EA7\u5B89\u5168\u53C2\u6570\u914D\u7F6E\u5417\uFF1F\u4FDD\u5B58\u540E\u5C06\u7ACB\u5373\u751F\u6548\u5E76\u4FDD\u5B58\u5230 ~/.dsh/remote-mobile/settings.json\u3002",
797
797
  restoreDefaultsConfirm: "\u786E\u5B9A\u8981\u5C06\u5B89\u5168\u53C2\u6570\u6062\u590D\u4E3A\u7CFB\u7EDF\u9ED8\u8BA4\u503C\uFF0860\u6B21/\u5206\u30015\u6B21\u5931\u8D25\u3001\u9501\u5B9A15\u5206\u949F\uFF09\u5417\uFF1F\u786E\u8BA4\u540E\u5C06\u76F4\u63A5\u751F\u6548\u5E76\u81EA\u52A8\u540C\u6B65\u3002",
798
- saveConfigSuccessToast: "\u9AD8\u7EA7\u5B89\u5168\u53C2\u6570\u5DF2\u6210\u529F\u4FDD\u5B58\u5E76\u540C\u6B65\u5230 settings.yaml\uFF01",
798
+ saveConfigSuccessToast: "\u9AD8\u7EA7\u5B89\u5168\u53C2\u6570\u5DF2\u6210\u529F\u4FDD\u5B58\u5E76\u540C\u6B65\u5230 settings.json\uFF01",
799
799
  restoreDefaultsSuccessToast: "\u5DF2\u6210\u529F\u6062\u590D\u9ED8\u8BA4\u5B89\u5168\u53C2\u6570\u5E76\u7ACB\u5373\u751F\u6548\uFF01",
800
800
  saveConfigFailTip: "\u53C2\u6570\u5FC5\u987B\u4E3A\u5927\u4E8E 0 \u7684\u6709\u6548\u6574\u6570",
801
801
  // 卡片 8: 移动端样式片段(样式小插件)
@@ -936,8 +936,8 @@ var translations = {
936
936
  storageStylesFile: "Mobile style snippets file (toggleable CSS mini-plugins):",
937
937
  // Card 7: Advanced Security Config
938
938
  configCardTitle: "Global Security Policy Configuration",
939
- configCardDesc: "Configure per-IP rate limits, brute-force failure thresholds, and lockout durations. Changes take effect immediately and sync to ~/.dsh/settings.yaml.",
940
- copySettingsPathBtn: "\u{1F4CB} Copy settings.yaml Path",
939
+ configCardDesc: "Configure per-IP rate limits, brute-force failure thresholds, and lockout durations. Changes take effect immediately and are saved to ~/.dsh/remote-mobile/settings.json.",
940
+ copySettingsPathBtn: "\u{1F4CB} Copy settings.json Path",
941
941
  fieldVisitsLimit: "\u23F1\uFE0F Max Visits per Minute (visits/min)",
942
942
  fieldVisitsLimitHint: "Rate limit triggered when a single IP opens login page more than this value",
943
943
  fieldFailedLimit: "\u{1F6E1}\uFE0F Max Failed Password Attempts (times)",
@@ -947,9 +947,9 @@ var translations = {
947
947
  restoreDefaultsBtn: "\u{1F504} Restore Defaults",
948
948
  saveConfigBtn: "\u{1F4BE} Save Configuration",
949
949
  savingConfigBtn: "Saving...",
950
- saveConfigConfirm: "Are you sure you want to save the current security policy? It will take effect immediately and sync to ~/.dsh/settings.yaml.",
950
+ saveConfigConfirm: "Are you sure you want to save the current security policy? It will take effect immediately and be saved to ~/.dsh/remote-mobile/settings.json.",
951
951
  restoreDefaultsConfirm: "Are you sure you want to restore security policies to default values (60 visits/min, 5 failed attempts, 15m lockout)? It will take effect immediately.",
952
- saveConfigSuccessToast: "Security policy successfully saved and synced to settings.yaml!",
952
+ saveConfigSuccessToast: "Security policy successfully saved and synced to settings.json!",
953
953
  restoreDefaultsSuccessToast: "Default security policies restored and applied immediately!",
954
954
  saveConfigFailTip: "Parameters must be valid integers greater than 0",
955
955
  // Card 8: Mobile Style Snippets (style mini-plugin)
@@ -2783,8 +2783,8 @@ function ConfigCard({
2783
2783
  onClick: () => {
2784
2784
  if (typeof copyText === "function") {
2785
2785
  copyText(
2786
- "~/.dsh/settings.yaml",
2787
- lang === "en" ? "Settings path copied (~/.dsh/settings.yaml)" : "\u5DF2\u590D\u5236\u914D\u7F6E\u6587\u4EF6\u8DEF\u5F84\uFF08~/.dsh/settings.yaml\uFF09"
2786
+ "~/.dsh/remote-mobile/settings.json",
2787
+ lang === "en" ? "Settings path copied (~/.dsh/remote-mobile/settings.json)" : "\u5DF2\u590D\u5236\u914D\u7F6E\u6587\u4EF6\u8DEF\u5F84\uFF08~/.dsh/remote-mobile/settings.json\uFF09"
2788
2788
  );
2789
2789
  }
2790
2790
  },
@@ -3538,8 +3538,8 @@ function StylesCard({ lang, showToast }) {
3538
3538
  }
3539
3539
 
3540
3540
  // src/client/version.js
3541
- var PLUGIN_VERSION = "v1.8.0";
3542
- var BUILD_TIME = "2026-10-08 20:18:19";
3541
+ var PLUGIN_VERSION = "v1.9.0";
3542
+ var BUILD_TIME = "2026-10-09 20:14:06";
3543
3543
 
3544
3544
  // src/client/components/MainSection.jsx
3545
3545
  var import_jsx_runtime9 = require("react/jsx-runtime");
package/lib/index.d.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * 4. Tailscale 虚拟私网与局域网 LAN 智能免密直连切换;
10
10
  * 5. 防暴力破解限流与单 IP 异常访问熔断审计;
11
11
  * 6. 全链路网络上下文虚拟化桥接与 @linxin666 生态无缝兼容。
12
- * 7. 接入 DSH Settings 服务,配置统一持久化于 ~/.dsh/settings.yaml 的 dsh-remote-mobile 命名空间。
12
+ * 7. 配置持久化于插件自有文件 ~/.dsh/remote-mobile/settings.json(DSH 0.1.7+ 起官方已弃用 settings.yaml),
13
+ * 启动时一次性兼容迁移旧 settings.yaml / settings.yaml.imported 中的配置。
13
14
  */
14
15
  import { type SessionStoreOptions } from './auth/token.js';
15
16
  /** Cordis 插件名称标识 */
package/lib/index.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * 4. Tailscale 虚拟私网与局域网 LAN 智能免密直连切换;
10
10
  * 5. 防暴力破解限流与单 IP 异常访问熔断审计;
11
11
  * 6. 全链路网络上下文虚拟化桥接与 @linxin666 生态无缝兼容。
12
- * 7. 接入 DSH Settings 服务,配置统一持久化于 ~/.dsh/settings.yaml 的 dsh-remote-mobile 命名空间。
12
+ * 7. 配置持久化于插件自有文件 ~/.dsh/remote-mobile/settings.json(DSH 0.1.7+ 起官方已弃用 settings.yaml),
13
+ * 启动时一次性兼容迁移旧 settings.yaml / settings.yaml.imported 中的配置。
13
14
  */
14
15
  import { SessionStore } from './auth/token.js';
15
16
  import { createGlobalAuthGate } from './auth/gate.js';
@@ -24,7 +25,11 @@ export const REMOTE_MOBILE_SETTINGS_NAMESPACE = 'dsh-remote-mobile';
24
25
  /** Cordis 依赖注入声明:声明注入底座内置的 webServer 服务 */
25
26
  export const inject = ['webServer'];
26
27
  /**
27
- * 注册并绑定 DSH Settings 服务(若存在)
28
+ * 尽力接入 DSH Settings 服务(若宿主提供旧版 register API)。
29
+ *
30
+ * 说明:DSH 0.1.7+ 起官方 dsh-settings 已改为基于 profile cordis.patch.yml 的
31
+ * Config 表单模型(无 register 方法),本函数在该版本下会安全跳过;插件的配置
32
+ * 持久化改由自有 settings.json 承担(见 token.ts)。
28
33
  */
29
34
  function bindDshSettings(ctx, store) {
30
35
  try {
@@ -39,19 +44,19 @@ function bindDshSettings(ctx, store) {
39
44
  base: store.getOptions(),
40
45
  });
41
46
  if (scope) {
42
- // 1. 同步 settings.yaml 中的最新配置到 SessionStore
47
+ // 1. 同步宿主 Settings 中的最新配置到 SessionStore
43
48
  const initialVal = scope.get?.();
44
49
  if (initialVal && typeof initialVal === 'object') {
45
50
  store.updateOptions(initialVal, false);
46
51
  }
47
- // 2. 监听 settings.yaml 或 Web UI 配置变更
52
+ // 2. 监听宿主 Settings 或 Web UI 配置变更
48
53
  scope.watch?.(() => {
49
54
  const updated = scope.get?.();
50
55
  if (updated && typeof updated === 'object') {
51
56
  store.updateOptions(updated, false);
52
57
  }
53
58
  });
54
- // 3. 绑定反向写入钩子:当插件 API 修改配置时同步写回 settings.yaml
59
+ // 3. 绑定反向写入钩子:插件 API 修改配置时同步写回宿主 Settings(旧版 API)
55
60
  store.setSettingsMutator((patch) => {
56
61
  try {
57
62
  const ops = Object.entries(patch).map(([field, value]) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-mobile",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "DeepSeek Harness 远程与移动端安全网关插件:零修改 DSH 底层代码安全开放局域网与 Tailscale 连接 | DeepSeek Harness (DSH) Remote & Mobile Security Guard: safely opens Tailscale/LAN with zero core modifications, QR scan auth, RSA encryption & brute-force defense.",
5
5
  "keywords": [
6
6
  "dsh",