dsh-mobile 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Notable changes to DSH Mobile are recorded here. GitHub Releases remain the source for downloadable packages and complete generated commit notes.
4
4
 
5
+ ## 0.4.4 - 2026-09-20
6
+
7
+ - Keep DSH running if mDNS response callbacks, the multicast-dns emitter, or the already-bound UDP discovery socket reports a network error. LAN discovery records the degraded channel and logs the error code while the authenticated HTTP and WebSocket gateway remains available (thanks @KMGTPEZY for [#95](https://github.com/saya-ch/dsh-mobile/issues/95)).
8
+ - Accept pinned cpolar and cloudflared component downloads when a proxy omits `Content-Length`, and stop reading as soon as the response exceeds the pinned size.
9
+ - Credit community PR authors, including work adapted after a PR was closed, and every historical issue author by contribution type in [CONTRIBUTORS.md](CONTRIBUTORS.md), separately from GitHub's commit-based Contributors panel.
10
+
5
11
  ## 0.4.3 - 2026-09-19
6
12
 
7
13
  - Support Linux for the Funnel, cpolar and cloudflared remote providers, including x64 and arm64 Funnel host binaries.
@@ -0,0 +1,53 @@
1
+ # 贡献者 / Contributors
2
+
3
+ GitHub 的 [Contributors 图表](https://github.com/saya-ch/dsh-mobile/graphs/contributors) 按进入默认分支的提交自动统计,无法手动加入仅提交 issue 或 PR 提案的成员。本页按实际参与方式感谢社区成员;仓库维护者与自动依赖更新仍可在 GitHub 历史记录中查看。
4
+
5
+ GitHub's Contributors graph counts commits on the default branch; issue reports and PR proposals cannot be manually added to it. This page credits community members by how they contributed, through 2026-09-20. The repository maintainer and automated dependency updates remain visible in GitHub's history.
6
+
7
+ ## 已合并 PR / Merged pull requests
8
+
9
+ | 社区成员 / Community member | 已合并 PR / Merged PRs |
10
+ | --- | --- |
11
+ | [@1624318455](https://github.com/1624318455) | [#93](https://github.com/saya-ch/dsh-mobile/pull/93) |
12
+ | [@abworks-dev](https://github.com/abworks-dev) | [#91](https://github.com/saya-ch/dsh-mobile/pull/91) |
13
+ | [@idoall](https://github.com/idoall) | [#61](https://github.com/saya-ch/dsh-mobile/pull/61), [#66](https://github.com/saya-ch/dsh-mobile/pull/66), [#67](https://github.com/saya-ch/dsh-mobile/pull/67), [#77](https://github.com/saya-ch/dsh-mobile/pull/77) |
14
+ | [@JackRushante](https://github.com/JackRushante) | [#16](https://github.com/saya-ch/dsh-mobile/pull/16) |
15
+ | [@longisland-icetea](https://github.com/longisland-icetea) | [#59](https://github.com/saya-ch/dsh-mobile/pull/59), [#60](https://github.com/saya-ch/dsh-mobile/pull/60), [#63](https://github.com/saya-ch/dsh-mobile/pull/63) |
16
+ | [@qzyqmzn](https://github.com/qzyqmzn) | [#38](https://github.com/saya-ch/dsh-mobile/pull/38), [#75](https://github.com/saya-ch/dsh-mobile/pull/75) |
17
+ | [@StrawberryAO](https://github.com/StrawberryAO) | [#34](https://github.com/saya-ch/dsh-mobile/pull/34), [#40](https://github.com/saya-ch/dsh-mobile/pull/40) |
18
+ | [@xingleiwu](https://github.com/xingleiwu) | [#79](https://github.com/saya-ch/dsh-mobile/pull/79), [#84](https://github.com/saya-ch/dsh-mobile/pull/84), [#85](https://github.com/saya-ch/dsh-mobile/pull/85), [#86](https://github.com/saya-ch/dsh-mobile/pull/86) |
19
+
20
+ ## 已吸收但未直接合并的 PR / Incorporated without a direct merge
21
+
22
+ [@BlueandwhiteXD](https://github.com/BlueandwhiteXD) 的 [#15](https://github.com/saya-ch/dsh-mobile/pull/15) 提出了并实现了 Android 键盘遮挡修复。由于发布分支已有重叠的状态栏改动,该方案改写后进入主线提交 [`09ef315`](https://github.com/saya-ch/dsh-mobile/commit/09ef31590219f2a436bd801214703338c215b00d),并在 [`04e6a0d`](https://github.com/saya-ch/dsh-mobile/commit/04e6a0da030353d4f0897419d0f692dc538a752d) 以 `Co-authored-by` 致谢。PR 的 Closed 状态不应掩盖这项已采用的代码贡献。
23
+
24
+ [@qzyqmzn](https://github.com/qzyqmzn) 的 [#32](https://github.com/saya-ch/dsh-mobile/pull/32) 和 [#37](https://github.com/saya-ch/dsh-mobile/pull/37) 被后续已合并的 [#38](https://github.com/saya-ch/dsh-mobile/pull/38) 取代;其正式贡献已列在上方。
25
+
26
+ [@BlueandwhiteXD](https://github.com/BlueandwhiteXD)'s [#15](https://github.com/saya-ch/dsh-mobile/pull/15) supplied the Android keyboard-overlap fix. It was adapted into [`09ef315`](https://github.com/saya-ch/dsh-mobile/commit/09ef31590219f2a436bd801214703338c215b00d) alongside overlapping status-bar work and credited with `Co-authored-by` in [`04e6a0d`](https://github.com/saya-ch/dsh-mobile/commit/04e6a0da030353d4f0897419d0f692dc538a752d). Its Closed PR status does not mean the code contribution was rejected. @qzyqmzn's [#32](https://github.com/saya-ch/dsh-mobile/pull/32) and [#37](https://github.com/saya-ch/dsh-mobile/pull/37) were superseded by merged [#38](https://github.com/saya-ch/dsh-mobile/pull/38).
27
+
28
+ ## 问题反馈与建议 / Issue reports and ideas
29
+
30
+ 以下名单包括提出问题、复现缺陷和建议功能的成员;出现在本表不表示提交过代码。 / These members opened repository issues; listing them does not imply code authorship.
31
+
32
+ | 反馈贡献者 / Issue reporter | 提交的问题 / Issues |
33
+ | --- | --- |
34
+ | [@1624318455](https://github.com/1624318455) | [#92](https://github.com/saya-ch/dsh-mobile/issues/92) |
35
+ | [@CESAEREE](https://github.com/CESAEREE) | [#1](https://github.com/saya-ch/dsh-mobile/issues/1) |
36
+ | [@CharlesLueng](https://github.com/CharlesLueng) | [#68](https://github.com/saya-ch/dsh-mobile/issues/68) |
37
+ | [@chmzs](https://github.com/chmzs) | [#73](https://github.com/saya-ch/dsh-mobile/issues/73) |
38
+ | [@idoall](https://github.com/idoall) | [#42](https://github.com/saya-ch/dsh-mobile/issues/42), [#45](https://github.com/saya-ch/dsh-mobile/issues/45), [#46](https://github.com/saya-ch/dsh-mobile/issues/46), [#47](https://github.com/saya-ch/dsh-mobile/issues/47), [#62](https://github.com/saya-ch/dsh-mobile/issues/62), [#64](https://github.com/saya-ch/dsh-mobile/issues/64) |
39
+ | [@IvyC-zz](https://github.com/IvyC-zz) | [#58](https://github.com/saya-ch/dsh-mobile/issues/58), [#78](https://github.com/saya-ch/dsh-mobile/issues/78) |
40
+ | [@jillkayat](https://github.com/jillkayat) | [#10](https://github.com/saya-ch/dsh-mobile/issues/10) |
41
+ | [@KMGTPEZY](https://github.com/KMGTPEZY) | [#95](https://github.com/saya-ch/dsh-mobile/issues/95) |
42
+ | [@LWping](https://github.com/LWping) | [#94](https://github.com/saya-ch/dsh-mobile/issues/94) |
43
+ | [@oliverwan97](https://github.com/oliverwan97) | [#26](https://github.com/saya-ch/dsh-mobile/issues/26) |
44
+ | [@rgshendong](https://github.com/rgshendong) | [#87](https://github.com/saya-ch/dsh-mobile/issues/87) |
45
+ | [@RonaldinhoL](https://github.com/RonaldinhoL) | [#89](https://github.com/saya-ch/dsh-mobile/issues/89) |
46
+ | [@shengyvself](https://github.com/shengyvself) | [#13](https://github.com/saya-ch/dsh-mobile/issues/13), [#14](https://github.com/saya-ch/dsh-mobile/issues/14) |
47
+ | [@sortjiajun](https://github.com/sortjiajun) | [#90](https://github.com/saya-ch/dsh-mobile/issues/90) |
48
+ | [@StrawberryAO](https://github.com/StrawberryAO) | [#39](https://github.com/saya-ch/dsh-mobile/issues/39) |
49
+ | [@Surpass567](https://github.com/Surpass567) | [#80](https://github.com/saya-ch/dsh-mobile/issues/80), [#81](https://github.com/saya-ch/dsh-mobile/issues/81) |
50
+ | [@taki-ohh](https://github.com/taki-ohh) | [#19](https://github.com/saya-ch/dsh-mobile/issues/19) |
51
+ | [@Xingcheng-wang](https://github.com/Xingcheng-wang) | [#76](https://github.com/saya-ch/dsh-mobile/issues/76) |
52
+ | [@Yurzi](https://github.com/Yurzi) | [#17](https://github.com/saya-ch/dsh-mobile/issues/17) |
53
+ | [@Ztyss](https://github.com/Ztyss) | [#22](https://github.com/saya-ch/dsh-mobile/issues/22), [#23](https://github.com/saya-ch/dsh-mobile/issues/23) |
package/README.en.md CHANGED
@@ -24,20 +24,21 @@
24
24
  <a href="#third-party-plugin-compatibility">Third-party plugins</a> ·
25
25
  <a href="#security">Security</a> ·
26
26
  <a href="#compatibility">Compatibility</a> ·
27
+ <a href="#contributors">Contributors</a> ·
27
28
  <a href="CHANGELOG.md">Changelog</a> ·
28
29
  <a href="README.md">简体中文</a>
29
30
  </p>
30
31
 
31
32
  > DSH Mobile is a DeepSeek Harness community plugin; the native app supports Android only.
32
33
  >
33
- > **0.4.3 update**: the Funnel, cpolar and cloudflared remote channels now support Linux (x64/arm64); a disconnected saved LAN interface no longer blocks the whole DSH boot (it stays dormant until the network returns); and heavy profiles no longer white-screen phones (boot-batch chunking plus large-bundle pass-through). [Details](CHANGELOG.md).
34
+ > **0.4.4 update**: runtime mDNS or UDP discovery errors no longer take down DSH, and component downloads accept proxy responses without `Content-Length`. [Details](CHANGELOG.md).
34
35
  >
35
- > **Upgrade reminder**: the 0.4.3 plugin continues to work with existing Android apps and paired devices do not need re-pairing. Install the 0.4.3 app as well if you want this Android build. [Compatibility notes](#compatibility).
36
+ > **Upgrade reminder**: the 0.4.4 plugin continues to work with existing Android apps and paired devices do not need re-pairing. Install the 0.4.4 app if you want this Android build. [Compatibility notes](#compatibility).
36
37
 
37
38
  <p align="center">
38
- <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.3/dsh-mobile-android-v0.4.3.apk"><img src="assets/brand/app-icon-rounded.svg" alt="DSH Mobile Android app icon" width="72" height="72"></a><br>
39
- <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.3/dsh-mobile-android-v0.4.3.apk"><strong>Download Android app 0.4.3</strong></a><br>
40
- <sub><a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.4.3">Release notes and checksums</a></sub>
39
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.4/dsh-mobile-android-v0.4.4.apk"><img src="assets/brand/app-icon-rounded.svg" alt="DSH Mobile Android app icon" width="72" height="72"></a><br>
40
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.4/dsh-mobile-android-v0.4.4.apk"><strong>Download Android app 0.4.4</strong></a><br>
41
+ <sub><a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.4.4">Release notes and checksums</a></sub>
41
42
  </p>
42
43
 
43
44
  DSH Mobile is a DeepSeek Harness plugin that lets a mobile browser or the Android app connect over a protected LAN or an optional Tailscale Funnel, cpolar, cloudflared, self-hosted FRP, or own reverse-proxy remote path. Local and remote access keep the same sessions, Workspaces, messages, and tools while using separate switches and paired-device stores without modifying DeepSeek Harness source.
@@ -215,7 +216,7 @@ Compatibility and WebSocket rules:
215
216
 
216
217
  Proxied pages allow HTTP frames for compatibility with some community plugins; those pages are unencrypted and can be altered, and browsers may still block them as mixed content. Use HTTPS for sensitive work. The same warning appears at the top of the remote panel when it is opened over HTTPS.
217
218
 
218
- - The released 0.4.3 is contract-checked against DSH `0.1.6-alpha.2` (renderer-v2). The DSH page must expose the standard session, `main`/`panelInfo`, and `rightbar` slots; the community plugin must register its panel or sidebar content through DSH's standard entry points.
219
+ - Version 0.4.4 is contract-checked against DSH `0.1.6-alpha.2` (renderer-v2). The DSH page must expose the standard session, `main`/`panelInfo`, and `rightbar` slots; the community plugin must register its panel or sidebar content through DSH's standard entry points.
219
220
  - The gateway allows first-party DSH WebSocket paths by default, including `/sidebar/ws/terminal`. Other paths used by community sidebar plugins are blocked by default and appear in Diagnostics; the `/sidebar/ws/agent-opens` and `/sidebar/ws/agent-terminals` paths in the image are examples that must be reviewed for the actual plugin.
220
221
  - In **Connection diagnostics → Third-party WebSocket paths**, select **Allow** only for an exact path you have verified. Query strings and fuzzy prefixes are rejected; **Allow all** is not recommended. Approved paths can be removed at any time, and the same policy applies to LAN and remote connections.
221
222
  - Approval only lets that path pass through the authenticated, same-origin DSH Mobile gateway. It does not open arbitrary TCP/UDP ports or bypass device pairing. If a community plugin still fails, check the path recorded by Diagnostics and approve one path at a time.
@@ -302,6 +303,7 @@ On macOS, local network, self-hosted FRP, and the own reverse proxy work; the th
302
303
 
303
304
  | DSH Mobile plugin | Verified DeepSeek Harness version |
304
305
  | --- | --- |
306
+ | `0.4.4` | `0.1.6-alpha.2` (local source and renderer-v2 contract check) |
305
307
  | `0.4.3` | `0.1.6-alpha.2` (local source and renderer-v2 contract check) |
306
308
  | `0.4.2` | `0.1.6-alpha.1` (local source and renderer-v2 contract check) |
307
309
  | `0.4.1` | `0.1.6-alpha.1` (local source and renderer-v2 contract check) |
@@ -330,6 +332,10 @@ dsh plugin --profile web remove dsh-mobile
330
332
 
331
333
  Source users replace `dsh` with `pnpm dsh`.
332
334
 
335
+ ## Contributors
336
+
337
+ Thanks to everyone who submitted PRs, reproduced issues, or proposed improvements. [The contributors list](CONTRIBUTORS.md) credits merged PRs, PR work later incorporated without a direct merge, and issue reports separately. GitHub's sidebar Contributors panel is generated from commits on the default branch and cannot be manually extended with issue-only contributors.
338
+
333
339
  ## Development
334
340
 
335
341
  ```powershell
package/README.md CHANGED
@@ -24,20 +24,21 @@
24
24
  <a href="#第三方插件适配">第三方插件适配</a> ·
25
25
  <a href="#安全">安全</a> ·
26
26
  <a href="#兼容性">兼容性</a> ·
27
+ <a href="#贡献者">贡献者</a> ·
27
28
  <a href="CHANGELOG.md">更新记录</a> ·
28
29
  <a href="README.en.md">English</a>
29
30
  </p>
30
31
 
31
32
  > DSH Mobile 是 DeepSeek Harness 社区插件,原生 App 仅支持 Android。
32
33
  >
33
- > **0.4.3 更新**:Funnel、cpolar、cloudflared 远程通道支持 Linux(x64/arm64);保存的局域网网卡断开不再阻断整个 DSH 启动(休眠等网卡回来);插件多的用户手机端白屏修好(启动包分片 + 大 bundle 直通)。[详细记录](CHANGELOG.md)。
34
+ > **0.4.4 更新**:局域网发现遇到运行期 mDNS/UDP 错误时不再带崩 DSH;组件下载也能处理缺少 `Content-Length` 的代理响应。[详细记录](CHANGELOG.md)。
34
35
  >
35
- > **升级提醒**:0.4.3 插件可继续使用现有 Android App,已有设备无需重新配对。若要使用本次 Android 构建,请同时安装 0.4.3 App。[兼容说明](#兼容性)。
36
+ > **升级提醒**:0.4.4 插件可继续使用现有 Android App,已有设备无需重新配对。若要使用本次 Android 构建,请安装 0.4.4 App。[兼容说明](#兼容性)。
36
37
 
37
38
  <p align="center">
38
- <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.3/dsh-mobile-android-v0.4.3.apk"><img src="assets/brand/app-icon-rounded.svg" alt="DSH Mobile 安卓应用图标" width="72" height="72"></a><br>
39
- <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.3/dsh-mobile-android-v0.4.3.apk"><strong>下载 Android App 0.4.3</strong></a><br>
40
- <sub><a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.4.3">版本说明与校验文件</a></sub>
39
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.4/dsh-mobile-android-v0.4.4.apk"><img src="assets/brand/app-icon-rounded.svg" alt="DSH Mobile 安卓应用图标" width="72" height="72"></a><br>
40
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.4.4/dsh-mobile-android-v0.4.4.apk"><strong>下载 Android App 0.4.4</strong></a><br>
41
+ <sub><a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.4.4">版本说明与校验文件</a></sub>
41
42
  </p>
42
43
 
43
44
  DSH Mobile 是一个 DeepSeek Harness 插件,让手机浏览器或 Android App 通过局域网,或可选的 Tailscale Funnel、cpolar、cloudflared、自建 FRP 或自有反向代理远程通道连接电脑,继续使用同一份会话、工作区、消息和工具。局域网与远程访问分别启停、分别管理设备,且都不修改 DeepSeek Harness 源码。
@@ -215,7 +216,7 @@ Android App 用一个“已配对设备”列表同时显示多台电脑:局
215
216
 
216
217
  代理页面为兼容部分社区插件允许嵌入 HTTP 页面;这类内容未加密,可能被篡改,浏览器也可能因混合内容策略拦截。处理敏感内容时请使用 HTTPS。通过 HTTPS 管理入口打开远程面板时,页面顶部会显示相同提醒。
217
218
 
218
- - 已发布的 0.4.3 按 DSH `0.1.6-alpha.2` 做过 renderer-v2 合同检查。DSH 页面需要提供标准的会话、`main`/`panelInfo` 和 `rightbar` 插槽;插件自身需要通过 DSH 的标准面板或侧边栏入口注册内容。
219
+ - 0.4.4 按 DSH `0.1.6-alpha.2` 做 renderer-v2 契约检查。DSH 页面需要提供标准的会话、`main`/`panelInfo` 和 `rightbar` 插槽;插件自身需要通过 DSH 的标准面板或侧边栏入口注册内容。
219
220
  - 网关默认只允许 DSH 内置的第一方 WebSocket 路径。社区侧边栏插件使用的其他路径默认拦截,通常会在诊断页显示为待处理项目;截图中的`/sidebar/ws/agent-opens` 和`/sidebar/ws/agent-terminals` 就属于这类需要按实际插件确认的路径。
220
221
  - 在 **连接诊断 → 第三方 WebSocket 路径** 中,只对确认过的精确路径点击 **允许**。系统不接受带查询字符串或模糊前缀的路径;不建议使用“全部允许”。已允许的路径可以随时移除,局域网和远程连接使用同一套规则。
221
222
  - 放行只代表该路径可以通过已认证、同源的 DSH Mobile 网关,不会开放任意 TCP/UDP 端口,也不会绕过设备配对。若社区插件仍然连接失败,先看诊断页的实际拦截路径,再按一条路径放行。
@@ -303,6 +304,7 @@ macOS 上局域网、自建 FRP 与自有反向代理可用;三个托管组件
303
304
 
304
305
  | DSH Mobile 插件 | 验证支持的 DeepSeek Harness 版本 |
305
306
  | ----------------------------------------- | -------------------------------------------------------------- |
307
+ | `0.4.4` | `0.1.6-alpha.2`(本机源码与 renderer-v2 契约检查) |
306
308
  | `0.4.3` | `0.1.6-alpha.2`(本机源码与 renderer-v2 契约检查) |
307
309
  | `0.4.2` | `0.1.6-alpha.1`(本机源码与 renderer-v2 契约检查) |
308
310
  | `0.4.1` | `0.1.6-alpha.1`(本机源码与 renderer-v2 契约检查) |
@@ -331,6 +333,10 @@ dsh plugin --profile web remove dsh-mobile
331
333
 
332
334
  源码模式把上述 `dsh` 换成 `pnpm dsh`。
333
335
 
336
+ ## 贡献者
337
+
338
+ 感谢提交 PR、复现问题和提出建议的社区成员。[贡献者名单](CONTRIBUTORS.md)按已合并 PR、后来吸收的 PR 工作和 issue 反馈分别致谢;GitHub 右侧的 Contributors 区域由进入默认分支的提交自动生成,不能手动加入仅反馈问题的成员。
339
+
334
340
  ## 开发
335
341
 
336
342
  ```powershell
package/lib/index.d.mts CHANGED
@@ -597,6 +597,7 @@ declare class MobileAccessGateway {
597
597
  private readonly upstreamAuthenticatedUrl?;
598
598
  private readonly extraWebSocketPaths?;
599
599
  private readonly blockedUpgradeLog?;
600
+ private readonly onDiscoveryDegraded?;
600
601
  readonly access: AccessController;
601
602
  private readonly listenerTlsEnabled;
602
603
  private readonly tlsEnabled;
@@ -611,6 +612,7 @@ declare class MobileAccessGateway {
611
612
  * it, "the phone cannot find this computer" has no diagnosable cause on the host.
612
613
  */
613
614
  private discoveryError;
615
+ private mdnsError;
614
616
  /** Names of the bundled mobile assets that could not be read, if any. */
615
617
  private mobileAssetError;
616
618
  private bonjour;
@@ -644,10 +646,13 @@ declare class MobileAccessGateway {
644
646
  private readonly probeLimiter;
645
647
  constructor(config: ResolvedGatewayConfig, store: DeviceStore, extensions?: MobileAccessService | undefined, upstreamAuthenticatedUrl?: string | undefined, extraWebSocketPaths?: {
646
648
  has(pathname: string): boolean;
647
- } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined);
649
+ } | undefined, blockedUpgradeLog?: BlockedUpgradePathLog | undefined, onDiscoveryDegraded?: ((source: 'broadcast' | 'mdns', code: string) => void) | undefined);
648
650
  /** Initialize durable state, validate TLS, and bind the externally reachable listener. */
649
651
  start(): Promise<void>;
650
652
  private startDiscovery;
653
+ private reportDiscoveryDegraded;
654
+ private recordBroadcastFailure;
655
+ private recordMdnsFailure;
651
656
  private discoveryAnnouncement;
652
657
  private closeFailedStart;
653
658
  private closeBonjour;
@@ -668,6 +673,7 @@ declare class MobileAccessGateway {
668
673
  readonly broadcast: boolean;
669
674
  readonly mdns: boolean;
670
675
  readonly errorCode?: string;
676
+ readonly mdnsErrorCode?: string;
671
677
  readonly mobileAssetsErrorCode?: string;
672
678
  };
673
679
  private requirePolicy;
package/lib/index.mjs CHANGED
@@ -2991,6 +2991,13 @@ function discoveryBroadcastTargets(cidrs) {
2991
2991
  }
2992
2992
  return [...targets];
2993
2993
  }
2994
+ function discoveryFailure(error) {
2995
+ const code = error instanceof Error && typeof error.code === "string" ? error.code : "unknown";
2996
+ return Object.freeze({
2997
+ code,
2998
+ message: error instanceof Error ? error.message : String(error)
2999
+ });
3000
+ }
2994
3001
  function extensionTarget(pathname) {
2995
3002
  const prefix = `${AUTH_PREFIX}/extensions`;
2996
3003
  if (pathname === prefix || pathname === `${prefix}/` || pathname === `${prefix}/manifest`) return { kind: "manifest" };
@@ -3088,6 +3095,7 @@ var MobileAccessGateway = class {
3088
3095
  upstreamAuthenticatedUrl;
3089
3096
  extraWebSocketPaths;
3090
3097
  blockedUpgradeLog;
3098
+ onDiscoveryDegraded;
3091
3099
  access;
3092
3100
  listenerTlsEnabled;
3093
3101
  tlsEnabled;
@@ -3102,6 +3110,7 @@ var MobileAccessGateway = class {
3102
3110
  * it, "the phone cannot find this computer" has no diagnosable cause on the host.
3103
3111
  */
3104
3112
  discoveryError;
3113
+ mdnsError;
3105
3114
  /** Names of the bundled mobile assets that could not be read, if any. */
3106
3115
  mobileAssetError;
3107
3116
  bonjour;
@@ -3133,12 +3142,13 @@ var MobileAccessGateway = class {
3133
3142
  removeExtensionContentListener;
3134
3143
  renewLimiter;
3135
3144
  probeLimiter;
3136
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog) {
3145
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded) {
3137
3146
  this.config = config;
3138
3147
  this.extensions = extensions;
3139
3148
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
3140
3149
  this.extraWebSocketPaths = extraWebSocketPaths;
3141
3150
  this.blockedUpgradeLog = blockedUpgradeLog;
3151
+ this.onDiscoveryDegraded = onDiscoveryDegraded;
3142
3152
  this.listenerTlsEnabled = config.tls.mode === "provided";
3143
3153
  this.tlsEnabled = config.publicTls;
3144
3154
  this.access = new AccessController(store, {
@@ -3252,33 +3262,56 @@ var MobileAccessGateway = class {
3252
3262
  const socket = createSocket("udp4");
3253
3263
  this.discoverySocket = socket;
3254
3264
  const announcement = this.discoveryAnnouncement(port);
3265
+ let binding = true;
3266
+ socket.on("error", (error) => {
3267
+ if (!binding) this.recordBroadcastFailure(error, true);
3268
+ });
3269
+ const sendAnnouncement = (targetPort, address) => {
3270
+ if (this.closing || this.discoveryError !== void 0) return;
3271
+ try {
3272
+ socket.send(announcement, targetPort, address, (error) => {
3273
+ if (error !== null) this.recordBroadcastFailure(error, true);
3274
+ });
3275
+ } catch (error) {
3276
+ this.recordBroadcastFailure(error, true);
3277
+ }
3278
+ };
3255
3279
  socket.on("message", (message, remote) => {
3256
- if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3257
- socket.send(announcement, remote.port, remote.address, () => void 0);
3280
+ if (this.closing || this.discoveryError !== void 0 || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
3281
+ sendAnnouncement(remote.port, remote.address);
3258
3282
  });
3259
3283
  const bindHost = isIP(this.config.listenHost) === 4 && isLoopbackAddress(this.config.listenHost) ? this.config.listenHost : "0.0.0.0";
3260
3284
  if (await new Promise((resolve) => {
3261
3285
  const onBindError = (error) => {
3262
3286
  socket.off("error", onBindError);
3263
- this.discoveryError = Object.freeze({
3264
- code: typeof error.code === "string" ? error.code : "unknown",
3265
- message: typeof error.message === "string" ? error.message : String(error)
3266
- });
3287
+ this.recordBroadcastFailure(error, false);
3267
3288
  resolve(false);
3268
3289
  };
3269
3290
  socket.once("error", onBindError);
3270
- socket.bind(port, bindHost, () => {
3271
- socket.off("error", onBindError);
3272
- socket.setBroadcast(true);
3273
- resolve(true);
3274
- });
3275
- })) {
3291
+ try {
3292
+ socket.bind(port, bindHost, () => {
3293
+ socket.off("error", onBindError);
3294
+ binding = false;
3295
+ try {
3296
+ socket.setBroadcast(true);
3297
+ resolve(true);
3298
+ } catch (error) {
3299
+ this.recordBroadcastFailure(error, true);
3300
+ resolve(false);
3301
+ }
3302
+ });
3303
+ } catch (error) {
3304
+ onBindError(error);
3305
+ }
3306
+ }) && this.discoveryError === void 0) {
3276
3307
  const announce = () => {
3277
- for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
3308
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) sendAnnouncement(port, target);
3278
3309
  };
3279
3310
  announce();
3280
- this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3281
- this.discoveryTimer.unref();
3311
+ if (this.discoveryError === void 0) {
3312
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
3313
+ this.discoveryTimer.unref();
3314
+ }
3282
3315
  } else {
3283
3316
  this.discoverySocket = void 0;
3284
3317
  try {
@@ -3286,8 +3319,12 @@ var MobileAccessGateway = class {
3286
3319
  } catch {}
3287
3320
  }
3288
3321
  const deviceName = discoveryDeviceName();
3289
- const bonjour = new Bonjour({ disableIPv6: true });
3322
+ const onMdnsError = (error) => {
3323
+ this.recordMdnsFailure(error);
3324
+ };
3325
+ const bonjour = new Bonjour({ disableIPv6: true }, onMdnsError);
3290
3326
  this.bonjour = bonjour;
3327
+ bonjour.server.mdns.on("error", onMdnsError);
3291
3328
  bonjour.publish({
3292
3329
  name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
3293
3330
  type: MDNS_SERVICE_TYPE,
@@ -3303,6 +3340,25 @@ var MobileAccessGateway = class {
3303
3340
  }
3304
3341
  });
3305
3342
  }
3343
+ reportDiscoveryDegraded(source, code) {
3344
+ try {
3345
+ this.onDiscoveryDegraded?.(source, code);
3346
+ } catch (error) {
3347
+ process.emitWarning(`DSH Mobile could not log ${source} discovery error: ${String(error)}`, { code: "DSH_MOBILE_DISCOVERY_LOG_FAILED" });
3348
+ }
3349
+ }
3350
+ recordBroadcastFailure(error, notify) {
3351
+ if (this.closing || this.discoveryError !== void 0) return;
3352
+ this.discoveryError = discoveryFailure(error);
3353
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
3354
+ this.discoveryTimer = void 0;
3355
+ if (notify) this.reportDiscoveryDegraded("broadcast", this.discoveryError.code);
3356
+ }
3357
+ recordMdnsFailure(error) {
3358
+ if (this.closing || this.mdnsError !== void 0) return;
3359
+ this.mdnsError = discoveryFailure(error);
3360
+ this.reportDiscoveryDegraded("mdns", this.mdnsError.code);
3361
+ }
3306
3362
  discoveryAnnouncement(port) {
3307
3363
  return Buffer.from(JSON.stringify({
3308
3364
  deviceName: discoveryDeviceName(),
@@ -3360,8 +3416,9 @@ var MobileAccessGateway = class {
3360
3416
  discoveryStatus() {
3361
3417
  return Object.freeze({
3362
3418
  broadcast: this.discoverySocket !== void 0 && this.discoveryTimer !== void 0,
3363
- mdns: this.bonjour !== void 0,
3419
+ mdns: this.bonjour !== void 0 && this.mdnsError === void 0,
3364
3420
  ...this.discoveryError === void 0 ? {} : { errorCode: `discovery_broadcast_${this.discoveryError.code}` },
3421
+ ...this.mdnsError === void 0 ? {} : { mdnsErrorCode: `discovery_mdns_${this.mdnsError.code}` },
3365
3422
  ...this.mobileAssetError === void 0 ? {} : { mobileAssetsErrorCode: this.mobileAssetError }
3366
3423
  });
3367
3424
  }
@@ -6721,10 +6778,23 @@ async function attemptDownload(request) {
6721
6778
  });
6722
6779
  const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
6723
6780
  if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
6724
- const declared = Number(response.headers.get("content-length"));
6725
- if (Number.isFinite(declared) && declared !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6726
- const bytes = new Uint8Array(await response.arrayBuffer());
6727
- if (bytes.byteLength !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6781
+ const contentLength = response.headers.get("content-length");
6782
+ if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6783
+ if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6784
+ const bytes = new Uint8Array(request.expectedBytes);
6785
+ const reader = response.body.getReader();
6786
+ let received = 0;
6787
+ while (true) {
6788
+ const chunk = await reader.read();
6789
+ if (chunk.done) break;
6790
+ if (chunk.value.byteLength > bytes.byteLength - received) {
6791
+ await reader.cancel();
6792
+ throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6793
+ }
6794
+ bytes.set(chunk.value, received);
6795
+ received += chunk.value.byteLength;
6796
+ }
6797
+ if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
6728
6798
  return bytes;
6729
6799
  }
6730
6800
  /**
@@ -11312,7 +11382,9 @@ async function apply(ctx, config) {
11312
11382
  ...candidateConfig,
11313
11383
  upstreamOrigin: template.upstreamOrigin.origin
11314
11384
  });
11315
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths);
11385
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
11386
+ logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
11387
+ });
11316
11388
  await candidate.start();
11317
11389
  lanGateway = candidate;
11318
11390
  const removeTaskSink = taskEventHub.add(candidate);