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

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +3 -3
  3. package/README.zh.md +3 -3
  4. package/lib/client.js +55 -21
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +174 -39
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +11 -1
  9. package/lib/types/client/RemotePanel.d.ts +5 -1
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +2 -2
  12. package/lib/types/client/locales.d.ts +8 -0
  13. package/lib/types/client/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.js +8 -0
  15. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  16. package/lib/types/client/mobile-adapt.js +7 -12
  17. package/lib/types/client/remote-channel.d.ts +0 -2
  18. package/lib/types/client/remote-channel.d.ts.map +1 -1
  19. package/lib/types/client/remote-channel.js +0 -4
  20. package/lib/types/index.d.ts +9 -1
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +54 -27
  23. package/lib/types/loopback-proxy.d.ts.map +1 -1
  24. package/lib/types/loopback-proxy.js +16 -0
  25. package/lib/types/remote-api.d.ts +1 -1
  26. package/lib/types/remote-api.d.ts.map +1 -1
  27. package/lib/types/remote-api.js +1 -1
  28. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  29. package/lib/types/remote-channel-boot.js +0 -1
  30. package/lib/types/remote-channel-rules.d.ts +0 -1
  31. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  32. package/lib/types/remote-channel-rules.js +0 -1
  33. package/lib/types/remote-methods.d.ts +1 -4
  34. package/lib/types/remote-methods.d.ts.map +1 -1
  35. package/lib/types/remote-methods.js +1 -5
  36. package/lib/types/routes.d.ts +26 -2
  37. package/lib/types/routes.d.ts.map +1 -1
  38. package/lib/types/routes.js +150 -5
  39. package/package.json +18 -18
  40. package/src/client/RemoteEntry.tsx +12 -0
  41. package/src/client/RemotePanel.tsx +37 -2
  42. package/src/client/locales.ts +8 -0
  43. package/src/client/mobile-adapt.ts +5 -10
  44. package/src/client/remote-channel.ts +0 -3
  45. package/src/index.ts +59 -26
  46. package/src/loopback-proxy.ts +15 -0
  47. package/src/remote-api.ts +0 -1
  48. package/src/remote-channel-boot.ts +0 -1
  49. package/src/remote-channel-rules.ts +0 -2
  50. package/src/remote-methods.ts +1 -6
  51. package/src/routes.ts +141 -7
@@ -67,6 +67,87 @@ export function isTrustedApiRequest(request, trustedHosts) {
67
67
  return false;
68
68
  }
69
69
  }
70
+ /**
71
+ * Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
72
+ * Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
73
+ * machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
74
+ */
75
+ export function isPrivateOrLocalHostname(hostname) {
76
+ const normalized = hostname.toLowerCase().trim();
77
+ if (normalized === 'localhost' ||
78
+ normalized.endsWith('.local') ||
79
+ normalized.endsWith('.lan') ||
80
+ normalized.endsWith('.internal') ||
81
+ normalized.endsWith('.home.arpa')) {
82
+ return true;
83
+ }
84
+ let ip = normalized;
85
+ if (ip.startsWith('[') && ip.endsWith(']'))
86
+ ip = ip.slice(1, -1);
87
+ if (ip === '::1' || ip === '127.0.0.1')
88
+ return true;
89
+ if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80'))
90
+ return true;
91
+ const parts = ip.split('.').map(Number);
92
+ if (parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
93
+ const [a, b] = parts;
94
+ if (a === 10)
95
+ return true;
96
+ if (a === 127)
97
+ return true;
98
+ if (a === 169 && b === 254)
99
+ return true;
100
+ if (a === 172 && b >= 16 && b <= 31)
101
+ return true;
102
+ if (a === 192 && b === 168)
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
109
+ * and ensures Origin matches Host when present).
110
+ */
111
+ export function isNonCrossSite(request) {
112
+ if (request.headers['sec-fetch-site'] === 'cross-site')
113
+ return false;
114
+ const host = request.headers.host;
115
+ if (typeof host !== 'string')
116
+ return false;
117
+ const origin = request.headers.origin;
118
+ if (origin === undefined)
119
+ return true;
120
+ try {
121
+ const originUrl = new URL(origin);
122
+ const hostUrl = new URL(`http://${host}`);
123
+ return originUrl.host === hostUrl.host;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * The private-LAN fallback authority of a request: its Host header when the
131
+ * hostname is private/local (RFC 1918 / ULA / mDNS / loopback) and the
132
+ * browser markers are non-cross-site, else undefined. The accept paths and
133
+ * the lanFence fallback use this to serve container-bridged and reverse-proxy
134
+ * topologies whose Host is not among the advertised or configured authorities.
135
+ */
136
+ function privateLanHostOf(request) {
137
+ const host = request.headers.host;
138
+ if (typeof host !== 'string')
139
+ return undefined;
140
+ let hostName = '';
141
+ try {
142
+ hostName = new URL(`http://${host}`).hostname;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ if (hostName === '' || !isPrivateOrLocalHostname(hostName) || !isNonCrossSite(request))
148
+ return undefined;
149
+ return host;
150
+ }
70
151
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
71
152
  const MAX_BODY_BYTES = 4096;
72
153
  /**
@@ -84,6 +165,28 @@ export function acceptLimitKey(socketIp, forwarded, bucket) {
84
165
  }
85
166
  return `${bucket}|${socketIp}`;
86
167
  }
168
+ /**
169
+ * Cap on the dynamically trusted hosts (see {@link addBounded}): room for
170
+ * every legitimate router/proxy aliasing shape, small enough that a flood of
171
+ * distinct private Host headers cannot grow the table unboundedly.
172
+ */
173
+ export const MAX_DYNAMIC_TRUSTED_HOSTS = 64;
174
+ /**
175
+ * FIFO-bounded Set insert: past `max` entries the oldest one is evicted
176
+ * (a Set iterates in insertion order). The dynamic trusted-host table is fed
177
+ * by caller-controlled Host headers, so it must stay bounded; an evicted
178
+ * legitimate host re-adds itself on that device's next gated request.
179
+ */
180
+ export function addBounded(set, value, max) {
181
+ if (set.has(value))
182
+ return;
183
+ if (set.size >= max) {
184
+ const oldest = set.values().next().value;
185
+ if (oldest !== undefined)
186
+ set.delete(oldest);
187
+ }
188
+ set.add(value);
189
+ }
87
190
  /**
88
191
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
89
192
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -324,20 +427,45 @@ export class PairingEventsStream {
324
427
  * @returns the exact routes to register on webServer.
325
428
  */
326
429
  export function makeRoutes(deps) {
327
- const { service, lanAddresses, requirePairingForLan = true } = deps;
430
+ const { service, requirePairingForLan = true } = deps;
328
431
  const pairingRequired = () => typeof requirePairingForLan === 'function'
329
432
  ? requirePairingForLan()
330
433
  : requirePairingForLan;
331
434
  const events = new PairingEventsStream(service);
435
+ const dynamicTrustedHosts = new Set();
332
436
  /** Loopback-only fence: the desktop panel's control endpoints. */
333
437
  const loopbackFence = (req) => isTrustedApiRequest(req, []);
334
- /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
438
+ /** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
335
439
  const lanFence = (req) => {
336
440
  const publicHost = publicHostOf(service.publicBaseUrl);
337
441
  // The service's LAN bases re-read per request: a hot rebind (the lan-bind
338
442
  // toggle) updates them mid-process, and the fence must follow.
339
443
  const bases = service.lanAddresses;
340
- return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost]);
444
+ const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? []);
445
+ const combined = [
446
+ ...bases,
447
+ ...(publicHost !== undefined ? [publicHost] : []),
448
+ ...extraHosts,
449
+ ...dynamicTrustedHosts,
450
+ ];
451
+ if (isTrustedApiRequest(req, combined))
452
+ return true;
453
+ // If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
454
+ const privateLanHost = privateLanHostOf(req);
455
+ if (privateLanHost !== undefined) {
456
+ const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName);
457
+ let queryDeviceId = null;
458
+ try {
459
+ queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device');
460
+ }
461
+ catch { }
462
+ const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined;
463
+ if (deviceId !== undefined && service.hasDevice(deviceId)) {
464
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
465
+ return true;
466
+ }
467
+ }
468
+ return false;
341
469
  };
342
470
  const requireMethod = (req, res, method) => {
343
471
  if (req.method === method)
@@ -434,7 +562,8 @@ export function makeRoutes(deps) {
434
562
  const handleAccept = async (req, res) => {
435
563
  if (!requireMethod(req, res, 'POST'))
436
564
  return;
437
- if (!lanFence(req)) {
565
+ const privateLanHost = privateLanHostOf(req);
566
+ if (!lanFence(req) && privateLanHost === undefined) {
438
567
  writeJson(res, 403, { ok: false, code: 'forbidden' });
439
568
  return;
440
569
  }
@@ -451,9 +580,16 @@ export function makeRoutes(deps) {
451
580
  const ua = req.headers['user-agent'];
452
581
  const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined);
453
582
  if (!result.ok) {
583
+ if (!lanFence(req)) {
584
+ writeJson(res, 403, { ok: false, code: 'forbidden' });
585
+ return;
586
+ }
454
587
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
455
588
  return;
456
589
  }
590
+ if (privateLanHost !== undefined) {
591
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
592
+ }
457
593
  // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
458
594
  // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
459
595
  // top-level navigations working while blocking cross-site subrequests.
@@ -570,7 +706,8 @@ export function makeRoutes(deps) {
570
706
  const handleAcceptPage = async (req, res) => {
571
707
  if (!requireMethod(req, res, 'GET'))
572
708
  return;
573
- if (!lanFence(req)) {
709
+ const privateLanHost = privateLanHostOf(req);
710
+ if (!lanFence(req) && privateLanHost === undefined) {
574
711
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
575
712
  res.end('forbidden');
576
713
  return;
@@ -585,6 +722,11 @@ export function makeRoutes(deps) {
585
722
  const ua = req.headers['user-agent'];
586
723
  const result = token === '' ? { ok: false, code: 'invalid' } : service.accept(token, typeof ua === 'string' ? ua : undefined);
587
724
  if (!result.ok) {
725
+ if (!lanFence(req)) {
726
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
727
+ res.end('forbidden');
728
+ return;
729
+ }
588
730
  // accept() now refuses only expired/unknown/stopped tokens: a consumed
589
731
  // token stays re-usable until its expiry or replacement, so a mobile
590
732
  // flow that split across cookie contexts (camera preview, in-app
@@ -604,6 +746,9 @@ export function makeRoutes(deps) {
604
746
  res.end(pairingFailurePage());
605
747
  return;
606
748
  }
749
+ if (privateLanHost !== undefined) {
750
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
751
+ }
607
752
  // Land the paired device on the cookieless app page: the official shell
608
753
  // served by this plugin, with the device id in the URL. No harness index
609
754
  // gate, no browser-auth cookie hop - the channel gate accepts the device
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.13",
4
+ "version": "0.3.15",
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.4"
39
+ "dsh": ">=0.1.2-rc.1"
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.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",
66
+ "@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
67
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
68
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
69
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-rc.1",
70
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
71
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
72
+ "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-rc.1",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
78
+ "@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
79
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
80
+ "@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
81
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
82
82
  "@testing-library/dom": "^10.4.1",
83
83
  "@testing-library/react": "^16.3.2",
84
84
  "@types/node": "^22.20.0",
@@ -62,6 +62,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
62
62
  const stateRef = useRef(state)
63
63
  useEffect(() => { stateRef.current = state }, [state])
64
64
  const [copied, setCopied] = useState<boolean>(false)
65
+ const [copiedToken, setCopiedToken] = useState<boolean>(false)
65
66
  const eventSource = useRef<EventSource | undefined>(undefined)
66
67
  // Generation counter for the open flow: closing (or re-opening) the panel
67
68
  // bumps it, so an in-flight issue() that resolves after a close does not
@@ -94,6 +95,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
94
95
  return {
95
96
  kind: 'ready',
96
97
  url: result.url,
98
+ token: result.token,
97
99
  expiresAt: result.expiresAt,
98
100
  expired: Date.now() > result.expiresAt,
99
101
  phase: 'waiting',
@@ -215,6 +217,14 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
215
217
  })
216
218
  }, [])
217
219
 
220
+ const handleCopyToken = useCallback((token: string) => {
221
+ void copyText(token).then((ok) => {
222
+ if (!ok) return
223
+ setCopiedToken(true)
224
+ window.setTimeout(() => { setCopiedToken(false) }, 1500)
225
+ })
226
+ }, [])
227
+
218
228
  return (
219
229
  <>
220
230
  <div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
@@ -228,10 +238,12 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
228
238
  t={t}
229
239
  state={state}
230
240
  copied={copied}
241
+ copiedToken={copiedToken}
231
242
  onClose={closePanel}
232
243
  onStop={handleStop}
233
244
  onRefresh={handleRefresh}
234
245
  onCopy={handleCopy}
246
+ onCopyToken={handleCopyToken}
235
247
  onPickAddress={handlePickAddress}
236
248
  onPickPublic={handlePickPublic}
237
249
  onRevoke={handleRevoke}
@@ -41,6 +41,8 @@ export type PanelState =
41
41
  address: string
42
42
  /** Every constructible LAN literal (interface order). */
43
43
  lanAddresses: string[]
44
+ /** The active pairing token itself (useful in Docker/reverse proxy topologies). */
45
+ token?: string
44
46
  /** Whether this QR is built on the configured public (tunneled) base. */
45
47
  public: boolean
46
48
  /** The configured public (tunneled) base URL, when present. */
@@ -58,10 +60,12 @@ export interface RemotePanelProps {
58
60
  t: TranslateNS<'remote'>
59
61
  state: PanelState
60
62
  copied: boolean
63
+ copiedToken?: boolean
61
64
  onClose(): void
62
65
  onStop(): void
63
66
  onRefresh(): void
64
67
  onCopy(url: string): void
68
+ onCopyToken?(token: string): void
65
69
  /** Re-mint the QR against a different LAN address. */
66
70
  onPickAddress(address: string): void
67
71
  /** Re-mint the QR against the configured public (tunneled) base. */
@@ -89,7 +93,20 @@ function statusOf(
89
93
  * @param props - copy, state, and actions.
90
94
  * @returns the panel element tree.
91
95
  */
92
- export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }: RemotePanelProps) {
96
+ export function RemotePanel({
97
+ t,
98
+ state,
99
+ copied,
100
+ copiedToken,
101
+ onClose,
102
+ onStop,
103
+ onRefresh,
104
+ onCopy,
105
+ onCopyToken,
106
+ onPickAddress,
107
+ onPickPublic,
108
+ onRevoke,
109
+ }: RemotePanelProps) {
93
110
  return (
94
111
  <div className={css.panel} role="dialog" aria-modal="true" aria-label={t('title')}>
95
112
  <div className={css.header}>
@@ -157,8 +174,26 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
157
174
  {copied ? t('action.copied') : t('action.copyLink')}
158
175
  </button>
159
176
  </div>
177
+ {state.token !== undefined && state.token !== '' && (
178
+ <div className={css.pairLinkRow}>
179
+ <div className={css.pairLinkText}>
180
+ <span className={css.pairLinkLabel}>{t('pair.tokenLabel')}</span>
181
+ <code className={css.link} title={state.token}>{state.token}</code>
182
+ </div>
183
+ <button
184
+ type="button"
185
+ className={css.copyLink}
186
+ onClick={() => onCopyToken ? onCopyToken(state.token!) : onCopy(state.token!)}
187
+ >
188
+ <IconCopyOutline16 size={14} />
189
+ {copiedToken ? t('action.copiedToken') : t('action.copyToken')}
190
+ </button>
191
+ </div>
192
+ )}
160
193
  </div>
161
- <p className={css.oneTimeHint}>{t('pair.oneTimeHint')}</p>
194
+ <p className={css.oneTimeHint}>
195
+ {t('pair.oneTimeHint')} {t('pair.dockerHint')}
196
+ </p>
162
197
  {state.phase === 'stopped' && <p className={css.stoppedHint}>{t('stopped.hint')}</p>}
163
198
  {state.tunnel !== undefined && state.tunnel.state !== 'running' && (
164
199
  <p className={state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote} role="status">
@@ -29,6 +29,8 @@ export const zh = {
29
29
  'pair.expires': '二维码有效至 {time}',
30
30
  'pair.expired': '二维码已过期,请刷新',
31
31
  'pair.linkLabel': '配对链接',
32
+ 'pair.tokenLabel': '配对令牌',
33
+ 'pair.dockerHint': 'Docker 或反向代理环境下,可直接复制此令牌并在目标设备配对页面输入。',
32
34
  'pair.oneTimeHint': '链接含一次性令牌;任一设备配对成功后立即失效,配对下一台设备请刷新二维码。',
33
35
  'pair.failed.title': '配对失败',
34
36
  'pair.failed.detail': '链接无效或已使用,请回到电脑端刷新二维码后重新扫码。',
@@ -52,7 +54,9 @@ export const zh = {
52
54
  'action.refresh': '刷新二维码',
53
55
  'action.copy': '复制链接',
54
56
  'action.copyLink': '复制链接',
57
+ 'action.copyToken': '复制令牌',
55
58
  'action.copied': '已复制',
59
+ 'action.copiedToken': '已复制令牌',
56
60
  'devices.title': '已授权设备',
57
61
  'devices.empty': '还没有已配对的设备。扫码或打开链接后会出现在这里。',
58
62
  'devices.unknown': '未知设备',
@@ -199,6 +203,8 @@ export const en = {
199
203
  'pair.expires': 'QR code valid until {time}',
200
204
  'pair.expired': 'QR code expired — refresh it',
201
205
  'pair.linkLabel': 'Pairing link',
206
+ 'pair.tokenLabel': 'Pairing token',
207
+ 'pair.dockerHint': 'In Docker or reverse proxy environments, copy this token to pair directly on the target device.',
202
208
  'pair.oneTimeHint': 'The link carries one single-use token; it dies as soon as any device pairs. Refresh the QR to pair the next device.',
203
209
  'pair.failed.title': 'Pairing failed',
204
210
  'pair.failed.detail': 'The link is invalid or was already used. Refresh the QR code on your computer and scan again.',
@@ -222,7 +228,9 @@ export const en = {
222
228
  'action.refresh': 'Refresh QR',
223
229
  'action.copy': 'Copy link',
224
230
  'action.copyLink': 'Copy link',
231
+ 'action.copyToken': 'Copy token',
225
232
  'action.copied': 'Copied',
233
+ 'action.copiedToken': 'Token copied',
226
234
  'devices.title': 'Authorized devices',
227
235
  'devices.empty': 'No paired devices yet. Scan the QR or open the link to add one.',
228
236
  'devices.unknown': 'Unknown device',
@@ -236,7 +236,6 @@ const ADAPT_CSS: readonly string[] = [
236
236
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"task-board\"],`,
237
237
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"git-graph\"],`,
238
238
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"pet\"],`,
239
- `body.${ACTIVE_CLASS} [data-dsh-plugin=\"perf\"],`,
240
239
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"usage\"]{display:none !important}`,
241
240
  // The official workbench (Files / source control) mounts into a
242
241
  // full-viewport portal layer with no data-dsh-plugin root, so the plugin
@@ -921,15 +920,11 @@ export function startMobileAdapt(): void {
921
920
  }
922
921
  lanOrigFocus.call(this, options)
923
922
  }
924
- let lastComposerTapRegistered = false
925
- if (!lastComposerTapRegistered) {
926
- lastComposerTapRegistered = true
927
- document.addEventListener('pointerdown', (e) => {
928
- if (!active) return
929
- const t = e.target
930
- if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
931
- }, true)
932
- }
923
+ document.addEventListener('pointerdown', (e) => {
924
+ if (!active) return
925
+ const t = e.target
926
+ if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
927
+ }, true)
933
928
 
934
929
  // The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once
935
930
  // it is live, and flips the master switch when the settings snapshot
@@ -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
package/src/index.ts CHANGED
@@ -126,6 +126,13 @@ export interface Config {
126
126
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
127
127
  */
128
128
  publicBaseUrl?: string
129
+ /**
130
+ * Extra trusted host authorities (exact host:port or port-less host) allowed
131
+ * to access pairing endpoints and remote web services. Useful in Docker, reverse proxy,
132
+ * or NAT environments where incoming request Host headers differ from the container's
133
+ * internal network addresses. Can also be set via the DSH_REMOTE_TRUSTED_HOSTS env var.
134
+ */
135
+ trustedHosts?: string[]
129
136
  /**
130
137
  * Absolute path to a JSON file where paired device sessions are persisted.
131
138
  * Defaults to `$DSH_HOME/remote-web-ui-devices.json` so a paired device
@@ -199,6 +206,7 @@ export const Config: z<Config> = z.object({
199
206
  cookieName: z.string().min(1).default('dsh_pair'),
200
207
  requirePairingForLan: z.boolean().default(true),
201
208
  publicBaseUrl: z.string(),
209
+ trustedHosts: z.array(z.string()),
202
210
  devicesFile: z.string(),
203
211
  autoTunnel: z.boolean().default(false),
204
212
  tunnelToken: z.string().role('secret'),
@@ -216,8 +224,9 @@ const SWEEP_INTERVAL_MS = 10_000
216
224
  * which legitimately resolves to `undefined` when unset (the schema keeps it
217
225
  * optional, so `Required` alone would over-narrow it to `string`).
218
226
  */
219
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
227
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
220
228
  publicBaseUrl: string | undefined
229
+ trustedHosts: string[] | undefined
221
230
  devicesFile: string
222
231
  /** undefined until the user flips the toggle once; undefined never writes the patch. */
223
232
  lanBind: boolean | undefined
@@ -259,6 +268,7 @@ const DEFAULTS: ResolvedConfig = {
259
268
  cookieName: 'dsh_pair',
260
269
  requirePairingForLan: true,
261
270
  publicBaseUrl: undefined,
271
+ trustedHosts: undefined,
262
272
  devicesFile: defaultDevicesFile(),
263
273
  autoTunnel: false,
264
274
  tunnelToken: undefined,
@@ -276,6 +286,7 @@ const DEFAULTS: ResolvedConfig = {
276
286
  export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
277
287
 
278
288
  function applyImpl(ctx: Context, config?: Config): void {
289
+ const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined
279
290
  const resolved: ResolvedConfig = {
280
291
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
281
292
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
@@ -283,7 +294,8 @@ function applyImpl(ctx: Context, config?: Config): void {
283
294
  idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
284
295
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
285
296
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
286
- publicBaseUrl: config?.publicBaseUrl,
297
+ publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
298
+ trustedHosts: config?.trustedHosts,
287
299
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
288
300
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
289
301
  tunnelToken: config?.tunnelToken,
@@ -305,7 +317,8 @@ function applyImpl(ctx: Context, config?: Config): void {
305
317
  idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
306
318
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
307
319
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
308
- publicBaseUrl: value.publicBaseUrl,
320
+ publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
321
+ trustedHosts: value.trustedHosts,
309
322
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
310
323
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
311
324
  tunnelToken: value.tunnelToken,
@@ -445,13 +458,15 @@ function applyImpl(ctx: Context, config?: Config): void {
445
458
  // directory on update, so a boot-time captured path would fail to read
446
459
  // after a successful update; versions are re-read from disk per check.
447
460
  const requireFromHost = createRequire(import.meta.url)
448
- const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(specifier => {
461
+ /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
462
+ const hostResolve = (specifier: string): string | undefined => {
449
463
  try {
450
464
  return requireFromHost.resolve(specifier)
451
465
  } catch {
452
466
  return undefined
453
467
  }
454
- })
468
+ }
469
+ const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(hostResolve)
455
470
 
456
471
  const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
457
472
  const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
@@ -467,13 +482,7 @@ function applyImpl(ctx: Context, config?: Config): void {
467
482
  fence: request => isTrustedApiRequest(request, []),
468
483
  check: () => checkUpdates({
469
484
  anchorManifestPath: resolveAnchorPath(),
470
- resolve: specifier => {
471
- try {
472
- return requireFromHost.resolve(specifier)
473
- } catch {
474
- return undefined
475
- }
476
- },
485
+ resolve: hostResolve,
477
486
  fetchLatest: name => fetchLatestVersion(name, fetch),
478
487
  fetchReleaseNotes: fetchReleaseNotesCached,
479
488
  }),
@@ -497,13 +506,7 @@ function applyImpl(ctx: Context, config?: Config): void {
497
506
  run: { profileDir: target.profileDir, packages: target.packages },
498
507
  check: {
499
508
  anchorManifestPath: resolveAnchorPath(),
500
- resolve: specifier => {
501
- try {
502
- return requireFromHost.resolve(specifier)
503
- } catch {
504
- return undefined
505
- }
506
- },
509
+ resolve: hostResolve,
507
510
  fetchLatest: name => fetchLatestVersion(name, fetch),
508
511
  fetchReleaseNotes: fetchReleaseNotesCached,
509
512
  },
@@ -589,10 +592,29 @@ function applyImpl(ctx: Context, config?: Config): void {
589
592
  const routes = [
590
593
  ...makeRoutes({
591
594
  service,
592
- lanAddresses: service.lanAddresses,
593
595
  requirePairingForLan: () => resolve().requirePairingForLan,
594
596
  lanBindStatus,
595
597
  indexDocument: fetchAppShell,
598
+ trustedHosts: () => {
599
+ const list: string[] = []
600
+ const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS
601
+ if (typeof envHosts === 'string' && envHosts.trim() !== '') {
602
+ for (const item of envHosts.split(',')) {
603
+ const trimmed = item.trim()
604
+ if (trimmed !== '') list.push(trimmed)
605
+ }
606
+ }
607
+ const cfgHosts = resolve().trustedHosts
608
+ if (Array.isArray(cfgHosts)) {
609
+ for (const item of cfgHosts) {
610
+ if (typeof item === 'string') {
611
+ const trimmed = item.trim()
612
+ if (trimmed !== '') list.push(trimmed)
613
+ }
614
+ }
615
+ }
616
+ return list
617
+ },
596
618
  }),
597
619
  // The remote desktop channel: policy-gated `/remote` prefix that
598
620
  // re-issues fenced paths to loopback (see remote-api.ts). The live
@@ -852,13 +874,24 @@ function applyImpl(ctx: Context, config?: Config): void {
852
874
  }), 'remote-web-ui: remote channel boot patch')
853
875
 
854
876
  ctx.inject(['settings'], (settingsCtx) => {
855
- settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
856
- setSource: (source) => {
857
- current = source
877
+ try {
878
+ if (typeof settingsCtx.settings?.installSection === 'function') {
879
+ settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
880
+ setSource: (source) => {
881
+ current = source
882
+ sync()
883
+ },
884
+ onChange: sync,
885
+ })
886
+ } else if (typeof settingsCtx.settings?.register === 'function') {
887
+ const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
888
+ current = () => scope?.get?.() ?? (config ?? {})
889
+ scope?.watch?.(() => { sync() })
858
890
  sync()
859
- },
860
- onChange: sync,
861
- })
891
+ }
892
+ } catch {
893
+ // Defensive fallback against settings registration differences
894
+ }
862
895
  })
863
896
  sync()
864
897
  }