dsh-mobile 0.6.11 → 0.6.12

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,14 @@
2
2
 
3
3
  Notable changes are recorded here. GitHub Releases provide the downloadable packages and generated commit notes.
4
4
 
5
+ ## 0.6.12 - 2026-10-18
6
+
7
+ - Send explicit non-navigation Fetch Metadata during the Gateway's internal DSH cookie exchange. This avoids the HTML navigation bounce produced by community authentication plugins without accepting arbitrary HTTP 200 pages, following redirects, or forwarding the upstream Cookie to the phone. Thanks @jamorzzz for [#230](https://github.com/saya-ch/dsh-mobile/issues/230).
8
+ - Separate invalid pairing/renewal/probe attempts from valid credentials behind a shared proxy. Invalid requests retain direct-peer limits; valid pairing secrets receive bounded admission, while renewal and probe budgets belong to the authenticated device. Keep operations accounted for until they settle even when the caller disconnects, and recheck credentials inside serialized writes. Forwarded IP headers remain untrusted. Thanks @Akiyuki1130 for [#231](https://github.com/saya-ch/dsh-mobile/issues/231).
9
+ - Extend the pre-boot compatibility script with identified missing array, string, object and structured-clone APIs. Add Chinese, English and Italian startup-failure feedback with explicit reload/wait actions when DSH has not rendered; mounted DSH and its own loader are not replaced, and no automatic reload is added.
10
+ - Identify unsupported class static-block syntax in DSH 0.2.1-rc.2's published frontend: Chromium 92 cannot parse that bundle. API shims and the existing old-engine advisory do not establish support for that engine. The feedback script itself targets Chromium 92; it does not transpile DSH. Thanks @yunyunlyk for the detailed engine and entry-mode comparison in [#232](https://github.com/saya-ch/dsh-mobile/issues/232).
11
+ - Add DSH 0.2.1-rc.2 to the packed-browser CI matrix and distinguish plugin-update SSE from conversation transport in troubleshooting. Android App 0.6.5 (build 81) remains independently published; no native change or new APK is part of this release.
12
+
5
13
  ## 0.6.11 - 2026-10-17
6
14
 
7
15
  - Stabilize earlier-history loading by requiring fresh touch or wheel input before an automatic load near the top. DSH's own scroll-position compensation cannot re-arm loading; switching sessions or scroll containers withdraws the previous input. Track only touch contacts started on the owned scroller so another finger elsewhere cannot keep an old loading intent active.
package/CONTRIBUTORS.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # 贡献者 / Contributors
2
2
 
3
- GitHub 的 [Contributors 图表](https://github.com/saya-ch/dsh-mobile/graphs/contributors) 按进入默认分支的提交自动统计,无法手动加入仅提交 issue 或 PR 提案的成员。本页按截至 2026-10-17 的实际参与方式感谢社区成员;仓库维护者与自动依赖更新仍可在 GitHub 历史记录中查看。
3
+ GitHub 的 [Contributors 图表](https://github.com/saya-ch/dsh-mobile/graphs/contributors) 按进入默认分支的提交自动统计,无法手动加入仅提交 issue 或 PR 提案的成员。本页按截至 2026-10-18 的实际参与方式感谢社区成员;仓库维护者与自动依赖更新仍可在 GitHub 历史记录中查看。
4
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-10-17. The repository maintainer and automated dependency updates remain visible in GitHub's history.
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-10-18. The repository maintainer and automated dependency updates remain visible in GitHub's history.
6
+
7
+ ## 0.6.12 问题定位 / Issue findings for 0.6.12
8
+
9
+ 感谢 [@jamorzzz](https://github.com/jamorzzz) 在 [#230](https://github.com/saya-ch/dsh-mobile/issues/230) 提供社区认证插件的导航响应与网关握手分析,[@Akiyuki1130](https://github.com/Akiyuki1130) 在 [#231](https://github.com/saya-ch/dsh-mobile/issues/231) 复现共享代理下的匿名限流耗尽,以及 [@yunyunlyk](https://github.com/yunyunlyk) 在 [#232](https://github.com/saya-ch/dsh-mobile/issues/232) 提供具体华为内核版本与 App/浏览器对照。这些报告分别促成认证握手、凭据限流和旧内核启动反馈改进;未将报告者真机写成已完成修复验收。
10
+
11
+ Thanks [@jamorzzz](https://github.com/jamorzzz) for the community-auth navigation and Gateway exchange analysis in [#230](https://github.com/saya-ch/dsh-mobile/issues/230), [@Akiyuki1130](https://github.com/Akiyuki1130) for reproducing anonymous rate-limit exhaustion behind a shared proxy in [#231](https://github.com/saya-ch/dsh-mobile/issues/231), and [@yunyunlyk](https://github.com/yunyunlyk) for the exact Huawei engine version and App/browser comparison in [#232](https://github.com/saya-ch/dsh-mobile/issues/232). These reports informed authentication, credential-admission and old-engine startup feedback improvements; they are not recorded as completed acceptance on the reporters' devices.
6
12
 
7
13
  ## 0.6.11 代码贡献 / Code contributions in 0.6.11
8
14
 
package/README.en.md CHANGED
@@ -26,13 +26,13 @@
26
26
  <a href="#compatibility">Compatibility</a> ·
27
27
  <a href="docs/README.md">Documentation</a> ·
28
28
  <a href="#contributors">Contributors</a> ·
29
- <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.11">Release notes</a> ·
29
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12">Release notes</a> ·
30
30
  <a href="README.md">简体中文</a>
31
31
  </p>
32
32
 
33
33
  > DSH Mobile is a DeepSeek Harness community plugin; the native app supports Android only.
34
34
  >
35
- > **Current stable release: 0.6.11**. Improve scroll stability while loading history, retain a manual loading action, and add sanitized HTTP authentication-rejection diagnostics. Android app **0.6.5** independently improves connection cancellation, timeouts and loading presentation. [Release notes and downloads](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.11) · [Full changelog](CHANGELOG.md).
35
+ > **Current stable release: 0.6.12**. Fix page 502 errors with community authentication and pairing/renewal limits behind shared proxies; add missing old-engine APIs and actionable startup feedback. Android App **0.6.5** is unchanged and needs no matching update. [Release notes and downloads](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12) · [Full changelog](CHANGELOG.md).
36
36
  >
37
37
  > **Version policy**: since plugin **0.6.2**, the Android app and plugin use independent version numbers and releases. Their numbers do not need to match, and a plugin update does not imply an app update. [Compatibility notes](#compatibility).
38
38
 
@@ -336,9 +336,11 @@ Selection priority is device choice → computer defaults → plugin configurati
336
336
 
337
337
  The Android app is a thin Kotlin WebView shell and contains no frontend copy; mobile browsers load the same page. Developers can append `?frontend=stock` to an authenticated mobile URL to diagnose boot and layout behavior. This entry skips dedicated mobile boot rewriting and module selection, but installed client adaptation and authenticated customizations may still apply. It is not a plugin-free control; some API operations may be rejected, so it does not replace normal mobile access. See [skins and mobile layout](docs/SKIN_COMPATIBILITY.en.md).
338
338
 
339
- In 0.5.5, the dedicated mobile page synchronously loads the authenticated, same-origin `/mobile-access/compat.js` before the first DSH boot script. This bundled core-js compatibility layer supplies `Iterator` / Iterator helpers to WebViews that lack them, preventing the startup error `Iterator is not defined`. It uses feature detection to preserve or repair native helpers, needs no CDN, and does not weaken CSP. The desktop and `?frontend=stock` pages do not load this gateway script.
339
+ The dedicated mobile page synchronously loads the authenticated, same-origin `/mobile-access/compat.js` before the first DSH boot script. This bundled compatibility layer supplies missing `Iterator` / Iterator helpers, `Promise.withResolvers` and `AbortSignal.any`. It uses feature detection to preserve or repair native implementations, needs no CDN, and does not weaken CSP. The desktop and `?frontend=stock` pages do not load this gateway script. On WebViews that support document-start injection, the Android App also supplies `Promise.withResolvers` early for the paired exact Origin.
340
340
 
341
- Version 0.6.0 adds feature-detected `Promise.withResolvers` and `AbortSignal.any` implementations to the same pre-boot script. On WebViews that support document-start injection, the Android app also supplies `Promise.withResolvers` early for the paired exact Origin. This covers identified missing APIs, not every old engine; the frontend still targets ES2022, and this version has not been verified on the reported physical Huawei WebView 114 device. If other compatibility errors remain and the device allows updates, update Android System WebView / Chrome first. If the system component cannot be updated, retain the full error, engine version, and entry mode for diagnosis.
341
+ **Old-engine limits**: API shims cannot repair JavaScript syntax an engine cannot parse. The published **DSH 0.2.1-rc.2** frontend contains class static blocks, which Chromium **92** cannot parse. This syntax became enabled by default in Chrome 94, but that does not establish full DSH compatibility on 94. [V8 announcement](https://v8.dev/blog/v8-release-94). The App's below-100 advisory is not a compatibility guarantee; choosing Continue does not make the current DSH runnable. If system WebView cannot be updated, use a phone browser already confirmed to work with the computer's mobile address, without clearing existing App pairings.
342
+
343
+ **Plugin 0.6.12** adds identified missing array, string, object and structured-clone APIs, plus startup-failure feedback with Reload page and Keep waiting actions when DSH has not rendered. It does not transpile DSH or reload automatically; acceptance on the physical Huawei Chromium 92 App remains unverified. Reports should include DSH/plugin/App and WebView versions, entry mode and the first redacted console error, never tokens or complete pairing URLs.
342
344
 
343
345
  > **Community client (unofficial)**: [WeChat Mini-Program client](https://github.com/StrawberryAO/dsh-mobile-minapp)
344
346
  > A native WeChat Mini-Program that reuses the Mobile Access pairing and Remote stream protocol (requires dsh-mobile ≥ 0.3.8).
@@ -405,6 +407,7 @@ On macOS, local network, cloudflared, self-hosted FRP and the own reverse proxy
405
407
  | DSH Mobile plugin | Verified DeepSeek Harness version |
406
408
  | --- | --- |
407
409
  | `0.6.11` | Isolated packed-browser CI on `0.1.7-alpha.2`, `0.1.7-rc.1`, `0.1.7-rc.2`, `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-alpha.2` and `0.2.1-alpha.3`; additional real history-pagination, viewport-preservation and manual-loading checks on `0.2.0-rc.2` and `0.2.1-alpha.3`. Browser checks do not establish acceptance of every phone engine, third-party plugin or public route |
410
+ | `0.6.12` | Packed installation, mobile boot, missing-API simulation, Workspace streaming, retained-page transport recovery and mobile settings on `0.2.1-rc.2`; authentication/resource regression with the actual community `dsh-web-startup-auth@0.1.12` on `0.2.0-rc.2`. This does not establish physical Chromium 92, arbitrary auth-plugin or public-route acceptance |
408
411
 
409
412
  <details>
410
413
  <summary>Historical version checks</summary>
package/README.md CHANGED
@@ -26,13 +26,13 @@
26
26
  <a href="#兼容性">兼容性</a> ·
27
27
  <a href="docs/README.md">文档</a> ·
28
28
  <a href="#贡献者">贡献者</a> ·
29
- <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.11">更新记录</a> ·
29
+ <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12">更新记录</a> ·
30
30
  <a href="README.en.md">English</a>
31
31
  </p>
32
32
 
33
33
  > DSH Mobile 是 DeepSeek Harness 社区插件,原生 App 仅支持 Android。
34
34
  >
35
- > **当前正式版本:0.6.11**。改善历史加载时的滚动稳定性,保留手动加载入口,并增加脱敏的 HTTP 认证拒绝诊断。Android App **0.6.5** 独立更新取消连接、超时与加载等待。[更新与下载](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.11) · [完整更新记录](CHANGELOG.md)。
35
+ > **当前正式版本:0.6.12**。修复社区认证插件下的页面 502、共享代理下的配对与续期限流问题,并补充旧内核 API 和启动失败提示。Android App **0.6.5** 保持不变,无需同步升级。[更新与下载](https://github.com/saya-ch/dsh-mobile/releases/tag/v0.6.12) · [完整更新记录](CHANGELOG.md)。
36
36
  >
37
37
  > **版本说明**:自插件 **0.6.2** 起,Android App 与插件独立编号、独立发布,版本号无需一致;插件升级不代表 App 需要同步升级。[兼容说明](#兼容性)。
38
38
 
@@ -336,9 +336,11 @@ excludedClientModules:
336
336
 
337
337
  Android App 只是 Kotlin WebView 薄壳,不内置另一份网页;手机浏览器访问的是同一页面。开发者可在已认证移动地址后追加 `?frontend=stock` 诊断启动与布局行为;该入口跳过专用移动启动改写和模块选择,但仍可能加载客户端移动适配及已认证定制,并非不含插件的对照页。部分 API 操作可能被拒绝,不能代替正常移动入口。详见[皮肤与移动布局](docs/SKIN_COMPATIBILITY.md)。
338
338
 
339
- 0.5.5 的专用移动页面会在 DSH 的第一个启动脚本之前,同步加载经网关鉴权的同源 `/mobile-access/compat.js`,为缺少 `Iterator` / Iterator helpers 的 WebView 提供随插件打包的 core-js 兼容实现,避免启动时出现 `Iterator is not defined`。兼容层按能力检测保留或修正原生 helper,不依赖 CDN,也不会放宽 CSP;桌面页面与 `?frontend=stock` 页面不会加载这个网关脚本。
339
+ 专用移动页面会在 DSH 的第一个启动脚本之前,同步加载经网关鉴权的同源 `/mobile-access/compat.js`,为缺少 `Iterator` / Iterator helpers、`Promise.withResolvers` 和 `AbortSignal.any` 的 WebView 提供随插件打包的兼容实现。兼容层按能力检测保留或修正原生实现,不依赖 CDN,也不会放宽 CSP;桌面页面与 `?frontend=stock` 页面不会加载这个网关脚本。Android App 还会在支持 document-start 注入的 WebView 中,为配对的精确 Origin 提前补齐 `Promise.withResolvers`。
340
340
 
341
- 0.6.0 在同一个启动前脚本中增加 `Promise.withResolvers` 与 `AbortSignal.any` 的能力检测和兼容实现。Android App 还会在支持 document-start 注入的 WebView 中,为配对的精确 Origin 提前补齐 `Promise.withResolvers`。这只覆盖已识别的缺失 API,不是所有旧内核的支持承诺;前端仍以 ES2022 为构建目标,尚未在报告中的华为 WebView 114 真机上验收。出现其他兼容错误时,若设备允许更新,请优先更新 Android System WebView / Chrome;若系统组件不可更新,请保留完整错误、内核版本与进入方式供排查。
341
+ **旧内核限制**:API 兼容实现不能修复内核无法解析的 JavaScript 语法。DSH **0.2.1-rc.2** 的正式前端包含 class static blocks,Chromium **92** 无法解析;该语法从 Chrome 94 默认可用,但这不代表 94 已通过整个 DSH 的兼容验收。[V8 说明](https://v8.dev/blog/v8-release-94)。App 的“内核低于 100”提醒只是建议,点击继续不代表当前 DSH 可以运行。若系统 WebView 无法更新,可使用已确认正常的手机浏览器访问电脑端提供的移动地址,无需清除原有 App 配对。
342
+
343
+ 插件 **0.6.12** 补齐已识别的数组、字符串、对象和结构化克隆 API,并在 DSH 尚未显示时提供启动失败说明、“重新载入页面”和“继续等待”。它不转译 DSH,也不自动刷新页面;华为 Chromium 92 的实际 App 尚未完成验收。反馈问题时请提供 DSH/插件/App 与 WebView 版本、进入方式和脱敏后的首个控制台错误,不要发送令牌或完整配对地址。
342
344
 
343
345
  > **社区客户端(非官方)**:[微信小程序客户端](https://github.com/StrawberryAO/dsh-mobile-minapp)
344
346
  > 原生微信小程序实现,复用「移动访问」的配对与 Remote 流协议(需 dsh-mobile ≥ 0.3.8)。
@@ -406,6 +408,7 @@ macOS 上局域网、cloudflared、自建 FRP 与自有反向代理可用;Funn
406
408
  | DSH Mobile 插件 | 已验证的 DeepSeek Harness 版本与范围 |
407
409
  | --- | --- |
408
410
  | `0.6.11` | `0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.3` 的隔离打包浏览器 CI;`0.2.0-rc.2` 与 `0.2.1-alpha.3` 另通过真实历史分页、滚动位置保持和手动加载检查。浏览器检查不代表所有手机内核、第三方插件或公网线路已验收 |
411
+ | `0.6.12` | `0.2.1-rc.2` 的打包安装、移动启动、缺失 API 模拟、工作区实时读取、保留页面的断线恢复及移动设置;`0.2.0-rc.2` 搭配实际社区认证插件 `dsh-web-startup-auth@0.1.12` 的认证与资源回归。未将 Chromium 92 真机、任意认证插件或公网线路写成已验收 |
409
412
 
410
413
  <details>
411
414
  <summary>历史版本验证记录</summary>
package/SECURITY.md CHANGED
@@ -28,6 +28,13 @@ The maintainer will acknowledge a complete report within seven days. Publication
28
28
  - Keep pairing closed except during a short local onboarding action.
29
29
  - Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
30
30
 
31
+ ### Authentication admission and proxy peers
32
+
33
+ - Invalid pairing guesses, malformed renewal/probe requests and invalid device credentials are limited by the direct TCP peer. `X-Forwarded-For`, `X-Real-IP` and `Forwarded` do not identify an authenticated client. A reverse proxy can therefore give anonymous requests a shared failure budget; this is not a reason to trust forwarded headers without an explicit proxy policy.
34
+ - From plugin 0.6.12, a current valid one-time pairing secret does not consume that anonymous budget. At most one valid pairing candidate can wait for a durable mutation; it is rechecked inside the serialized operation. Renewal and native reachability probes use separate per-device budgets after credential verification, with at most one in-flight operation of each kind per device. Device capacity is independent of the anonymous peer-table capacity. Expiry and revocation are rechecked before mutation; a disconnected caller does not release work that is still pending.
35
+ - These limits protect bounded application work and prevent anonymous failures from exhausting valid credentials' budgets. They do not make an exposed listener immune to connection, bandwidth or CPU exhaustion. Keep pairing windows short, protect proxy ingress, and retain the existing Host/Origin, device-token and CSRF checks.
36
+ - The Gateway's internal upstream cookie exchange requires the configured loopback DSH Origin, its expected redirect status and a valid authentication Cookie. Explicit non-navigation Fetch Metadata avoids community authentication plugins' navigation HTML response; no arbitrary successful HTML page is accepted and no redirect is followed. The upstream Cookie remains server-side.
37
+
31
38
  ### Installation identity and device-state upgrades
32
39
 
33
40
  - Automatically identified entries without a pairing CA use the random `installation-id` file in the active Mobile state directory for new pairings, not a hash of the state-file path. Explicit operator IDs and CA-bound LAN/self-signed FRP identities remain unchanged. The identifier is public, not an authentication secret. Copying the entire state directory also copies its identity and credentials; this fix does not distinguish cloned installations. Do not delete the identity file to troubleshoot a connection.
@@ -79,6 +79,8 @@ The native soft-keyboard adapter uses plain Enter for a line break only with a v
79
79
 
80
80
  System bars, cutouts and keyboard occupancy are reserved once. WebView 151+ receives a minimum 64 MiB HTTP-cache quota for reusable versioned DSH resources; older engines retain their default quota. A Chromium-below-100 reminder is advisory, not a promise that every DSH API is supported on that engine.
81
81
 
82
+ A successful App connection does not establish that its engine can execute the page. The DSH 0.2.1-rc.2 frontend contains syntax Chromium 92 cannot parse. If system WebView cannot be updated, use a browser confirmed to work without clearing pairings. See the [old-engine notes](../../README.en.md#app-or-mobile-browser) for the plugin's startup feedback and support limits.
83
+
82
84
  ### Voice
83
85
 
84
86
  DSH records audio through `getUserMedia` and sends it to the configured computer-side speech provider, not browser SpeechRecognition or the extension bridge. Android asks for microphone permission and accepts audio-only capture from the paired HTTPS Origin. Provider components, models and network access must work; granting recording permission does not verify transcription. Same-origin frontend plugins share that page permission.
@@ -79,6 +79,8 @@ DSH 的文件入口仍在 **添加** 组;当前附件 owner 支持图片时,
79
79
 
80
80
  系统栏、屏幕缺口和键盘空间只预留一次。WebView 151+ 为 App 的可复用版本化 DSH 资源提供至少 64 MiB HTTP 缓存配额;旧内核保持默认配额。Chromium 低于 100 的提醒不阻断使用,也不承诺旧内核支持全部 DSH 接口。
81
81
 
82
+ App 连接成功不代表网页脚本能够运行。DSH 0.2.1-rc.2 的前端含有 Chromium 92 无法解析的语法;系统内核无法更新时可使用已验证可用的浏览器,不必清除配对。插件的启动反馈与支持范围见[旧内核说明](../../README.md#app-与手机浏览器)。
83
+
82
84
  ### 语音
83
85
 
84
86
  DSH 通过 `getUserMedia` 录音并交给电脑已配置的语音提供方,不使用浏览器 SpeechRecognition 或扩展 Bridge。Android 申请麦克风权限,仅接受配对 HTTPS Origin 的纯音频请求;电脑组件、模型和网络仍需可用,录音授权不代表转写成功。同源前端插件共享该页面权限。
@@ -37,6 +37,8 @@ Bind to an explicit loopback or RFC1918 IPv4 already assigned to the DSH compute
37
37
 
38
38
  CIDRs match the proxy's **direct TCP peer**, not the phone or `X-Forwarded-For` / `X-Real-IP` / `Forwarded`. Docker or NAT may change that peer. Prefer the actual single-host /32, not an entire subnet. Up to 16 canonical private/loopback CIDRs are accepted, separated by spaces or commas; explicit private proxy sources are required for a LAN bind.
39
39
 
40
+ Phones behind a proxy may share that direct peer. From plugin 0.6.12, invalid credentials still share its failure budget, but anonymous failures cannot exhaust a valid one-time pairing secret's admission. Authenticated renewals and probes have per-device limits and bounded pending work. Adding or trusting forwarded-IP headers is unnecessary. For `429 rate_limited` on older versions, update the plugin while retaining pairings; do not widen source CIDRs or expose the ordinary DSH listener.
41
+
40
42
  Select **Save and start backend**. An active backend reloads its settings. The panel separates the public HTTPS origin from the actual listening HTTP backend; saved settings remain visible after stopping.
41
43
 
42
44
  ## Proxy requirements, including Lucky
@@ -38,6 +38,8 @@
38
38
 
39
39
  CIDR 限制针对 TCP 连接的**直接来源地址**,不是手机 IP,也不是 `X-Forwarded-For`、`X-Real-IP` 或 `Forwarded`。Docker、桥接或 NAT 可能改变这个地址,应填写后端实际看到的私网来源。优先用单地址 `/32`,不要为了排错放宽整个局域网。最多 16 项,用空格或逗号分隔;只允许回环/私网网段,网络地址不能带主机位。LAN 监听必须显式列出私网代理来源。
40
40
 
41
+ 代理后的手机可能共享一个直接来源地址。插件 0.6.12 起,错误凭据仍共享该来源的失败限额,但有效的一次性配对码不会被匿名失败耗尽;已认证设备的续期和探测按设备分别限频,并限制未完成操作。不需要为此添加或信任转发 IP 请求头。旧版出现 `429 rate_limited` 时先升级插件并保留配对记录,不要放宽来源网段或公开裸 DSH 端口。
42
+
41
43
  点击 **保存并启动后端**。已经开启时会重新载入配置。面板会分别显示公网 HTTPS 地址与**正在监听的 HTTP 后端**;停止后仍可查看、复制已保存的后端地址。
42
44
 
43
45
  ## 反向代理要求(含 Lucky)
@@ -20,6 +20,12 @@ Both [#126](https://github.com/saya-ch/dsh-mobile/issues/126) and [#146](https:/
20
20
 
21
21
  Plugin 0.6.11 and later write limited `auth failure` HTTP diagnostics to the plugin log (`$DSH_HOME/mobile-access/logs/dsh-mobile.log`): authorization versus CSRF rejection, status, method, a safe route or route category, related Cookie/header presence and counts, normalized Origin and recognized fetch-site values. It omits tokens, Cookie values, query strings and arbitrary original paths. Each Gateway rate-limits its two categories separately: the first event is immediate and same-category rejections aggregate within a 30-second window; `suppressed` counts previously suppressed records of that category, not all requests or only the current path. This helps investigate missing credentials and CSRF failures; it does not automatically repair a 401/403. The absence of a record does not establish successful authentication: earlier Host/Origin rejections, WebSocket upgrades and native pairing/renewal are outside these two recording points. Review logs before sharing them.
22
22
 
23
+ ## Keep event streams distinct
24
+
25
+ In DSH 0.2.0-rc.2, `/plugins/events` is the SSE stream for plugin-module graph and build updates, not conversation-message delivery. Its `200` response, small body or repeated reconnects alone cannot establish successful delivery, failure or a missing processing animation. Inspect the `/api/remote.mux` WebSocket and the actual HTTP request for that operation as well.
26
+
27
+ An ordinary message bubble and cleared draft can precede `POST /api/session/prompt`: they are an optimistic local echo, not Host acceptance. If no corresponding request appears, retain redacted request timing, status codes and DSH logs to distinguish a request never started by the browser, proxy rejection and Host acceptance. The plugin does not automatically replay messages, approvals or other writes; check the Session before retrying an operation with an unknown outcome.
28
+
23
29
  ## Slow transfers can exceed the default deadlines
24
30
 
25
31
  DSH 0.2.0-rc.2's API gateway sends a WebSocket Ping every **2 seconds** by default and ends the connection after two missed Pongs, allowing roughly **4–6 seconds** in practice. The client separately allows **15 seconds** for live-connection initialization. Large Session data, a slow uplink, or congestion can delay a Pong or the first synchronization and trigger disconnection and another synchronization.
@@ -20,6 +20,12 @@
20
20
 
21
21
  插件 0.6.11 起会在插件日志(`$DSH_HOME/mobile-access/logs/dsh-mobile.log`)记录有限的 `auth failure` HTTP 诊断:区分授权与 CSRF 拒绝,包含状态、方法、安全路径或路径类别、相关 Cookie/请求头是否存在及计数、规范化 Origin 和受支持的 fetch-site 值,不记录令牌、Cookie 值、查询参数或任意原始路径。每个网关的两类诊断分别限频,首条立即记录,30 秒窗口内同类拒绝聚合;`suppressed` 表示此前被抑制的同类记录数量,不是全部请求数,也不限定于当前路径。它帮助排查凭据未携带或 CSRF 校验失败,不会自动修复 401/403。没有日志也不代表认证成功:更早的 Host/Origin 拒绝、WebSocket 升级和原生配对/续期不在这两处记录范围内。分享前仍应检查日志。
22
22
 
23
+ ## 不同事件流不能混为一谈
24
+
25
+ 在 DSH 0.2.0-rc.2 中,`/plugins/events` 是插件模块图和构建更新的 SSE,不是对话消息投递通道。它返回 `200`、传输很少字节或反复重连,不能单独证明消息发送成功、失败或处理中动画丢失;同时检查 `/api/remote.mux` WebSocket 与那次操作实际发出的 HTTP 请求。
26
+
27
+ 普通消息的气泡和清空草稿可能先于 `POST /api/session/prompt`,属于本地乐观回显,不是 Host 受理确认。若找不到对应请求,请保留脱敏后的请求时序、状态码及 DSH 日志,区分浏览器未发起、代理拒绝与 Host 已受理。插件不会自动重放消息、审批或其他写请求;结果不明时先确认会话,避免重复副作用。
28
+
23
29
  ## 慢速传输可能超过默认等待时间
24
30
 
25
31
  DSH 0.2.0-rc.2 的 API 网关默认每 **2 秒**发送一次 WebSocket Ping,连续两次未收到 Pong 后会结束连接,实际容忍时间约为 **4–6 秒**。客户端另有 **15 秒**的实时连接初始化期限。较大的会话数据、低速上行或拥塞可能延迟 Pong 或首次数据同步,触发断开和重新同步。
package/lib/index.d.mts CHANGED
@@ -124,6 +124,8 @@ export declare class BoundedRateLimiter {
124
124
  constructor(limit: number, windowMs: number, maximumKeys: number);
125
125
  /** Consume one attempt; unknown keys fail closed when the bounded table is full. */
126
126
  take(key: string, now: number): boolean;
127
+ /** Discard entries that no longer identify an eligible key in a trusted, finite key set. */
128
+ retainKeys(eligible: ReadonlySet<string>): void;
127
129
  /** Current table size, exposed for bounded-state assertions. */
128
130
  get size(): number;
129
131
  }
@@ -135,6 +137,7 @@ export declare class AccessController {
135
137
  private readonly pairLimiter;
136
138
  private devices;
137
139
  private pairingWindow;
140
+ private pairPending;
138
141
  private readonly sessions;
139
142
  private readonly sessionEndedListeners;
140
143
  private mutation;
@@ -157,6 +160,8 @@ export declare class AccessController {
157
160
  token: string;
158
161
  expiresAt: number;
159
162
  }>;
163
+ /** Limit invalid guesses by peer without letting them consume a valid one-time secret's admission. */
164
+ private rejectPairingAttempt;
160
165
  /** Consume the pairing window exactly once and persist only the device-token digest. */
161
166
  pair(sourceKey: string, token: string, label?: string): Promise<PairingResult>;
162
167
  /** Exchange a valid persistent device credential for a new short Session. */
@@ -986,8 +991,12 @@ export declare class MobileAccessGateway {
986
991
  private closeTask;
987
992
  private readonly removeSessionListener;
988
993
  private readonly removeExtensionContentListener;
989
- private readonly renewLimiter;
990
- private readonly probeLimiter;
994
+ private readonly renewFailureLimiter;
995
+ private readonly renewDeviceLimiter;
996
+ private readonly renewInFlight;
997
+ private readonly probeFailureLimiter;
998
+ private readonly probeDeviceLimiter;
999
+ private readonly probeInFlight;
991
1000
  private readonly clientModulePreferences;
992
1001
  private readonly clientModuleOperations;
993
1002
  private readonly clientModuleTasks;
@@ -1032,6 +1041,8 @@ export declare class MobileAccessGateway {
1032
1041
  private requireCsrf;
1033
1042
  private setSessionCookies;
1034
1043
  private setBrowserDeviceBinding;
1044
+ /** Separate malformed or invalid requests from device-authenticated work without trusting forwarded addresses. */
1045
+ private withDeviceRequest;
1035
1046
  private handlePair;
1036
1047
  private handleRenew;
1037
1048
  /** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
package/lib/index.mjs CHANGED
@@ -64,6 +64,10 @@ var BoundedRateLimiter = class {
64
64
  current.count += 1;
65
65
  return true;
66
66
  }
67
+ /** Discard entries that no longer identify an eligible key in a trusted, finite key set. */
68
+ retainKeys(eligible) {
69
+ for (const key of this.buckets.keys()) if (!eligible.has(key)) this.buckets.delete(key);
70
+ }
67
71
  /** Current table size, exposed for bounded-state assertions. */
68
72
  get size() {
69
73
  return this.buckets.size;
@@ -104,6 +108,7 @@ var AccessController = class {
104
108
  pairLimiter;
105
109
  devices = [];
106
110
  pairingWindow;
111
+ pairPending = false;
107
112
  sessions = /* @__PURE__ */ new Map();
108
113
  sessionEndedListeners = /* @__PURE__ */ new Set();
109
114
  mutation = Promise.resolve();
@@ -212,40 +217,52 @@ var AccessController = class {
212
217
  });
213
218
  });
214
219
  }
220
+ /** Limit invalid guesses by peer without letting them consume a valid one-time secret's admission. */
221
+ rejectPairingAttempt(sourceKey, now) {
222
+ if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
223
+ throw new AccessError(401, "authentication_failed");
224
+ }
215
225
  /** Consume the pairing window exactly once and persist only the device-token digest. */
216
226
  async pair(sourceKey, token, label) {
217
227
  this.requireInitialized();
218
- const now = this.now();
219
- if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
220
- if (token.length > 512) throw new AccessError(401, "authentication_failed");
221
- return this.exclusive(async () => {
222
- const window = this.pairingWindow;
223
- if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
224
- if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
225
- throw new AccessError(401, "authentication_failed");
226
- }
227
- this.pairingWindow = void 0;
228
- if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
229
- const deviceToken = opaqueToken();
230
- const device = Object.freeze({
231
- id: randomBytes(16).toString("hex"),
232
- label: normalizeLabel(label),
233
- tokenDigest: digestHex(deviceToken),
234
- createdAt: now,
235
- expiresAt: now + this.options.deviceTtlMs,
236
- lastSeenAt: now,
237
- ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
238
- });
239
- const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
240
- await this.store.save(this.snapshot(next));
241
- this.devices = next;
242
- const session = this.createSession(device, now);
243
- return Object.freeze({
244
- ...session,
245
- deviceToken,
246
- deviceExpiresAt: device.expiresAt
228
+ const initialNow = this.now();
229
+ const initialWindow = this.pairingWindow;
230
+ if (token.length > 512 || initialWindow === void 0 || initialWindow.expiresAt <= initialNow || !matchesDigest(token, initialWindow.digest)) this.rejectPairingAttempt(sourceKey, initialNow);
231
+ if (this.pairPending) throw new AccessError(429, "rate_limited");
232
+ this.pairPending = true;
233
+ try {
234
+ return await this.exclusive(async () => {
235
+ const now = this.now();
236
+ const window = this.pairingWindow;
237
+ if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
238
+ if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
239
+ this.rejectPairingAttempt(sourceKey, now);
240
+ }
241
+ this.pairingWindow = void 0;
242
+ if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
243
+ const deviceToken = opaqueToken();
244
+ const device = Object.freeze({
245
+ id: randomBytes(16).toString("hex"),
246
+ label: normalizeLabel(label),
247
+ tokenDigest: digestHex(deviceToken),
248
+ createdAt: now,
249
+ expiresAt: now + this.options.deviceTtlMs,
250
+ lastSeenAt: now,
251
+ ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
252
+ });
253
+ const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
254
+ await this.store.save(this.snapshot(next));
255
+ this.devices = next;
256
+ const session = this.createSession(device, now);
257
+ return Object.freeze({
258
+ ...session,
259
+ deviceToken,
260
+ deviceExpiresAt: device.expiresAt
261
+ });
247
262
  });
248
- });
263
+ } finally {
264
+ this.pairPending = false;
265
+ }
249
266
  }
250
267
  /** Exchange a valid persistent device credential for a new short Session. */
251
268
  async renew(deviceToken) {
@@ -5395,8 +5412,12 @@ var MobileAccessGateway = class {
5395
5412
  closeTask;
5396
5413
  removeSessionListener;
5397
5414
  removeExtensionContentListener;
5398
- renewLimiter;
5399
- probeLimiter;
5415
+ renewFailureLimiter;
5416
+ renewDeviceLimiter;
5417
+ renewInFlight = /* @__PURE__ */ new Set();
5418
+ probeFailureLimiter;
5419
+ probeDeviceLimiter;
5420
+ probeInFlight = /* @__PURE__ */ new Set();
5400
5421
  clientModulePreferences;
5401
5422
  clientModuleOperations = /* @__PURE__ */ new Set();
5402
5423
  clientModuleTasks = /* @__PURE__ */ new Set();
@@ -5425,8 +5446,12 @@ var MobileAccessGateway = class {
5425
5446
  maxPairingAttempts: config.maxPairingAttempts,
5426
5447
  maxRateLimitKeys: config.maxRateLimitKeys
5427
5448
  });
5428
- this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5429
- this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5449
+ const renewLimit = Math.min(100, config.maxPairingAttempts * 4);
5450
+ const probeLimit = Math.min(1e3, config.maxDevices * 4);
5451
+ this.renewFailureLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5452
+ this.renewDeviceLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxDevices);
5453
+ this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5454
+ this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
5430
5455
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
5431
5456
  if (reason === "revoked") {
5432
5457
  const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
@@ -5754,6 +5779,27 @@ var MobileAccessGateway = class {
5754
5779
  maxAgeSeconds: (deviceExpiresAt - Date.now()) / 1e3
5755
5780
  })]);
5756
5781
  }
5782
+ /** Separate malformed or invalid requests from device-authenticated work without trusting forwarded addresses. */
5783
+ async withDeviceRequest(request, failureLimiter, deviceLimiter, inFlight, validate, operation) {
5784
+ let device;
5785
+ try {
5786
+ device = await validate();
5787
+ } catch (error) {
5788
+ if ((error instanceof HttpError || error instanceof AccessError) && error.status >= 400 && error.status < 500 && !failureLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5789
+ throw error;
5790
+ }
5791
+ if (inFlight.has(device.deviceId)) throw new HttpError(429, "rate_limited");
5792
+ const now = Date.now();
5793
+ const activeIds = new Set(this.access.listDevices().filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now).map((candidate) => candidate.id));
5794
+ deviceLimiter.retainKeys(activeIds);
5795
+ if (!deviceLimiter.take(device.deviceId, now)) throw new HttpError(429, "rate_limited");
5796
+ inFlight.add(device.deviceId);
5797
+ try {
5798
+ return await operation(device);
5799
+ } finally {
5800
+ inFlight.delete(device.deviceId);
5801
+ }
5802
+ }
5757
5803
  async handlePair(request, response) {
5758
5804
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5759
5805
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
@@ -5776,21 +5822,28 @@ var MobileAccessGateway = class {
5776
5822
  }, this.tlsEnabled);
5777
5823
  }
5778
5824
  async handleRenew(request, response) {
5779
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5780
- await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5781
- const cookies = browserAuthCookies(request.headers.cookie);
5782
- const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5783
- if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5784
- const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5785
- const result = await this.access.renew(device.value);
5786
- this.setSessionCookies(response, result, Date.now());
5787
- this.setBrowserDeviceBinding(response, result.deviceId, device.result.deviceExpiresAt);
5788
- sendJson(response, 200, {
5789
- renewed: true,
5790
- deviceId: result.deviceId,
5791
- csrfToken: result.csrfToken,
5792
- sessionExpiresAt: result.sessionExpiresAt
5793
- }, this.tlsEnabled);
5825
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5826
+ await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5827
+ const cookies = browserAuthCookies(request.headers.cookie);
5828
+ const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5829
+ if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5830
+ const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5831
+ return {
5832
+ deviceId: device.result.deviceId,
5833
+ token: device.value,
5834
+ credential: device.result
5835
+ };
5836
+ }, async (selected) => {
5837
+ const result = await this.access.renew(selected.token);
5838
+ this.setSessionCookies(response, result, Date.now());
5839
+ this.setBrowserDeviceBinding(response, result.deviceId, selected.credential.deviceExpiresAt);
5840
+ sendJson(response, 200, {
5841
+ renewed: true,
5842
+ deviceId: result.deviceId,
5843
+ csrfToken: result.csrfToken,
5844
+ sessionExpiresAt: result.sessionExpiresAt
5845
+ }, this.tlsEnabled);
5846
+ });
5794
5847
  }
5795
5848
  /** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
5796
5849
  nativeResponseInstanceId(result) {
@@ -5811,28 +5864,40 @@ var MobileAccessGateway = class {
5811
5864
  }, this.tlsEnabled);
5812
5865
  }
5813
5866
  async handleNativeRenew(request, response) {
5814
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5815
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5816
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5817
- const result = await this.access.renew(body.deviceToken);
5818
- sendJson(response, 200, {
5819
- instanceId: this.nativeResponseInstanceId(result),
5820
- deviceId: result.deviceId,
5821
- sessionToken: result.sessionToken,
5822
- csrfToken: result.csrfToken,
5823
- sessionExpiresAt: result.sessionExpiresAt
5824
- }, this.tlsEnabled);
5867
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5868
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5869
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5870
+ return {
5871
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
5872
+ token: body.deviceToken
5873
+ };
5874
+ }, async (selected) => {
5875
+ const result = await this.access.renew(selected.token);
5876
+ sendJson(response, 200, {
5877
+ instanceId: this.nativeResponseInstanceId(result),
5878
+ deviceId: result.deviceId,
5879
+ sessionToken: result.sessionToken,
5880
+ csrfToken: result.csrfToken,
5881
+ sessionExpiresAt: result.sessionExpiresAt
5882
+ }, this.tlsEnabled);
5883
+ });
5825
5884
  }
5826
5885
  async handleNativeProbe(request, response) {
5827
- if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5828
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5829
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5830
- const result = await this.access.probe(body.deviceToken);
5831
- sendJson(response, 200, {
5832
- instanceId: this.nativeResponseInstanceId(result),
5833
- deviceId: result.deviceId,
5834
- deviceExpiresAt: result.deviceExpiresAt
5835
- }, this.tlsEnabled);
5886
+ await this.withDeviceRequest(request, this.probeFailureLimiter, this.probeDeviceLimiter, this.probeInFlight, async () => {
5887
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5888
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5889
+ return {
5890
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
5891
+ token: body.deviceToken
5892
+ };
5893
+ }, async (selected) => {
5894
+ const result = await this.access.probe(selected.token);
5895
+ sendJson(response, 200, {
5896
+ instanceId: this.nativeResponseInstanceId(result),
5897
+ deviceId: result.deviceId,
5898
+ deviceExpiresAt: result.deviceExpiresAt
5899
+ }, this.tlsEnabled);
5900
+ });
5836
5901
  }
5837
5902
  async handleLogout(request, response) {
5838
5903
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
@@ -6390,7 +6455,8 @@ var MobileAccessGateway = class {
6390
6455
  headers: {
6391
6456
  host: this.config.upstreamOrigin.host,
6392
6457
  accept: "text/html",
6393
- "accept-encoding": "identity"
6458
+ "accept-encoding": "identity",
6459
+ "sec-fetch-mode": "same-origin"
6394
6460
  },
6395
6461
  agent: false
6396
6462
  });