@linxin666/dsh-remote-web-ui 0.3.11 → 0.3.13

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 (46) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -4
  3. package/README.zh.md +24 -4
  4. package/lib/client.js +77 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +409 -44
  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/index.d.ts +28 -2
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +138 -23
  23. package/lib/types/pairing.d.ts +17 -0
  24. package/lib/types/pairing.d.ts.map +1 -1
  25. package/lib/types/pairing.js +13 -0
  26. package/lib/types/relay-registry.d.ts +97 -0
  27. package/lib/types/relay-registry.d.ts.map +1 -0
  28. package/lib/types/relay-registry.js +191 -0
  29. package/lib/types/tunnel-plan.d.ts +36 -0
  30. package/lib/types/tunnel-plan.d.ts.map +1 -0
  31. package/lib/types/tunnel-plan.js +43 -0
  32. package/lib/types/tunnel.d.ts +66 -13
  33. package/lib/types/tunnel.d.ts.map +1 -1
  34. package/lib/types/tunnel.js +93 -21
  35. package/package.json +18 -18
  36. package/src/client/RemoteEntry.tsx +2 -1
  37. package/src/client/RemotePanel.tsx +11 -0
  38. package/src/client/RemoteSettingsCard.tsx +43 -1
  39. package/src/client/locales.ts +16 -4
  40. package/src/client/pair-api.ts +11 -0
  41. package/src/client/settings-card.module.css +14 -8
  42. package/src/index.ts +153 -25
  43. package/src/pairing.ts +30 -0
  44. package/src/relay-registry.ts +232 -0
  45. package/src/tunnel-plan.ts +51 -0
  46. package/src/tunnel.ts +120 -24
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
3
- * tunnel --url <local>`) through the `cloudflared` npm package — its
4
- * postinstall downloads the platform binary, so no user-side tooling is
5
- * involved — surfaces the minted `https://xxx.trycloudflare.com` URL, and
6
- * restarts the process after unexpected exits with exponential backoff.
2
+ * Auto-tunnel manager: spawns a Cloudflare tunnel through the `cloudflared`
3
+ * npm package — its postinstall downloads the platform binary, so no
4
+ * user-side tooling is involved — surfaces the public URL, and restarts the
5
+ * process after unexpected exits with exponential backoff. Two modes: the
6
+ * accountless quick tunnel (`https://xxx.trycloudflare.com`, hostname minted
7
+ * per start) and an account named tunnel (`cloudflared tunnel run --token`,
8
+ * fixed public hostname — the mode that lets a paired phone keep its
9
+ * bookmark and pairing cookie across restarts).
7
10
  *
8
11
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
9
12
  * owns the lifecycle policy (binary readiness, URL timeout, restart
@@ -12,15 +15,76 @@
12
15
  * a real binary or network.
13
16
  */
14
17
  import { existsSync } from 'node:fs';
18
+ import { EventEmitter } from 'node:events';
15
19
  import { bin, install, Tunnel } from 'cloudflared';
20
+ /** Compare two targets for the start idempotence check. */
21
+ function sameTarget(left, right) {
22
+ if (left === undefined)
23
+ return false;
24
+ return JSON.stringify(left) === JSON.stringify(right);
25
+ }
26
+ /**
27
+ * Wrap a named-tunnel process so it fits the quick-tunnel handle shape: the
28
+ * fixed public URL is reported through the same `url` event once the first
29
+ * edge connection registers (cloudflared fires `connected` per connection,
30
+ * so only the first is taken), and exit/error pass through. The manager's
31
+ * URL timeout, crash-restart backoff, and stop semantics then stay fully
32
+ * mode-agnostic.
33
+ * @param inner - the running named-tunnel process.
34
+ * @param publicUrl - the fixed public hostname ingress maps to this server.
35
+ * @returns a handle emitting `url` once the tunnel is reachable.
36
+ */
37
+ export function namedTunnelHandle(inner, publicUrl) {
38
+ const handle = new EventEmitter();
39
+ let registered = false;
40
+ const onConnected = () => {
41
+ // cloudflared fires `connected` per edge connection (four on a healthy
42
+ // registration); the fixed URL only needs reporting once.
43
+ if (registered)
44
+ return;
45
+ registered = true;
46
+ handle.emit('url', publicUrl);
47
+ };
48
+ const onExit = (code, signal) => {
49
+ handle.emit('exit', code, signal);
50
+ };
51
+ const onError = (value) => {
52
+ handle.emit('error', value);
53
+ };
54
+ inner.on('connected', onConnected);
55
+ inner.on('exit', onExit);
56
+ inner.on('error', onError);
57
+ handle.stop = () => {
58
+ inner.off('connected', onConnected);
59
+ inner.off('exit', onExit);
60
+ inner.off('error', onError);
61
+ return inner.stop();
62
+ };
63
+ return handle;
64
+ }
16
65
  /** Default binary readiness: download the platform binary on first use. */
17
66
  async function defaultEnsureBinary() {
18
67
  if (existsSync(bin))
19
68
  return;
20
69
  await install(bin);
21
70
  }
22
- /** Default factory: the cloudflared package's quick tunnel (no account). */
23
- function defaultFactory(targetUrl) {
71
+ /** The flags every tunnel mode shares (see the quick factory comment). */
72
+ const SHARED_TUNNEL_FLAGS = { '--no-autoupdate': true, '--protocol': 'http2' };
73
+ /**
74
+ * Spawn flags for one quick target. `originHostHeader` stamps the Host the
75
+ * local webserver sees: the plugin's phone-facing fence trusts the configured
76
+ * public host (the stable relay origin, Host-bound) and the harness
77
+ * browser-auth binds cookies to the Host authority, while the Workers relay
78
+ * cannot control the origin-side Host at all (fetch forces it to the URL
79
+ * authority) — so the connector must restamp it with the stable origin.
80
+ */
81
+ export function quickTunnelFlags(originHostHeader) {
82
+ return originHostHeader === undefined
83
+ ? { ...SHARED_TUNNEL_FLAGS }
84
+ : { ...SHARED_TUNNEL_FLAGS, '--http-host-header': originHostHeader };
85
+ }
86
+ /** Default factory: the cloudflared package's quick and named tunnels. */
87
+ function defaultFactory(target) {
24
88
  // `--no-autoupdate`: the binary must never upgrade itself out from under
25
89
  // the manager (a self-updated binary would break the pinned lifecycle).
26
90
  // `--protocol http2`: the default `auto` prefers QUIC (UDP 7844), which
@@ -29,7 +93,10 @@ function defaultFactory(targetUrl) {
29
93
  // and the auto fallback never fires on a half-open QUIC handshake. http2
30
94
  // rides TCP through the same paths reliably (verified live: auto stuck at
31
95
  // ready 0 on two fresh registrations; http2 ready immediately).
32
- return Tunnel.quick(targetUrl, { '--no-autoupdate': true, '--protocol': 'http2' });
96
+ if (target.kind === 'quick') {
97
+ return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader));
98
+ }
99
+ return namedTunnelHandle(Tunnel.withToken(target.token, { ...SHARED_TUNNEL_FLAGS }), target.publicUrl);
33
100
  }
34
101
  /** Node timers. */
35
102
  const nodeTimer = { setTimeout, clearTimeout };
@@ -47,7 +114,7 @@ export class TunnelManager {
47
114
  phase = 'stopped';
48
115
  url;
49
116
  error;
50
- targetUrl;
117
+ target;
51
118
  handle;
52
119
  urlTimer;
53
120
  restartTimer;
@@ -78,17 +145,22 @@ export class TunnelManager {
78
145
  };
79
146
  }
80
147
  /**
81
- * Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
82
- * different target tears the old tunnel down first; restarting with the
83
- * same target while running is a no-op.
84
- * @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
148
+ * Start (or keep) a tunnel toward `target`. Restarting with a different
149
+ * target tears the old tunnel down first; restarting with the same target
150
+ * while running is a no-op. A string is the quick mode's local target URL.
151
+ *
152
+ * In quick mode the URL surfaces when cloudflared mints the ephemeral
153
+ * trycloudflare hostname; in named mode the fixed `publicUrl` surfaces
154
+ * once the first edge connection registers (see {@link namedTunnelHandle}).
155
+ * @param target - what to run (a string means quick toward that local URL).
85
156
  */
86
- start(targetUrl) {
87
- if (this.targetUrl === targetUrl && (this.phase === 'starting' || this.phase === 'running'))
157
+ start(target) {
158
+ const resolved = typeof target === 'string' ? { kind: 'quick', targetUrl: target } : target;
159
+ if (sameTarget(this.target, resolved) && (this.phase === 'starting' || this.phase === 'running'))
88
160
  return;
89
161
  this.teardown();
90
162
  this.stopping = false;
91
- this.targetUrl = targetUrl;
163
+ this.target = resolved;
92
164
  this.attempts = 0;
93
165
  this.generation += 1;
94
166
  this.attempt();
@@ -97,7 +169,7 @@ export class TunnelManager {
97
169
  stop() {
98
170
  this.teardown();
99
171
  this.stopping = false;
100
- this.targetUrl = undefined;
172
+ this.target = undefined;
101
173
  this.setPhase('stopped');
102
174
  }
103
175
  /** Alias of {@link stop} for plugin-effect disposal. */
@@ -115,7 +187,7 @@ export class TunnelManager {
115
187
  return () => { this.phaseListeners.delete(listener); };
116
188
  }
117
189
  attempt() {
118
- if (this.stopping || this.targetUrl === undefined)
190
+ if (this.stopping || this.target === undefined)
119
191
  return;
120
192
  const gen = this.generation;
121
193
  this.setPhase('starting');
@@ -123,9 +195,9 @@ export class TunnelManager {
123
195
  this.url = undefined;
124
196
  this.error = undefined;
125
197
  void this.ensureBinary().then(() => {
126
- if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
198
+ if (this.stopping || this.target === undefined || gen !== this.generation)
127
199
  return;
128
- const handle = this.factory(this.targetUrl);
200
+ const handle = this.factory(this.target);
129
201
  this.handle = handle;
130
202
  this.urlTimer = this.timer.setTimeout(() => {
131
203
  // The tunnel never reported a URL: kill it and retry with backoff.
@@ -150,7 +222,7 @@ export class TunnelManager {
150
222
  });
151
223
  }).catch((value) => {
152
224
  // Binary install failed (no network, no platform build): report it.
153
- if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
225
+ if (this.stopping || this.target === undefined || gen !== this.generation)
154
226
  return;
155
227
  const message = value instanceof Error ? value.message : String(value);
156
228
  this.fail(`could not obtain the cloudflared binary: ${message}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
- "version": "0.3.11",
4
+ "version": "0.3.13",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -36,7 +36,7 @@
36
36
  "license": "Apache-2.0",
37
37
  "dsh": {
38
38
  "engines": {
39
- "dsh": ">=0.1.2-alpha.3"
39
+ "dsh": ">=0.1.2-alpha.4"
40
40
  },
41
41
  "bundle": {
42
42
  "patch": "./cordis.patch.yml"
@@ -63,22 +63,22 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@deepseek-ai/cordis": "^4.0.2",
66
- "@deepseek-ai/dsh-agent": "^0.1.2-alpha.3",
67
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.3",
68
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.3",
69
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.3",
70
- "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.3",
71
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.3",
72
- "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.3",
73
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.3",
74
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.3",
75
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.3",
76
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-alpha.3",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.3",
78
- "@deepseek-ai/dsh-commands": "^0.1.2-alpha.3",
79
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.3",
80
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
81
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.3",
66
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.4",
67
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.4",
68
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.4",
69
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.4",
70
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.4",
71
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.4",
72
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.4",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.4",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.4",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.4",
76
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-alpha.4",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.4",
78
+ "@deepseek-ai/dsh-commands": "^0.1.2-alpha.4",
79
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.4",
80
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.4",
81
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.4",
82
82
  "@testing-library/dom": "^10.4.1",
83
83
  "@testing-library/react": "^16.3.2",
84
84
  "@types/node": "^22.20.0",
@@ -12,7 +12,7 @@ import { createPortal } from 'react-dom'
12
12
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
13
13
  import type { PairingPhase } from '../pairing.ts'
14
14
  import { RemotePanel, type PanelState } from './RemotePanel.tsx'
15
- import { copyText, issuePair, revokePair, stopPair, type DeviceFrame, type IssueResponse, type PairStateFrame, type TunnelStatusFrame } from './pair-api.ts'
15
+ import { copyText, issuePair, revokePair, stopPair, type DeviceFrame, type IssueResponse, type PairStateFrame, type RelayStatusFrame, type TunnelStatusFrame } from './pair-api.ts'
16
16
  import { PhoneIcon } from './PhoneIcon.tsx'
17
17
  import { UpdateEntry } from './UpdateEntry.tsx'
18
18
  import css from './remote.module.css'
@@ -43,6 +43,7 @@ function mergeFrame(state: PanelState, frame: PairStateFrame): PanelState {
43
43
  onlineCount: frame.onlineCount,
44
44
  devices: frame.devices ?? [],
45
45
  ...(frame.tunnel !== undefined ? { tunnel: frame.tunnel as TunnelStatusFrame } : {}),
46
+ ...(frame.relay !== undefined ? { relay: frame.relay } : {}),
46
47
  ...(frame.posture !== undefined ? { posture: frame.posture } : {}),
47
48
  }
48
49
  }
@@ -16,6 +16,7 @@ import {
16
16
  formatLastSeen,
17
17
  type DeviceFrame,
18
18
  type PostureFrame,
19
+ type RelayStatusFrame,
19
20
  type TunnelStatusFrame,
20
21
  } from './pair-api.ts'
21
22
  import { deviceNameFromUserAgent } from './device-name.ts'
@@ -46,6 +47,8 @@ export type PanelState =
46
47
  publicBaseUrl?: string
47
48
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
48
49
  tunnel?: TunnelStatusFrame
50
+ /** Relay-registry status, while the stable-origin relay is in play. */
51
+ relay?: RelayStatusFrame
49
52
  /** Latest /api posture probe, once a round has completed. */
50
53
  posture?: PostureFrame
51
54
  }
@@ -164,6 +167,14 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
164
167
  : t('tunnel.starting')}
165
168
  </p>
166
169
  )}
170
+ {state.relay?.state === 'registering' && (
171
+ <p className={css.tunnelNote} role="status">{t('relay.registering')}</p>
172
+ )}
173
+ {state.relay?.state === 'failed' && (
174
+ <p className={css.tunnelFailed} role="status">
175
+ {t('relay.failed', { error: state.relay.error ?? t('tunnel.unknownError') })}
176
+ </p>
177
+ )}
167
178
 
168
179
  {(state.publicBaseUrl !== undefined || state.lanAddresses.length > 1) && (
169
180
  <fieldset className={css.addresses}>
@@ -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
  }
@@ -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;