dsh-mobile 0.8.6 → 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.
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
 
@@ -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.
@@ -129,6 +129,6 @@ For everyday setup, send a test notification first, then check the categories yo
129
129
 
130
130
  ## Verification status
131
131
 
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 passed Debug-package foreground-service startup, persistent notification and enable/disable checks, but a real task-completion event and the official-signer APK remain unverified.
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.
133
133
 
134
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>);仅完成地址配置不代表推送已验证送达。
@@ -129,6 +129,6 @@ Android 原生权限/SSE 路径与 iOS 网页推送相互独立。新版 Andro
129
129
 
130
130
  ## 验证状态
131
131
 
132
- 网页推送的历史验收采信贡献者在 **iPhone 17 Pro Max / iOS 27.0.1** 上的开发实测,覆盖六类提醒、后台/锁屏/关闭页面、通知点击、停用与撤销,以及离线和主机重启。维护者没有独立 Apple 真机复验;iPad 与其他设备/系统组合没有对应真机记录。停用后复用订阅和自定义 Host 文案仍需真实 iOS 复验;Android 后台提醒已在隔离 Debug 包上验证前台服务启动、常驻通知和开关停止,真实任务完成事件与正式签名 APK 仍未验收。
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 仍未验收。
133
133
 
134
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/client.js CHANGED
@@ -3813,8 +3813,18 @@ window.__ModuleLoader__.load({
3813
3813
  return;
3814
3814
  }
3815
3815
  }
3816
- /** Parse a `task-notify` SSE payload; rejects anything malformed or empty. */
3817
- function parseTaskNotifyPayload(value) {
3816
+ /** Resolve the opaque Session id carried by the App's internal notification route. */
3817
+ function sessionIdFromNotificationRoute(route, base = "https://mobile.invalid/") {
3818
+ if (route === null || route === void 0) return void 0;
3819
+ let encoded;
3820
+ try {
3821
+ encoded = new URL(route, base).searchParams.get("dsh-session");
3822
+ } catch {
3823
+ return;
3824
+ }
3825
+ return decodeTaskNotificationNavigate(encoded);
3826
+ }
3827
+ /** Parse a `task-notify` SSE payload; rejects anything malformed or empty. */ function parseTaskNotifyPayload(value) {
3818
3828
  let record;
3819
3829
  if (typeof value === "string") try {
3820
3830
  const parsed = JSON.parse(value);
@@ -4306,6 +4316,13 @@ window.__ModuleLoader__.load({
4306
4316
  .dsh-mobile-settings_selector:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover); }
4307
4317
  .dsh-mobile-settings_selector:active:not(:disabled) { background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover)); }
4308
4318
  .dsh-mobile-settings_selector:focus-visible { outline:2px solid var(--dsw-alias-label-primary-bluish,#2563eb); outline-offset:2px; }
4319
+ /* Row switches (session prefetch, composer collapse) pair a visually-hidden
4320
+ native checkbox with an On/Off pill. Hide the checkbox so the pill is the only
4321
+ visible control, while the label keeps the 48px row a tap target and the input
4322
+ stays keyboard-focusable; the pill's :focus-visible ring is driven by the
4323
+ checkbox's focus via :has. */
4324
+ .dsh-mobile-settings_switchInput { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; opacity:0; }
4325
+ .dsh-mobile-settings_row:has(.dsh-mobile-settings_switchInput:focus-visible) .dsh-mobile-settings_selector { outline:2px solid var(--dsw-alias-label-primary-bluish,#2563eb); outline-offset:2px; }
4309
4326
  .dsh-mobile-settings_selector:disabled { cursor:wait; opacity:.55; }
4310
4327
  .dsh-mobile-settings_fontControls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
4311
4328
  .dsh-mobile-settings_fontControls button { min-width:48px; padding:0 12px; }
@@ -9945,6 +9962,104 @@ window.__ModuleLoader__.load({
9945
9962
  disabled: true
9946
9963
  }, copy.busy)));
9947
9964
  }
9965
+ // src/session-prefetch.ts
9966
+ /**
9967
+ * Storage key for the opt-in switch.
9968
+ *
9969
+ * The setting row lives in the client bundle while the tracker lives in the
9970
+ * layout bundle (tsdown.config.ts emits them separately and inlines this module
9971
+ * into each), so a module-level singleton cannot carry the value across. The
9972
+ * layout bundle owns the persisted state instead; the row notifies it through
9973
+ * both the storage event (other tabs) and a custom event (same page).
9974
+ */
9975
+ const SESSION_PREFETCH_STORAGE_KEY = "dsh-mobile.session-prefetch.enabled";
9976
+ const SESSION_PREFETCH_EVENT = "dsh-mobile:session-prefetch-change";
9977
+ /** Read the persisted opt-in. Defaults to off; unreadable storage keeps it off. */
9978
+ function readSessionPrefetchEnabled(storage) {
9979
+ try {
9980
+ return (storage ?? globalThis.localStorage)?.getItem(SESSION_PREFETCH_STORAGE_KEY) === "true";
9981
+ } catch {
9982
+ return false;
9983
+ }
9984
+ }
9985
+ /** Persist the opt-in and notify the layout bundle in the same page. */
9986
+ function writeSessionPrefetchEnabled(enabled, storage) {
9987
+ try {
9988
+ (storage ?? globalThis.localStorage)?.setItem(SESSION_PREFETCH_STORAGE_KEY, String(enabled));
9989
+ } catch {}
9990
+ try {
9991
+ globalThis.dispatchEvent?.(new CustomEvent(SESSION_PREFETCH_EVENT, { detail: { enabled } }));
9992
+ } catch {}
9993
+ }
9994
+ /**
9995
+ * Subscribe to opt-in changes coming from either bundle. The listener is not
9996
+ * called with the initial value; read it once with readSessionPrefetchEnabled().
9997
+ */
9998
+ function subscribeSessionPrefetchEnabled(listener) {
9999
+ const onStorage = (event) => {
10000
+ if (event.key !== null && event.key !== "dsh-mobile.session-prefetch.enabled") return;
10001
+ listener(readSessionPrefetchEnabled());
10002
+ };
10003
+ const onCustom = (event) => {
10004
+ const detail = event.detail;
10005
+ listener(detail?.enabled ?? readSessionPrefetchEnabled());
10006
+ };
10007
+ globalThis.addEventListener?.("storage", onStorage);
10008
+ globalThis.addEventListener?.(SESSION_PREFETCH_EVENT, onCustom);
10009
+ return () => {
10010
+ globalThis.removeEventListener?.("storage", onStorage);
10011
+ globalThis.removeEventListener?.(SESSION_PREFETCH_EVENT, onCustom);
10012
+ };
10013
+ }
10014
+ // src/session-prefetch-settings.ts
10015
+ const SESSION_PREFETCH_COPY = {
10016
+ en: {
10017
+ title: "Prefetch the session you just left",
10018
+ desc: "After switching sessions, warm the previous one in the background so switching back opens faster. Read-only, at most one session per switch, with a per-session cooldown.",
10019
+ on: "On",
10020
+ off: "Off"
10021
+ },
10022
+ zh: {
10023
+ title: "预取刚离开的会话",
10024
+ desc: "切换会话后在后台预热上一个会话,切回去时打开更快。只读、每次切换最多预热一个会话,并带单会话冷却。",
10025
+ on: "已开启",
10026
+ off: "已关闭"
10027
+ },
10028
+ it: {
10029
+ title: "Prefetch della sessione appena lasciata",
10030
+ desc: "Dopo un cambio di sessione riscalda in background quella precedente, così tornare indietro è più rapido. Sola lettura, al massimo una sessione per cambio, con cooldown per sessione.",
10031
+ on: "Attivo",
10032
+ off: "Disattivo"
10033
+ }
10034
+ };
10035
+ /** Browser-only settings row; toggling it only persists the flag. */
10036
+ function SessionPrefetchSettings({ locale, read, write, subscribe }) {
10037
+ const readEnabled = read ?? readSessionPrefetchEnabled;
10038
+ const writeEnabled = write ?? writeSessionPrefetchEnabled;
10039
+ const enabled = (0, react.useSyncExternalStore)(subscribe ?? subscribeSessionPrefetchEnabled, readEnabled, () => false);
10040
+ const copy = SESSION_PREFETCH_COPY[locale];
10041
+ return (0, react.createElement)("label", {
10042
+ className: "dsh-mobile-settings_row",
10043
+ lang: locale === "zh" ? "zh-CN" : locale,
10044
+ "data-dsh-mobile-session-prefetch": enabled ? "on" : "off"
10045
+ }, (0, react.createElement)("div", { className: "dsh-mobile-settings_rowText" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
10046
+ className: "dsh-mobile-settings_desc",
10047
+ "aria-live": "polite"
10048
+ }, copy.desc)), (0, react.createElement)("input", {
10049
+ type: "checkbox",
10050
+ role: "switch",
10051
+ className: "dsh-mobile-settings_switchInput",
10052
+ checked: enabled,
10053
+ "aria-label": copy.title,
10054
+ "aria-checked": enabled ? "true" : "false",
10055
+ onChange: (event) => {
10056
+ writeEnabled(event.currentTarget.checked);
10057
+ }
10058
+ }), (0, react.createElement)("span", {
10059
+ className: "dsh-mobile-settings_selector",
10060
+ "aria-hidden": "true"
10061
+ }, enabled ? copy.on : copy.off));
10062
+ }
9948
10063
  // src/mobile-push/navigation.ts
9949
10064
  /**
9950
10065
  * Consume one authenticated tap reference after the public Workspace and Session
@@ -11458,7 +11573,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11458
11573
  enabled: "关闭后台提醒",
11459
11574
  disabled: "开启后台提醒",
11460
11575
  busy: "正在处理…",
11461
- error: "无法更新后台提醒;请确认任务通知权限和当前连接。"
11576
+ error: "无法更新后台提醒;请确认任务通知权限和当前连接。",
11577
+ connected: "已连接",
11578
+ disconnected: "未连接"
11462
11579
  };
11463
11580
  if (locale === "it") return {
11464
11581
  title: "Promemoria in background",
@@ -11466,7 +11583,9 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11466
11583
  enabled: "Disattiva promemoria",
11467
11584
  disabled: "Attiva promemoria",
11468
11585
  busy: "Operazione…",
11469
- error: "Impossibile aggiornare i promemoria; controlla il permesso notifiche e la connessione attiva."
11586
+ error: "Impossibile aggiornare i promemoria; controlla il permesso notifiche e la connessione attiva.",
11587
+ connected: "Connesso",
11588
+ disconnected: "Non connesso"
11470
11589
  };
11471
11590
  return {
11472
11591
  title: "Background task reminders",
@@ -11474,13 +11593,24 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11474
11593
  enabled: "Disable background reminders",
11475
11594
  disabled: "Enable background reminders",
11476
11595
  busy: "Working…",
11477
- error: "Could not update background reminders. Check notification permission and the active connection."
11596
+ error: "Could not update background reminders. Check notification permission and the active connection.",
11597
+ connected: "Connected",
11598
+ disconnected: "Not connected"
11478
11599
  };
11479
11600
  }
11601
+ /** Render the App's real connection state when the installed App reports it. */
11602
+ function mobileBackgroundStatusText(locale, connected, lastError) {
11603
+ if (connected === void 0) return void 0;
11604
+ const copy = mobileBackgroundNotificationCopy(locale);
11605
+ if (connected) return copy.connected;
11606
+ return lastError === void 0 ? copy.disconnected : `${copy.disconnected} (${lastError})`;
11607
+ }
11480
11608
  /** Android-only opt-in for the foreground SSE fallback when the WebView is suspended. */
11481
11609
  function MobileBackgroundNotificationRow() {
11482
11610
  const [supported, setSupported] = (0, react.useState)(false);
11483
11611
  const [enabled, setEnabled] = (0, react.useState)(false);
11612
+ const [connected, setConnected] = (0, react.useState)(void 0);
11613
+ const [lastError, setLastError] = (0, react.useState)(void 0);
11484
11614
  const [busy, setBusy] = (0, react.useState)(false);
11485
11615
  const [error, setError] = (0, react.useState)(false);
11486
11616
  const generation = (0, react.useRef)(0);
@@ -11505,6 +11635,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11505
11635
  if (!active || attempt !== generation.current) return;
11506
11636
  if (typeof result?.enabled !== "boolean") throw new Error("invalid background notification status");
11507
11637
  setEnabled(result.enabled);
11638
+ setConnected(typeof result.connected === "boolean" ? result.connected : void 0);
11639
+ setLastError(typeof result.lastError === "string" ? result.lastError : void 0);
11508
11640
  setError(false);
11509
11641
  }).catch(() => {
11510
11642
  if (active && attempt === generation.current) {
@@ -11524,6 +11656,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11524
11656
  if (!supported) return null;
11525
11657
  const locale = selectedMobileControlLocale();
11526
11658
  const copy = mobileBackgroundNotificationCopy(locale);
11659
+ const statusText = enabled ? mobileBackgroundStatusText(locale, connected, lastError) : void 0;
11527
11660
  const toggle = () => {
11528
11661
  const bridge = window.__DSH_MOBILE_NATIVE__;
11529
11662
  if (bridge === void 0 || busy) return;
@@ -11535,6 +11668,13 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11535
11668
  const next = result?.enabled;
11536
11669
  if (typeof next !== "boolean") throw new Error("invalid background notification status");
11537
11670
  setEnabled(next);
11671
+ bridge.invoke("notification.background.get", {}).then((statusResult) => {
11672
+ if (attempt !== generation.current) return;
11673
+ const status = statusResult;
11674
+ if (typeof status?.enabled === "boolean") setEnabled(status.enabled);
11675
+ setConnected(typeof status?.connected === "boolean" ? status.connected : void 0);
11676
+ setLastError(typeof status?.lastError === "string" ? status.lastError : void 0);
11677
+ }).catch(() => void 0);
11538
11678
  }).catch(() => {
11539
11679
  if (attempt === generation.current) setError(true);
11540
11680
  }).finally(() => {
@@ -11547,7 +11687,7 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
11547
11687
  }, (0, react.createElement)("div", { className: "dsh-mobile-settings_rowText" }, (0, react.createElement)("div", { className: "dsh-mobile-settings_title" }, copy.title), (0, react.createElement)("div", {
11548
11688
  className: "dsh-mobile-settings_desc",
11549
11689
  "aria-live": "polite"
11550
- }, error ? copy.error : copy.description)), (0, react.createElement)("button", {
11690
+ }, error ? copy.error : statusText ?? copy.description)), (0, react.createElement)("button", {
11551
11691
  type: "button",
11552
11692
  className: "dsh-mobile-settings_selector",
11553
11693
  disabled: busy,
@@ -17656,6 +17796,45 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
17656
17796
  fiber.dispose();
17657
17797
  };
17658
17798
  }
17799
+ /**
17800
+ * Open a Session in the live page from an Android notification tap. The App calls this through
17801
+ * `window.__dshMobileOpenSession` instead of reloading the document; returning false asks the App
17802
+ * to fall back to a route navigation (for example while the page is still booting).
17803
+ */
17804
+ function installNativeNotificationNavigation(ctx, view) {
17805
+ if (view.__DSH_MOBILE_FRONTEND__ !== "dedicated" || ctx.inject === void 0) return () => void 0;
17806
+ let closed = false;
17807
+ const target = view;
17808
+ const fiber = ctx.inject(mobilePushNavigationServices, (navigationContext) => {
17809
+ if (closed) return () => void 0;
17810
+ const context = navigationContext;
17811
+ const open = (route) => {
17812
+ if (closed || typeof route !== "string") return false;
17813
+ const sessionId = sessionIdFromNotificationRoute(route);
17814
+ if (sessionId === void 0) return false;
17815
+ const workspace = context.uiWorkspace;
17816
+ if (workspace === void 0 || typeof workspace.openSession !== "function") return false;
17817
+ const sessions = context.sessions?.list.getSnapshot();
17818
+ if (sessions !== void 0 && sessions.phase === "ready" && !sessions.ids.includes(sessionId)) return false;
17819
+ try {
17820
+ workspace.openSession(sessionId);
17821
+ return true;
17822
+ } catch {
17823
+ return false;
17824
+ }
17825
+ };
17826
+ target.__dshMobileOpenSession = open;
17827
+ return () => {
17828
+ if (target.__dshMobileOpenSession === open) delete target.__dshMobileOpenSession;
17829
+ };
17830
+ });
17831
+ return () => {
17832
+ if (closed) return;
17833
+ closed = true;
17834
+ delete target.__dshMobileOpenSession;
17835
+ fiber.dispose();
17836
+ };
17837
+ }
17659
17838
  /** Own the browser-only push controller, settings row, and optional authenticated tap navigation. */
17660
17839
  function installMobilePushBrowserClient(ctx, runtime) {
17661
17840
  const { page, view } = runtime;
@@ -17729,6 +17908,19 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
17729
17908
  controller.stop();
17730
17909
  };
17731
17910
  }
17911
+ /**
17912
+ * Register the session-prefetch opt-in on every dedicated Mobile page, including the Android
17913
+ * App webview. The row used to live inside the browser-push installer, which returns early when
17914
+ * the native bridge exists, so App users never saw the switch for a layout-side feature they use.
17915
+ */
17916
+ function installSessionPrefetchSettings(ctx, view) {
17917
+ if (view.__DSH_MOBILE_FRONTEND__ !== "dedicated") return () => void 0;
17918
+ return ctx.slots.inject("settings.general.item", () => ctx.slots.register({
17919
+ name: "settings.general.item",
17920
+ id: "dsh-mobile-session-prefetch",
17921
+ order: 130
17922
+ }, () => (0, react.createElement)(SessionPrefetchSettings, { locale: selectedMobileControlLocale() })));
17923
+ }
17732
17924
  /** Mount the desktop control or mobile feature enhancements. */
17733
17925
  function apply(ctx) {
17734
17926
  let gatewayRejectionSink;
@@ -17768,6 +17960,8 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
17768
17960
  view: window
17769
17961
  }), "dsh-mobile: authenticated browser push lifecycle");
17770
17962
  ctx.effect(() => installTaskNotificationNavigation(ctx, document, window), "dsh-mobile: native task notification navigation");
17963
+ ctx.effect(() => installNativeNotificationNavigation(ctx, window), "dsh-mobile: in-page notification navigation");
17964
+ ctx.effect(() => installSessionPrefetchSettings(ctx, window), "dsh-mobile: session prefetch settings");
17771
17965
  ctx.effect(() => {
17772
17966
  const desktopAdmin = isDesktopAdminSurface(location.hostname, location.search, window.__DSH_MOBILE_FRONTEND__, location.protocol);
17773
17967
  const gatewayCustomization = ownsMobileCustomization(document.querySelector("meta[name=\"dsh-mobile-gateway\"][content=\"authenticated\"]") !== null, window.__DSH_MOBILE_FRONTEND__);
@@ -17944,9 +18138,12 @@ dialog.dshm-picker[open]{display:grid;place-items:center}dialog.dshm-picker::bac
17944
18138
  exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
17945
18139
  exports.installMobileNativeBack = installMobileNativeBack;
17946
18140
  exports.installMobilePushBrowserClient = installMobilePushBrowserClient;
18141
+ exports.installNativeNotificationNavigation = installNativeNotificationNavigation;
18142
+ exports.installSessionPrefetchSettings = installSessionPrefetchSettings;
17947
18143
  exports.installTaskNotificationNavigation = installTaskNotificationNavigation;
17948
18144
  exports.localizedDiagnosticEntryCopy = localizedDiagnosticEntryCopy;
17949
18145
  exports.mobileBackBlockedMessage = mobileBackBlockedMessage;
18146
+ exports.mobileBackgroundStatusText = mobileBackgroundStatusText;
17950
18147
  exports.mobilePushNavigationNotice = mobilePushNavigationNotice;
17951
18148
  exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
17952
18149
  exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;