@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
@@ -9,7 +9,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
9
9
  import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
10
10
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
11
11
  import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
12
- import { CardForm, booleanField, numberField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
12
+ import { CardForm, booleanField, numberField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
13
  import { readLanBindStatus, type LanBindFrame } from './pair-api.ts'
14
14
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
15
15
 
@@ -33,6 +33,18 @@ export interface RemoteSettings {
33
33
  publicBaseUrl?: string
34
34
  /** When on, the plugin runs its own Cloudflare quick tunnel automatically. */
35
35
  autoTunnel?: boolean
36
+ /**
37
+ * Cloudflare named-tunnel token: when set (and autoTunnel is off), the
38
+ * plugin runs the named tunnel toward the fixed public hostname — a phone
39
+ * paired once keeps its session across restarts.
40
+ */
41
+ tunnelToken?: string
42
+ /**
43
+ * Stable-origin relay: when on (default), the quick tunnel is fronted by a
44
+ * fixed `<id>.dsh-market.com` subdomain so the phone's bookmark and
45
+ * pairing cookie survive restarts without any setup.
46
+ */
47
+ relay?: boolean
36
48
  /**
37
49
  * LAN bind toggle: once flipped, the plugin manages the profile patch's
38
50
  * webserver block (0.0.0.0 / 127.0.0.1) and the host firewall rule.
@@ -60,6 +72,10 @@ export interface RemoteSettingsCardState extends CardShell {
60
72
  publicBaseUrl: CardFieldState
61
73
  /** Auto public tunnel switch. */
62
74
  autoTunnel: CardFieldState
75
+ /** Named-tunnel token (stored redacted by the Host). */
76
+ tunnelToken: CardFieldState
77
+ /** Stable-origin relay switch. */
78
+ relay: CardFieldState
63
79
  /** LAN bind toggle. */
64
80
  lanBind: CardFieldState
65
81
  }
@@ -89,6 +105,8 @@ export class RemoteSettingsCardController {
89
105
  booleanField('requirePairingForLan'),
90
106
  textField('publicBaseUrl'),
91
107
  booleanField('autoTunnel'),
108
+ secretField('tunnelToken'),
109
+ booleanField('relay'),
92
110
  booleanField('lanBind'),
93
111
  ])
94
112
  this.store = this.form.bind(() => this.projection())
@@ -106,6 +124,8 @@ export class RemoteSettingsCardController {
106
124
  requirePairingForLan: this.form.field('requirePairingForLan'),
107
125
  publicBaseUrl: this.form.field('publicBaseUrl'),
108
126
  autoTunnel: this.form.field('autoTunnel'),
127
+ tunnelToken: this.form.field('tunnelToken'),
128
+ relay: this.form.field('relay'),
109
129
  lanBind: this.form.field('lanBind'),
110
130
  }
111
131
  }
@@ -253,6 +273,28 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
253
273
  onEdit={(text) => { props.edit('autoTunnel', text) }}
254
274
  onReset={() => { props.resetField('autoTunnel') }}
255
275
  />
276
+ <ValueField
277
+ id="settings-remote-tunnel-token"
278
+ label={t('settings.tunnelToken')}
279
+ hint={t('settings.tunnelTokenHint')}
280
+ placeholder="eyJ..."
281
+ {...fieldProps}
282
+ {...state.tunnelToken}
283
+ onEdit={(text) => { props.edit('tunnelToken', text) }}
284
+ onReset={() => { props.resetField('tunnelToken') }}
285
+ />
286
+ <BooleanField
287
+ id="settings-remote-relay"
288
+ label={t('settings.relay')}
289
+ hint={t('settings.relayHint')}
290
+ inheritLabel={t('settings.inherit')}
291
+ onLabel={t('settings.on')}
292
+ offLabel={t('settings.off')}
293
+ {...fieldProps}
294
+ {...state.relay}
295
+ onEdit={(text) => { props.edit('relay', text) }}
296
+ onReset={() => { props.resetField('relay') }}
297
+ />
256
298
  <LanBindStatus t={t} />
257
299
  <BooleanField
258
300
  id="settings-remote-lan-bind"
@@ -65,6 +65,8 @@ export const zh = {
65
65
  'tunnel.starting': '公网隧道启动中,二维码将自动变为公网链接…',
66
66
  'tunnel.failed': '公网隧道启动失败:{error}',
67
67
  'tunnel.unknownError': '未知错误',
68
+ 'relay.registering': '正在把临时隧道同步到固定域名(<id>.dsh-market.com)…',
69
+ 'relay.failed': '固定域名同步失败:{error}。正在自动重试,期间二维码可能使用临时地址。',
68
70
  'close.label': '关闭远程访问面板',
69
71
  // 插件设置卡片(settings.plugin.item 席位)。
70
72
  'settings.title': '远程访问设置',
@@ -84,9 +86,13 @@ export const zh = {
84
86
  'settings.requirePairingForLan': '局域网访问要求配对',
85
87
  'settings.requirePairingForLanHint': '开启:非本机回环的桌面 Web GUI 改走门控的 /remote,必须携带有效配对 Cookie;关闭:桌面继续走普通路径(仅在该来源已被 SDK 信任时有意义),配对只管理令牌与状态。',
86
88
  'settings.publicBaseUrl': '公网地址(可选)',
87
- 'settings.publicBaseUrlHint': '内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。',
89
+ 'settings.publicBaseUrlHint': '内网穿透的公网地址,如 Cloudflare Tunnel:https://xxx.trycloudflare.com;固定域名隧道(下方令牌)也填这里。填写后二维码将生成公网链接,手机不在同一网络也能配对;留空则仅局域网可用。',
88
90
  'settings.autoTunnel': '自动公网隧道',
89
- 'settings.autoTunnelHint': '开启后插件自动启动 Cloudflare quick tunnel(无需安装任何工具),并自动更新公网地址与信任配置,手机随时可用公网配对;关闭时使用上方手动填写的公网地址。',
91
+ 'settings.autoTunnelHint': '开启后插件自动启动 Cloudflare quick tunnel(无需安装任何工具),并自动更新公网地址与信任配置,手机随时可用公网配对;注意临时域名每次重启都会变化,手机需重新扫码。开启时忽略下方固定隧道令牌与上方手动公网地址。',
92
+ 'settings.tunnelToken': '固定域名隧道令牌(Cloudflare Tunnel)',
93
+ 'settings.tunnelTokenHint': '填入 Cloudflare 命名隧道的 Token(即 cloudflared tunnel run --token 的值),插件会自动运行该隧道:公共主机名固定不变,手机配对一次后重启也无需重新配对。需先在 Cloudflare 控制台把公共主机名映射到 http://127.0.0.1:<端口>,并在上方"公网地址"填写同一域名。优先级低于"自动公网隧道"。',
94
+ 'settings.relay': '固定域名中继(推荐开启)',
95
+ 'settings.relayHint': '开启后自动隧道会额外注册一个固定不变的公网子域名(<id>.dsh-market.com,由 dsh-market 边缘中继到你的临时隧道),手机配对一次后重启无需重新扫码,二维码与书签地址永远不变。流量经由 dsh-market 边缘转发(与临时隧道相同的信任面);关闭则回退为纯临时地址,每次重启需重新配对。仅对"自动公网隧道"生效。',
90
96
  'settings.lanBind': '局域网访问(绑定 0.0.0.0)',
91
97
  'settings.lanBindHint': '开启后插件把绑定默认改写为 0.0.0.0 并写入 profile 补丁(显式 --host 仍优先),同时维护主机防火墙放行(Windows/Linux;macOS 无需管理);关闭回退 127.0.0.1。绑定变化通常在重启 dsh web 后生效。',
92
98
  'lan.cardTitle': '局域网访问',
@@ -229,6 +235,8 @@ export const en = {
229
235
  'tunnel.starting': 'The public tunnel is starting; the QR code will switch to a public link shortly…',
230
236
  'tunnel.failed': 'The public tunnel failed to start: {error}',
231
237
  'tunnel.unknownError': 'unknown error',
238
+ 'relay.registering': 'Syncing the ephemeral tunnel to the stable hostname (<id>.dsh-market.com)…',
239
+ 'relay.failed': 'Stable-hostname sync failed: {error}. Retrying automatically; the QR may use the ephemeral address meanwhile.',
232
240
  'close.label': 'Close remote access panel',
233
241
  // Plugin settings card (the `settings.plugin.item` seat).
234
242
  'settings.title': 'Remote access settings',
@@ -248,9 +256,13 @@ export const en = {
248
256
  'settings.requirePairingForLan': 'Require pairing for LAN access',
249
257
  '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.',
250
258
  'settings.publicBaseUrl': 'Public address (optional)',
251
- '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.',
259
+ '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.',
252
260
  'settings.autoTunnel': 'Auto public tunnel',
253
- '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.',
261
+ '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.',
262
+ 'settings.tunnelToken': 'Fixed-hostname tunnel token (Cloudflare Tunnel)',
263
+ '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.',
264
+ 'settings.relay': 'Stable-hostname relay (recommended)',
265
+ '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.',
254
266
  'settings.lanBind': 'LAN access (bind 0.0.0.0)',
255
267
  '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.',
256
268
  'lan.cardTitle': 'LAN access',
@@ -62,6 +62,15 @@ export interface TunnelStatusFrame {
62
62
  error?: string
63
63
  }
64
64
 
65
+ /** One relay-registry status frame (absent while the relay is not in play). */
66
+ export interface RelayStatusFrame {
67
+ state: 'off' | 'registering' | 'running' | 'failed'
68
+ /** The stable relay origin, once registered. */
69
+ url?: string
70
+ /** Human-readable failure detail of the last sync attempt. */
71
+ error?: string
72
+ }
73
+
65
74
  /** One /api posture frame (host half probe; see src/posture.ts). */
66
75
  export interface PostureFrame {
67
76
  checkedAt: number
@@ -81,6 +90,8 @@ export interface PairStateFrame {
81
90
  devices?: DeviceFrame[]
82
91
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
83
92
  tunnel?: TunnelStatusFrame
93
+ /** Relay-registry status, while the stable-origin relay is in play. */
94
+ relay?: RelayStatusFrame
84
95
  /** Latest /api fence posture probe, once a round has completed. */
85
96
  posture?: PostureFrame
86
97
  }
@@ -11,8 +11,6 @@
11
11
  * - the pairing routes (`/api/pair/*`) stay where they are — accept must
12
12
  * work BEFORE a device is paired;
13
13
  * - the update endpoints (`/api/update/*`) stay loopback-only;
14
- * - desktop-launcher create/shutdown (`/api/dsh-desktop-launcher/*`) stay
15
- * loopback-only (host-local files and process exit);
16
14
  * - the family settings bridge (`/api/dsh-web-ui-settings/*`) stays
17
15
  * loopback-only (same plane as SDK settings.*);
18
16
  * - `/api/*` (SDK methods and `/api/<plugin>/...` plugin namespaces),
@@ -77,7 +75,6 @@ export function isLoopbackHostname(hostname: string): boolean {
77
75
  export function shouldRewriteFetchPath(pathname: string): boolean {
78
76
  if (pathname.startsWith(RULES.pairPrefix)) return false
79
77
  if (pathname.startsWith(RULES.updatePrefix)) return false
80
- if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false
81
78
  if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false
82
79
  if (pathname.startsWith(RULES.apiPrefix)) return true
83
80
  if (pathname.startsWith(RULES.sidebarPrefix) || pathname === '/sidebar') return true
@@ -72,7 +72,10 @@
72
72
  }
73
73
 
74
74
  .description {
75
- color: var(--dsw-alias-label-tertiary);
75
+ /* Secondary, not tertiary: skins whose tertiary token is tuned for large
76
+ print leave 12-13px hint text below readable contrast (orca-link #778399
77
+ on cream measures ~3.4:1). */
78
+ color: var(--dsw-alias-label-secondary);
76
79
  font-size: 13px;
77
80
  line-height: 1.5;
78
81
  }
@@ -106,7 +109,7 @@
106
109
  }
107
110
 
108
111
  .readOnly {
109
- color: var(--dsw-alias-label-tertiary);
112
+ color: var(--dsw-alias-label-secondary);
110
113
  margin: 12px 0 0;
111
114
  font-size: 12px;
112
115
  line-height: 1.5;
@@ -130,7 +133,10 @@
130
133
 
131
134
  .failed {
132
135
  min-width: 0;
133
- color: var(--dsw-alias-label-error);
136
+ /* The suite's error alias is state-error-primary; a label-error token does
137
+ not exist, so the var() would resolve to nothing and the message would
138
+ inherit the ink color, losing the failure signal. */
139
+ color: var(--dsw-alias-state-error-primary, #b42318);
134
140
  flex: 1;
135
141
  margin: 0;
136
142
  font-size: 12px;
@@ -278,7 +284,7 @@
278
284
  }
279
285
 
280
286
  .inputInvalid {
281
- border: 1px solid var(--dsw-alias-label-error);
287
+ border: 1px solid var(--dsw-alias-state-error-primary, #b42318);
282
288
  background: var(--dsw-alias-bg-layer-3);
283
289
  height: 34px;
284
290
  font: inherit;
@@ -290,9 +296,9 @@
290
296
  }
291
297
 
292
298
  .inputInvalid:focus-visible {
293
- outline: 2px solid var(--dsw-alias-label-error);
299
+ outline: 2px solid var(--dsw-alias-state-error-primary, #b42318);
294
300
  outline-offset: 1px;
295
- border-color: var(--dsw-alias-label-error);
301
+ border-color: var(--dsw-alias-state-error-primary, #b42318);
296
302
  }
297
303
 
298
304
  .selectWrap {
@@ -384,14 +390,14 @@
384
390
  }
385
391
 
386
392
  .invalid {
387
- color: var(--dsw-alias-label-error);
393
+ color: var(--dsw-alias-state-error-primary, #b42318);
388
394
  margin: 0;
389
395
  font-size: 12px;
390
396
  line-height: 1.5;
391
397
  }
392
398
 
393
399
  .hint {
394
- color: var(--dsw-alias-label-tertiary);
400
+ color: var(--dsw-alias-label-secondary);
395
401
  margin: 0;
396
402
  font-size: 12px;
397
403
  line-height: 1.5;
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from
32
32
  import { lanIPv4Addresses } from './lan.ts'
33
33
  import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firewall.ts'
34
34
  import { lanBindState, writeLanBind } from './lan-bind.ts'
35
+ import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
36
+ import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
35
37
  import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
36
38
  import { createInnerAuth } from './inner-auth.ts'
37
39
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
@@ -136,9 +138,34 @@ export interface Config {
136
138
  * cloudflared binary ships with the package — no user-side install) and
137
139
  * feeds the minted public URL into the QR base and the phone-facing
138
140
  * pairing fence dynamically, so phones anywhere can pair without any manual
139
- * tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
141
+ * tunnel setup. The minted hostname changes on every start, so a paired
142
+ * phone must re-pair after each restart. The manual `publicBaseUrl` and
143
+ * `tunnelToken` are ignored while this is on.
140
144
  */
141
145
  autoTunnel?: boolean
146
+ /**
147
+ * Cloudflare named-tunnel token (`cloudflared tunnel run --token <t>`).
148
+ * When set (and `autoTunnel` is off), the plugin runs the named tunnel
149
+ * itself — same binary, same lifecycle management — toward the fixed
150
+ * public hostname configured in the Cloudflare dashboard. Because that
151
+ * hostname never changes, a paired phone keeps its bookmark and its
152
+ * pairing cookie across `dsh web` restarts: pair once, never again.
153
+ * Requires `publicBaseUrl` to name that same hostname (the token does not
154
+ * carry it); without a valid `publicBaseUrl` the tunnel stays off and a
155
+ * warning explains what is missing. Treated as a secret: the settings
156
+ * surface stores it redacted.
157
+ */
158
+ tunnelToken?: string
159
+ /**
160
+ * Stable-origin relay: when on (default), the quick tunnel is fronted by a
161
+ * fixed `https://<id>.dsh-market.com` subdomain (the dsh-market worker's
162
+ * registry), so the phone's bookmark and pairing cookie survive `dsh web`
163
+ * restarts without any user setup. Traffic transits the dsh-market edge
164
+ * (the same trust point as the quick tunnel itself); turn off to fall back
165
+ * to the raw ephemeral quick URL. Named tunnels keep their dashboard
166
+ * hostname and never touch the relay.
167
+ */
168
+ relay?: boolean
142
169
  /**
143
170
  * LAN bind toggle. When the user flips it (true or false) the plugin
144
171
  * writes the managed webserver block into the profile patch — true pins
@@ -174,6 +201,8 @@ export const Config: z<Config> = z.object({
174
201
  publicBaseUrl: z.string(),
175
202
  devicesFile: z.string(),
176
203
  autoTunnel: z.boolean().default(false),
204
+ tunnelToken: z.string().role('secret'),
205
+ relay: z.boolean().default(true),
177
206
  lanBind: z.boolean(),
178
207
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
179
208
  enabled: z.boolean().default(true),
@@ -187,11 +216,12 @@ const SWEEP_INTERVAL_MS = 10_000
187
216
  * which legitimately resolves to `undefined` when unset (the schema keeps it
188
217
  * optional, so `Required` alone would over-narrow it to `string`).
189
218
  */
190
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile'>> & {
219
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
191
220
  publicBaseUrl: string | undefined
192
221
  devicesFile: string
193
222
  /** undefined until the user flips the toggle once; undefined never writes the patch. */
194
223
  lanBind: boolean | undefined
224
+ tunnelToken: string | undefined
195
225
  profile: string
196
226
  }
197
227
 
@@ -231,6 +261,8 @@ const DEFAULTS: ResolvedConfig = {
231
261
  publicBaseUrl: undefined,
232
262
  devicesFile: defaultDevicesFile(),
233
263
  autoTunnel: false,
264
+ tunnelToken: undefined,
265
+ relay: true,
234
266
  lanBind: undefined,
235
267
  profile: process.env.DSH_PROFILE ?? 'web',
236
268
  enabled: true,
@@ -254,6 +286,8 @@ function applyImpl(ctx: Context, config?: Config): void {
254
286
  publicBaseUrl: config?.publicBaseUrl,
255
287
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
256
288
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
289
+ tunnelToken: config?.tunnelToken,
290
+ relay: config?.relay ?? DEFAULTS.relay,
257
291
  lanBind: config?.lanBind,
258
292
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
259
293
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -274,6 +308,8 @@ function applyImpl(ctx: Context, config?: Config): void {
274
308
  publicBaseUrl: value.publicBaseUrl,
275
309
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
276
310
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
311
+ tunnelToken: value.tunnelToken,
312
+ relay: value.relay ?? DEFAULTS.relay,
277
313
  lanBind: value.lanBind,
278
314
  profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
279
315
  enabled: value.enabled ?? DEFAULTS.enabled,
@@ -288,24 +324,89 @@ function applyImpl(ctx: Context, config?: Config): void {
288
324
  // mutation is needed here (a distributable plugin must not change the
289
325
  // harness's connection plugin).
290
326
  const tunnel = new TunnelManager()
291
- let autoTunnel = resolved.autoTunnel
327
+ // Relay registry: one stable `<id>.dsh-market.com` subdomain per profile
328
+ // in front of the quick tunnel. The QR base prefers the relay origin once
329
+ // it is registered; until then (or when the registry is unreachable) the
330
+ // raw quick URL is used, exactly as before. Named tunnels keep their fixed
331
+ // dashboard hostname and never touch the relay.
332
+ let relayRegistrar: RelayRegistrar | undefined
333
+ let relayUrl: string | undefined
334
+ let rawTunnelUrl: string | undefined
335
+ /** The tunnel target the registrar last announced (dedupes sync re-runs). */
336
+ let relayAnnouncedFor: string | undefined
337
+ const setPublicBase = (): void => {
338
+ service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl)
339
+ }
340
+ const disposeRelayRegistrar = (unregister: boolean = false): void => {
341
+ const registrar = relayRegistrar
342
+ relayRegistrar = undefined
343
+ relayUrl = undefined
344
+ relayAnnouncedFor = undefined
345
+ if (registrar === undefined) return
346
+ // Toggle-off removes the registry row so the stable origin stops
347
+ // proxying; teardown/mode changes keep the row (the phone then sees the
348
+ // relay's offline page instead of a dangling claim, and re-enabling
349
+ // reuses the same identity).
350
+ if (unregister) void registrar.unregister().catch(() => undefined)
351
+ registrar.dispose()
352
+ }
353
+ const ensureRelayRegistrar = (): RelayRegistrar | undefined => {
354
+ if (resolve().relay === false) return undefined
355
+ if (relayRegistrar === undefined) {
356
+ try {
357
+ const identity = loadRelayIdentity(resolve().profile)
358
+ relayRegistrar = new RelayRegistrar(identity, (state: RelayState) => {
359
+ service.setRelayStatus(state.state === 'off' ? undefined : state)
360
+ if (state.state === 'running') {
361
+ relayUrl = state.url
362
+ } else if (state.state === 'off') {
363
+ relayUrl = undefined
364
+ } else if (state.state === 'failed') {
365
+ // Keep the last relay URL on failures: the phone origin only
366
+ // breaks when the mapping itself goes stale, not when one
367
+ // refresh call fails. The registrar retries with backoff.
368
+ console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`)
369
+ }
370
+ setPublicBase()
371
+ })
372
+ } catch (error) {
373
+ console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`)
374
+ }
375
+ }
376
+ return relayRegistrar
377
+ }
378
+ const announceRelay = (registrar: RelayRegistrar, target: string): void => {
379
+ if (relayAnnouncedFor === target) return
380
+ relayAnnouncedFor = target
381
+ void registrar.announce(target)
382
+ }
383
+ // 'off' until a sync pass turns a mode on; the phase listener only feeds
384
+ // the public base while a plugin-managed tunnel (quick or named) runs.
385
+ let tunnelMode: 'off' | 'quick' | 'named' = resolved.autoTunnel ? 'quick' : 'off'
292
386
  tunnel.onPhase((info: TunnelInfo) => {
293
- if (!autoTunnel) return
387
+ if (tunnelMode === 'off') return
294
388
  if (info.phase === 'running' && info.url !== undefined) {
295
- service.setPublicBaseUrl(info.url)
389
+ rawTunnelUrl = info.url
390
+ setPublicBase()
296
391
  service.setTunnelStatus({ state: 'running', url: info.url })
392
+ const registrar = tunnelMode === 'quick' ? ensureRelayRegistrar() : undefined
393
+ if (registrar !== undefined) announceRelay(registrar, info.url)
297
394
  runPostureProbe()
298
395
  } else if (info.phase === 'starting') {
299
396
  // A restart mints a NEW hostname: the previous URL dies with the old
300
397
  // process, so clear it now rather than advertising a dead link.
301
- service.setPublicBaseUrl(undefined)
398
+ rawTunnelUrl = undefined
399
+ relayUrl = undefined
400
+ setPublicBase()
302
401
  service.setTunnelStatus({ state: 'starting' })
303
402
  } else if (info.phase === 'failed') {
304
- service.setPublicBaseUrl(undefined)
403
+ rawTunnelUrl = undefined
404
+ setPublicBase()
305
405
  service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error })
306
406
  }
307
407
  })
308
408
  ctx.effect(() => () => {
409
+ disposeRelayRegistrar()
309
410
  tunnel.dispose()
310
411
  }, 'remote-web-ui: auto tunnel')
311
412
  // The bind facts are known by now (webServer is an inject edge): the LAN
@@ -639,17 +740,54 @@ function applyImpl(ctx: Context, config?: Config): void {
639
740
  }
640
741
  }
641
742
  }
642
- // The auto tunnel owns the public base while enabled: the minted URL
643
- // lands in the service through the tunnel's phase listener. The manual
644
- // publicBaseUrl applies only when the auto tunnel is off.
645
- autoTunnel = value.autoTunnel === true
646
- if (autoTunnel) {
647
- if (value.publicBaseUrl !== undefined) {
648
- console.warn('remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl')
743
+ // The plugin-managed tunnels own the public base while one runs: the URL
744
+ // lands in the service through the tunnel's phase listener (the minted
745
+ // quick URL, or the named tunnel's fixed public hostname). The manual
746
+ // publicBaseUrl applies only when no tunnel runs.
747
+ const plan = tunnelPlanOf(value, ctx.webServer.port)
748
+ tunnelMode = plan.mode
749
+ if (plan.mode !== 'quick') {
750
+ // The relay only fronts the quick tunnel; named mode owns its fixed
751
+ // dashboard hostname and the off mode has no public base at all.
752
+ disposeRelayRegistrar()
753
+ if (plan.mode !== 'named') setPublicBase()
754
+ } else if (value.relay === false) {
755
+ // The relay toggle is off: no stable origin, the raw quick URL is the
756
+ // QR base exactly as before the relay existed.
757
+ disposeRelayRegistrar(true)
758
+ setPublicBase()
759
+ } else if (rawTunnelUrl !== undefined) {
760
+ // The relay just turned on (or the registrar is new) while the tunnel
761
+ // already runs: announce now — no phase event will fire for an
762
+ // unchanged target.
763
+ const registrar = ensureRelayRegistrar()
764
+ if (registrar !== undefined) announceRelay(registrar, rawTunnelUrl)
765
+ }
766
+ if (plan.mode === 'quick') {
767
+ for (const ignored of plan.ignored) {
768
+ console.warn(`remote-web-ui: autoTunnel is on — ignoring the configured ${ignored}`)
649
769
  }
650
- tunnel.start(`http://127.0.0.1:${String(ctx.webServer.port)}`)
770
+ // With the relay on, the connector stamps the stable origin as the
771
+ // origin-side Host (`--http-host-header`): the plugin fence and the
772
+ // harness browser-auth are Host-bound to the relay origin, and the
773
+ // Workers relay cannot control the origin-side Host (fetch forces it
774
+ // to the URL authority). The target identity change is also what
775
+ // restarts a running tunnel on a relay toggle.
776
+ const registrar = ensureRelayRegistrar()
777
+ const originHostHeader = registrar === undefined ? undefined : new URL(registrar.baseUrl).host
778
+ tunnel.start(originHostHeader === undefined
779
+ ? plan.targetUrl
780
+ : { kind: 'quick', targetUrl: plan.targetUrl, originHostHeader })
781
+ } else if (plan.mode === 'named') {
782
+ tunnel.start({ kind: 'named', token: plan.token, publicUrl: plan.publicUrl })
651
783
  } else {
652
784
  tunnel.stop()
785
+ // A named-tunnel token without a usable public hostname cannot serve
786
+ // the QR: stay off and say exactly what is missing instead of running
787
+ // a tunnel nothing points at.
788
+ if (value.tunnelToken !== undefined && value.tunnelToken !== '') {
789
+ console.warn('remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it')
790
+ }
653
791
  // A malformed public base is ignored with a warning — LAN-only behavior
654
792
  // stays intact rather than silently minting unusable QR links.
655
793
  if (value.publicBaseUrl !== undefined && !isHttpUrl(value.publicBaseUrl)) {
@@ -724,13 +862,3 @@ function applyImpl(ctx: Context, config?: Config): void {
724
862
  })
725
863
  sync()
726
864
  }
727
-
728
- /** Whether a configured public base is a parseable http(s) URL with a host. */
729
- function isHttpUrl(value: string): boolean {
730
- try {
731
- const url = new URL(value)
732
- return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== ''
733
- } catch {
734
- return false
735
- }
736
- }
package/src/pairing.ts CHANGED
@@ -103,6 +103,19 @@ export interface TunnelStatus {
103
103
  error?: string
104
104
  }
105
105
 
106
+ /**
107
+ * One relay-registry status frame (undefined while the stable-origin relay
108
+ * is not in play: autoTunnel off, or the relay toggle disabled).
109
+ */
110
+ export interface RelayStatus {
111
+ /** registering: a sync is in flight; running: the mapping is accepted. */
112
+ state: 'off' | 'registering' | 'running' | 'failed'
113
+ /** The stable relay origin (`https://<id>.dsh-market.com`). */
114
+ url?: string
115
+ /** Human-readable failure detail of the last sync attempt. */
116
+ error?: string
117
+ }
118
+
106
119
  /** One snapshot frame pushed to desktop status streams. */
107
120
  export interface PairingSnapshot {
108
121
  phase: PairingPhase
@@ -114,6 +127,8 @@ export interface PairingSnapshot {
114
127
  publicUrl?: string
115
128
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
116
129
  tunnel?: TunnelStatus
130
+ /** Relay-registry status, while the stable-origin relay is in play. */
131
+ relay?: RelayStatus
117
132
  /** Latest /api posture probe (undefined until the first round completes). */
118
133
  posture?: PostureSnapshot
119
134
  /** Opaque (non-secret) id of the active token (undefined when stopped/lan-required). */
@@ -202,6 +217,7 @@ export class PairingService {
202
217
  private publicBase: string | undefined
203
218
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
204
219
  private tunnelStatus: TunnelStatus | undefined
220
+ private relayStatus: RelayStatus | undefined
205
221
  private posture: PostureSnapshot | undefined
206
222
  /** True when lastSeenAt changed since the last persist (flushed on sweep). */
207
223
  private dirty = false
@@ -339,6 +355,12 @@ export class PairingService {
339
355
  this.notify()
340
356
  }
341
357
 
358
+ /** Set or clear the relay-registry status frame (undefined when not in play). */
359
+ setRelayStatus(status: RelayStatus | undefined): void {
360
+ this.relayStatus = status
361
+ this.notify()
362
+ }
363
+
342
364
  /** Set the latest /api posture probe result (see posture.ts). */
343
365
  setPosture(snapshot: PostureSnapshot | undefined): void {
344
366
  this.posture = snapshot
@@ -497,6 +519,7 @@ export class PairingService {
497
519
  lanAddresses: [...this.lanBases.keys()],
498
520
  ...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
499
521
  ...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
522
+ ...(this.relayStatus !== undefined ? { relay: this.relayStatus } : {}),
500
523
  ...(this.posture !== undefined ? { posture: this.posture } : {}),
501
524
  ...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
502
525
  deviceCount: this.devices.size,
@@ -588,6 +611,7 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
588
611
  && sameStrings(a.lanAddresses, b.lanAddresses)
589
612
  && a.publicUrl === b.publicUrl
590
613
  && tunnelEqual(a.tunnel, b.tunnel)
614
+ && relayEqual(a.relay, b.relay)
591
615
  && a.tokenId === b.tokenId
592
616
  && a.tokenExpiresAt === b.tokenExpiresAt
593
617
  && a.deviceCount === b.deviceCount
@@ -614,6 +638,12 @@ function tunnelEqual(a: TunnelStatus | undefined, b: TunnelStatus | undefined):
614
638
  && a.state === b.state && a.url === b.url && a.error === b.error)
615
639
  }
616
640
 
641
+ /** Relay frame equality (same shape as the tunnel frame). */
642
+ function relayEqual(a: RelayStatus | undefined, b: RelayStatus | undefined): boolean {
643
+ return a === b || (a !== undefined && b !== undefined
644
+ && a.state === b.state && a.url === b.url && a.error === b.error)
645
+ }
646
+
617
647
  /** Element-wise string list equality (interface order is meaningful). */
618
648
  function sameStrings(a: readonly string[], b: readonly string[]): boolean {
619
649
  return a.length === b.length && a.every((value, index) => value === b[index])