dsh-mobile 0.8.5 → 0.8.7

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.
@@ -40,7 +40,7 @@ Close, Escape and the App's Back action ask before discarding unsaved changes. A
40
40
 
41
41
  Computer pages and `?frontend=stock` pages are unaffected. DSH or community-plugin updates can change the catalog and dependencies. When a saved selection prevents the mobile page from starting, the page offers “Load all modules for this device and retry”; it changes this device’s selection only when you click, without changing computer defaults, other devices or pairing authorization. Settings APIs still reject invalid selections and enforce required-module and dependency checks.
42
42
 
43
- The plugin 0.8.2 candidate marks the installed General settings provider as required for boot, fixing old minimal selections that made the settings entry unavailable. If an old record excludes that provider, reopening the mobile page shows recovery without requiring settings access first. Only selecting Load all saves and retries; a failure retains the old selection for an explicit retry. Pairings need not be deleted. Version 0.8.2 is not released yet; see [CHANGELOG](../CHANGELOG.md) for release status.
43
+ Since plugin 0.8.2, the installed General settings provider has been required for boot, fixing old minimal selections that made the settings entry unavailable. If an old record excludes that provider, reopening the mobile page shows recovery without requiring settings access first. Only selecting Load all saves and retries; a failure retains the old selection for an explicit retry. Pairings need not be deleted.
44
44
 
45
45
  ## Measure sizes and minimize loading
46
46
 
@@ -40,7 +40,7 @@ DSH 或插件更新后,旧排除项可能已不在目录中,或变为启动
40
40
 
41
41
  电脑页面与 `?frontend=stock` 页面不受模块选择影响。DSH 或社区插件更新可能改变模块目录和依赖;已保存的选择阻止移动页面启动时,页面提供“为本设备加载全部模块并重试”,由你点击后恢复本设备的加载选择,不改变电脑默认、其他设备或配对授权。设置接口仍拒绝无效选择,不绕过启动必需或依赖检查。
42
42
 
43
- 插件 0.8.2 候选将已安装的“通用设置”入口组件列为启动必需项,修复旧最小加载选择导致设置入口不可用的问题。旧记录若排除了该组件,重新打开移动页面时会先显示恢复页,无需先进入设置。点击加载全部才会保存并重试;失败时保留原选择,可以明确重试,不需要删除配对。0.8.2 尚未发布,发布状态见 [CHANGELOG](../CHANGELOG.md)。
43
+ 自插件 0.8.2 起,已安装的“通用设置”入口组件被列为启动必需项,修复旧最小加载选择导致设置入口不可用的问题。旧记录若排除了该组件,重新打开移动页面时会先显示恢复页,无需先进入设置。点击加载全部才会保存并重试;失败时保留原选择,可以明确重试,不需要删除配对。
44
44
 
45
45
  ## 体积测量与最小加载
46
46
 
@@ -22,7 +22,7 @@ The region must match the reservation. The official command example uses `cn_vip
22
22
 
23
23
  Find the current profile directory, for example `$DSH_HOME/profiles/<profile>/cordis.patch.yml`. Update the existing `mobile-access` configuration override. If none exists, add the override below to a profile that already loads the DSH Mobile bundle. **Do not create a second `mobile-access` instance or copy the bundle's `insert` list again.**
24
24
 
25
- The DSH loader replaces an entry's entire `config`, rather than merging individual keys. Do not paste an override containing only the subdomain and region. This example includes every configuration field from this repository's standard bundle, followed by the two new settings; **it is only suitable for a standard installation without custom configuration for this entry**:
25
+ The DSH loader replaces an entry's entire `config`, rather than merging individual keys. Do not paste an override containing only the subdomain and region. This example includes every field of the standard bundle's `config` block in this repository, followed by the two new settings; **it is only suitable for a standard installation without custom configuration for this entry**:
26
26
 
27
27
  ```yaml
28
28
  - id: mobile-access
@@ -22,7 +22,7 @@ cpolar 默认分配临时公网地址。账号预留子域名后,可请求使
22
22
 
23
23
  找到当前 profile 目录,例如 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`。修改已有的 `mobile-access` 配置覆盖条目;没有时,可在已加载 DSH Mobile bundle 的 profile 中添加下面的覆盖。**不要新增第二个 `mobile-access` 实例,也不要重新复制 bundle 的 `insert` 列表。**
24
24
 
25
- DSH 加载器会整体替换条目的 `config`,不是逐键合并。因此不能只粘贴子域名和地区两个字段。下面展示本仓库标准 bundle 的全部配置,再追加两个设置;**仅适用于尚未自定义该条目的标准安装**:
25
+ DSH 加载器会整体替换条目的 `config`,不是逐键合并。因此不能只粘贴子域名和地区两个字段。下面展示本仓库标准 bundle 的 `config` 块全部字段,再追加两个设置;**仅适用于尚未自定义该条目的标准安装**:
26
26
 
27
27
  ```yaml
28
28
  - id: mobile-access
package/docs/IROH.en.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  iroh replaces public domains and tunnel accounts with an encrypted direct connection (relay fallback when direct fails): the phone finds the computer's cryptographic identity first, then traffic returns to the loopback gateway. You need no domain, port mapping, or separate HTTPS tunnel.
4
4
 
5
- > Plugin 0.7.0 and the full App 0.7.0 integrate the computer provider, pinned TLS, pairing UI and Android native transport. The feature is off by default and remains experimental. Direct connectivity, relay use and speed depend on both networks; availability on every campus, corporate or carrier network is not guaranteed.
5
+ > Plugin 0.7.0 and the full App 0.7.0 integrate the computer provider, pinned TLS, pairing UI and Android native transport; the feature is off by default and remains experimental. This guide was verified on plugin 0.8.6 and the full Android App 0.7.3 (build 88); iroh has been available since 0.7.0. Direct connectivity, relay use and speed depend on both networks; availability on every campus, corporate or carrier network is not guaranteed.
6
6
  > The design lives in the companion's [integration seam](https://github.com/saya-ch/dsh-mobile-iroh/blob/main/docs/INTEGRATION_SEAM.md).
7
7
 
8
8
  ## Prerequisites
@@ -26,13 +26,13 @@ To reclaim component storage, turn off iroh remote access, expand the component
26
26
 
27
27
  ## Security boundaries
28
28
 
29
- - The sidecar proxies only to a `127.0.0.1`/`::1` loopback gateway with end-to-end TLS; commands and events are versioned, bounded, and rejected closed on unknown fields.
30
- - A rotated fingerprint, replaced binary, or moved file fails verification and parks the channel at `unavailable` instead of silently running a stale one.
29
+ - The sidecar proxies only to a `127.0.0.1`/`::1` loopback gateway with end-to-end TLS; commands and events are versioned and bounded (16 KiB command lines, 64 KiB peer lines), and rejected closed on unknown fields.
30
+ - A rotated fingerprint, replaced binary, or moved file fails verification instead of silently running a stale one: a missing or damaged component parks the channel at `unavailable`, while an identity or TLS fingerprint mismatch reports `error` (`gateway_start_failed`).
31
31
  - Transport revocation and device-token revocation are separate authorizations; their unified management is Phase 2 work. Computer-side revocation keeps working through the existing device management.
32
32
 
33
33
  ## Troubleshooting
34
34
 
35
- Store the iroh identity and certificates in a directory tree writable only by the current user or trusted system accounts. DSH's default state under the Windows user profile is intended for this; custom locations also need private ancestors. Some AppData or shared locations grant other accounts write access and are rejected. Do not loosen ACLs, delete real identities or move state into untrusted storage to bypass the check. Back up and preserve existing identity files.
35
+ Store the iroh identity and certificates in a directory tree writable only by the current user or trusted system accounts. DSH's default state under the Windows user profile usually qualifies; custom locations also need private ancestors, and even the default location is rejected when an ancestor grants write or replacement rights to another account (some AppData, shared or redirected profiles). Do not loosen ACLs, delete real identities or move state into untrusted storage to bypass that refusal. Back up and preserve existing identity files.
36
36
 
37
37
  Before a component release, verify first use with the exact catalog-pinned executable, not only a locally compiled host or a directory initialized by hand. The controller invokes `tls-prepare` because `id` only reads existing identity state. On Windows, `DSH_IROH_PINNED_FIRST_USE=<verified executable> npx vitest run tests/iroh-pinned-first-use.test.ts` checks initialization, reuse and corruption refusal in owned private storage. A failure blocks component acceptance; do not loosen directory ACL checks or replace real identities to make it pass.
38
38
 
@@ -41,7 +41,7 @@ Before a component release, verify first use with the exact catalog-pinned execu
41
41
  | `iroh_companion_missing` | Nothing installed yet: press Install first, or point `DSH_MOBILE_IROH_COMPANION` at a companion absolute path to reuse |
42
42
  | `iroh_sidecar_invalid` | An installed file was modified; reinstalling overwrites it in place, or reinstall the companion for a damaged companion copy |
43
43
  | `iroh_platform_unsupported` | No sidecar is published for this OS/architecture |
44
- | `iroh_data_directory_insecure` | A Windows parent directory of the state path grants write or replacement rights to another account. Move `DSH_HOME` to private storage or correct those ACLs; the plugin log retains the sidecar's bounded stderr explanation |
44
+ | `iroh_data_directory_insecure` | A Windows parent directory of the state path grants write or replacement rights to another account. Move `DSH_HOME` to private storage or correct those ACLs; the plugin log retains the sidecar's bounded stderr explanation. If `DSH_HOME` must stay on another volume, point `stateFile` at a private directory under the user profile, which also relocates the state files derived from that path; when editing `config` in a profile patch, supply every field, because DSH replaces the whole entry config instead of merging keys (see the same rule in [trusted networks](TRUSTED_NETWORKS.en.md)) |
45
45
  | `iroh_identity_invalid` / `iroh_identity_failed` | Check private permissions on the state directory and its ancestors and check for damaged identity files, preserving originals. `id` reads an existing identity; first use invokes `tls-prepare`, not copying or deleting an identity |
46
46
  | `iroh_start_timeout` | Inspect iroh errors in the DSH log and network connectivity, and verify that the pinned component runs; repeated pairing is not a substitute for process diagnosis |
47
47
  | Channel `ready` but no phone connects | Check that the App is the full build, the device supports a packaged ABI and the descriptor is fresh. Computer readiness does not establish phone connectivity; a relay-service connection alone does not establish that data uses the relay |
package/docs/IROH.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  iroh 用加密直连(打不通时走中继)代替公网域名和隧道账号:手机先找到电脑的加密身份,再把流量送回本机网关。你不需要准备域名、端口映射或另一套 HTTPS 隧道。
4
4
 
5
- > 插件 0.7.0 和 App 0.7.0 完整版整合电脑端通道、固定 TLS、配对界面和 Android 原生传输。功能默认关闭,仍标记为实验性;能否直连、是否经过中继及连接速度取决于两端网络,不保证在所有校园网、企业网或运营商环境下可用。
5
+ > 插件 0.7.0 和 App 0.7.0 完整版整合电脑端通道、固定 TLS、配对界面和 Android 原生传输;功能默认关闭,仍标记为实验性。本文在插件 0.8.6 与 Android 完整版 0.7.3(build 88)上验证,iroh 自 0.7.0 起可用;能否直连、是否经过中继及连接速度取决于两端网络,不保证在所有校园网、企业网或运营商环境下可用。
6
6
  > 相关设计见独立仓的[集成接缝](https://github.com/saya-ch/dsh-mobile-iroh/blob/main/docs/INTEGRATION_SEAM.md)。
7
7
 
8
8
  ## 前提
9
9
 
10
10
  - Windows x64 或 Linux x64(sidecar 只发布这两个目标)。
11
- - Android 10+,安装含 iroh 的完整版 `-iroh.apk`,支持 arm64-v8a 和 x86_64。普通 `.apk` 是无原生库的兼容版,保留原有 HTTPS 连接并供旧版自动更新使用;不支持 iroh,也不支持手机浏览器直接接入。两个 APK 使用相同包名和正式签名,只需安装一个。
11
+ - Android 10+,安装含 iroh 的完整版 `-iroh.apk`,支持 arm64-v8a 和 x86_64。普通 `.apk` 是无原生库的兼容版,保留原有 HTTPS 连接并供旧版自动更新使用;兼容版 APK 不能使用 iroh,手机浏览器同样不能通过 iroh 接入。两个 APK 使用相同包名和正式签名,只需安装一个。
12
12
  - 点 **安装官方组件** 一次下好 sidecar:插件从固定的 `dsh-mobile-iroh 0.1.0-alpha.1` 发行版按平台下载(Windows x64 约 15 MB,Linux x64 约 24 MB),校验 SHA-256 后放进插件私有目录;已装配套插件且副本校验通过时会直接复用,不再下载。Rust 源码随主仓在 `native/iroh-host`(公共 core 在 `native/iroh-core`)维护,CI 按平台构建校验。
13
13
  - DSH Mobile 保持启用;iroh 默认关闭。局域网独立运行,远程通道为单选,切换到 iroh 会停止之前的远程通道。
14
14
 
@@ -26,13 +26,13 @@ iroh 用加密直连(打不通时走中继)代替公网域名和隧道账号
26
26
 
27
27
  ## 安全边界
28
28
 
29
- - sidecar 只允许代理到 `127.0.0.1`/`::1` 的回环网关,TLS 仍端到端;命令与事件都是版本化、定长、未知字段直接拒绝。
30
- - 换指纹/换二进制/挪文件都会让校验失败并停在 `unavailable`,不会静默跑旧通道。
29
+ - sidecar 只允许代理到 `127.0.0.1`/`::1` 的回环网关,TLS 仍端到端;命令与事件都是版本化、有界(命令行 16 KiB、peer 行 64 KiB)、未知字段直接拒绝。
30
+ - 换指纹/换二进制/挪文件都会让校验失败,不会静默跑旧通道:缺少或损坏的组件停在 `unavailable`,身份或 TLS 指纹不符则进入 `error`(面板状态是 `gateway_start_failed`)。
31
31
  - 传输层撤销与设备 Token 撤销是两套授权,统一管理界面是 Phase 2 内容;电脑端撤销仍按现有设备管理生效。
32
32
 
33
33
  ## 排障
34
34
 
35
- iroh 身份和证书必须存放在仅当前用户或受信系统账户可写的目录树。Windows 默认的用户 profile 下 DSH 状态目录符合此用途;自定义状态位置时也要检查父目录权限。某些 AppData 或共享目录允许其他账户写入,会被拒绝;不要通过放宽 ACL、删除真实身份或移入不可信目录来绕过检查。已有身份应先备份并保留。
35
+ iroh 身份和证书必须存放在仅当前用户或受信系统账户可写的目录树。Windows 默认的用户 profile 下 DSH 状态目录通常符合此用途;自定义状态位置时也要检查父目录权限,默认位置也可能因祖先目录允许其他账户写入(部分 AppData、共享或重定向 profile)而被拒绝。遇到拒绝时不要通过放宽 ACL、删除真实身份或移入不可信目录来绕过检查。已有身份应先备份并保留。
36
36
 
37
37
  组件发布前必须使用目录中固定的二进制验证首次启用,不能只测本地编译的 host 或手工初始化过的目录。控制器调用 `tls-prepare`,因为 `id` 只读取既有身份。Windows 可设置 `DSH_IROH_PINNED_FIRST_USE=<已校验的程序>` 后运行 `npx vitest run tests/iroh-pinned-first-use.test.ts`,检查私有测试目录中的初始化、复用与损坏拒绝。失败应阻止组件验收,不得通过放松目录 ACL 检查或替换真实身份来让测试通过。
38
38
 
@@ -41,7 +41,7 @@ iroh 身份和证书必须存放在仅当前用户或受信系统账户可写的
41
41
  | `iroh_companion_missing` | 还没点安装(或配套不在常规布局旁);先点安装官方组件,或用 `DSH_MOBILE_IROH_COMPANION` 指向配套绝对路径复用 |
42
42
  | `iroh_sidecar_invalid` | 已装文件被改动过;重新点安装即可覆盖修复,配套副本损坏则重装配套 |
43
43
  | `iroh_platform_unsupported` | 当前系统/架构没有 sidecar 发布 |
44
- | `iroh_data_directory_insecure` | Windows 状态目录的某个上级目录允许其他账户写入或替换。请把 `DSH_HOME` 移到私有目录,或修正上级目录 ACL;插件日志会保留 sidecar 的脱敏 stderr 说明 |
44
+ | `iroh_data_directory_insecure` | Windows 状态目录的某个上级目录允许其他账户写入或替换。请把 `DSH_HOME` 移到私有目录,或修正上级目录 ACL;插件日志会保留 sidecar 的脱敏 stderr 说明。若 `DSH_HOME` 必须留在其他卷,可把 `stateFile` 指向用户 profile 下的私有目录,插件从该路径派生的状态文件也会随之迁移;在 profile patch 里改 `config` 时要写全所有字段,因为 DSH 整体替换该条目配置,不逐键合并(参见[可信网络](TRUSTED_NETWORKS.md)的同类说明) |
45
45
  | `iroh_identity_invalid` / `iroh_identity_failed` | 先检查状态目录及父目录是否私有、已有身份是否损坏,保留原文件再排查;`id` 仅检查已有身份,首次创建使用 `tls-prepare`,不是靠复制或删除身份修复 |
46
46
  | `iroh_start_timeout` | 查看 DSH 日志中的 iroh 错误及网络连接,确认固定组件可运行;不要反复重新配对来替代进程排查 |
47
47
  | 通道 `ready` 但手机连不上 | 检查 App 是否为完整版、设备是否支持打包的 ABI,以及描述符是否有效;电脑就绪不等于手机已连通,连接中继服务也不等于数据一定经过中继 |
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](MOBILE_COMPOSER_TOOLBAR.md) · [Third-party compatibility](../README.en.md#third-party-plugin-compatibility)
4
4
 
5
- > Layout changes have been available since plugin 0.6.3, with public-slot wrapper touch-target corrections since 0.6.4. Android and mobile browsers use the plugin-served page; these web changes do not require reinstalling the App.
5
+ > Compact, wrapping narrow-screen composer controls have been available since plugin 0.6.0; toolbar layout corrections since 0.6.3; and public-slot wrapper touch-target corrections since 0.6.4. Android and mobile browsers use the plugin-served page; these web changes do not require reinstalling the App.
6
6
 
7
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
8
 
@@ -18,7 +18,7 @@ The layout preserves native parent groups and adjusts wrapping, spacing and reac
18
18
 
19
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
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.
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 the upstream slot 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
22
 
23
23
  ## Display size and available space
24
24
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](MOBILE_COMPOSER_TOOLBAR.en.md) · [第三方插件适配](../README.md#第三方插件适配)
4
4
 
5
- > 布局调整自插件 0.6.3 起提供,公共插槽包装层的触摸目标修正自 0.6.4 起提供。Android App 与手机浏览器使用插件提供的页面,不需要为这些网页改动重装 App。
5
+ > 窄屏输入栏的紧凑与换行自插件 0.6.0 起提供,工具栏布局修正自 0.6.3 起提供,公共插槽包装层的触摸目标修正自 0.6.4 起提供。Android App 与手机浏览器使用插件提供的页面,不需要为这些网页改动重装 App。
6
6
 
7
7
  窄屏输入栏保留 DSH 原生结构和事件处理。模型名称在空间不足时截断,附加控件正常换行,发送/停止保持触摸尺寸,整组放不下时按实际宽度换行。插件开关不会被拉成操作按钮;内联弹窗、菜单和列表不会套用工具栏的紧凑尺寸。
8
8
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  公共插槽测试另覆盖自动继续开关、Fast 图标、两行额度摘要及已有任务恢复入口的紧凑形态。它们是无服务行为的展示夹具,不代表特定社区插件的实际接口、任务操作或配额请求已通过验证。
20
20
 
21
- 小屏下,公共 left/right 插槽中直接包含普通按钮的 span/div 包装层会为 44px 点击区域保留尺寸,避免按钮越过原先 30px 包装层而挤到相邻控件。此规则不按第三方类名识别控件;不匹配纯 switch、弹窗包装或只在更深层含按钮的包装,且不在宽屏生效。
21
+ 小屏下,公共 left/right 插槽中直接包含普通按钮的 span/div 包装层会为 44px 点击区域保留尺寸,避免按钮越过上游插槽包装层而挤到相邻控件。此规则不按第三方类名识别控件;不匹配纯 switch、弹窗包装或只在更深层含按钮的包装,且不在宽屏生效。
22
22
 
23
23
  ## 显示大小与可用空间
24
24
 
@@ -18,6 +18,15 @@ This guide is for DSH plugin authors, aiming for **usable small screens without
18
18
 
19
19
  These describe the current layout, not fixed dimensions a plugin should copy. Overlays, sidebars and main panels have different available space; verify the actual mounting location.
20
20
 
21
+ ## Back, full-screen overlays and safe areas
22
+
23
+ Android back is not the browser's `popstate`: the App first dispatches a cancelable `dsh-mobile:native-back` event, and only performs its own root behavior (closing the App) when nothing in the page claims it. Therefore:
24
+
25
+ - A plugin that expands its own full-screen panel or overlay must listen for `dsh-mobile:native-back`, collapse while open, and call `preventDefault()`. Listening only for DOM Escape or `popstate` does not claim Android back.
26
+ - Do not assume `env(safe-area-inset-top)` works in an Android WebView: the App normally insets the WebView for system bars, so `env()` is often 0 inside the page. A self-made host with `position:fixed; top:0` sits at the top of the page and its top controls can be hidden by the status bar or cutout; place controls below the page top, or offer a collapse control that does not depend on the top safe area.
27
+ - Prefer public slots (`main`, sidebars, right panel or overlay). DSH Mobile provides a return bar for `main` panels and handles back for the details/rightbar; a full-screen host appended directly to `document.body` is not part of the mobile layout and gains none of that behavior.
28
+ - When persisting an expanded state, default to collapsed on narrow screens, or keep the collapse control outside the top safe area.
29
+
21
30
  ## Adapt to available space
22
31
 
23
32
  Make the base layout shrink first: give flex/grid children `min-width: 0`, let toolbars wrap, and use content-driven height. Panel width is not always viewport width: a 400px panel in a desktop sidebar needs compact layout too.
@@ -18,6 +18,15 @@
18
18
 
19
19
  这些是当前布局行为,不是要求插件复制的固定尺寸。浮层、侧栏和主面板的可用空间不同,需在实际挂载位置验证。
20
20
 
21
+ ## 返回键、全屏浮层与安全区
22
+
23
+ Android 返回不是浏览器的 `popstate`:App 会先派发可取消的 `dsh-mobile:native-back` 事件,页面里没有任何监听者认领时,App 才执行自己的根行为(退出)。因此:
24
+
25
+ - 自己全屏展开面板或浮层的插件,必须监听 `dsh-mobile:native-back`,在展开时收起并调用 `preventDefault()`;只监听 DOM 的 Escape 或 `popstate` 不足以接管 Android 返回。
26
+ - 不要假设 `env(safe-area-inset-top)` 在 Android WebView 中可用:App 通常已按系统栏内缩 WebView,页面里的 `env()` 经常是 0。`position:fixed; top:0` 的自建宿主会紧贴页面顶部,顶部按钮可能被状态栏或刘海挡住;请把控件放在页面顶部以下,或提供不依赖顶部安全区的收起入口。
27
+ - 优先通过公开 slot(`main`、侧栏、右栏或 overlay)渲染。DSH Mobile 会为 `main` 面板提供返回栏,并处理 details/rightbar 的返回;直接 `appendChild(document.body)` 的全屏宿主不会进入移动布局,也不会获得这些行为。
28
+ - 持久化展开态时,窄屏应默认收起,或保证收起入口在顶部安全区之外。
29
+
21
30
  ## 按可用空间适配
22
31
 
23
32
  先让基础布局能够收缩:给 flex/grid 子项设置 `min-width: 0`,允许工具栏换行,使用内容驱动的高度。面板宽度不一定等于视口宽度:桌面侧栏中的 400px 面板也需要紧凑布局。
@@ -27,7 +27,7 @@ Web, iOS and Android use different units and scopes. Project recommendations do
27
27
  | 48dp | [Android minimum interactive component size](https://developer.android.com/reference/kotlin/androidx/compose/material/minimumInteractiveComponentSize.modifier) | Native controls only; do not convert it into a CSS px value for a Web panel |
28
28
  | Input type ≥16px | [Design reference](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md) (the mobile shell already enforces it); iOS zooms the page when a focused form control's font size is below 16px | Do not shrink inputs below that to make a layout fit |
29
29
  | `env(safe-area-inset-*)` | [MDN · env()](https://developer.mozilla.org/en-US/docs/Web/CSS/env) | Requires `viewport-fit=cover`; add it only where the content really reaches the edge and the shell has not already handled it |
30
- | `@container` | [MDN · Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | Components lay out by container width; all major browsers have supported it since 2023, so keep a fluid fallback for older engines |
30
+ | `@container` | [MDN · Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | Components lay out by container width; all major browsers have supported it since 2022–2023 (Chrome 105, Safari 16, Firefox 110), so keep a fluid fallback for older engines |
31
31
  | `overscroll-behavior: contain` | [MDN · overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) | Stops scroll chaining: reaching the panel's end must not drag the page behind it |
32
32
  | `prefers-reduced-motion` | [MDN · prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) | Drop decorative animation; a loading indicator may stay |
33
33
 
@@ -27,7 +27,7 @@ Web、iOS 和 Android 的单位与适用范围不同。项目建议不代替平
27
27
  | 48dp | [Android 交互组件最小尺寸](https://developer.android.com/reference/kotlin/androidx/compose/material/minimumInteractiveComponentSize.modifier) | 只适用于原生控件;不要换算成 CSS px 写进 Web 面板 |
28
28
  | 输入字号 ≥16px | [设计参考](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md)(移动外壳已强制);iOS 在聚焦字号小于 16px 的表单控件时会自动放大页面 | 插件不要把输入框写成更小的固定字号来「塞进」布局 |
29
29
  | `env(safe-area-inset-*)` | [MDN · env()](https://developer.mozilla.org/en-US/docs/Web/CSS/env) | 需要 `viewport-fit=cover`;只在确实贴边、且外壳未处理的区域追加,避免重复留白 |
30
- | `@container` | [MDN · 容器查询](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | 组件按容器宽度排版;主流浏览器自 2023 年起支持,更旧的引擎要保留流式回退 |
30
+ | `@container` | [MDN · 容器查询](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | 组件按容器宽度排版;主流浏览器自 2022–2023 年起陆续支持(Chrome 105 / Safari 16 / Firefox 110),更旧的引擎要保留流式回退 |
31
31
  | `overscroll-behavior: contain` | [MDN · overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) | 阻止滚动链:面板到底时不要把背后的页面带走 |
32
32
  | `prefers-reduced-motion` | [MDN · prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) | 收起装饰性动画;加载指示可以保留 |
33
33
 
package/docs/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  | 功能 | 中文 | English | 使用状态 |
30
30
  | --- | --- | --- | --- |
31
- | 区分电脑、DNS、出口与手机的诊断证据 | [连接诊断](CONNECTION_DIAGNOSTICS.md) | [Connection diagnostics](CONNECTION_DIAGNOSTICS.en.md) | 分层报告为 0.8.2 候选;不代表手机路径已验收 |
31
+ | 区分电脑、DNS、出口与手机的诊断证据 | [连接诊断](CONNECTION_DIAGNOSTICS.md) | [Connection diagnostics](CONNECTION_DIAGNOSTICS.en.md) | 分层报告自 0.8.2 起提供;不代表手机路径已验收 |
32
32
  | 核对移动字号、字体与 App 页面缩放 | [显示大小](MOBILE_COMPOSER_TOOLBAR.md#显示大小与可用空间) | [Display size](MOBILE_COMPOSER_TOOLBAR.en.md#display-size-and-available-space) | 字体按主题能力显示;App 缩放与网页字号独立 |
33
33
  | 为移动页面选择加载模块 | [模块设置](CLIENT_MODULES.md) | [Module settings](CLIENT_MODULES.en.md) | 电脑默认与设备覆盖分别保存,不自动刷新会话 |
34
34
  | 允许受控路由网络访问 LAN Gateway | [额外可信网段](TRUSTED_NETWORKS.md) | [Trusted networks](TRUSTED_NETWORKS.en.md) | 不建立 VPN,不自动扩大防火墙规则 |
@@ -14,7 +14,7 @@ Home Screen installation does not provide offline DSH: the computer, DSH and its
14
14
 
15
15
  - A capable iPhone/iPad Home Screen web app, opened in standalone mode, with the page-level `window.pushManager` API. The plugin detects capabilities rather than trusting a browser version string. Ordinary Safari tabs and unsupported browsers do not get a service-worker fallback.
16
16
  - A **stable, trusted HTTPS origin** and an existing DSH Mobile browser pairing. HTTP and continuing through a certificate warning are not supported push configurations. The Android app's private-CA pin does not make an iPhone browser trust that CA.
17
- - Plugin **0.8.0 or later** on the computer, with DSH and the Mobile Gateway for the exact HTTPS origin running. Packed push automation covers DSH **0.2.1-rc.2 / rc.3**; other versions depend on the required public services. Missing services disable push without disabling ordinary Mobile access.
17
+ - Plugin **0.8.0 or later** on the computer, with DSH and the Mobile Gateway for the exact HTTPS origin running. Packed push automation covers DSH **0.2.1-rc.2 through 0.2.2-rc.1** (rc.2, rc.3, rc.5, 0.2.2-alpha.3, 0.2.2-rc.1); other versions depend on the required public services. Missing services disable push without disabling ordinary Mobile access.
18
18
  - Outbound access to Apple's push infrastructure from the host and phone. No Apple developer membership, notification SaaS account or new public inbound port is required. This feature does not create a VPN, change certificates, open a firewall or configure a proxy.
19
19
 
20
20
  A publicly trusted private HTTPS hostname is architecturally eligible. The phone must still reach that origin to install, enable, authenticate and open a conversation. Delivery over a private/Tailscale origin is **unverified**; do not expose a private backend just to try push. Random tunnel hostnames are unsuitable for stable installation consent: an origin change never migrates authorization or subscriptions automatically. For persistent addresses, see [Cloudflare named tunnels](<CLOUDFLARE_TUNNEL.en.md>) or [your own HTTPS reverse proxy](<SELF_HOSTED_ORIGIN.en.md>); configuring an address alone does not prove push delivery.
@@ -40,7 +40,7 @@ Safari and the installed app may have separate login state. This recovery is for
40
40
 
41
41
  Push is best effort, not an alarm service. Host sleep/shutdown, disabled origin, phone connectivity, Focus settings and platform scheduling can delay or prevent delivery.
42
42
 
43
- Closing the page does **not** disable saved push consent. Use **Disable** to stop future eligible sends; explicit logout and device revocation have the separate effects listed [below](<#consent-logout-and-revocation>). A notification tap may require login and never answers or approves anything; **Send test** opens the app root, not a conversation.
43
+ Closing the page does **not** disable saved push consent. **Disable** stops future eligible sends but retains the browser subscription, avoiding an unusable replacement after a quick iOS unsubscribe/subscribe cycle; the next explicit Enable reuses the existing subscription. Explicit logout and device revocation have the separate effects listed [below](<#consent-logout-and-revocation>). A notification tap may require login and never answers or approves anything; **Send test** opens the app root, not a conversation.
44
44
 
45
45
  ## What can notify
46
46
 
@@ -64,7 +64,7 @@ Lock-screen text is generic and localized in English, Simplified Chinese or Ital
64
64
  | Action/event | Effect |
65
65
  | --- | --- |
66
66
  | Close the page or let its short login session expire | Saved push consent remains while persistent pairing authorization and the origin remain valid. Opening DSH still requires authentication. |
67
- | Select Disable | The host durably stops sending first, then the page best-effort unsubscribes only a subscription it owns. A failed browser unsubscribe does not restore host consent. |
67
+ | Select Disable | Durably stop Host sends and revoke the registration while retaining the browser subscription in a non-delivering state for the next explicit Enable. Clear the subscription through browser site-data controls when required. |
68
68
  | Explicit Mobile logout | Disable this paired device's push on the current origin before clearing its short session; retain the pairing. A failed disable must not report a completed logout. |
69
69
  | Revoke/reset a device or let its persistent credential expire | Purge affected push registrations and queued work, including devices with no open page. |
70
70
  | Stop a Gateway/provider or change its origin | Stop that origin's sends. Other origins keep their own consent. Re-enabling on a new origin is explicit. |
@@ -80,6 +80,21 @@ After the public Workspace and Session lists are ready, Mobile rechecks that the
80
80
 
81
81
  The browser chooses warm versus cold navigation; do not assume unsent drafts or in-flight sends survive every notification navigation. A fresh DSH launch may initially show the most recently active conversation; select the intended session manually when needed. The plugin does not register or unregister a service worker or cache authenticated pages/API data.
82
82
 
83
+ ## Host-plugin custom text
84
+
85
+ A trusted computer-side plugin may depend on the `mobilePush` service and submit a Session, title and body for categories the user already enabled:
86
+
87
+ ```ts
88
+ await ctx.mobilePush.notify({
89
+ sessionId,
90
+ title: 'Conversation name',
91
+ body: 'The response is ready',
92
+ category: 'response',
93
+ })
94
+ ```
95
+
96
+ Titles are limited to 80 characters and bodies to 200; control characters become spaces. The result reports how many recipient deliveries were newly queued. The Host still checks Session existence, pairing authorization, each device's category preference, registration limits, expiry and concurrency. A tap keeps the Gateway-generated opaque reference instead of putting the raw Session ID in the URL. Custom text remains in the private push state until delivery or at most 30 minutes of expiry; do not submit passwords, Tokens or content that must never leave the computer.
97
+
83
98
  ## Limits and delivery failures
84
99
 
85
100
  - At most **4 registrations per paired device**, **256 registrations and 256 pending deliveries per authorization scope**, and **4 concurrent sends**. Overflow rejects new work rather than deleting another installation or blocking DSH.
@@ -97,6 +112,7 @@ The sender accepts only validated Apple HTTPS push endpoints, rejects redirects
97
112
  | No row / unavailable | Check required page-level capability, trusted HTTPS, authenticated dedicated browser context and DSH public services. There is no worker or old-iOS fallback. |
98
113
  | Install guidance | Launch the Home Screen icon rather than an ordinary browser tab. |
99
114
  | Permission denied | Change the site's notification permission in system/browser settings, return to the app and make a new explicit Enable action. No prompt is forced. |
115
+ | It says granted but the Home Screen app stays silent | iOS records notification permission at site level; a re-added Home Screen app can inherit `granted` without receiving an independent authorization. The settings row shows this site-level state. If Disable/Enable still produces no delivery, remove the Home Screen app, clear the site's data, then add and authorize it again; do not guess or silently recreate the current app's subscription. |
100
116
  | Registration failed / stale settings | Refresh/reopen to read current state, then explicitly retry. Do not repeatedly resubmit an old request. |
101
117
  | Existing subscription uses another application key | Do not automatically unsubscribe it. After a host key reset, retrying Enable alone will not fix this conflict; identify its owner and arrange deliberate browser-side repair. |
102
118
  | Suspended / repair-required | Check host key/configuration and private-file access. Do not delete paired-device state or silently replace VAPID keys. |
@@ -107,12 +123,12 @@ Lost/corrupt VAPID material with existing registrations fails closed. A host adm
107
123
 
108
124
  ## Independent channels and acceptance
109
125
 
110
- Android's native permission/SSE path and APK/signing remain unchanged. The independently maintained **dsh-ping** channel is not modified or automatically disabled; enabling both can produce separate alerts. Web Push adds no cross-channel deduplication or foreground suppression.
126
+ Android's native permission/SSE path stays independent of iOS Web Push. A newer Android App can explicitly enable background reminders below Task notifications: a foreground service keeps the paired connection available after Android suspends the page. This requires an additional persistent notification and remains subject to foreground-service time limits, battery policy and connectivity; iroh does not participate in this background service yet. The independently maintained **dsh-ping** channel is not modified or automatically disabled; enabling both can produce separate alerts. Web Push adds no cross-channel deduplication or foreground suppression.
111
127
 
112
128
  For everyday setup, send a test notification first, then check the categories you need. Keep the existing Home Screen installation and pairing data; reopen the page after updating the computer plugin to load its new interface. See [contribution checks](<../CONTRIBUTING.md#select-behavior-checks>) for packed runtime validation and maintenance commands.
113
129
 
114
130
  ## Verification status
115
131
 
116
- This release accepts the contributor's development testing on **iPhone 17 Pro Max / iOS 27.0.1**, covering six notification categories, background/locked/closed-page delivery, taps, disable/revocation, offline windows and host restarts. The maintainer has not independently repeated Apple-device tests; no corresponding physical iPad or other device/OS record is available.
132
+ Historical Web Push acceptance uses the contributor's development testing on **iPhone 17 Pro Max / iOS 27.0.1**, covering six notification categories, background/locked/closed-page delivery, taps, disable/revocation, offline windows and host restarts. The maintainer has not independently repeated Apple-device tests; no corresponding physical iPad or other device/OS record is available. Reusing a subscription after Disable and custom Host text still require physical iOS verification; Android background reminders have been verified on Debug build 0.7.3/build 88 (one device over a cloudflared public tunnel) for the foreground service, the persistent notification, a real completed-turn event arriving while backgrounded and locked, a tap opening the `dsh-session` conversation, and disabling the opt-in stopping the service and removing the notification; the official-signer APK, iroh, and other networks/OEMs remain unverified.
117
133
 
118
134
  Automation covers settings, authentication, encrypted payloads, tap ownership, restart and cleanup, not actual Apple delivery. The missing-target fixture uses a never-created session; a genuinely persisted-then-deleted session remains untested because the current public DSH API does not expose the needed delete operation. Push is best effort and does not replace manual confirmation of important tasks.
package/docs/WEB_PUSH.md CHANGED
@@ -14,7 +14,7 @@ DSH Mobile 插件运行在**电脑**上。iPhone/iPad 用户用 **Safari** 打
14
14
 
15
15
  - 支持页面级 `window.pushManager` 的 iPhone/iPad 主屏幕 Web App,并以独立模式打开。插件检查实际能力,不根据浏览器版本字符串放行;普通 Safari 标签页和不支持的浏览器没有 Service Worker 降级方案。
16
16
  - **固定、受信任的 HTTPS Origin**,以及现有 DSH Mobile 浏览器配对。HTTP 和绕过证书警告不属于受支持的推送配置;Android App 固定私有 CA,不等于 iPhone 浏览器信任该 CA。
17
- - 电脑端已更新到插件 **0.8.0 或更高版本**,DSH 和对应 HTTPS 地址的 Mobile Gateway 正在运行。推送的打包自动化检查覆盖 DSH **0.2.1-rc.2 / rc.3**;其他版本仍取决于所需公共服务是否可用,缺少服务只关闭推送,不关闭普通移动访问。
17
+ - 电脑端已更新到插件 **0.8.0 或更高版本**,DSH 和对应 HTTPS 地址的 Mobile Gateway 正在运行。推送的打包自动化检查覆盖 DSH **0.2.1-rc.2 至 0.2.2-rc.1**(rc.2、rc.3、rc.5、0.2.2-alpha.3、0.2.2-rc.1);其他版本仍取决于所需公共服务是否可用,缺少服务只关闭推送,不关闭普通移动访问。
18
18
  - 主机和手机能出站连接 Apple 推送基础设施。不需要 Apple 开发者会员、通知 SaaS 账号或新增公网入站端口。此功能不创建 VPN、不改证书/防火墙,也不配置代理。
19
19
 
20
20
  使用公开受信任证书的私有 HTTPS 主机名在架构上可以使用。安装、启用、认证和打开会话时,手机仍须能访问该 Origin。私有/Tailscale Origin 的投递**尚未实测确认**;不要为了尝试推送把私有后端直接暴露到公网。随机隧道域名不适合作为长期安装地址:Origin 改变后,不自动迁移授权或订阅。需要长期地址时,可参考 [Cloudflare 命名隧道](<CLOUDFLARE_TUNNEL.md>)或[自有 HTTPS 反向代理](<SELF_HOSTED_ORIGIN.md>);仅完成地址配置不代表推送已验证送达。
@@ -40,7 +40,7 @@ Safari 与已安装 App 可能使用不同登录状态。本恢复步骤仅针
40
40
 
41
41
  推送是尽力而为,不是警报服务。主机睡眠/关机、Origin 停用、手机网络、专注模式和系统调度均可能延迟或阻止投递。
42
42
 
43
- 关闭页面**不会**停用已保存的推送同意。用**停用**阻止后续合格发送;明确退出与撤销设备各有不同效果,见[下表](<#同意状态退出与撤销>)。点击通知可能需要重新登录,绝不会代为回答或批准;**发送测试**的通知打开应用根入口,而不是某个会话。
43
+ 关闭页面**不会**停用已保存的推送同意。用**停用**阻止后续合格发送,但会保留浏览器订阅,避免 iOS 在快速退订/重订后得到无法投递的新订阅;下一次明确启用会复用原订阅。明确退出与撤销设备各有不同效果,见[下表](<#同意状态退出与撤销>)。点击通知可能需要重新登录,绝不会代为回答或批准;**发送测试**的通知打开应用根入口,而不是某个会话。
44
44
 
45
45
  ## 会提醒什么
46
46
 
@@ -64,7 +64,7 @@ Safari 与已安装 App 可能使用不同登录状态。本恢复步骤仅针
64
64
  | 操作/事件 | 效果 |
65
65
  | --- | --- |
66
66
  | 关闭页面或短登录会话过期 | 持久配对授权与 Origin 仍有效时保留推送同意;打开 DSH 仍需认证。 |
67
- | 点击停用 | 先持久保存主机停发,再尽力取消本功能拥有的浏览器订阅;浏览器取消失败不会恢复主机同意。 |
67
+ | 点击停用 | 持久保存主机停发并撤销注册;浏览器订阅保留为不投递状态,供下一次明确启用复用。需要清除订阅时,使用浏览器站点数据管理。 |
68
68
  | 明确退出 Mobile | 清除短会话前,先关闭此配对设备在当前 Origin 的推送,保留配对。停用保存失败不能报告退出已完成。 |
69
69
  | 撤销/重置设备,或持久凭据过期 | 清理相关注册和队列,包含没有打开页面的休眠设备。 |
70
70
  | 停止 Gateway/服务商或更改 Origin | 停止该 Origin 发送;其他 Origin 保留各自同意,新地址须重新明确启用。 |
@@ -80,6 +80,21 @@ Safari 与已安装 App 可能使用不同登录状态。本恢复步骤仅针
80
80
 
81
81
  冷启动还是复用页面由浏览器决定;不要假定所有通知导航都会保留未发送草稿或进行中的发送。DSH 全新启动时可能先显示最近活跃会话,此时可从会话列表手动选择目标。插件不注册/注销 Service Worker,也不缓存已认证页面或 API 内容。
82
82
 
83
+ ## Host 插件自定义文案
84
+
85
+ 受信任的电脑端插件可依赖 `mobilePush` 服务,为已启用的网页推送类别提交会话、标题和正文:
86
+
87
+ ```ts
88
+ await ctx.mobilePush.notify({
89
+ sessionId,
90
+ title: '会话名称',
91
+ body: '回复已准备好',
92
+ category: 'response',
93
+ })
94
+ ```
95
+
96
+ 标题最多 80 个字符,正文最多 200 个字符;控制字符会被替换为空格。接口返回本次新排队的接收端数量;服务端仍按会话存在性、配对授权、设备类别开关、注册数量、时效和并发上限检查。通知点击继续使用网关生成的不透明引用,不把原始会话 ID 写入点击 URL。自定义文案会在私有推送状态中保留到发送完成或最长 30 分钟过期;不要放入密码、Token 或不应离开电脑的敏感内容。
97
+
83
98
  ## 数量、时效与失败处理
84
99
 
85
100
  - 每个配对设备最多 **4 个注册**;每个授权范围最多 **256 个注册和 256 个待投递项**;最多 **4 个并发发送**。满额时拒绝新通知工作,不删除其他安装,也不阻塞 DSH。
@@ -97,6 +112,7 @@ Safari 与已安装 App 可能使用不同登录状态。本恢复步骤仅针
97
112
  | 没有入口/不可用 | 检查页面级能力、受信任 HTTPS、已认证的专用浏览器页面及 DSH 公共服务;没有 Worker 或旧 iOS 降级。 |
98
113
  | 提示安装 | 从主屏幕图标启动,不在普通标签页开启。 |
99
114
  | 权限拒绝 | 在系统/浏览器设置中修改该网站的通知权限,再返回 App 作新的明确启用;不会强制再次弹窗。 |
115
+ | 显示“已允许”但主屏幕 App 不响 | iOS 的通知权限按站点记录;重新添加的主屏幕 App 可能继承 `granted` 而没有独立授权。设置页会显示该站点级状态。若停用/重启用后仍无投递,移除主屏幕 App 并清除该站点数据,再重新添加和授权;不要对当前 App 猜测或静默重建订阅。 |
100
116
  | 保存失败/设置已过期 | 刷新或重开读取当前状态,再明确重试,不反复提交旧请求。 |
101
117
  | 现有订阅属于其他应用密钥 | 不自动取消它;主机密钥重置后仅重试启用无法解决,先确认归属,再安排明确的浏览器端修复。 |
102
118
  | 已暂停/需要修复 | 检查主机密钥、配置和私有文件访问;不要删除配对状态或静默更换 VAPID 密钥。 |
@@ -107,12 +123,12 @@ Safari 与已安装 App 可能使用不同登录状态。本恢复步骤仅针
107
123
 
108
124
  ## 独立通道与验收
109
125
 
110
- Android 原生权限/SSE 路径、APK 和签名保持不变。独立维护的 **dsh-ping** 不被修改或自动停用;同时开启可能收到不同通道的重复提醒。网页推送不增加跨通道去重,也不因页面在前台而压制通知。
126
+ Android 原生权限/SSE 路径与 iOS 网页推送相互独立。新版 Android App 可在“任务通知”下显式开启后台提醒:它会以前台服务保持配对连接,页面被系统挂起后仍接收任务完成事件。该选项需要额外常驻通知,受 Android 前台服务时限、电池策略和网络影响;iroh 暂不参与该后台服务。独立维护的 **dsh-ping** 不被修改或自动停用;同时开启可能收到不同通道的重复提醒。网页推送不增加跨通道去重,也不因页面在前台而压制通知。
111
127
 
112
128
  日常使用建议先发送一条测试通知,再检查自己关注的类别。保留现有主屏幕安装和配对数据;更新电脑端插件后重新打开页面即可加载新版界面。打包运行时检查与维护命令见[贡献指南](<../CONTRIBUTING.md#select-behavior-checks>)。
113
129
 
114
130
  ## 验证状态
115
131
 
116
- 本版采信贡献者在 **iPhone 17 Pro Max / iOS 27.0.1** 上的开发实测,覆盖六类提醒、后台/锁屏/关闭页面、通知点击、停用与撤销,以及离线和主机重启。维护者没有独立 Apple 真机复验;iPad 与其他设备/系统组合没有对应真机记录。
132
+ 网页推送的历史验收采信贡献者在 **iPhone 17 Pro Max / iOS 27.0.1** 上的开发实测,覆盖六类提醒、后台/锁屏/关闭页面、通知点击、停用与撤销,以及离线和主机重启。维护者没有独立 Apple 真机复验;iPad 与其他设备/系统组合没有对应真机记录。停用后复用订阅和自定义 Host 文案仍需真实 iOS 复验;Android 后台提醒已在 Debug 包 0.7.3/build 88(单设备、cloudflared 公网隧道)上验证前台服务、常驻通知、锁屏后台收到真实 completed-turn 事件、点击打开 `dsh-session` 会话,以及关闭开关后停止服务并移除通知;正式签名 APK、iroh、其他网络和 OEM 仍未验收。
117
133
 
118
134
  自动化检查覆盖设置、鉴权、加密载荷、点击归属、重启与清理,不代表 Apple 实际送达。缺失目标的自动化检查使用未创建的会话;真正“先持久化再删除”的会话受当前 DSH 公共接口限制,尚无同类检查。推送为尽力投递,不能替代重要任务的人工确认。
package/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Package entry used by plugin loaders before the compiled implementation is built.
3
+ * The implementation lives in ./lib/index.mjs; this file only re-exports it.
4
+ */
5
+ export * from './lib/index.mjs'
6
+ export { apply, inject, name, originGatewayConfig } from './lib/index.mjs'
package/lib/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { homedir, networkInterfaces } from "node:os";
2
+ import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, networkInterfaces, tmpdir } 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";
@@ -9,6 +9,39 @@ import { performance } from "node:perf_hooks";
9
9
  import "@deepseek-ai/cordis";
10
10
  import "@deepseek-ai/schemastery";
11
11
  // src/exec-file.ts
12
+ /**
13
+ * Longest stream text attached to a failed subprocess result.
14
+ *
15
+ * The text exists for diagnostics only: a caller decides which part is safe to
16
+ * show. It is bounded here so a chatty child cannot turn one failure into an
17
+ * unbounded buffer in every future consumer of that rejection.
18
+ */
19
+ const MAX_SUBPROCESS_ERROR_STREAM_CHARS = 8192;
20
+ function boundedStream(value) {
21
+ if (typeof value !== "string" || value === "") return void 0;
22
+ return value.length <= 8192 ? value : value.slice(0, MAX_SUBPROCESS_ERROR_STREAM_CHARS);
23
+ }
24
+ /**
25
+ * Attach the captured streams to a subprocess failure, best effort.
26
+ *
27
+ * `execFile` reports a non-zero exit as an Error whose only text is the command
28
+ * line with stderr merged into `message`; the raw stream strings are handed to
29
+ * the callback and otherwise discarded, so a caller cannot read them from the
30
+ * rejection. They are copied onto it when it accepts properties. A frozen,
31
+ * exotic or primitive rejection value must keep `message` as its diagnostic
32
+ * rather than turn one failure into a different error, so every write is
33
+ * guarded and an existing property is never overwritten.
34
+ */
35
+ function attachCapturedStreams(error, stdout, stderr) {
36
+ if (typeof error !== "object" || error === null) return;
37
+ const target = error;
38
+ try {
39
+ const capturedStderr = boundedStream(stderr);
40
+ const capturedStdout = boundedStream(stdout);
41
+ if (target.stderr === void 0 && capturedStderr !== void 0) target.stderr = capturedStderr;
42
+ if (target.stdout === void 0 && capturedStdout !== void 0) target.stdout = capturedStdout;
43
+ } catch {}
44
+ }
12
45
  /** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */
13
46
  function execFileText(file, args, options = {}) {
14
47
  return new Promise((resolve, reject) => {
@@ -18,6 +51,7 @@ function execFileText(file, args, options = {}) {
18
51
  encoding: "utf8"
19
52
  }, (error, stdout, stderr) => {
20
53
  if (error !== null) {
54
+ attachCapturedStreams(error, stdout, stderr);
21
55
  reject(error);
22
56
  return;
23
57
  }
@@ -517,16 +551,71 @@ const MAX_MANAGED_PORT = 65535;
517
551
  function assertManagedPort(port, name) {
518
552
  if (!Number.isSafeInteger(port) || port < MIN_MANAGED_PORT || port > MAX_MANAGED_PORT) throw new Error(`${name} must be from ${String(MIN_MANAGED_PORT)} through ${String(MAX_MANAGED_PORT)}`);
519
553
  }
520
- function powerShellFailureDetail(error) {
554
+ /** Longest failure detail carried in the thrown message. */
555
+ const MAX_FAILURE_DETAIL_CHARS = 512;
556
+ /**
557
+ * Strip the elevated invocation and filesystem locations from PowerShell text.
558
+ *
559
+ * The launcher's stderr quotes the command it ran, including the base64
560
+ * `-EncodedCommand` payload, and the elevated script quotes its own scratch
561
+ * path. Neither helps an operator, and both can push the one useful line past
562
+ * the length bound, so they are replaced before the detail is bounded.
563
+ */
564
+ function redactPowerShellDetail(value) {
565
+ return value.replace(/EncodedCommand\s+\S+/giu, "EncodedCommand <redacted>").replace(/\b[A-Za-z0-9+/]{64,}={0,2}\b/gu, "<redacted>").replace(/(?<![A-Za-z])[A-Za-z]:[\\/][^\s'"]*/gu, "<path>");
566
+ }
567
+ /** Bounded, redacted detail for a failed elevated Windows operation. */
568
+ function powerShellFailureDetail(error, innerError = "") {
521
569
  if (!(error instanceof Error)) return "unknown failure";
522
- const code = "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
523
- const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim().replace(/\s+/gu, " ").slice(0, 512) : "";
524
- return `${code}${stderr === "" ? "" : `: ${stderr}`}`;
570
+ const code = "code" in error && (typeof error.code === "string" || typeof error.code === "number") ? ` (${String(error.code)})` : "";
571
+ const detail = redactPowerShellDetail(innerError.trim() === "" && "stderr" in error && typeof error.stderr === "string" ? error.stderr : innerError).trim().replace(/\s+/gu, " ").slice(0, MAX_FAILURE_DETAIL_CHARS);
572
+ return `${code}${detail === "" ? "" : `: ${detail}`}`;
573
+ }
574
+ /** Default sink for a scratch directory that could not be removed. */
575
+ function warnFirewallCleanupFailure(detail) {
576
+ process.emitWarning(`Windows firewall temporary cleanup failed (${detail})`, { code: "DSH_MOBILE_FIREWALL_CLEANUP_FAILED" });
577
+ }
578
+ /**
579
+ * Remove the elevated helper's scratch directory without touching the run's outcome.
580
+ *
581
+ * The directory is created before elevation and removed in a `finally`, so a
582
+ * cleanup failure must not replace the firewall error that explains the
583
+ * failure, and must not fail a run that already succeeded. It stays observable
584
+ * instead: the removal error's code is reported to the warning sink, whose own
585
+ * failure is ignored for the same reason.
586
+ * @param directory - scratch path created for this run.
587
+ * @param options - injectable removal and warning sink for tests.
588
+ */
589
+ async function discardFirewallScratch(directory, options = {}) {
590
+ const remove = options.remove ?? ((path) => rm(path, {
591
+ recursive: true,
592
+ force: true
593
+ }));
594
+ try {
595
+ await remove(directory);
596
+ } catch (error) {
597
+ const code = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
598
+ const warn = options.warn ?? warnFirewallCleanupFailure;
599
+ try {
600
+ warn(code);
601
+ } catch {}
602
+ }
525
603
  }
526
604
  async function runElevatedPowerShell(script) {
605
+ const temporary = await mkdtemp(join(tmpdir(), "dsh-mobile-firewall-"));
606
+ const errorFile = join(temporary, "error.log");
607
+ const wrapped = [
608
+ `$__dshMobileErrorFile = '${errorFile.replaceAll("'", "''")}'`,
609
+ "try {",
610
+ script,
611
+ "} catch {",
612
+ " try { [IO.File]::WriteAllText($__dshMobileErrorFile, ($_ | Out-String)) } catch {}",
613
+ " throw",
614
+ "}"
615
+ ].join("\n");
527
616
  const launch = [
528
617
  "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
529
- ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
618
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(wrapped, "utf16le").toString("base64")}')`,
530
619
  "; exit $process.ExitCode"
531
620
  ].join(" ");
532
621
  try {
@@ -537,7 +626,13 @@ async function runElevatedPowerShell(script) {
537
626
  launch
538
627
  ], { windowsHide: true });
539
628
  } catch (error) {
540
- throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error)}`, { cause: error });
629
+ let innerError = "";
630
+ try {
631
+ innerError = (await readFile(errorFile, "utf8")).replace(/^\uFEFF/u, "");
632
+ } catch {}
633
+ throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error, innerError)}`, { cause: error });
634
+ } finally {
635
+ await discardFirewallScratch(temporary);
541
636
  }
542
637
  }
543
638
  function firewallRuleScript(name, protocol, port) {
@@ -552,7 +647,7 @@ function firewallRuleScript(name, protocol, port) {
552
647
  " $rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -RemoteAddress LocalSubnet",
553
648
  " }",
554
649
  "}",
555
- `$verified = @(Get-NetFirewallRule -DisplayName '${name}' -Enabled True -Direction Inbound -Action Allow -Profile Any -ErrorAction Stop)`,
650
+ `$verified = @(Get-NetFirewallRule -DisplayName '${name}' -ErrorAction Stop | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' -and $_.Profile -eq 'Any' })`,
556
651
  `$matchingPort = @($verified | Get-NetFirewallPortFilter | Where-Object { $_.Protocol -eq '${protocol}' -and $_.LocalPort -contains '${String(port)}' })`,
557
652
  `$matchingAddress = @($verified | Get-NetFirewallAddressFilter | Where-Object { $_.RemoteAddress -contains 'LocalSubnet' })`,
558
653
  `if ($verified.Count -eq 0 -or $matchingPort.Count -eq 0 -or $matchingAddress.Count -eq 0) { throw "DSH Mobile firewall rule '${name}' is not active with the expected scope" }`