@linxin666/dsh-remote-web-ui 0.3.12 → 0.3.14

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.
Files changed (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +26 -6
  3. package/README.zh.md +26 -6
  4. package/lib/client.js +77 -12
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +412 -52
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +1 -0
  9. package/lib/types/client/RemotePanel.d.ts +3 -1
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +1 -1
  12. package/lib/types/client/RemoteSettingsCard.d.ts +16 -0
  13. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteSettingsCard.js +6 -2
  15. package/lib/types/client/locales.d.ts +12 -0
  16. package/lib/types/client/locales.d.ts.map +1 -1
  17. package/lib/types/client/locales.js +16 -4
  18. package/lib/types/client/pair-api.d.ts +10 -0
  19. package/lib/types/client/pair-api.d.ts.map +1 -1
  20. package/lib/types/client/remote-channel.d.ts +0 -2
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +0 -4
  23. package/lib/types/index.d.ts +28 -2
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/index.js +138 -23
  26. package/lib/types/pairing.d.ts +17 -0
  27. package/lib/types/pairing.d.ts.map +1 -1
  28. package/lib/types/pairing.js +13 -0
  29. package/lib/types/relay-registry.d.ts +97 -0
  30. package/lib/types/relay-registry.d.ts.map +1 -0
  31. package/lib/types/relay-registry.js +191 -0
  32. package/lib/types/remote-api.d.ts +1 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +1 -1
  35. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  36. package/lib/types/remote-channel-boot.js +0 -1
  37. package/lib/types/remote-channel-rules.d.ts +0 -1
  38. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  39. package/lib/types/remote-channel-rules.js +0 -1
  40. package/lib/types/remote-methods.d.ts +1 -4
  41. package/lib/types/remote-methods.d.ts.map +1 -1
  42. package/lib/types/remote-methods.js +1 -5
  43. package/lib/types/tunnel-plan.d.ts +36 -0
  44. package/lib/types/tunnel-plan.d.ts.map +1 -0
  45. package/lib/types/tunnel-plan.js +43 -0
  46. package/lib/types/tunnel.d.ts +66 -13
  47. package/lib/types/tunnel.d.ts.map +1 -1
  48. package/lib/types/tunnel.js +93 -21
  49. package/package.json +18 -18
  50. package/src/client/RemoteEntry.tsx +2 -1
  51. package/src/client/RemotePanel.tsx +11 -0
  52. package/src/client/RemoteSettingsCard.tsx +43 -1
  53. package/src/client/locales.ts +16 -4
  54. package/src/client/pair-api.ts +11 -0
  55. package/src/client/remote-channel.ts +0 -3
  56. package/src/client/settings-card.module.css +14 -8
  57. package/src/index.ts +153 -25
  58. package/src/pairing.ts +30 -0
  59. package/src/relay-registry.ts +232 -0
  60. package/src/remote-api.ts +0 -1
  61. package/src/remote-channel-boot.ts +0 -1
  62. package/src/remote-channel-rules.ts +0 -2
  63. package/src/remote-methods.ts +1 -6
  64. package/src/tunnel-plan.ts +51 -0
  65. package/src/tunnel.ts +120 -24
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: dd351eb70f24c9cac30373d58a752d71c631e8f9
6
- README.zh.md: 3d54236159f4e30c6282a52a038efa12b33d227c
5
+ README.md: 62dd01494df6877b90725715495c39f115dd1e5a
6
+ README.zh.md: 0e1ce635be94a16bfe14bd2ced9a26c8df140e21
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # DSH Remote Web UI
2
2
 
3
3
  English | [中文](README.zh.md)
4
- > Remote access for the dsh web GUI that shares **one interface**: pair a phone or another computer from a QR beside the settings button, and both run the same official Web GUI this machine runs — phones get an injected portrait-touch adaptation, PCs get the full desktop — through one-time pairing tokens and revocable device sessions. A settings toggle binds the server to the LAN, an optional Cloudflare quick tunnel reaches the internet, and the sidebar checks for a newer dsh-web release with one-click self-update.
4
+ > Remote access for the dsh web GUI that shares **one interface**: pair a phone or another computer from a QR beside the settings button, and both run the same official Web GUI this machine runs — phones get an injected portrait-touch adaptation, PCs get the full desktop — through one-time pairing tokens and revocable device sessions. A settings toggle binds the server to the LAN, an optional Cloudflare quick tunnel reaches the internet — fronted by a never-changing stable hostname so the phone's bookmark and pairing survive restarts with zero setup — and the sidebar checks for a newer dsh-web release with one-click self-update.
5
5
 
6
6
  This repository is an external plugin package for DeepSeek Harness (DSH). It is a single dual-face package: the host half owns pairing tokens, device sessions, the `/api/pair` route family, the gated `/remote` channel, the LAN bind toggle, and the `/api/update` surface; the browser half renders the sidebar-foot entries (the download trigger and the remote-access entry beside the settings button), the pairing panel with a QR code, live device status, the authorized-device roster, the settings card, the portrait-touch adaptation layer over the official UI, and the update panel.
7
7
 
@@ -27,13 +27,14 @@ The official desktop layout already auto-collapses the sidebar below 1024px. On
27
27
  - **mobile plugin scope**: while the layer is active, the right-hand details column and the desktop-oriented tool surfaces (SSH terminal, skill explorer, task board, git graph, pet, perf, usage) are hidden - keyed on the L2 semantic roots (`data-dsh-plugin`), so ownership stays with the declaring plugin and class churn cannot resurrect them. These are render suppressions; the client bundles still load. Activation also closes the details panel through the official `ctx.layout.closeDetails()`.
28
28
  - a **manual opt-out**: `sessionStorage.dsh-remote-force-desktop = 1` disables the whole layer; landscape, desktop, and wide viewports are never touched.
29
29
 
30
- The paired remote desktop also runs in **host mode**: on this harness line the "configuration plane is local" behavior is a client-side branch on `connection.isLoopback`, and the channel boot script installs the transport hook (`__DSH_TRANSPORT__.ownsHost = true`) on non-loopback origins before any boot entry. Settings, credentials, agent presets, and deliverables therefore work on the phone exactly as on the desktop — every call still rides the gated `/remote` channel. Four control planes stay physically local: `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`, and `/api/dsh-desktop-launcher/*`.
30
+ The paired remote desktop also runs in **host mode**: on this harness line the "configuration plane is local" behavior is a client-side branch on `connection.isLoopback`, and the channel boot script installs the transport hook (`__DSH_TRANSPORT__.ownsHost = true`) on non-loopback origins before any boot entry. Settings, credentials, agent presets, and deliverables therefore work on the phone exactly as on the desktop — every call still rides the gated `/remote` channel. Three control planes stay physically local: `/api/pair/*`, `/api/update/*`, and `/api/plugin-manager/*`.
31
31
 
32
32
  ## Requirements
33
33
 
34
34
  - A DSH installation whose `dsh` CLI supports profiles (`dsh --profile`, `dsh plugin`) — the profile/bundle mechanism this package rides on.
35
35
  - For LAN use, either flip the **局域网访问** toggle in the settings card (writes the bind block; effective from the next `dsh web` start) or start with `dsh web --host 0.0.0.0`. With the default `127.0.0.1` bind the panel shows an explicit explanation instead of a dead QR code — unless a public base URL is configured (see "Remote access over the internet" below), which makes the QR reachable from anywhere without rebinding. The panel's mint/stop endpoints are loopback-only by design: a desktop browser opened at the LAN URL sees a "配对面板仅限本机使用" banner.
36
36
  - For the one-click public tunnel (`autoTunnel`), the `cloudflared` platform binary ships with the package (its postinstall downloads it; a runtime download covers installers that skip postinstall scripts). No user-side tooling, account, or domain is needed.
37
+ - The same binary also powers the fixed-hostname named-tunnel mode (`tunnelToken`): the public hostname never changes, so a paired phone keeps its bookmark and its pairing cookie across restarts and never re-pairs.
37
38
 
38
39
  ## Install
39
40
 
@@ -61,13 +62,31 @@ Restart the profile (`dsh web`), then open the phone icon in the sidebar foot. T
61
62
  3. Scan with the phone (or open the copied link): the device pairs and boots the **official Web GUI** served cookieless by the plugin (`/pair-accept` → `/pair-app`), which reloads into `/`. On a phone, the portrait adaptation layer is already active — same layout as the desktop, same live state. Later reopens from history or a bookmark go straight back into the app (https origins; see Security model).
62
63
  4. **To pair a PC instead**: copy the same link and open it in a browser on the other computer. After the same round trip the full Web GUI runs there over the gated `/remote` channel; unpaired PCs see the guided blocking page and no data. One active token pairs one device; mint a fresh QR for the next device.
63
64
  5. The desktop badge flips to 已连接 in real time; the device roster lists the paired devices with per-device 取消配对, and 停止 revokes everything.
64
- 6. Configure pairing lifetime, device limits, the LAN fence policy (`requirePairingForLan`), the public base URL, and the auto tunnel in the same settings card.
65
+ 6. Configure pairing lifetime, device limits, the LAN fence policy (`requirePairingForLan`), the public base URL, the auto tunnel, the stable-hostname relay, and the named-tunnel token in the same settings card.
65
66
 
66
67
  ## Remote access over the internet (tunnels)
67
68
 
68
69
  ### One-click public tunnel (recommended)
69
70
 
70
- Set **自动公网隧道** (autoTunnel) in the settings card. The plugin runs its own Cloudflare quick tunnel (`cloudflared` ships with the package), feeds the minted public URL into the QR base and the pairing fence dynamically, and keeps the posture probe informed — a phone anywhere can pair at any time. The manual public address below is ignored while this is on.
71
+ Set **自动公网隧道** (autoTunnel) in the settings card. The plugin runs its own Cloudflare quick tunnel (`cloudflared` ships with the package), feeds the minted public URL into the QR base and the pairing fence dynamically, and keeps the posture probe informed — a phone anywhere can pair at any time. The manual public address and the named-tunnel token below are ignored while this is on. The minted hostname is ephemeral and changes on every `dsh web` restart — the stable-hostname relay below keeps the phone's origin fixed across those restarts.
72
+
73
+ ### Stable-hostname relay (default on; pair once across restarts)
74
+
75
+ While the quick tunnel runs, the plugin additionally registers its current tunnel URL with the dsh-market relay registry and rebuilds the QR on a fixed origin — `https://<id>.dsh-market.com` — minted once per profile and stored under `$DSH_HOME/remote-web-ui-registry/`. The hostname never changes, so the phone's bookmark and its pairing cookie keep working across `dsh web` restarts with no Cloudflare account, no dashboard, and no domain of your own.
76
+
77
+ - Registration is secret-authenticated: the plugin mints a 256-bit secret next to the identity and re-syncs the mapping on every tunnel start (crash restarts included) with capped backoff. If the registry is unreachable, the QR falls back to the raw quick URL for that session and the panel says so.
78
+ - A phone opening the stable origin while the instance is offline sees an explicit "instance offline — refresh in a moment, no re-pairing needed" page instead of a dead link.
79
+ - Turn **固定域名中继** (relay) off in the settings card to keep the deployment entirely on the ephemeral origin; the registry row is removed at once. Trust note: relayed traffic transits the dsh-market edge (a Cloudflare Worker operated by the package author) — see Security model.
80
+
81
+ ### Fixed-hostname named tunnel (bring your own domain)
82
+
83
+ The relay above already fixes the phone's origin on a shared dsh-market subdomain. A Cloudflare named tunnel is the alternative for deployments that want a hostname on **their own** domain: its public hostname is permanent, so the phone's bookmark and its pairing cookie keep working across `dsh web` restarts — pair once, never again (until the session idles past `idleExpireMs`).
84
+
85
+ 1. In the Cloudflare dashboard create a Tunnel and map a public hostname — e.g. `dsh.example.com` — to `http://127.0.0.1:3080` (the port `dsh web` listens on).
86
+ 2. Copy the tunnel's token and paste it into **固定域名隧道令牌** (tunnelToken) in the settings card.
87
+ 3. Set the same hostname as **公网地址** (publicBaseUrl). The token does not carry the hostname, so without this step the tunnel stays off and a warning names the missing piece.
88
+
89
+ The plugin then runs `cloudflared tunnel run --token` with the same lifecycle management as the quick tunnel: the binary ships with the package, unexpected exits restart with backoff, and the posture probe audits the `/api` fence for the fixed host. The token is stored as a settings secret and read back redacted. The auto quick tunnel takes precedence while it is on.
71
90
 
72
91
  ### Manual tunnels (bring your own)
73
92
 
@@ -137,7 +156,7 @@ The public path is the same round trip through a tunnel (see "Remote access over
137
156
  - **Pairing is the access control for the `/remote` channel**: while `requirePairingForLan` is on (default), every request must carry a live paired-device cookie, enforced before any bytes are forwarded. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body.
138
157
  - **The channel carries the process's own inner credential.** The harness browser-auth cookie is authority-bound (minted for the exact `host:port` a browser visited) and has no loopback exemption, so a proxied re-issue to `127.0.0.1` cannot reuse a device's cookie. The plugin therefore redeems its own launch token once — the same exchange a first browser visit performs — and attaches that cookie to re-issued requests. The credential is only ever exercised behind the pairing gate above; 停止/取消配对 immediately stop exercising it.
139
158
  - **Cohort reality: pairing does not gate direct `/api`.** On the pinned 0.1.2-alpha.2 line nothing emits the `api/gate` seam, so a direct `/api` call from a LAN origin is governed solely by the harness fence (which auto-trusts LAN literals under a `0.0.0.0` bind) plus the harness browser-auth cookie. A browser credential a device has already redeemed therefore survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the `/remote` channel and the pairing cookie, not that credential. The plugin probes the `/api` posture and warns loudly; treat a LAN-exposed bind as a deliberate decision, and prefer loopback plus tunnels when the machine is shared.
140
- - **A paired device is a full-control credential.** With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. Only the four control planes (pairing, self-update, plugin install/remove, desktop launcher) stay physically local. Only pair devices you control; 停止 or per-device 取消配对 revokes immediately.
159
+ - **A paired device is a full-control credential.** With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. Only the three control planes (pairing, self-update, plugin install/remove) stay physically local. Only pair devices you control; 停止 or per-device 取消配对 revokes immediately.
141
160
  - **Control endpoints stay loopback-only**: mint/stop/revoke, the device roster, the lan-bind status, and the update endpoints answer only to loopback. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
142
161
  - **The app landing is cookieless-optional.** After pairing, the QR lands the device on `/pair-app`, served by this plugin: the official shell is delivered without passing the harness index gate, and the device credential rides a `x-dsh-remote-device` header (fetch) / `device` query (WebSocket upgrades) that the boot patch attaches from sessionStorage. The flow therefore works when the device browser blocks all cookies; the pairing cookie remains the primary credential where the browser stores it, and the harness browser-auth cookie is no longer required on the phone path at all.
143
162
  - **Reopens are owned by a service worker (https origins).** A paired phone returning from history, a bookmark, or tab restore navigates to bare `/` — a path the plugin does not own, where the harness fallback seat answers with its browser-auth 401 (the cookieless flow never holds that credential). The app shell therefore registers `/pair-app.sw.js` (fenced like `/pair-app`; the script is inert logic with no secrets): it intercepts navigations to `/` only, re-serves the shell network-first through `/pair-app` — which re-validates the device cookie and refreshes its presence, so every reopen also keeps the session alive — falls back to the cached shell offline, and passes the navigation through when the plugin no longer answers (a revoked device then sees the harness response or the bilingual re-scan page). Plain-HTTP LAN origins are not secure contexts and never register the worker; there a reopen means scanning a fresh QR.
@@ -145,7 +164,8 @@ The public path is the same round trip through a tunnel (see "Remote access over
145
164
  - **Paired device sessions persist by default**: device sessions (not the one-time QR token) are written to `$DSH_HOME/remote-web-ui-devices.json` (0600, temp file + atomic rename). A paired cookie still works after a `dsh web` restart. Refreshing the QR still mints a new token; restarting does not restore the current QR. Sessions idle for `idleExpireMs` (default 30 days; the reopen service worker refreshes the window on every navigation it serves) are deleted and must pair again. Device ids are session credentials. Override `devicesFile` with another absolute path when needed. Changing `cookieName` invalidates existing devices (expected).
146
165
  - **The LAN bind block owns the webserver row**: while the toggle has been flipped, the managed block pins the bind; the plugin re-asserts it each boot so explicit `--host`/`--port` flags win by rewriting the block. Hand-editing the block is detected and surfaced in the card (`blockHost` shows the literal).
147
166
  - **The desktop gate policy is public**: `/api/pair/status` exposes only the boolean `requirePairingForLan` policy so a remote desktop can choose the correct transport before its settings scope is available. This field is not a credential and does not expose tokens, devices, counts, or tunnel URLs.
148
- - **Quick-tunnel hostnames change per run**: a `trycloudflare.com` URL is random on every `cloudflared` start, so `publicBaseUrl` (or the auto tunnel) must be refreshed with it. A named tunnel avoids the churn.
167
+ - **Quick-tunnel hostnames change per run**: a `trycloudflare.com` URL is random on every `cloudflared` start, so `publicBaseUrl` (or the auto tunnel) must be refreshed with it. The stable-hostname relay pins a fixed `<id>.dsh-market.com` origin in front of that ephemeral URL (default on with the auto tunnel); the named-tunnel mode (`tunnelToken`) is the own-domain alternative. The token itself is stored as a redacted settings secret — never logged, never echoed to the browser half.
168
+ - **The relay moves the origin, not the trust root**: with the relay on, the phone talks to `https://<id>.dsh-market.com`, a dsh-market Cloudflare Worker that looks up the instance's current tunnel URL and forwards request bytes verbatim — the pairing cookie and every application-level check stay on the instance, and the worker never terminates a pairing. What changes is the transit path: the relayed traffic passes infrastructure operated by the package author on Cloudflare's edge, so the author's worker can observe it — the same visibility Cloudflare itself has over a raw `trycloudflare.com` quick tunnel. The registry binds each id to a SHA-256 hash of a 256-bit secret (the plaintext lives only in `$DSH_HOME`, 0600), accepts only `*.trycloudflare.com` targets, rate-limits registrations, and stores nothing but the mapping; turn the relay off to leave the deployment entirely on the ephemeral origin.
149
169
 
150
170
  ## Known Limitations and Deferred Work
151
171
 
package/README.zh.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # DSH 远程访问(Remote Web UI)
2
2
 
3
3
  [English](README.md) | 中文
4
- > 为 dsh web GUI 提供共享**同一份界面**的远程访问:在设置按钮旁的二维码配对手机或另一台电脑,两端运行的都和本机一样是官方 Web GUI——手机获得注入的竖屏触控适配,电脑获得完整桌面;准入基于一次性配对令牌与可撤销设备会话。设置卡片可将服务绑定到局域网,可选 Cloudflare 快速隧道触达公网,侧栏还会检查 dsh-web 新版本并支持一键自更新。
4
+ > 为 dsh web GUI 提供共享**同一份界面**的远程访问:在设置按钮旁的二维码配对手机或另一台电脑,两端运行的都和本机一样是官方 Web GUI——手机获得注入的竖屏触控适配,电脑获得完整桌面;准入基于一次性配对令牌与可撤销设备会话。设置卡片可将服务绑定到局域网,可选 Cloudflare 快速隧道触达公网——并由一个永不变化的固定主机名前置,手机的书签与配对零配置跨重启有效——侧栏还会检查 dsh-web 新版本并支持一键自更新。
5
5
 
6
6
  本仓库是 DeepSeek Harness(DSH)的外部插件包,为单一双面包:host 半区持有配对令牌、设备会话、`/api/pair` 路由族、门控 `/remote` 通道、局域网绑定开关与 `/api/update` 界面;浏览器半区渲染侧栏底部入口(下载触发器与设置按钮旁的远程访问入口)、带二维码的配对面板、实时设备状态、已授权设备列表、设置卡片、官方界面之上的竖屏触控适配层,以及更新面板。
7
7
 
@@ -27,13 +27,14 @@
27
27
  - **移动端插件范围**:适配层激活时,右侧详情列与面向桌面的工具界面(SSH 终端、技能中心、任务看板、git graph、宠物、性能引擎、使用统计)一律隐藏——以 L2 语义根(`data-dsh-plugin`)为键,归属由声明方插件负责,官方类名变动也不会复活它们。这些是渲染抑制;客户端 bundle 仍会加载。激活时还会经官方 `ctx.layout.closeDetails()` 真正关闭详情面板。
28
28
  - **手动退出**:`sessionStorage.dsh-remote-force-desktop = 1` 关闭整个适配层;横屏、桌面与宽视口永不触碰。
29
29
 
30
- 配对远程桌面同时运行在 **host 模式**:在本 harness 线上,「配置面仅限本机」的行为是客户端分支(`connection.isLoopback`),通道 boot 脚本在一切 boot entry 之前对非回环源安装传输钩子(`__DSH_TRANSPORT__.ownsHost = true`)。设置、凭据、Agent 预设与产出物在手机上与桌面完全一致——所有调用仍走门控 `/remote` 通道。四个控制面保持物理本地:`/api/pair/*`、`/api/update/*`、`/api/plugin-manager/*` 与 `/api/dsh-desktop-launcher/*`。
30
+ 配对远程桌面同时运行在 **host 模式**:在本 harness 线上,「配置面仅限本机」的行为是客户端分支(`connection.isLoopback`),通道 boot 脚本在一切 boot entry 之前对非回环源安装传输钩子(`__DSH_TRANSPORT__.ownsHost = true`)。设置、凭据、Agent 预设与产出物在手机上与桌面完全一致——所有调用仍走门控 `/remote` 通道。三个控制面保持物理本地:`/api/pair/*`、`/api/update/*` 与 `/api/plugin-manager/*`。
31
31
 
32
32
  ## 环境要求
33
33
 
34
34
  - DSH 安装的 `dsh` CLI 需支持 profile(`dsh --profile`、`dsh plugin`)——本包依托的 profile/bundle 机制。
35
35
  - 局域网使用:在设置卡片打开**局域网访问**(写入绑定块,自下一次 `dsh web` 启动生效),或以 `dsh web --host 0.0.0.0` 启动。默认 `127.0.0.1` 绑定时面板会显示明确说明而非失效二维码——除非已配置公网地址(见下文),此时二维码随处可达而无需重绑。面板的铸造/停止端点设计上仅限回环:在局域网地址打开的桌面浏览器会看到「配对面板仅限本机使用」横幅。
36
36
  - 一键公网隧道(`autoTunnel`)所需的 `cloudflared` 平台二进制随包分发(postinstall 下载;跳过 postinstall 的安装方式由运行时下载兜底)。无需用户侧工具、账号或域名。
37
+ - 同一二进制也支撑固定域名命名隧道模式(`tunnelToken`):公共主机名永久不变,手机配对一次后书签与配对 Cookie 跨重启持续有效,无需重新配对。
37
38
 
38
39
  ## 安装
39
40
 
@@ -61,13 +62,31 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
61
62
  3. 手机扫码(或打开复制的链接):设备完成配对,进入由插件不依赖 cookie 交付的**官方 Web GUI**(`/pair-accept` → `/pair-app`),并重载到 `/`。手机上竖屏适配层已生效——与桌面同布局、同实时状态。此后的重开(历史、书签)直接回到应用(https 源;见安全模型)。
62
63
  4. **改为配对电脑**:复制同一链接,在另一台电脑的浏览器打开。相同往返后完整 Web GUI 在彼处经门控 `/remote` 通道运行;未配对电脑看到引导式拦截页,其后没有数据。一枚令牌配一台设备;下一台设备请刷新二维码。
63
64
  5. 桌面徽标实时切换为已连接;设备列表列出已配对设备并支持逐设备取消配对,停止则全部撤销。
64
- 6. 配对有效期、设备上限、局域网栅栏策略(`requirePairingForLan`)、公网地址与自动隧道都在同一设置卡片配置。
65
+ 6. 配对有效期、设备上限、局域网栅栏策略(`requirePairingForLan`)、公网地址、自动隧道、固定域名中继与命名隧道令牌都在同一设置卡片配置。
65
66
 
66
67
  ## 通过互联网远程访问(隧道)
67
68
 
68
69
  ### 一键公网隧道(推荐)
69
70
 
70
- 在设置卡片打开**自动公网隧道**(autoTunnel)。插件运行自己的 Cloudflare 快速隧道(`cloudflared` 随包分发),把铸造的公网 URL 动态喂给二维码与配对栅栏,并同步姿态探测——手机随时随地都能配对。开启期间忽略下方手动公网地址。
71
+ 在设置卡片打开**自动公网隧道**(autoTunnel)。插件运行自己的 Cloudflare 快速隧道(`cloudflared` 随包分发),把铸造的公网 URL 动态喂给二维码与配对栅栏,并同步姿态探测——手机随时随地都能配对。开启期间忽略下方手动公网地址与命名隧道令牌。铸造的域名是临时的,每次 `dsh web` 重启都会变化——下方的固定域名中继会把手机的访问源钉在固定地址上,重启不再需要重新扫码。
72
+
73
+ ### 固定域名中继(默认开启;配对一次,重启不再重配)
74
+
75
+ 快速隧道运行期间,插件会额外把当前隧道地址注册到 dsh-market 中继注册表,并把二维码重建在一个固定源上——`https://<id>.dsh-market.com`——每个 profile 首次运行铸造一次,存放在 `$DSH_HOME/remote-web-ui-registry/`。主机名永不变化,手机的书签与配对 Cookie 在 `dsh web` 重启后依然有效,且不需要 Cloudflare 账号、控制台操作或自己的域名。
76
+
77
+ - 注册以密钥认证:插件在身份旁铸造一个 256 位密钥,每次隧道启动(含崩溃重启)以退避重试同步映射。注册表不可达时,该次会话的二维码回退为裸快速隧道地址,并在面板中说明。
78
+ - 实例离线时,手机打开固定源会看到明确的「实例离线——稍后刷新即可,无需重新配对」页面,而不是死链。
79
+ - 在设置卡片关闭**固定域名中继**(relay)可让部署完全留在临时源上;注册表行会立即删除。信任说明:中继流量经由 dsh-market 边缘(由包作者运营的 Cloudflare Worker)转发——见安全模型。
80
+
81
+ ### 固定域名命名隧道(自带域名)
82
+
83
+ 上方的中继已经把手机的访问源固定在共享的 dsh-market 子域名上。Cloudflare 命名隧道适合希望主机名落在**自己域名**下的部署:公共主机名永久固定,手机的书签与配对 Cookie 在 `dsh web` 重启后依然有效——配对一次,终身使用(会话空闲超过 `idleExpireMs` 除外)。
84
+
85
+ 1. 在 Cloudflare 控制台创建一个 Tunnel,并把一个公共主机名(如 `dsh.example.com`)映射到 `http://127.0.0.1:3080`(`dsh web` 监听的端口)。
86
+ 2. 复制隧道 Token,粘贴到设置卡片的**固定域名隧道令牌**(tunnelToken)。
87
+ 3. 把同一主机名填入**公网地址**(publicBaseUrl)。Token 本身不携带主机名,缺了这一步隧道不会启动,并会给出缺失警告。
88
+
89
+ 插件随后以与快速隧道相同的生命周期管理运行 `cloudflared tunnel run --token`:二进制随包分发,意外退出自动退避重启,姿态探测持续审计该固定主机的 `/api` 围栏。Token 作为设置密文存储,读取时脱敏。自动快速隧道开启时优先级更高。
71
90
 
72
91
  ### 手动隧道(自带)
73
92
 
@@ -137,7 +156,7 @@ pnpm run build
137
156
  - **配对是 `/remote` 通道的访问控制**:`requirePairingForLan` 开启(默认)时,每个请求必须携带有效配对设备 cookie,在任何字节转发之前强制。缺失或被撤销的会话收到 HTTP 403,JSON 拒绝携带 `error.code: "unpaired"`;浏览器 `EventSource` API 只暴露流失败,不暴露响应体。
138
157
  - **通道携带进程自己的内部凭据。** harness 浏览器认证 cookie 与 authority 绑定(为浏览器访问过的确切 `host:port` 签发)且没有回环豁免,因此转发到 `127.0.0.1` 的再发起请求无法复用设备的 cookie。插件因此自行兑换一次自己的启动令牌——与浏览器首次访问执行的是同一次交换——并把所得 cookie 附到再发起请求上。该凭据只在上面的配对门之后被使用;停止/取消配对会立即停止对它的使用。
139
158
  - **本 cohort 的现实:配对不门控直连 `/api`。** 在锚定的 0.1.2-alpha.2 线上,没有任何组件发出 `api/gate` seam,因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
140
- - **配对设备是完全控制凭据。** host 模式下它可达完整 host API——聊天、会话、设置、凭据、Agent 预设、产出物——与 SDK 对回环桌面的信任一致。只有四个控制面(配对、自更新、插件安装/卸载、桌面启动器)保持物理本地。只配对你控制的设备;停止或逐设备取消配对立即撤销。
159
+ - **配对设备是完全控制凭据。** host 模式下它可达完整 host API——聊天、会话、设置、凭据、Agent 预设、产出物——与 SDK 对回环桌面的信任一致。只有三个控制面(配对、自更新、插件安装/卸载)保持物理本地。只配对你控制的设备;停止或逐设备取消配对立即撤销。
141
160
  - **控制端点仅限回环**:铸造/停止/撤销、设备列表、lan-bind 状态与更新端点只应答回环。局域网源浏览器看到「配对面板仅限本机使用」横幅。
142
161
  - **应用落地页不依赖 cookie。** 配对后二维码把设备带到 `/pair-app`——由本插件直接交付官方应用壳,不经过 harness 索引认证门;设备凭据经 `x-dsh-remote-device` 请求头(fetch)与 `device` 查询参数(WebSocket 升级)由引导补丁从 sessionStorage 挂载。因此手机浏览器完全禁用 cookie 时链路依然成立;有 cookie 时配对 cookie 仍是主凭据,手机路径不再需要 harness 浏览器认证 cookie。
143
162
  - **重开由 service worker 接管(仅 https 源)。** 配对过的手机从历史、书签或标签恢复回来时导航到裸 `/`——插件不拥有的路径,harness 兜底座会用浏览器认证 401 应答(不依赖 cookie 的流程永远拿不到那份凭据)。应用壳因此注册 `/pair-app.sw.js`(与 `/pair-app` 同一栅栏;脚本是不含任何秘密的惰性逻辑):只拦截对 `/` 的导航,经 `/pair-app` 网络优先地重发应用壳——同时校验设备 cookie 并刷新其活跃时间,每次重开也在为会话续期——离线时回退缓存的壳,插件不再应答时把导航原样放行(被撤销的设备随后看到 harness 应答或双语重扫页)。纯 HTTP 的局域网源不是安全上下文,永远不会注册该 worker;那里的重开意味着重新扫码。
@@ -145,7 +164,8 @@ pnpm run build
145
164
  - **配对设备会话默认持久化**:设备会话(非一次性 QR 令牌)写入 `$DSH_HOME/remote-web-ui-devices.json`(0600,临时文件 + 原子改名)。`dsh web` 重启后配对 cookie 依然有效。刷新二维码铸造新令牌;重启不会恢复当前二维码。空闲超过 `idleExpireMs`(默认 30 天;重开 service worker 每次接管导航都会刷新该窗口)的会话被删除并须重新配对。设备 id 即会话凭据。需要时可用 `devicesFile` 指定其他绝对路径。更换 `cookieName` 会使现有设备失效(预期行为)。
146
165
  - **局域网绑定块拥有 webserver 行**:开关翻过后受管块固定绑定;插件每次启动重断言,显式 `--host`/`--port` 旗标通过重写块获胜。手工编辑该块会被检测并在卡片展示(`blockHost` 显示字面量)。
147
166
  - **桌面栅栏策略公开**:`/api/pair/status` 只暴露布尔 `requirePairingForLan` 策略,供远程桌面在设置作用域可用前选择正确传输。该字段不是凭据,不暴露令牌、设备、计数或隧道 URL。
148
- - **快速隧道主机名每次运行都变**:`trycloudflare.com` URL 每次 `cloudflared` 启动都随机,`publicBaseUrl`(或自动隧道)须随之刷新。命名隧道可避免抖动。
167
+ - **快速隧道主机名每次运行都变**:`trycloudflare.com` URL 每次 `cloudflared` 启动都随机,`publicBaseUrl`(或自动隧道)须随之刷新。固定域名中继会把一个固定的 `<id>.dsh-market.com` 源前置在该临时地址上(随自动隧道默认开启);命名隧道模式(`tunnelToken`)是自带域名的替代路径。Token 本身作为设置密文存储(读取脱敏),不会写日志,也不会回传浏览器半区。
168
+ - **中继只改变访问源,不改变信任根**:中继开启时,手机访问的是 `https://<id>.dsh-market.com`——一个 dsh-market 的 Cloudflare Worker,它查询实例当前的隧道地址并逐字节转发请求——配对 Cookie 与所有应用层校验仍留在实例上,worker 不终结任何配对。变化的是传输路径:中继流量经过包作者在 Cloudflare 边缘运营的基础设施,作者的 worker 可以观察到这些流量——这与 Cloudflare 自身对裸 `trycloudflare.com` 快速隧道的可见性相同。注册表把每个 id 绑定到一个 256 位密钥的 SHA-256 哈希(明文只存于 `$DSH_HOME`,0600),只接受 `*.trycloudflare.com` 目标,对注册做限流,且除映射外不存储任何数据;关闭中继即可让部署完全留在临时源上。
149
169
 
150
170
  ## 已知限制与后续工作
151
171
 
package/lib/client.js CHANGED
@@ -1377,7 +1377,7 @@ window.__ModuleLoader__.load({
1377
1377
  //#endregion
1378
1378
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/remote.module.css.mjs
1379
1379
  const css$1 = ".fThDlq_overlay{z-index:1000;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.fThDlq_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}.fThDlq_trigger{width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex;position:relative}.fThDlq_trigger[data-update-available]{color:var(--dsw-alias-brand-primary)}.fThDlq_trigger[data-update-available]:after{box-sizing:border-box;border:2px solid var(--dsw-alias-bg-layer-1);background:var(--dsw-alias-brand-primary);content:\"\";pointer-events:none;border-radius:50%;width:8px;height:8px;position:absolute;top:4px;right:4px}.fThDlq_trigger[data-wide=wide]{border-radius:999px;flex:auto;justify-content:flex-start;gap:8px;width:auto;min-width:0;padding:0 10px}.fThDlq_trigger[data-update-available][data-wide=wide]{flex:none;gap:6px}.fThDlq_trigger[data-update-available][data-wide=wide]:after{display:none}.fThDlq_updateBadgeText{white-space:nowrap;font-size:13px;font-weight:500;line-height:1}.fThDlq_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_trigger:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_trigger:disabled{opacity:.5;cursor:default}.fThDlq_panel{z-index:1;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);width:560px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:24px;flex-direction:column;gap:14px;padding:24px;font-size:14px;line-height:22px;display:flex;position:relative;overflow:auto}.fThDlq_header{align-items:flex-start;gap:12px;display:flex}.fThDlq_heading{flex:1;min-width:0}.fThDlq_title{margin:0;font-size:18px;font-weight:600;line-height:26px}.fThDlq_subtitle{color:var(--dsw-alias-label-secondary);margin:4px 0 0;font-size:13px}.fThDlq_close{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;flex:none;justify-content:center;align-items:center;padding:0;transition:background-color .12s,color .12s,box-shadow .12s;display:inline-flex}.fThDlq_close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_close:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_close:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_close:disabled{opacity:.5;cursor:default}.fThDlq_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;align-items:center;gap:12px;padding:16px;display:flex}.fThDlq_cardHeader{justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.fThDlq_cardTitle{font-weight:500}.fThDlq_badge{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;min-width:0;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex}.fThDlq_badge:before{content:\"\";background:currentColor;border-radius:50%;width:8px;height:8px}.fThDlq_badge-waiting{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-connected{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-disconnected{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badge-stopped{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badgePublic{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_badges{flex:none;align-items:center;gap:6px;display:inline-flex}.fThDlq_qrWrap{background:var(--dsw-alias-bg-base);border-radius:12px;justify-content:center;align-items:center;padding:12px;display:flex}.fThDlq_qr{display:block}.fThDlq_expired{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_expiry{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}.fThDlq_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_link{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-mono,ui-monospace, monospace);margin:0;font-size:12px;display:block;overflow:hidden}.fThDlq_pairLinks{flex-direction:column;gap:8px;display:flex}.fThDlq_pairLinkRow{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;align-items:center;gap:10px;min-width:0;padding:10px 12px;display:flex}.fThDlq_pairLinkText{flex:1;min-width:0}.fThDlq_pairLinkLabel{color:var(--dsw-alias-label-secondary);margin-bottom:3px;font-size:12px;display:block}.fThDlq_copyLink{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);min-height:30px;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;flex:none;align-items:center;gap:5px;padding:0 10px;display:inline-flex}.fThDlq_oneTimeHint{color:var(--dsw-alias-label-caption);margin:0;font-size:12px}.fThDlq_stoppedHint{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_tunnelNote{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_tunnelFailed{color:var(--dsw-alias-state-error-primary);margin:0;font-size:13px}.fThDlq_actions{gap:8px;display:flex}.fThDlq_action{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-elevated-fill);height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;border-radius:10px;justify-content:center;align-items:center;gap:6px;padding:0 14px;font-size:13px;transition:background-color .12s,border-color .12s,box-shadow .12s;display:inline-flex}.fThDlq_action:hover:not(:disabled){background:var(--dsw-alias-button-floating-hover)}.fThDlq_action:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.fThDlq_action:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}.fThDlq_action:disabled{opacity:.5;cursor:default}.fThDlq_banner{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;padding:16px}.fThDlq_bannerTitle{color:var(--dsw-alias-state-warn-primary);margin:0;font-weight:500}.fThDlq_bannerHint{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_fencePage{z-index:2000;box-sizing:border-box;background:var(--dsw-alias-bg-base);text-align:center;flex-direction:column;justify-content:center;align-items:center;padding:40px 24px;display:flex;position:fixed;inset:0;overflow:auto}.fThDlq_fenceCard{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);width:min(520px,100%);box-shadow:var(--dsw-shadow-lv3);text-align:center;border-radius:20px;margin-inline:auto;padding:36px 40px}.fThDlq_fenceMark{background:var(--dsw-alias-state-error-secondary);width:44px;height:44px;color:var(--dsw-alias-state-error-primary);border-radius:50%;place-items:center;margin-inline:auto;font-size:24px;line-height:1;display:grid}.fThDlq_fenceEyebrow{color:var(--dsw-alias-state-error-primary);margin:22px 0 8px;font-size:13px;font-weight:600}.fThDlq_fenceTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:24px;line-height:1.35}.fThDlq_fenceDetail{color:var(--dsw-alias-label-secondary);margin:12px 0 0;font-size:14px;line-height:1.65}.fThDlq_fenceSteps{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);text-align:left;border-radius:12px;margin:24px auto 0;padding:20px 20px 20px 42px;font-size:14px;line-height:1.65}.fThDlq_fenceSteps li+li{margin-top:8px}.fThDlq_fenceForm{width:100%;margin-top:20px}.fThDlq_fenceInputRow{gap:8px;width:100%;display:flex}.fThDlq_fenceInput{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);min-width:0;color:var(--dsw-alias-label-primary);font:inherit;border-radius:10px;flex:1;padding:10px 14px;font-size:13px;transition:border-color .12s,box-shadow .12s}.fThDlq_fenceInput:focus{border-color:var(--dsw-alias-brand-primary);outline:none;box-shadow:0 0 0 2px #0066ff26}.fThDlq_fencePairButton{border:1px solid var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary);color:#fff;font:inherit;cursor:pointer;border-radius:10px;flex-shrink:0;padding:10px 18px;font-size:13px;font-weight:500;transition:filter .12s,opacity .12s}.fThDlq_fencePairButton:hover:not(:disabled){filter:brightness(.96)}.fThDlq_fencePairButton:disabled{opacity:.55;cursor:not-allowed}.fThDlq_fencePairButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.fThDlq_fenceError{color:var(--dsw-alias-state-error-primary);text-align:left;margin:10px 0 0;font-size:13px;line-height:1.4}.fThDlq_fenceRetry{border:1px solid var(--dsw-alias-border-l2);width:100%;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;background:0 0;border-radius:10px;margin-top:12px;padding:10px 16px;font-weight:500;transition:background-color .12s,color .12s}.fThDlq_fenceRetry:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_fenceRetry:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:3px}.fThDlq_fenceFootnote{color:var(--dsw-alias-label-tertiary);margin:14px 0 0;font-size:12px;line-height:1.55}.fThDlq_addresses{border:none;margin:12px 0 0;padding:0}.fThDlq_addresses legend{color:var(--dsw-alias-label-secondary);padding:0;font-size:13px}.fThDlq_address{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;cursor:pointer;border-radius:6px;align-items:center;gap:8px;margin-top:6px;padding:4px 6px;font-size:13px;transition:background-color .12s;display:flex}.fThDlq_address:hover{background:var(--dsw-alias-interactive-bg-hover)}.fThDlq_address input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);border-radius:50%;outline:none}.fThDlq_addressValue{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-size:12px;overflow:hidden}.fThDlq_addressHint{color:var(--dsw-alias-label-tertiary);margin:6px 0 0;font-size:12px}.fThDlq_devices{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:16px;flex-direction:column;gap:8px;padding:12px 16px 14px;display:flex}.fThDlq_devicesTitle{margin:0;font-size:13px;font-weight:500;line-height:20px}.fThDlq_devicesEmpty{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px}.fThDlq_deviceList{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.fThDlq_deviceRow{justify-content:space-between;align-items:flex-start;gap:12px;display:flex}.fThDlq_deviceMeta{flex-direction:column;gap:2px;min-width:0;display:flex}.fThDlq_deviceName{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;overflow:hidden}.fThDlq_devicePresence{font-size:12px;line-height:18px}.fThDlq_deviceOnline{color:var(--dsw-alias-state-success-primary)}.fThDlq_deviceOffline{color:var(--dsw-alias-label-secondary)}.fThDlq_deviceSeen{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:12px}.fThDlq_deviceRevoke{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:8px;flex:none;padding:6px 10px;font-size:12px;transition:background-color .12s,color .12s}.fThDlq_deviceRevoke:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.fThDlq_deviceRevoke:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);outline:none}@media (prefers-reduced-motion:reduce){.fThDlq_trigger,.fThDlq_close,.fThDlq_action,.fThDlq_address,.fThDlq_deviceRevoke{transition:none}}.fThDlq_entryRow{flex:none;align-items:center;gap:6px;min-width:0;display:flex}.fThDlq_entryRow[data-rail=rail]{flex-direction:column-reverse;gap:4px}.fThDlq_updateStatus{margin:0;font-weight:600}.fThDlq_updateDetail{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px}.fThDlq_updateError{color:var(--dsw-alias-text-danger,var(--dsw-alias-label-primary));margin:0;font-weight:600}.fThDlq_updateOutput{background:var(--dsw-alias-bg-layer-1);max-height:180px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all;border-radius:10px;margin:10px 0 0;padding:10px 12px;font-size:12px;line-height:18px;overflow:auto}.fThDlq_updateList{flex-direction:column;gap:6px;margin:12px 0 0;padding:0;list-style:none;display:flex}.fThDlq_updateListItem{justify-content:space-between;align-items:baseline;gap:12px;font-size:13px;display:flex}.fThDlq_updateListName{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-primary);overflow:hidden}.fThDlq_updateListVersions{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;flex:none}.fThDlq_updateNotes{margin-top:14px}.fThDlq_updateNotesTitle{margin:0 0 8px;font-size:14px;font-weight:600}.fThDlq_updateNoteSection{flex-direction:column;gap:4px;margin-top:10px;display:flex}.fThDlq_updateNoteHeading{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px;font-weight:600}.fThDlq_updateNoteList{flex-direction:column;gap:4px;margin:0;padding-left:18px;font-size:13px;display:flex}.fThDlq_updateNoteItem{color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}.fThDlq_updateVersions{margin-top:12px}.fThDlq_updateVersionsSummary{cursor:pointer;color:var(--dsw-alias-label-secondary);user-select:none;font-size:12px}.fThDlq_updateActions{justify-content:flex-end;margin-top:16px;display:flex}.fThDlq_updateRetry{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;border:none;border-radius:10px;align-items:center;gap:6px;padding:8px 14px;font-size:13px;transition:background-color .12s,color .12s;display:inline-flex}.fThDlq_updateRetry:hover{background:var(--dsw-alias-interactive-bg-active)}@media (prefers-reduced-motion:reduce){.fThDlq_updateRetry{transition:none}}";
1380
- const tagId$1 = "@linxin666/dsh-remote-web-ui/remote.module.css";
1380
+ const tagId$1 = "@linxin666/dsh-remote-web-ui/packages/dsh-remote-web-ui/src/client/remote.module.css";
1381
1381
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
1382
1382
  const tag = document.createElement("style");
1383
1383
  tag.dataset.plugin = "@linxin666/dsh-remote-web-ui";
@@ -1658,6 +1658,16 @@ window.__ModuleLoader__.load({
1658
1658
  role: "status",
1659
1659
  children: state.tunnel.state === "failed" ? t("tunnel.failed", { error: state.tunnel.error ?? t("tunnel.unknownError") }) : t("tunnel.starting")
1660
1660
  }),
1661
+ state.relay?.state === "registering" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1662
+ className: remote_module_css_default.tunnelNote,
1663
+ role: "status",
1664
+ children: t("relay.registering")
1665
+ }),
1666
+ state.relay?.state === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1667
+ className: remote_module_css_default.tunnelFailed,
1668
+ role: "status",
1669
+ children: t("relay.failed", { error: state.relay.error ?? t("tunnel.unknownError") })
1670
+ }),
1661
1671
  (state.publicBaseUrl !== void 0 || state.lanAddresses.length > 1) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
1662
1672
  className: remote_module_css_default.addresses,
1663
1673
  children: [
@@ -2279,6 +2289,7 @@ window.__ModuleLoader__.load({
2279
2289
  onlineCount: frame.onlineCount,
2280
2290
  devices: frame.devices ?? [],
2281
2291
  ...frame.tunnel !== void 0 ? { tunnel: frame.tunnel } : {},
2292
+ ...frame.relay !== void 0 ? { relay: frame.relay } : {},
2282
2293
  ...frame.posture !== void 0 ? { posture: frame.posture } : {}
2283
2294
  };
2284
2295
  }
@@ -2510,8 +2521,8 @@ window.__ModuleLoader__.load({
2510
2521
  }
2511
2522
  //#endregion
2512
2523
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/settings-card.module.css.mjs
2513
- const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;box-sizing:border-box;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{box-sizing:border-box;border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2514
- const tagId = "@linxin666/dsh-remote-web-ui/settings-card.module.css";
2524
+ const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;box-sizing:border-box;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{box-sizing:border-box;border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-secondary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-state-error-primary,#b42318);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-state-error-primary,#b42318);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-state-error-primary,#b42318);outline-offset:1px;border-color:var(--dsw-alias-state-error-primary,#b42318)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-state-error-primary,#b42318);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2525
+ const tagId = "@linxin666/dsh-remote-web-ui/packages/dsh-remote-web-ui/src/client/settings-card.module.css";
2515
2526
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2516
2527
  const tag = document.createElement("style");
2517
2528
  tag.dataset.plugin = "@linxin666/dsh-remote-web-ui";
@@ -3008,6 +3019,18 @@ window.__ModuleLoader__.load({
3008
3019
  }
3009
3020
  };
3010
3021
  }
3022
+ /**
3023
+ * A free-text field the Host treats as a secret and redacts from the read-back
3024
+ * (role('secret') in the section schema). The card still edits it like text,
3025
+ * but a save never compares the redacted value back: the staged set is judged
3026
+ * by the mutation settling (see {@link FieldSpec.secret}).
3027
+ */
3028
+ function secretField(field) {
3029
+ return {
3030
+ ...textField(field),
3031
+ secret: true
3032
+ };
3033
+ }
3011
3034
  /** A boolean field, edited through true/false draft text. */
3012
3035
  function booleanField(field) {
3013
3036
  return {
@@ -3298,6 +3321,8 @@ window.__ModuleLoader__.load({
3298
3321
  booleanField("requirePairingForLan"),
3299
3322
  textField("publicBaseUrl"),
3300
3323
  booleanField("autoTunnel"),
3324
+ secretField("tunnelToken"),
3325
+ booleanField("relay"),
3301
3326
  booleanField("lanBind")
3302
3327
  ]);
3303
3328
  this.store = this.form.bind(() => this.projection());
@@ -3314,6 +3339,8 @@ window.__ModuleLoader__.load({
3314
3339
  requirePairingForLan: this.form.field("requirePairingForLan"),
3315
3340
  publicBaseUrl: this.form.field("publicBaseUrl"),
3316
3341
  autoTunnel: this.form.field("autoTunnel"),
3342
+ tunnelToken: this.form.field("tunnelToken"),
3343
+ relay: this.form.field("relay"),
3317
3344
  lanBind: this.form.field("lanBind")
3318
3345
  };
3319
3346
  }
@@ -3490,6 +3517,36 @@ window.__ModuleLoader__.load({
3490
3517
  props.resetField("autoTunnel");
3491
3518
  }
3492
3519
  }),
3520
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
3521
+ id: "settings-remote-tunnel-token",
3522
+ label: t("settings.tunnelToken"),
3523
+ hint: t("settings.tunnelTokenHint"),
3524
+ placeholder: "eyJ...",
3525
+ ...fieldProps,
3526
+ ...state.tunnelToken,
3527
+ onEdit: (text) => {
3528
+ props.edit("tunnelToken", text);
3529
+ },
3530
+ onReset: () => {
3531
+ props.resetField("tunnelToken");
3532
+ }
3533
+ }),
3534
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3535
+ id: "settings-remote-relay",
3536
+ label: t("settings.relay"),
3537
+ hint: t("settings.relayHint"),
3538
+ inheritLabel: t("settings.inherit"),
3539
+ onLabel: t("settings.on"),
3540
+ offLabel: t("settings.off"),
3541
+ ...fieldProps,
3542
+ ...state.relay,
3543
+ onEdit: (text) => {
3544
+ props.edit("relay", text);
3545
+ },
3546
+ onReset: () => {
3547
+ props.resetField("relay");
3548
+ }
3549
+ }),
3493
3550
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LanBindStatus, { t }),
3494
3551
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3495
3552
  id: "settings-remote-lan-bind",
@@ -3626,6 +3683,8 @@ window.__ModuleLoader__.load({
3626
3683
  "tunnel.starting": "公网隧道启动中,二维码将自动变为公网链接…",
3627
3684
  "tunnel.failed": "公网隧道启动失败:{error}",
3628
3685
  "tunnel.unknownError": "未知错误",
3686
+ "relay.registering": "正在把临时隧道同步到固定域名(<id>.dsh-market.com)…",
3687
+ "relay.failed": "固定域名同步失败:{error}。正在自动重试,期间二维码可能使用临时地址。",
3629
3688
  "close.label": "关闭远程访问面板",
3630
3689
  "settings.title": "远程访问设置",
3631
3690
  "settings.description": "配对安全与设备限额。",
@@ -3644,9 +3703,13 @@ window.__ModuleLoader__.load({
3644
3703
  "settings.requirePairingForLan": "局域网访问要求配对",
3645
3704
  "settings.requirePairingForLanHint": "开启:非本机回环的桌面 Web GUI 改走门控的 /remote,必须携带有效配对 Cookie;关闭:桌面继续走普通路径(仅在该来源已被 SDK 信任时有意义),配对只管理令牌与状态。",
3646
3705
  "settings.publicBaseUrl": "公网地址(可选)",
3647
- "settings.publicBaseUrlHint": "内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。",
3706
+ "settings.publicBaseUrlHint": "内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com;固定域名隧道(下方令牌)也填这里。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。",
3648
3707
  "settings.autoTunnel": "自动公网隧道",
3649
- "settings.autoTunnelHint": "开启后插件自动启动 Cloudflare quick tunnel(无需安装任何工具),并自动更新公网地址与信任配置,手机随时可用公网配对;关闭时使用上方手动填写的公网地址。",
3708
+ "settings.autoTunnelHint": "开启后插件自动启动 Cloudflare quick tunnel(无需安装任何工具),并自动更新公网地址与信任配置,手机随时可用公网配对;注意临时域名每次重启都会变化,手机需重新扫码。开启时忽略下方固定隧道令牌与上方手动公网地址。",
3709
+ "settings.tunnelToken": "固定域名隧道令牌(Cloudflare Tunnel)",
3710
+ "settings.tunnelTokenHint": "填入 Cloudflare 命名隧道的 Token(即 cloudflared tunnel run --token 的值),插件会自动运行该隧道:公共主机名固定不变,手机配对一次后重启也无需重新配对。需先在 Cloudflare 控制台把公共主机名映射到 http://127.0.0.1:<端口>,并在上方\"公网地址\"填写同一域名。优先级低于\"自动公网隧道\"。",
3711
+ "settings.relay": "固定域名中继(推荐开启)",
3712
+ "settings.relayHint": "开启后自动隧道会额外注册一个固定不变的公网子域名(<id>.dsh-market.com,由 dsh-market 边缘中继到你的临时隧道),手机配对一次后重启无需重新扫码,二维码与书签地址永远不变。流量经由 dsh-market 边缘转发(与临时隧道相同的信任面);关闭则回退为纯临时地址,每次重启需重新配对。仅对\"自动公网隧道\"生效。",
3650
3713
  "settings.lanBind": "局域网访问(绑定 0.0.0.0)",
3651
3714
  "settings.lanBindHint": "开启后插件把绑定默认改写为 0.0.0.0 并写入 profile 补丁(显式 --host 仍优先),同时维护主机防火墙放行(Windows/Linux;macOS 无需管理);关闭回退 127.0.0.1。绑定变化通常在重启 dsh web 后生效。",
3652
3715
  "lan.cardTitle": "局域网访问",
@@ -3781,6 +3844,8 @@ window.__ModuleLoader__.load({
3781
3844
  "tunnel.starting": "The public tunnel is starting; the QR code will switch to a public link shortly…",
3782
3845
  "tunnel.failed": "The public tunnel failed to start: {error}",
3783
3846
  "tunnel.unknownError": "unknown error",
3847
+ "relay.registering": "Syncing the ephemeral tunnel to the stable hostname (<id>.dsh-market.com)…",
3848
+ "relay.failed": "Stable-hostname sync failed: {error}. Retrying automatically; the QR may use the ephemeral address meanwhile.",
3784
3849
  "close.label": "Close remote access panel",
3785
3850
  "settings.title": "Remote access settings",
3786
3851
  "settings.description": "Pairing security and device limits.",
@@ -3799,9 +3864,13 @@ window.__ModuleLoader__.load({
3799
3864
  "settings.requirePairingForLan": "Require pairing for LAN access",
3800
3865
  "settings.requirePairingForLanHint": "On: a desktop Web GUI at a non-loopback origin rides the gated /remote channel and must carry a live paired-device cookie. Off: the desktop stays on its original paths (only useful when that origin is already trusted) and pairing only manages tokens/status.",
3801
3866
  "settings.publicBaseUrl": "Public address (optional)",
3802
- "settings.publicBaseUrlHint": "The public URL of a tunnel in front of this server, e.g. a Cloudflare Tunnel: https://xxx.trycloudflare.com. When set, the QR link is built from it so a phone anywhere can pair; leave blank for LAN-only usage.",
3867
+ "settings.publicBaseUrlHint": "The public URL of a tunnel in front of this server, e.g. a Cloudflare Tunnel: https://xxx.trycloudflare.com; a fixed-hostname tunnel (token below) also goes here. When set, the QR link is built from it so a phone anywhere can pair; leave blank for LAN-only usage.",
3803
3868
  "settings.autoTunnel": "Auto public tunnel",
3804
- "settings.autoTunnelHint": "When on, the plugin runs its own Cloudflare quick tunnel (no tool installation needed) and keeps the public address and trust config in sync automatically, so a phone anywhere can pair at any time; when off, the manually entered public address above applies.",
3869
+ "settings.autoTunnelHint": "When on, the plugin runs its own Cloudflare quick tunnel (no tool installation needed) and keeps the public address and trust config in sync automatically, so a phone anywhere can pair at any time; note the ephemeral hostname changes on every restart, so phones must scan again. The named-tunnel token below and the manual public address above are ignored while this is on.",
3870
+ "settings.tunnelToken": "Fixed-hostname tunnel token (Cloudflare Tunnel)",
3871
+ "settings.tunnelTokenHint": "Paste a Cloudflare named-tunnel token (the value of cloudflared tunnel run --token) and the plugin runs the tunnel itself: the public hostname stays fixed, so a phone pairs once and never again across restarts. First map the public hostname to http://127.0.0.1:<port> in the Cloudflare dashboard and enter the same hostname as the public address above. Lower precedence than the auto quick tunnel.",
3872
+ "settings.relay": "Stable-hostname relay (recommended)",
3873
+ "settings.relayHint": "When on, the auto tunnel additionally registers a never-changing public subdomain (<id>.dsh-market.com, relayed by the dsh-market edge to your ephemeral tunnel), so a phone pairs once and the QR and bookmark stay valid across restarts. Traffic transits the dsh-market edge (the same trust point as the ephemeral tunnel); turn off to fall back to the raw ephemeral address, which needs re-pairing after every restart. Applies to the auto public tunnel only.",
3805
3874
  "settings.lanBind": "LAN access (bind 0.0.0.0)",
3806
3875
  "settings.lanBindHint": "When on, the plugin writes a managed block into the profile patch defaulting the bind to 0.0.0.0 (an explicit --host flag still wins), and maintains the matching host firewall rule (Windows/Linux; other platforms need none). When off, the block pins 127.0.0.1. The bind change usually takes effect after dsh web restarts.",
3807
3876
  "lan.cardTitle": "LAN access",
@@ -3946,7 +4015,6 @@ window.__ModuleLoader__.load({
3946
4015
  apiPrefix: "/api/",
3947
4016
  pairPrefix: "/api/pair/",
3948
4017
  updatePrefix: "/api/update/",
3949
- desktopLauncherPrefix: "/api/dsh-desktop-launcher",
3950
4018
  settingsBridgePrefix: "/api/dsh-web-ui-settings",
3951
4019
  sidebarPrefix: "/sidebar/",
3952
4020
  gitPrefix: "/git/",
@@ -3978,8 +4046,6 @@ window.__ModuleLoader__.load({
3978
4046
  * - the pairing routes (`/api/pair/*`) stay where they are — accept must
3979
4047
  * work BEFORE a device is paired;
3980
4048
  * - the update endpoints (`/api/update/*`) stay loopback-only;
3981
- * - desktop-launcher create/shutdown (`/api/dsh-desktop-launcher/*`) stay
3982
- * loopback-only (host-local files and process exit);
3983
4049
  * - the family settings bridge (`/api/dsh-web-ui-settings/*`) stays
3984
4050
  * loopback-only (same plane as SDK settings.*);
3985
4051
  * - `/api/*` (SDK methods and `/api/<plugin>/...` plugin namespaces),
@@ -4016,7 +4082,6 @@ window.__ModuleLoader__.load({
4016
4082
  function shouldRewriteFetchPath(pathname) {
4017
4083
  if (pathname.startsWith(RULES.pairPrefix)) return false;
4018
4084
  if (pathname.startsWith(RULES.updatePrefix)) return false;
4019
- if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false;
4020
4085
  if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false;
4021
4086
  if (pathname.startsWith(RULES.apiPrefix)) return true;
4022
4087
  if (pathname.startsWith(RULES.sidebarPrefix) || pathname === "/sidebar") return true;
@@ -4345,7 +4410,7 @@ window.__ModuleLoader__.load({
4345
4410
  /** The building package's version, when the bundle carries it. */
4346
4411
  function bakedVersion() {
4347
4412
  try {
4348
- return "0.3.12";
4413
+ return "0.3.14";
4349
4414
  } catch {
4350
4415
  return;
4351
4416
  }