@linxin666/dsh-remote-web-ui 0.4.6 → 0.4.7

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 +13 -13
  3. package/README.zh.md +13 -13
  4. package/lib/client.js +21 -90
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +398 -43
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +3 -15
  9. package/lib/types/client/RemotePanel.d.ts +1 -9
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +7 -4
  12. package/lib/types/client/locales.d.ts +4 -8
  13. package/lib/types/client/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.js +4 -8
  15. package/lib/types/client/settings-entry-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-entry-form.js +0 -1
  17. package/lib/types/index.d.ts +14 -0
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +67 -17
  20. package/lib/types/lan-source.d.ts +65 -0
  21. package/lib/types/lan-source.d.ts.map +1 -0
  22. package/lib/types/lan-source.js +113 -0
  23. package/lib/types/lan.d.ts +49 -3
  24. package/lib/types/lan.d.ts.map +1 -1
  25. package/lib/types/lan.js +120 -6
  26. package/lib/types/remote-api.d.ts +7 -2
  27. package/lib/types/remote-api.d.ts.map +1 -1
  28. package/lib/types/remote-api.js +17 -2
  29. package/lib/types/routes.d.ts +71 -9
  30. package/lib/types/routes.d.ts.map +1 -1
  31. package/lib/types/routes.js +157 -27
  32. package/lib/types/tunnel-plan.d.ts +8 -2
  33. package/lib/types/tunnel-plan.d.ts.map +1 -1
  34. package/lib/types/tunnel-plan.js +9 -1
  35. package/package.json +20 -25
  36. package/src/client/RemoteEntry.tsx +2 -18
  37. package/src/client/RemotePanel.tsx +9 -43
  38. package/src/client/locales.ts +4 -8
  39. package/src/client/remote.module.css +2 -48
  40. package/src/client/settings-entry-form.ts +0 -1
  41. package/src/index.ts +77 -17
  42. package/src/lan-source.ts +132 -0
  43. package/src/lan.ts +126 -6
  44. package/src/remote-api.ts +24 -4
  45. package/src/routes.ts +178 -27
  46. package/src/tunnel-plan.ts +9 -2
  47. package/lib/invariant.js +0 -21
  48. package/lib/types/invariant.d.ts +0 -16
  49. package/lib/types/invariant.d.ts.map +0 -1
  50. package/lib/types/invariant.js +0 -23
  51. package/src/invariant.ts +0 -32
@@ -26,6 +26,7 @@ import { isLoopbackClient, readCookie } from "./gate.js";
26
26
  import { readJsonBody, writeJson } from "./http.js";
27
27
  import { createPairGrantStore } from "./pair-grant.js";
28
28
  import { REMOTE_HOST_GRANT_GLOBAL } from "./remote-channel-rules.js";
29
+ import { appShellLanSwitchScript } from "./lan-source.js";
29
30
  /**
30
31
  * Browser-trust fence for the /api/pair routes, mirroring the connection
31
32
  * package's internal fence semantics (Host/Origin based, DNS-rebinding and
@@ -299,18 +300,26 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
299
300
  * The cookieless device credential: pass the device id from the /pair-app
300
301
  * URL into sessionStorage (and localStorage for tab reloads) before any app
301
302
  * script runs - same key the boot patch and the channel gate read. The
302
- * replaceState to '/' hides the credential URL from the address bar and
303
- * leaves the SPA at its canonical root path. The reopen service worker is
304
- * registered in the same breath: later navigations to '/' (history,
305
- * bookmark, tab restore) must not fall through to the harness index gate,
306
- * which the cookieless flow can never satisfy.
303
+ * replaceState drops the one-time grant from the address bar and leaves the
304
+ * SPA on a path this plugin answers again; which path depends on the origin.
305
+ *
306
+ * A secure context registers the reopen service worker in the same breath,
307
+ * and that worker owns navigations to '/', so the SPA keeps its canonical
308
+ * root. A plain-HTTP LAN origin is never a secure context, so the worker
309
+ * cannot exist there and '/' falls through to the harness index gate the
310
+ * cookieless flow can never satisfy (issue #1867). The address bar therefore
311
+ * keeps the plugin-owned landing path, which re-validates the device
312
+ * credential on every reload and serves the bilingual re-pair page once the
313
+ * pairing is gone. Both targets are bare paths: the one-time grant never
314
+ * survives the rewrite, and no credential is ever left in the URL.
307
315
  */
308
316
  export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device';
309
317
  export function appShellCaptureScript(deviceId) {
310
318
  const safeId = JSON.stringify(deviceId);
311
319
  const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL);
320
+ const reopenPath = JSON.stringify(PAIR_PATHS.appPage);
312
321
  const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
313
- return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`;
322
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'',window.isSecureContext?'/':${reopenPath})}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`;
314
323
  }
315
324
  /**
316
325
  * Patch the official index document with the device-capture script. The script
@@ -319,9 +328,19 @@ export function appShellCaptureScript(deviceId) {
319
328
  * hook only when this server-served marker is already set (see
320
329
  * remote-channel-boot.ts), so host mode is granted by the device-gated landing
321
330
  * instead of being asserted from the origin.
331
+ *
332
+ * A landing reached on a base other than the machine's own LAN origin also
333
+ * carries the source-decision script (lan-source.ts): the phone decides at
334
+ * connect time whether a LAN candidate answers, and switches its origin when
335
+ * one does. It runs after the capture script so the granted page is fully
336
+ * usable before the probe's outcome can move it.
337
+ * @param html - the official index document.
338
+ * @param deviceId - the device session this landing is being served for.
339
+ * @param hop - the LAN candidates to probe, when a switch is possible.
340
+ * @returns the patched document.
322
341
  */
323
- export function patchAppShell(html, deviceId) {
324
- const script = appShellCaptureScript(deviceId);
342
+ export function patchAppShell(html, deviceId, hop) {
343
+ const script = appShellCaptureScript(deviceId) + (hop === undefined ? '' : appShellLanSwitchScript(hop));
325
344
  const head = /<head[^>]*>/i.exec(html);
326
345
  if (head === null)
327
346
  return script + html;
@@ -336,9 +355,10 @@ export function patchAppShell(html, deviceId) {
336
355
  * worker owns navigations to `/` instead: network-first through /pair-app
337
356
  * (which validates the device cookie and refreshes its presence), the
338
357
  * cached shell for offline opens, and a pass-through of the original
339
- * request when the plugin no longer answers. Plain-HTTP LAN origins are not
340
- * secure contexts, so the worker never registers there — the LAN reopen
341
- * path stays "scan a fresh QR", which is cheap in-network.
358
+ * request when the plugin no longer answers. A plain-HTTP LAN origin is
359
+ * never a secure context, so the worker cannot exist there; the capture
360
+ * script keeps the plugin-owned landing path in the address bar instead,
361
+ * and that landing re-validates the device credential on every reload.
342
362
  *
343
363
  * Kept as a plain string (same pattern as the capture script): it must load
344
364
  * with no build step, run on every JS engine that ships service workers,
@@ -437,6 +457,12 @@ export const PAIR_PATHS = {
437
457
  acceptPage: '/pair-accept',
438
458
  /** The cookieless app landing: serves the official shell for a paired device. */
439
459
  appPage: '/pair-app',
460
+ /**
461
+ * The landing page's LAN reachability beacon (lan-source.ts): an inert
462
+ * no-cors GET target, so a probe proves the origin answers without touching
463
+ * a credential or any state.
464
+ */
465
+ ping: '/pair-ping',
440
466
  /**
441
467
  * The reopen service worker (registered by the capture script). Root-level
442
468
  * path so its default script-directory scope already covers `/`.
@@ -517,6 +543,37 @@ export class PairingEventsStream {
517
543
  return this.streams.size;
518
544
  }
519
545
  }
546
+ /**
547
+ * The accepted {@link PairingSourcePreference} values, shared with the config
548
+ * schema so the patch that admits a value and the handler that reads it cannot
549
+ * drift apart.
550
+ */
551
+ export const PAIRING_SOURCE_OPTIONS = ['auto', 'public', 'lan'];
552
+ /**
553
+ * The LAN origins a landing page served from one authority may hop to: every
554
+ * LAN base the service advertises except the authority serving the request
555
+ * (hopping to the current origin would be a no-op), normalized through `URL`
556
+ * so a default port compares equal to `location.origin`.
557
+ * @param lanBases - the advertised LAN base URLs, in interface order.
558
+ * @param currentOrigin - the origin serving the landing page.
559
+ * @returns the distinct candidate origins, in interface order.
560
+ */
561
+ export function lanSourceCandidates(lanBases, currentOrigin) {
562
+ const origins = [];
563
+ for (const base of lanBases) {
564
+ let origin;
565
+ try {
566
+ origin = new URL(base).origin;
567
+ }
568
+ catch {
569
+ continue;
570
+ }
571
+ if (origin === currentOrigin || origins.includes(origin))
572
+ continue;
573
+ origins.push(origin);
574
+ }
575
+ return origins;
576
+ }
520
577
  /**
521
578
  * Build the /api/pair route family.
522
579
  * @param deps - service + fence inputs.
@@ -527,6 +584,28 @@ export function makeRoutes(deps) {
527
584
  const pairingRequired = () => typeof requirePairingForLan === 'function'
528
585
  ? requirePairingForLan()
529
586
  : requirePairingForLan;
587
+ /** The manual network override, re-read per request (default: automatic). */
588
+ const sourcePreference = () => deps.pairingSource?.() ?? 'auto';
589
+ /**
590
+ * Every authority this surface serves, as {@link isTrustedHost} wants them
591
+ * (bare `host` or `host:port` literals). The LAN literals are re-read per
592
+ * request: a hot rebind (the lan-bind toggle) updates them mid-process, and
593
+ * both the fence and the reachability beacon must follow.
594
+ */
595
+ const servedAuthorities = () => {
596
+ const publicHost = publicHostOf(service.publicBaseUrl);
597
+ const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? []);
598
+ return [
599
+ ...service.lanAddresses,
600
+ ...(publicHost !== undefined ? [publicHost] : []),
601
+ ...extraHosts,
602
+ ...dynamicTrustedHosts,
603
+ ];
604
+ };
605
+ /** Every LAN base URL the service currently advertises, in interface order. */
606
+ const lanBaseUrls = () => service.lanAddresses
607
+ .map(address => service.lanBaseUrlFor(address))
608
+ .filter((base) => base !== undefined);
530
609
  const grants = deps.grants ?? createPairGrantStore();
531
610
  const events = new PairingEventsStream(service);
532
611
  const dynamicTrustedHosts = new Set();
@@ -539,17 +618,7 @@ export function makeRoutes(deps) {
539
618
  * pairing entry pages), never for an API or subresource request.
540
619
  */
541
620
  const lanFence = (req, navigation = false) => {
542
- const publicHost = publicHostOf(service.publicBaseUrl);
543
- // The service's LAN bases re-read per request: a hot rebind (the lan-bind
544
- // toggle) updates them mid-process, and the fence must follow.
545
- const bases = service.lanAddresses;
546
- const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? []);
547
- const combined = [
548
- ...bases,
549
- ...(publicHost !== undefined ? [publicHost] : []),
550
- ...extraHosts,
551
- ...dynamicTrustedHosts,
552
- ];
621
+ const combined = servedAuthorities();
553
622
  if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined))
554
623
  return true;
555
624
  // If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
@@ -662,10 +731,23 @@ export function makeRoutes(deps) {
662
731
  // the injected portrait adaptation, PCs the full desktop UI), so the
663
732
  // remote surface can never drift from the official one.
664
733
  const { token, expiresAt } = service.issue(undefined, address);
665
- // The default base is the public (tunneled) URL when configured — a
666
- // phone anywhere can reach it — and the first LAN interface otherwise.
667
- // An explicit address always names a LAN literal.
668
- const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address);
734
+ // The advertised base is the public (tunneled/relayed) one whenever it is
735
+ // configured: it is the only base a phone anywhere can reach, so the QR
736
+ // never depends on the scanning phone being on this machine's network.
737
+ // The landing page then decides at connect time whether to move to a LAN
738
+ // origin (lan-source.ts). `lan` is the manual override that pins the QR to
739
+ // a LAN literal; an explicit address always names one.
740
+ const preference = sourcePreference();
741
+ const base = address !== undefined
742
+ ? service.lanBaseUrlFor(address)
743
+ : preference === 'lan'
744
+ ? service.lanBaseUrl
745
+ : preference === 'public'
746
+ // Pinned to public: with none configured there is no satisfiable
747
+ // link, which the caller reports as lan-required rather than
748
+ // silently advertising a base the override excluded.
749
+ ? service.publicBaseUrl
750
+ : (service.publicBaseUrl ?? service.lanBaseUrl);
669
751
  if (base === undefined)
670
752
  throw new Error('remote-web-ui: base unavailable');
671
753
  writeJson(res, 200, {
@@ -954,7 +1036,51 @@ export function makeRoutes(deps) {
954
1036
  'referrer-policy': 'no-referrer',
955
1037
  'set-cookie': [deviceCookie(req, service.config.cookieName, id)],
956
1038
  });
957
- res.end(patchAppShell(html, id));
1039
+ res.end(patchAppShell(html, id, lanHop(req, id)));
1040
+ };
1041
+ /**
1042
+ * The LAN hop this landing may take, or undefined when there is nothing to
1043
+ * decide. Three conditions have to hold together: the automatic preference
1044
+ * (both overrides answer the question already — `lan` serves the QR from a
1045
+ * LAN origin, `public` forbids the move), at least one LAN candidate other
1046
+ * than this origin, and a fresh one-time grant for the target landing. The
1047
+ * grant is what lets the target origin serve the shell: the device cookie is
1048
+ * origin-scoped, so the LAN origin has no credential of its own, and the
1049
+ * grant table lives in this process regardless of which interface answered.
1050
+ * The candidate set is carried to the client as origins (lan-source.ts
1051
+ * compares them against `location.origin`).
1052
+ * @param req - the landing request (its authority is the current origin).
1053
+ * @param deviceId - the device session the landing is served for.
1054
+ * @returns the hop payload, or undefined when no switch is possible.
1055
+ */
1056
+ const lanHop = (req, deviceId) => {
1057
+ if (sourcePreference() !== 'auto')
1058
+ return undefined;
1059
+ const candidates = lanSourceCandidates(lanBaseUrls(), appOrigin(req));
1060
+ if (candidates.length === 0)
1061
+ return undefined;
1062
+ return { candidates, path: PAIR_PATHS.appPage, pingPath: PAIR_PATHS.ping, grant: grants.issue(deviceId).grant };
1063
+ };
1064
+ /**
1065
+ * The landing page's LAN reachability beacon: a 204 for a Host this plugin
1066
+ * serves, a 403 otherwise. It carries no credential and no state — a phone
1067
+ * that reaches it has proved the origin answers, which is all the source
1068
+ * decision needs, and the fence's browser-marker half is deliberately not
1069
+ * applied here: the probe is cross-site by construction (a page on the
1070
+ * public origin fetching a LAN origin), so the marker check would turn every
1071
+ * probe into the same 403 that means "not ours" and make the two
1072
+ * indistinguishable.
1073
+ */
1074
+ const handlePing = (req, res) => {
1075
+ if (!requireMethod(req, res, 'GET'))
1076
+ return;
1077
+ if (!isTrustedHost(req, servedAuthorities())) {
1078
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
1079
+ res.end();
1080
+ return;
1081
+ }
1082
+ res.writeHead(204, { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
1083
+ res.end();
958
1084
  };
959
1085
  /**
960
1086
  * The reopen service worker script. Same phone-facing fence as the app
@@ -993,6 +1119,10 @@ export function makeRoutes(deps) {
993
1119
  routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind });
994
1120
  }
995
1121
  routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage });
1122
+ // The beacon is independent of the app landing: a deployment with no
1123
+ // indexDocument still advertises LAN bases, and a landing served some other
1124
+ // way may still probe them.
1125
+ routes.push({ kind: 'exact', path: PAIR_PATHS.ping, handler: handlePing });
996
1126
  if (deps.indexDocument !== undefined) {
997
1127
  routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage });
998
1128
  // The reopen worker is only meaningful when the app landing it
@@ -25,12 +25,18 @@ export type TunnelPlan = {
25
25
  * token AND a valid public hostname (the token does not carry the hostname,
26
26
  * so the QR base and the fence trust would have no source without it);
27
27
  * otherwise no tunnel and the manual public base applies.
28
+ *
29
+ * The quick target URL names the local webServer port, so a plan made before
30
+ * the listener binds cannot be quick: it comes back `off` and the caller —
31
+ * which can observe the port settle — re-plans. The planner itself never
32
+ * emits an `http://127.0.0.1:undefined` target.
28
33
  * @param value - the resolved settings slice that drives the tunnel.
29
- * @param port - the local webServer port the tunnel forwards to.
34
+ * @param port - the local webServer port the tunnel forwards to (undefined
35
+ * before the listener binds).
30
36
  */
31
37
  export declare function tunnelPlanOf(value: {
32
38
  autoTunnel?: boolean;
33
39
  tunnelToken?: string;
34
40
  publicBaseUrl?: string;
35
- }, port: number): TunnelPlan;
41
+ }, port: number | undefined): TunnelPlan;
36
42
  //# sourceMappingURL=tunnel-plan.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel-plan.d.ts","sourceRoot":"","sources":["../../src/tunnel-plan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAOhD;AAED,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC,aAAa,GAAG,eAAe,CAAC,CAAA;CAAE,GACrF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAA;AAEvD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAC7E,IAAI,EAAE,MAAM,GACX,UAAU,CAcZ"}
1
+ {"version":3,"file":"tunnel-plan.d.ts","sourceRoot":"","sources":["../../src/tunnel-plan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAOhD;AAED,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC,aAAa,GAAG,eAAe,CAAC,CAAA;CAAE,GACrF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAA;AAEvD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAC7E,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,UAAU,CAeZ"}
@@ -21,11 +21,19 @@ export function isHttpUrl(value) {
21
21
  * token AND a valid public hostname (the token does not carry the hostname,
22
22
  * so the QR base and the fence trust would have no source without it);
23
23
  * otherwise no tunnel and the manual public base applies.
24
+ *
25
+ * The quick target URL names the local webServer port, so a plan made before
26
+ * the listener binds cannot be quick: it comes back `off` and the caller —
27
+ * which can observe the port settle — re-plans. The planner itself never
28
+ * emits an `http://127.0.0.1:undefined` target.
24
29
  * @param value - the resolved settings slice that drives the tunnel.
25
- * @param port - the local webServer port the tunnel forwards to.
30
+ * @param port - the local webServer port the tunnel forwards to (undefined
31
+ * before the listener binds).
26
32
  */
27
33
  export function tunnelPlanOf(value, port) {
28
34
  if (value.autoTunnel === true) {
35
+ if (port === undefined)
36
+ return { mode: 'off' };
29
37
  const ignored = [];
30
38
  if (value.tunnelToken !== undefined && value.tunnelToken !== '')
31
39
  ignored.push('tunnelToken');
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.4.6",
4
+ "version": "0.4.7",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -14,10 +14,6 @@
14
14
  "types": "./lib/types/index.d.ts",
15
15
  "default": "./lib/index.js"
16
16
  },
17
- "./invariant": {
18
- "types": "./lib/types/invariant.d.ts",
19
- "default": "./lib/invariant.js"
20
- },
21
17
  "./client": {
22
18
  "types": "./lib/types/client/index.d.ts",
23
19
  "default": "./lib/client.js"
@@ -38,7 +34,7 @@
38
34
  "license": "Apache-2.0",
39
35
  "dsh": {
40
36
  "engines": {
41
- "dsh": ">=0.2.0-rc.2"
37
+ "dsh": ">=0.2.1-rc.2"
42
38
  },
43
39
  "bundle": {
44
40
  "patch": "./cordis.patch.yml"
@@ -53,7 +49,7 @@
53
49
  }
54
50
  },
55
51
  "dependencies": {
56
- "@deepseek-ai/schemastery": "^3.18.4",
52
+ "@deepseek-ai/schemastery": "^3.18.5",
57
53
  "cloudflared": "^0.7.3",
58
54
  "clsx": "^2.1.1",
59
55
  "qrcode.react": "^4.2.0",
@@ -61,28 +57,27 @@
61
57
  "zod": "^4.4.3"
62
58
  },
63
59
  "peerDependencies": {
64
- "@deepseek-ai/dsh": ">=0.2.0-rc.2",
60
+ "@deepseek-ai/dsh": ">=0.2.1-rc.2",
65
61
  "react": "^18.2.0",
66
62
  "react-dom": "^18.2.0"
67
63
  },
68
64
  "devDependencies": {
69
- "@deepseek-ai/cordis": "^4.0.4",
70
- "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
71
- "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.2",
72
- "@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.2",
73
- "@deepseek-ai/dsh-api-workspace-controller": "^0.2.0-rc.2",
74
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
75
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
76
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
77
- "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
78
- "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
79
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
80
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
81
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
82
- "@deepseek-ai/dsh-commands": "^0.2.0-rc.2",
83
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
84
- "@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
85
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
65
+ "@deepseek-ai/cordis": "^4.0.5",
66
+ "@deepseek-ai/dsh-agent": "^0.2.1-rc.2",
67
+ "@deepseek-ai/dsh-api-remotes": "^0.2.1-rc.2",
68
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.2",
69
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.2.1-rc.2",
70
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.2",
71
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-rc.2",
72
+ "@deepseek-ai/dsh-client-store": "^0.2.1-rc.2",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.1-rc.2",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.2",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-rc.2",
76
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.1-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.2",
78
+ "@deepseek-ai/dsh-commands": "^0.2.1-rc.2",
79
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2",
80
+ "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
86
81
  "@testing-library/dom": "^10.4.1",
87
82
  "@testing-library/react": "^16.3.2",
88
83
  "@types/node": "^22.20.0",
@@ -73,10 +73,10 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
73
73
  eventSource.current = undefined
74
74
  }, [])
75
75
 
76
- const mint = useCallback(async (address?: string): Promise<PanelState> => {
76
+ const mint = useCallback(async (): Promise<PanelState> => {
77
77
  let result: IssueResponse
78
78
  try {
79
- result = await issuePair(address)
79
+ result = await issuePair()
80
80
  } catch {
81
81
  // Fetch/network failure: show an explicit state instead of silently
82
82
  // leaving the panel on its initial banner.
@@ -104,10 +104,6 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
104
104
  // Whether this QR is built on the configured public (tunneled) base.
105
105
  public: publicBaseUrl !== undefined && result.url.startsWith(publicBaseUrl),
106
106
  ...(publicBaseUrl !== undefined ? { publicBaseUrl } : {}),
107
- // The issued URL names the requested (or default first) literal; the
108
- // public link has no LAN literal, so no radio row is selected then.
109
- address: address ?? result.lanAddresses[0] ?? '',
110
- lanAddresses: result.lanAddresses,
111
107
  }
112
108
  }, [])
113
109
 
@@ -203,16 +199,6 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
203
199
  void mint().then(setState)
204
200
  }, [mint])
205
201
 
206
- /** Re-mint against another LAN literal (multi-homed machines). */
207
- const handlePickAddress = useCallback((address: string) => {
208
- void mint(address).then(setState)
209
- }, [mint])
210
-
211
- /** Re-mint against the configured public (tunneled) base. */
212
- const handlePickPublic = useCallback(() => {
213
- void mint().then(setState)
214
- }, [mint])
215
-
216
202
  const handleCopy = useCallback((url: string) => {
217
203
  void copyText(url).then((ok) => {
218
204
  if (!ok) return
@@ -247,8 +233,6 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
247
233
  onRefresh={handleRefresh}
248
234
  onCopy={handleCopy}
249
235
  onCopyToken={handleCopyToken}
250
- onPickAddress={handlePickAddress}
251
- onPickPublic={handlePickPublic}
252
236
  onRevoke={handleRevoke}
253
237
  />
254
238
  </div>
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * The mobile remote-control panel body: status card (state text + badge),
3
- * the QR code, the open-on-phone hint with the link text, and the three
4
- * actions (stop / refresh / copy). Pure presentation — all state and
3
+ * the QR code, the open-on-phone hint with the link text, and the two
4
+ * actions (stop / refresh / copy). The advertised network is not a choice:
5
+ * the QR names the public base and the landing page decides at connect time
6
+ * whether to move to a LAN origin (lan-source.ts), so the panel only states
7
+ * which source the current link carries. Pure presentation — all state and
5
8
  * actions arrive through props from the entry's behavior component.
6
9
  */
7
10
  import clsx from 'clsx'
@@ -47,10 +50,6 @@ export type PanelState =
47
50
  onlineCount: number
48
51
  /** Authorized devices for the roster under the QR card. */
49
52
  devices: DeviceFrame[]
50
- /** The LAN literal the current QR was built from. */
51
- address: string
52
- /** Every constructible LAN literal (interface order). */
53
- lanAddresses: string[]
54
53
  /** The active pairing token itself (useful in Docker/reverse proxy topologies). */
55
54
  token?: string
56
55
  /** Whether this QR is built on the configured public (tunneled) base. */
@@ -76,10 +75,6 @@ export interface RemotePanelProps {
76
75
  onRefresh(): void
77
76
  onCopy(url: string): void
78
77
  onCopyToken?(token: string): void
79
- /** Re-mint the QR against a different LAN address. */
80
- onPickAddress(address: string): void
81
- /** Re-mint the QR against the configured public (tunneled) base. */
82
- onPickPublic(): void
83
78
  /** Revoke one paired device. */
84
79
  onRevoke(deviceId: string): void
85
80
  }
@@ -113,8 +108,6 @@ export function RemotePanel({
113
108
  onRefresh,
114
109
  onCopy,
115
110
  onCopyToken,
116
- onPickAddress,
117
- onPickPublic,
118
111
  onRevoke,
119
112
  }: RemotePanelProps) {
120
113
  return (
@@ -221,37 +214,10 @@ export function RemotePanel({
221
214
  </p>
222
215
  )}
223
216
 
224
- {(state.publicBaseUrl !== undefined || state.lanAddresses.length > 1) && (
225
- <fieldset className={css.addresses}>
226
- <legend>{t('address.label')}</legend>
227
- {state.publicBaseUrl !== undefined && (
228
- <label key="public" className={css.address}>
229
- <input
230
- type="radio"
231
- name="lan-address"
232
- aria-label={t('address.public')}
233
- checked={state.public}
234
- onChange={onPickPublic}
235
- />
236
- <span>{t('address.public')}</span>
237
- <code className={css.addressValue}>{state.publicBaseUrl}</code>
238
- </label>
239
- )}
240
- {state.lanAddresses.map(address => (
241
- <label key={address} className={css.address}>
242
- <input
243
- type="radio"
244
- name="lan-address"
245
- aria-label={address}
246
- checked={!state.public && address === state.address}
247
- onChange={() => onPickAddress(address)}
248
- />
249
- <span>{t('address.lan')}</span>
250
- <code className={css.addressValue}>{address}</code>
251
- </label>
252
- ))}
253
- <p className={css.addressHint}>{t('address.hint')}</p>
254
- </fieldset>
217
+ {(state.publicBaseUrl !== undefined || !state.public) && (
218
+ <p className={css.addressHint}>
219
+ {state.public ? t('address.auto') : t('address.pinnedLan')}
220
+ </p>
255
221
  )}
256
222
 
257
223
  <div className={css.actions}>
@@ -20,10 +20,8 @@ export const zh = {
20
20
  'status.unreachableHint': '请刷新页面后重试。',
21
21
  'pair.hint': '无法扫码?可直接打开下方配对链接',
22
22
  'pair.publicHint': '公网链接:设备无需与本机处于同一网络',
23
- 'address.label': '选择二维码指向的网络',
24
- 'address.public': '公网地址',
25
- 'address.lan': '局域网',
26
- 'address.hint': '远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。',
23
+ 'address.auto': '二维码固定指向公网地址;手机与本机处于同一网络时会自动切换到局域网地址。',
24
+ 'address.pinnedLan': '二维码固定指向局域网地址。',
27
25
  'pair.expires': '二维码有效至 {time}',
28
26
  'pair.expired': '二维码已过期,请刷新',
29
27
  'pair.linkLabel': '配对链接',
@@ -149,10 +147,8 @@ export const en = {
149
147
  'status.unreachableHint': 'Refresh the page and try again.',
150
148
  'pair.hint': 'Cannot scan? Open one of the pairing links below',
151
149
  'pair.publicHint': 'Public links work when the device is not on the same network',
152
- 'address.label': 'Network the QR code points to',
153
- 'address.public': 'Public',
154
- 'address.lan': 'LAN',
155
- 'address.hint': 'Use the public address when the remote device is on another network; LAN addresses only work on the same network.',
150
+ 'address.auto': 'The QR code always points at the public address; a phone on the same network switches to the LAN address by itself.',
151
+ 'address.pinnedLan': 'The QR code points at a LAN address.',
156
152
  'pair.expires': 'QR code valid until {time}',
157
153
  'pair.expired': 'QR code expired — refresh it',
158
154
  'pair.linkLabel': 'Pairing link',
@@ -573,53 +573,8 @@
573
573
  line-height: 1.55;
574
574
  }
575
575
 
576
- /* Multi-homed address picker: one radio per constructible LAN literal. */
577
- .addresses {
578
- margin: 12px 0 0;
579
- padding: 0;
580
- border: none;
581
- }
582
-
583
- .addresses legend {
584
- padding: 0;
585
- color: var(--dsw-alias-label-secondary);
586
- font-size: 13px;
587
- }
588
-
589
- .address {
590
- display: flex;
591
- align-items: center;
592
- gap: 8px;
593
- margin-top: 6px;
594
- padding: 4px 6px;
595
- border-radius: 6px;
596
- color: var(--dsw-alias-label-primary);
597
- font-size: 13px;
598
- font-variant-numeric: tabular-nums;
599
- cursor: pointer;
600
- transition: background-color 120ms ease;
601
- }
602
-
603
- .address:hover {
604
- background: var(--dsw-alias-interactive-bg-hover);
605
- }
606
-
607
- .address input:focus-visible {
608
- outline: none;
609
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);
610
- border-radius: 50%;
611
- }
612
-
613
- .addressValue {
614
- flex: 1;
615
- min-width: 0;
616
- overflow: hidden;
617
- text-overflow: ellipsis;
618
- white-space: nowrap;
619
- color: var(--dsw-alias-label-secondary);
620
- font-size: 12px;
621
- }
622
-
576
+ /* Which network source the current QR link carries. Not a control: the landing
577
+ page decides the source at connect time, so the panel only states the fact. */
623
578
  .addressHint {
624
579
  margin: 6px 0 0;
625
580
  color: var(--dsw-alias-label-tertiary);
@@ -728,7 +683,6 @@
728
683
  .trigger,
729
684
  .close,
730
685
  .action,
731
- .address,
732
686
  .deviceRevoke {
733
687
  transition: none;
734
688
  }
@@ -52,7 +52,6 @@ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
52
52
  user: undefined,
53
53
  revision: undefined,
54
54
  writable: false,
55
- mode: 'host',
56
55
  }
57
56
  }
58
57