@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
@@ -63,10 +63,10 @@ export function RemoteEntry({ wide, t }) {
63
63
  eventSource.current?.close();
64
64
  eventSource.current = undefined;
65
65
  }, []);
66
- const mint = useCallback(async (address) => {
66
+ const mint = useCallback(async () => {
67
67
  let result;
68
68
  try {
69
- result = await issuePair(address);
69
+ result = await issuePair();
70
70
  }
71
71
  catch {
72
72
  // Fetch/network failure: show an explicit state instead of silently
@@ -97,10 +97,6 @@ export function RemoteEntry({ wide, t }) {
97
97
  // Whether this QR is built on the configured public (tunneled) base.
98
98
  public: publicBaseUrl !== undefined && result.url.startsWith(publicBaseUrl),
99
99
  ...(publicBaseUrl !== undefined ? { publicBaseUrl } : {}),
100
- // The issued URL names the requested (or default first) literal; the
101
- // public link has no LAN literal, so no radio row is selected then.
102
- address: address ?? result.lanAddresses[0] ?? '',
103
- lanAddresses: result.lanAddresses,
104
100
  };
105
101
  }, []);
106
102
  const openPanel = useCallback(async () => {
@@ -192,14 +188,6 @@ export function RemoteEntry({ wide, t }) {
192
188
  const handleRefresh = useCallback(() => {
193
189
  void mint().then(setState);
194
190
  }, [mint]);
195
- /** Re-mint against another LAN literal (multi-homed machines). */
196
- const handlePickAddress = useCallback((address) => {
197
- void mint(address).then(setState);
198
- }, [mint]);
199
- /** Re-mint against the configured public (tunneled) base. */
200
- const handlePickPublic = useCallback(() => {
201
- void mint().then(setState);
202
- }, [mint]);
203
191
  const handleCopy = useCallback((url) => {
204
192
  void copyText(url).then((ok) => {
205
193
  if (!ok)
@@ -216,7 +204,7 @@ export function RemoteEntry({ wide, t }) {
216
204
  window.setTimeout(() => { setCopiedToken(false); }, 1500);
217
205
  });
218
206
  }, []);
219
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: css.entryRow, "data-rail": wide ? undefined : 'rail', children: _jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel, expanded: open }) }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(RemotePanel, { t: t, state: state, copied: copied, copiedToken: copiedToken, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onCopyToken: handleCopyToken, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic, onRevoke: handleRevoke })] })), document.body)] }));
207
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: css.entryRow, "data-rail": wide ? undefined : 'rail', children: _jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel, expanded: open }) }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(RemotePanel, { t: t, state: state, copied: copied, copiedToken: copiedToken, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onCopyToken: handleCopyToken, onRevoke: handleRevoke })] })), document.body)] }));
220
208
  }
221
209
  /** The trigger: an icon-only control with a persistent accessible label. */
222
210
  function TooltipAnchor({ wide, label, onClick, expanded }) {
@@ -19,10 +19,6 @@ export type PanelState = {
19
19
  onlineCount: number;
20
20
  /** Authorized devices for the roster under the QR card. */
21
21
  devices: DeviceFrame[];
22
- /** The LAN literal the current QR was built from. */
23
- address: string;
24
- /** Every constructible LAN literal (interface order). */
25
- lanAddresses: string[];
26
22
  /** The active pairing token itself (useful in Docker/reverse proxy topologies). */
27
23
  token?: string;
28
24
  /** Whether this QR is built on the configured public (tunneled) base. */
@@ -47,10 +43,6 @@ export interface RemotePanelProps {
47
43
  onRefresh(): void;
48
44
  onCopy(url: string): void;
49
45
  onCopyToken?(token: string): void;
50
- /** Re-mint the QR against a different LAN address. */
51
- onPickAddress(address: string): void;
52
- /** Re-mint the QR against the configured public (tunneled) base. */
53
- onPickPublic(): void;
54
46
  /** Revoke one paired device. */
55
47
  onRevoke(deviceId: string): void;
56
48
  }
@@ -59,5 +51,5 @@ export interface RemotePanelProps {
59
51
  * @param props - copy, state, and actions.
60
52
  * @returns the panel element tree.
61
53
  */
62
- export declare function RemotePanel({ t, state, copied, copiedToken, onClose, onStop, onRefresh, onCopy, onCopyToken, onPickAddress, onPickPublic, onRevoke, }: RemotePanelProps): import("react").JSX.Element;
54
+ export declare function RemotePanel({ t, state, copied, copiedToken, onClose, onStop, onRefresh, onCopy, onCopyToken, onRevoke, }: RemotePanelProps): import("react").JSX.Element;
63
55
  //# sourceMappingURL=RemotePanel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAatB,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,2DAA2D;IAC3D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAA;IACf,yDAAyD;IACzD,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,uEAAuE;IACvE,KAAK,CAAC,EAAE,gBAAgB,CAAA;IACxB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAEL,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,KAAK,EAAE,UAAU,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,sDAAsD;IACtD,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,oEAAoE;IACpE,YAAY,IAAI,IAAI,CAAA;IACpB,gCAAgC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAC1B,CAAC,EACD,KAAK,EACL,MAAM,EACN,WAAW,EACX,OAAO,EACP,MAAM,EACN,SAAS,EACT,MAAM,EACN,WAAW,EACX,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,EAAE,gBAAgB,+BAyLlB"}
1
+ {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAatB,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,2DAA2D;IAC3D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,uEAAuE;IACvE,KAAK,CAAC,EAAE,gBAAgB,CAAA;IACxB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAEL,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,KAAK,EAAE,UAAU,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,WAAW,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,gCAAgC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAC1B,CAAC,EACD,KAAK,EACL,MAAM,EACN,WAAW,EACX,OAAO,EACP,MAAM,EACN,SAAS,EACT,MAAM,EACN,WAAW,EACX,QAAQ,GACT,EAAE,gBAAgB,+BA8JlB"}
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * The mobile remote-control panel body: status card (state text + badge),
4
- * the QR code, the open-on-phone hint with the link text, and the three
5
- * actions (stop / refresh / copy). Pure presentation — all state and
4
+ * the QR code, the open-on-phone hint with the link text, and the two
5
+ * actions (stop / refresh / copy). The advertised network is not a choice:
6
+ * the QR names the public base and the landing page decides at connect time
7
+ * whether to move to a LAN origin (lan-source.ts), so the panel only states
8
+ * which source the current link carries. Pure presentation — all state and
6
9
  * actions arrive through props from the entry's behavior component.
7
10
  */
8
11
  import clsx from 'clsx';
@@ -35,10 +38,10 @@ function statusOf(t, state) {
35
38
  * @param props - copy, state, and actions.
36
39
  * @returns the panel element tree.
37
40
  */
38
- export function RemotePanel({ t, state, copied, copiedToken, onClose, onStop, onRefresh, onCopy, onCopyToken, onPickAddress, onPickPublic, onRevoke, }) {
41
+ export function RemotePanel({ t, state, copied, copiedToken, onClose, onStop, onRefresh, onCopy, onCopyToken, onRevoke, }) {
39
42
  return (_jsxs("div", { className: css.panel, role: "dialog", "aria-modal": "true", "aria-label": t('title'), children: [_jsxs("div", { className: css.header, children: [_jsxs("div", { className: css.heading, children: [_jsx("h2", { className: css.title, children: t('title') }), _jsx("p", { className: css.subtitle, children: t('subtitle') })] }), _jsx("button", { type: "button", className: css.close, "aria-label": t('close.label'), onClick: onClose, children: _jsx(IconCloseOutlineRegular, { size: 14 }) })] }), state.kind === 'lan-required' ? (_jsxs("div", { className: css.banner, role: "alert", children: [_jsx("p", { className: css.bannerTitle, children: t('status.lanRequired') }), _jsx("p", { className: css.bannerHint, children: t('status.lanRequiredHint') })] })) : state.kind === 'loopback-required' ? (_jsxs("div", { className: css.banner, role: "alert", children: [_jsx("p", { className: css.bannerTitle, children: t('status.loopbackRequired') }), _jsx("p", { className: css.bannerHint, children: t('status.loopbackRequiredHint') })] })) : state.kind === 'unreachable' ? (_jsxs("div", { className: css.banner, role: "alert", children: [_jsx("p", { className: css.bannerTitle, children: t('status.unreachable') }), _jsx("p", { className: css.bannerHint, children: t('status.unreachableHint') })] })) : (_jsxs(_Fragment, { children: [state.posture !== undefined && state.posture.hosts.some(host => host.exposed) && (_jsxs("div", { className: css.banner, role: "alert", children: [_jsx("p", { className: css.bannerTitle, children: t('posture.exposed') }), _jsx("p", { className: css.bannerHint, children: t('posture.exposedHint', { hosts: state.posture.hosts.filter(host => host.exposed).map(host => host.host).join(', ') }) })] })), _jsxs("div", { className: css.card, children: [_jsxs("div", { className: css.cardHeader, children: [_jsx("span", { className: css.cardTitle, children: t('card.title') }), _jsxs("span", { className: css.badges, children: [state.public && _jsx("span", { className: clsx(css.badge, css.badgePublic), children: t('public.badge') }), _jsx("span", { className: clsx(css.badge, css[`badge-${statusOf(t, state).tone}`]), children: statusOf(t, state).text })] })] }), _jsx("div", { className: css.qrWrap, "data-testid": "remote-qr", children: _jsx(PairQrCode, { url: state.url, className: css.qr }) }), state.expired
40
43
  ? _jsx("p", { className: css.expired, children: t('pair.expired') })
41
44
  : _jsx("p", { className: css.expiry, children: t('pair.expires', { time: formatClock(state.expiresAt) }) })] }), _jsx("p", { className: css.hint, children: state.public ? t('pair.publicHint') : t('pair.hint') }), _jsxs("div", { className: css.pairLinks, children: [_jsxs("div", { className: css.pairLinkRow, children: [_jsxs("div", { className: css.pairLinkText, children: [_jsx("span", { className: css.pairLinkLabel, children: t('pair.linkLabel') }), _jsx("code", { className: css.link, title: state.url, children: state.url })] }), _jsxs("button", { type: "button", className: css.copyLink, onClick: () => onCopy(state.url), children: [_jsx(IconCopyOutlineRegular, { size: 14 }), copied ? t('action.copied') : t('action.copyLink')] })] }), state.token !== undefined && state.token !== '' && (_jsxs("div", { className: css.pairLinkRow, children: [_jsxs("div", { className: css.pairLinkText, children: [_jsx("span", { className: css.pairLinkLabel, children: t('pair.tokenLabel') }), _jsx("code", { className: css.link, title: state.token, children: state.token })] }), _jsxs("button", { type: "button", className: css.copyLink, onClick: () => onCopyToken ? onCopyToken(state.token) : onCopy(state.token), children: [_jsx(IconCopyOutlineRegular, { size: 14 }), copiedToken ? t('action.copiedToken') : t('action.copyToken')] })] }))] }), _jsxs("p", { className: css.oneTimeHint, children: [t('pair.oneTimeHint'), " ", t('pair.dockerHint')] }), state.phase === 'stopped' && _jsx("p", { className: css.stoppedHint, children: t('stopped.hint') }), state.tunnel !== undefined && state.tunnel.state !== 'running' && (_jsx("p", { className: state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote, role: "status", children: state.tunnel.state === 'failed'
42
45
  ? t('tunnel.failed', { error: state.tunnel.error ?? t('tunnel.unknownError') })
43
- : t('tunnel.starting') })), state.relay?.state === 'registering' && (_jsx("p", { className: css.tunnelNote, role: "status", children: t('relay.registering') })), state.relay?.state === 'failed' && (_jsx("p", { className: css.tunnelFailed, role: "status", children: t('relay.failed', { error: state.relay.error ?? t('tunnel.unknownError') }) })), (state.publicBaseUrl !== undefined || state.lanAddresses.length > 1) && (_jsxs("fieldset", { className: css.addresses, children: [_jsx("legend", { children: t('address.label') }), state.publicBaseUrl !== undefined && (_jsxs("label", { className: css.address, children: [_jsx("input", { type: "radio", name: "lan-address", "aria-label": t('address.public'), checked: state.public, onChange: onPickPublic }), _jsx("span", { children: t('address.public') }), _jsx("code", { className: css.addressValue, children: state.publicBaseUrl })] }, "public")), state.lanAddresses.map(address => (_jsxs("label", { className: css.address, children: [_jsx("input", { type: "radio", name: "lan-address", "aria-label": address, checked: !state.public && address === state.address, onChange: () => onPickAddress(address) }), _jsx("span", { children: t('address.lan') }), _jsx("code", { className: css.addressValue, children: address })] }, address))), _jsx("p", { className: css.addressHint, children: t('address.hint') })] })), _jsxs("div", { className: css.actions, children: [_jsxs("button", { type: "button", className: css.action, onClick: onStop, children: [_jsx(IconStopFillRegular, { size: 14 }), t('action.stop')] }), _jsxs("button", { type: "button", className: css.action, onClick: onRefresh, children: [_jsx(IconRefreshOutlineRegular, { size: 14 }), t('action.refresh')] })] }), _jsxs("section", { className: css.devices, "data-testid": "remote-devices", "aria-label": t('devices.title'), children: [_jsx("h3", { className: css.devicesTitle, children: t('devices.title') }), state.devices.length === 0 ? (_jsx("p", { className: css.devicesEmpty, children: t('devices.empty') })) : (_jsx("ul", { className: css.deviceList, children: state.devices.map(device => (_jsxs("li", { className: css.deviceRow, children: [_jsxs("div", { className: css.deviceMeta, children: [_jsx("span", { className: css.deviceName, children: deviceNameFromUserAgent(device.userAgent) ?? t('devices.unknown') }), _jsx("span", { className: clsx(css.devicePresence, device.online ? css.deviceOnline : css.deviceOffline), children: device.online ? t('devices.online') : t('devices.offline') }), _jsx("span", { className: css.deviceSeen, children: t('devices.lastSeen', { time: formatLastSeen(device.lastSeenAt) }) })] }), _jsx("button", { type: "button", className: css.deviceRevoke, "aria-label": t('devices.revoke.label'), onClick: () => { onRevoke(device.id); }, children: t('devices.revoke') })] }, device.id))) }))] })] }))] }));
46
+ : t('tunnel.starting') })), state.relay?.state === 'registering' && (_jsx("p", { className: css.tunnelNote, role: "status", children: t('relay.registering') })), state.relay?.state === 'failed' && (_jsx("p", { className: css.tunnelFailed, role: "status", children: t('relay.failed', { error: state.relay.error ?? t('tunnel.unknownError') }) })), (state.publicBaseUrl !== undefined || !state.public) && (_jsx("p", { className: css.addressHint, children: state.public ? t('address.auto') : t('address.pinnedLan') })), _jsxs("div", { className: css.actions, children: [_jsxs("button", { type: "button", className: css.action, onClick: onStop, children: [_jsx(IconStopFillRegular, { size: 14 }), t('action.stop')] }), _jsxs("button", { type: "button", className: css.action, onClick: onRefresh, children: [_jsx(IconRefreshOutlineRegular, { size: 14 }), t('action.refresh')] })] }), _jsxs("section", { className: css.devices, "data-testid": "remote-devices", "aria-label": t('devices.title'), children: [_jsx("h3", { className: css.devicesTitle, children: t('devices.title') }), state.devices.length === 0 ? (_jsx("p", { className: css.devicesEmpty, children: t('devices.empty') })) : (_jsx("ul", { className: css.deviceList, children: state.devices.map(device => (_jsxs("li", { className: css.deviceRow, children: [_jsxs("div", { className: css.deviceMeta, children: [_jsx("span", { className: css.deviceName, children: deviceNameFromUserAgent(device.userAgent) ?? t('devices.unknown') }), _jsx("span", { className: clsx(css.devicePresence, device.online ? css.deviceOnline : css.deviceOffline), children: device.online ? t('devices.online') : t('devices.offline') }), _jsx("span", { className: css.deviceSeen, children: t('devices.lastSeen', { time: formatLastSeen(device.lastSeenAt) }) })] }), _jsx("button", { type: "button", className: css.deviceRevoke, "aria-label": t('devices.revoke.label'), onClick: () => { onRevoke(device.id); }, children: t('devices.revoke') })] }, device.id))) }))] })] }))] }));
44
47
  }
@@ -19,10 +19,8 @@ export declare const zh: {
19
19
  'status.unreachableHint': string;
20
20
  'pair.hint': string;
21
21
  'pair.publicHint': string;
22
- 'address.label': string;
23
- 'address.public': string;
24
- 'address.lan': string;
25
- 'address.hint': string;
22
+ 'address.auto': string;
23
+ 'address.pinnedLan': string;
26
24
  'pair.expires': string;
27
25
  'pair.expired': string;
28
26
  'pair.linkLabel': string;
@@ -143,10 +141,8 @@ export declare const en: {
143
141
  'status.unreachableHint': string;
144
142
  'pair.hint': string;
145
143
  'pair.publicHint': string;
146
- 'address.label': string;
147
- 'address.public': string;
148
- 'address.lan': string;
149
- 'address.hint': string;
144
+ 'address.auto': string;
145
+ 'address.pinnedLan': string;
150
146
  'pair.expires': string;
151
147
  'pair.expired': string;
152
148
  'pair.linkLabel': string;
@@ -1 +1 @@
1
- {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,uEAAuE;AAEvE,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwHmB,CAAA;AAElC,sCAAsC;AACtC,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,EAAE,CAAA;AAEvC,wDAAwD;AACxD,MAAM,MAAM,eAAe,GAAG,SAAS,CAAA;AAEvC,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwHsB,CAAA"}
1
+ {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,uEAAuE;AAEvE,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsHmB,CAAA;AAElC,sCAAsC;AACtC,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,EAAE,CAAA;AAEvC,wDAAwD;AACxD,MAAM,MAAM,eAAe,GAAG,SAAS,CAAA;AAEvC,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsHsB,CAAA"}
@@ -19,10 +19,8 @@ export const zh = {
19
19
  'status.unreachableHint': '请刷新页面后重试。',
20
20
  'pair.hint': '无法扫码?可直接打开下方配对链接',
21
21
  'pair.publicHint': '公网链接:设备无需与本机处于同一网络',
22
- 'address.label': '选择二维码指向的网络',
23
- 'address.public': '公网地址',
24
- 'address.lan': '局域网',
25
- 'address.hint': '远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。',
22
+ 'address.auto': '二维码固定指向公网地址;手机与本机处于同一网络时会自动切换到局域网地址。',
23
+ 'address.pinnedLan': '二维码固定指向局域网地址。',
26
24
  'pair.expires': '二维码有效至 {time}',
27
25
  'pair.expired': '二维码已过期,请刷新',
28
26
  'pair.linkLabel': '配对链接',
@@ -141,10 +139,8 @@ export const en = {
141
139
  'status.unreachableHint': 'Refresh the page and try again.',
142
140
  'pair.hint': 'Cannot scan? Open one of the pairing links below',
143
141
  'pair.publicHint': 'Public links work when the device is not on the same network',
144
- 'address.label': 'Network the QR code points to',
145
- 'address.public': 'Public',
146
- 'address.lan': 'LAN',
147
- 'address.hint': 'Use the public address when the remote device is on another network; LAN addresses only work on the same network.',
142
+ 'address.auto': 'The QR code always points at the public address; a phone on the same network switches to the LAN address by itself.',
143
+ 'address.pinnedLan': 'The QR code points at a LAN address.',
148
144
  'pair.expires': 'QR code valid until {time}',
149
145
  'pair.expired': 'QR code expired — refresh it',
150
146
  'pair.linkLabel': 'Pairing link',
@@ -1 +1 @@
1
- {"version":3,"file":"settings-entry-form.d.ts","sourceRoot":"","sources":["../../../src/client/settings-entry-form.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,UAAU,EAAsB,WAAW,EAAE,MAAM,4CAA4C,CAAA;AAE7G,gDAAgD;AAChD,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACvC,kEAAkE;IAClE,KAAK,EAAE,WAAW,CAAA;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B;AAeD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CA4E1F"}
1
+ {"version":3,"file":"settings-entry-form.d.ts","sourceRoot":"","sources":["../../../src/client/settings-entry-form.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,UAAU,EAAsB,WAAW,EAAE,MAAM,4CAA4C,CAAA;AAE7G,gDAAgD;AAChD,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACvC,kEAAkE;IAClE,KAAK,EAAE,WAAW,CAAA;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAC5B;AAcD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CA4E1F"}
@@ -7,7 +7,6 @@ function pendingSnapshot() {
7
7
  user: undefined,
8
8
  revision: undefined,
9
9
  writable: false,
10
- mode: 'host',
11
10
  };
12
11
  }
13
12
  /**
@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis';
12
12
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
13
13
  import z from '@deepseek-ai/schemastery';
14
14
  import { type PairingConfig } from './pairing.ts';
15
+ import { type PairingSourcePreference } from './routes.ts';
15
16
  declare module '@deepseek-ai/cordis' {
16
17
  interface Events {
17
18
  /**
@@ -167,6 +168,16 @@ export interface Config {
167
168
  profile?: string;
168
169
  /** Master switch for the plugin (browser half + host pairing surfaces). */
169
170
  enabled?: boolean;
171
+ /**
172
+ * The manual override of the automatic pairing-network choice, for the case
173
+ * where the phone's own probe misjudges (see `PairingSourcePreference` in
174
+ * routes.ts). `auto` (default) names the public base in the QR link and lets
175
+ * the landing page switch to a LAN origin when one answers; `public` pins the
176
+ * public base and disables the probe; `lan` pins the first LAN literal and
177
+ * skips the public hop entirely. Set through the profile patch — the settings
178
+ * card carries no control for it, so a save from that card leaves it alone.
179
+ */
180
+ pairingSource?: PairingSourcePreference;
170
181
  }
171
182
  /**
172
183
  * Plugin config schema. Under the 0.1.7 settings model this schema IS the
@@ -204,6 +215,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
204
215
  lanBind: z<boolean, boolean, "volatile">;
205
216
  profile: z<string, string, "plain">;
206
217
  enabled: z<boolean, boolean, "volatile-defined">;
218
+ pairingSource: z<"auto" | "public" | "lan", "auto" | "public" | "lan", "volatile-defined">;
207
219
  }>>, Schemastery.ObjectT<NoInfer<{
208
220
  tokenTtlMs: z<number, number, "volatile-defined">;
209
221
  offlineAfterMs: z<number, number, "volatile-defined">;
@@ -220,6 +232,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
220
232
  lanBind: z<boolean, boolean, "volatile">;
221
233
  profile: z<string, string, "plain">;
222
234
  enabled: z<boolean, boolean, "volatile-defined">;
235
+ pairingSource: z<"auto" | "public" | "lan", "auto" | "public" | "lan", "volatile-defined">;
223
236
  }>>, "plain">;
224
237
  /**
225
238
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
@@ -262,6 +275,7 @@ export interface ResolvedConfigFields {
262
275
  lanBind?: ConfigField<boolean>;
263
276
  profile?: ConfigField<string>;
264
277
  enabled?: ConfigField<boolean>;
278
+ pairingSource?: ConfigField<PairingSourcePreference>;
265
279
  }
266
280
  /**
267
281
  * The single mapping from resolved plugin config to the pairing service
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
@@ -14,7 +14,7 @@ import { DEFAULT_IDLE_EXPIRE_MS, PairingService } from "./pairing.js";
14
14
  import { dshHome } from "./dsh-home.js";
15
15
  import { isPairedDeviceRequest, makeGateListener } from "./gate.js";
16
16
  import { RemoteWebUiPairing } from "./pairing-access.js";
17
- import { makeRoutes } from "./routes.js";
17
+ import { makeRoutes, PAIRING_SOURCE_OPTIONS } from "./routes.js";
18
18
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from "./remote-api.js";
19
19
  import { startRemotePresencePet } from "./remote-presence-pet.js";
20
20
  import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from "./posture.js";
@@ -81,9 +81,15 @@ export const Config = z.object({
81
81
  lanBind: z.boolean().volatile(),
82
82
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
83
83
  enabled: z.boolean().default(true).volatile(),
84
+ pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default('auto').volatile(),
84
85
  });
85
86
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
86
87
  const SWEEP_INTERVAL_MS = 10_000;
88
+ /**
89
+ * Re-plan retry while the quick tunnel waits for the webServer port to bind
90
+ * (a recompose pass can run this apply ahead of the listener's bind).
91
+ */
92
+ const PORT_SETTLE_RETRY_MS = 1_000;
87
93
  /** Read one resolved config field, following the live reference the schema produces. */
88
94
  function readConfigField(field, fallback) {
89
95
  if (field === undefined)
@@ -143,6 +149,7 @@ const DEFAULTS = {
143
149
  lanBind: undefined,
144
150
  profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
145
151
  enabled: true,
152
+ pairingSource: 'auto',
146
153
  };
147
154
  /**
148
155
  * The launched profile the Host publishes on its `profileContext` service
@@ -186,6 +193,7 @@ function applyImpl(ctx, config) {
186
193
  lanBind: readOptionalConfigField(config?.lanBind),
187
194
  profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
188
195
  enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
196
+ pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource),
189
197
  });
190
198
  const service = new PairingService(pairingConfigOf(resolve()));
191
199
  // ── auto tunnel ─────────────────────────────────────────────────────────
@@ -261,6 +269,14 @@ function applyImpl(ctx, config) {
261
269
  // 'off' until a sync pass turns a mode on; the phase listener only feeds
262
270
  // the public base while a plugin-managed tunnel (quick or named) runs.
263
271
  let tunnelMode = resolve().autoTunnel ? 'quick' : 'off';
272
+ /**
273
+ * Retry timer while the quick tunnel waits for the webServer port to bind
274
+ * (undefined when no deferral is pending). Unref'd: never keeps the
275
+ * process alive on its own.
276
+ */
277
+ let portSettleTimer;
278
+ /** Dedupes the unbound-port warning to one line per unsettled episode. */
279
+ let portSettleWarned = false;
264
280
  tunnel.onPhase((info) => {
265
281
  if (tunnelMode === 'off')
266
282
  return;
@@ -291,6 +307,8 @@ function applyImpl(ctx, config) {
291
307
  disposeRelayRegistrar();
292
308
  publicBase.dispose();
293
309
  tunnel.dispose();
310
+ if (portSettleTimer !== undefined)
311
+ clearTimeout(portSettleTimer);
294
312
  }, 'remote-web-ui: auto tunnel');
295
313
  // The bind facts are known by now (webServer is an inject edge): the LAN
296
314
  // bases are frozen per process, matching the CLI's once-per-invocation
@@ -413,6 +431,7 @@ function applyImpl(ctx, config) {
413
431
  ...makeRoutes({
414
432
  service,
415
433
  requirePairingForLan: () => resolve().requirePairingForLan,
434
+ pairingSource: () => resolve().pairingSource,
416
435
  lanBindStatus,
417
436
  indexDocument: fetchAppShell,
418
437
  trustedHosts: () => {
@@ -446,13 +465,13 @@ function applyImpl(ctx, config) {
446
465
  // credential to everything it forwards (issue #1665).
447
466
  ...makeRemoteApiRoutes({
448
467
  service,
449
- port: ctx.webServer.port,
468
+ port: () => ctx.webServer.port,
450
469
  auth: innerAuth,
451
470
  }),
452
471
  ];
453
472
  const upgrades = makeRemoteApiUpgradeRoutes({
454
473
  service,
455
- port: ctx.webServer.port,
474
+ port: () => ctx.webServer.port,
456
475
  auth: innerAuth,
457
476
  });
458
477
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
@@ -468,7 +487,13 @@ function applyImpl(ctx, config) {
468
487
  const runPostureProbe = () => {
469
488
  if (!resolve().enabled)
470
489
  return;
471
- const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port);
490
+ // The probe and the LAN targets need the bound port; before it settles
491
+ // (a recompose pass) there is nothing honest to probe — keep the previous
492
+ // snapshot and let the next trigger retry.
493
+ const port = ctx.webServer.port;
494
+ if (port === undefined)
495
+ return;
496
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port);
472
497
  if (targets.length === 0) {
473
498
  postureKey = undefined;
474
499
  service.setPosture(undefined);
@@ -479,7 +504,7 @@ function applyImpl(ctx, config) {
479
504
  if (!claim.run)
480
505
  return;
481
506
  postureKey = claim.next;
482
- void probePosture({ port: ctx.webServer.port, targets }).then((snapshot) => {
507
+ void probePosture({ port, targets }).then((snapshot) => {
483
508
  service.setPosture(snapshot);
484
509
  const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host);
485
510
  const exposed = exposedHosts.length > 0;
@@ -637,18 +662,13 @@ function applyImpl(ctx, config) {
637
662
  }));
638
663
  };
639
664
  ctx.effect(() => () => { lanBindWorkPending = false; }, 'remote-web-ui: lan-bind work');
640
- // Apply the effective configuration to the running surfaces once per
641
- // activation: the service tunables, the LAN-bind block/firewall, the tunnel
642
- // plan, the pairing routes, the presence sweep, and the posture probe.
643
- const sync = () => {
644
- const value = resolve();
645
- service.config = pairingConfigOf(value);
646
- scheduleLanBindWork();
647
- // The plugin-managed tunnels own the public base while one runs: the URL
648
- // lands in the service through the tunnel's phase listener (the minted
649
- // quick URL, or the named tunnel's fixed public hostname). The manual
650
- // publicBaseUrl applies only when no tunnel runs.
651
- const plan = tunnelPlanOf(value, ctx.webServer.port);
665
+ // Apply one tunnel plan to the running surfaces. The plugin-managed tunnels
666
+ // own the public base while one runs: the URL lands in the service through
667
+ // the tunnel's phase listener (the minted quick URL, or the named tunnel's
668
+ // fixed public hostname). The manual publicBaseUrl applies only when no
669
+ // tunnel runs.
670
+ const applyTunnelPlan = (value, port) => {
671
+ const plan = tunnelPlanOf(value, port);
652
672
  tunnelMode = plan.mode;
653
673
  publicBase.setMode(plan.mode);
654
674
  const liveTunnelUrl = publicBase.quickUrl();
@@ -714,6 +734,36 @@ function applyImpl(ctx, config) {
714
734
  service.setPublicBaseUrl(value.publicBaseUrl);
715
735
  }
716
736
  }
737
+ };
738
+ // Apply the effective configuration to the running surfaces once per
739
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
740
+ // plan, the pairing routes, the presence sweep, and the posture probe.
741
+ const sync = () => {
742
+ const value = resolve();
743
+ service.config = pairingConfigOf(value);
744
+ scheduleLanBindWork();
745
+ // The quick tunnel's loopback target URL needs the bound port. A recompose
746
+ // pass can run this apply before the listener binds: defer the tunnel
747
+ // branch (and re-plan on a short timer) instead of minting an
748
+ // http://127.0.0.1:undefined target; everything else in sync applies.
749
+ const livePort = ctx.webServer.port;
750
+ if (livePort === undefined && value.autoTunnel === true) {
751
+ if (!portSettleWarned) {
752
+ portSettleWarned = true;
753
+ console.warn('remote-web-ui: autoTunnel is on but the web server port is not bound yet — the quick tunnel starts once the port settles');
754
+ }
755
+ if (portSettleTimer === undefined) {
756
+ portSettleTimer = nodeSetTimeout(() => {
757
+ portSettleTimer = undefined;
758
+ sync();
759
+ }, PORT_SETTLE_RETRY_MS);
760
+ portSettleTimer.unref();
761
+ }
762
+ }
763
+ else {
764
+ portSettleWarned = false;
765
+ applyTunnelPlan(value, livePort);
766
+ }
717
767
  const enabled = value.enabled;
718
768
  if (!enabled) {
719
769
  service.stop();
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The landing page's network-source decision: which origin a paired phone
3
+ * should keep using.
4
+ *
5
+ * The QR link can only name one base, and the server cannot know whether the
6
+ * scanning phone sits on this machine's own network: the public (relayed)
7
+ * base is the only one that is always reachable, so the QR names it and the
8
+ * phone decides at connect time. The device-gated landing (/pair-app) is the
9
+ * plugin's own page already rendered ahead of the official shell's boot, so it
10
+ * is the natural place to probe: after the page is served, a short
11
+ * cross-origin `fetch` per LAN candidate (a no-cors GET at
12
+ * {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
13
+ * and the first one that does becomes the new origin. A probe that finds
14
+ * nothing, or that runs out of budget, changes nothing — the page the phone
15
+ * already holds stays paired and usable, so the probe can never block pairing.
16
+ *
17
+ * Browsers do not block this probe. An https page may not normally fetch
18
+ * `http://` subresources, but a host that is an IP literal is exempt from both
19
+ * the block and the automatic upgrade (verified against Chrome: the console
20
+ * reports "This request was not upgraded to HTTPS because its URL's host is an
21
+ * IP address" and delivers the response), which is exactly the shape every LAN
22
+ * candidate has.
23
+ *
24
+ * @module @linxin666/dsh-remote-web-ui/lan-source
25
+ */
26
+ /** sessionStorage key holding this session's source decision. */
27
+ export declare const LAN_SOURCE_STORAGE_KEY = "dsh-remote-lan-source";
28
+ /**
29
+ * Probe budget for the whole candidate set. Long enough for a LAN round trip,
30
+ * short enough that a phone on another network never waits on it — the probe
31
+ * is fire-and-forget and the page it runs on is already usable.
32
+ */
33
+ export declare const LAN_SOURCE_PROBE_TIMEOUT_MS = 800;
34
+ /**
35
+ * How recently a LAN hop must have been taken for a return to a non-LAN origin
36
+ * to read as "the hop did not stick". A phone that fails to load the LAN page
37
+ * re-scans the QR (which always names the public base) within seconds, and the
38
+ * landing then has to choose the public source instead of hopping again into
39
+ * the same black hole; a deliberate return much later is a fresh decision and
40
+ * re-probes.
41
+ */
42
+ export declare const LAN_SOURCE_RETRY_WINDOW_MS = 120000;
43
+ /** One probe payload: everything the injected script needs, JSON-serializable. */
44
+ export interface LanSourceHop {
45
+ /**
46
+ * The LAN origins the probe may hop to, normalized (`https://host:port` with
47
+ * a default port dropped) so they compare equal to `location.origin`.
48
+ */
49
+ candidates: readonly string[];
50
+ /** The path the hop targets on the winning origin. */
51
+ path: string;
52
+ /** The path the probe itself requests, on each candidate. */
53
+ pingPath: string;
54
+ /** The one-time grant the hop consumes on the winning origin. */
55
+ grant: string;
56
+ }
57
+ /**
58
+ * Build the inline script that runs the source decision on the landing page.
59
+ * Reads every browser global through `window`, so a test can run it against
60
+ * one fake object (the same seam the parse-time boot patch uses).
61
+ * @param hop - the probe payload for this landing render.
62
+ * @returns the `<script>` element to splice into the landing's head.
63
+ */
64
+ export declare function appShellLanSwitchScript(hop: LanSourceHop): string;
65
+ //# sourceMappingURL=lan-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lan-source.d.ts","sourceRoot":"","sources":["../../src/lan-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,iEAAiE;AACjE,eAAO,MAAM,sBAAsB,0BAA0B,CAAA;AAE7D;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAA;AAE9C;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,SAAU,CAAA;AAEjD,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAA;IACZ,6DAA6D;IAC7D,QAAQ,EAAE,MAAM,CAAA;IAChB,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CA+DjE"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The landing page's network-source decision: which origin a paired phone
3
+ * should keep using.
4
+ *
5
+ * The QR link can only name one base, and the server cannot know whether the
6
+ * scanning phone sits on this machine's own network: the public (relayed)
7
+ * base is the only one that is always reachable, so the QR names it and the
8
+ * phone decides at connect time. The device-gated landing (/pair-app) is the
9
+ * plugin's own page already rendered ahead of the official shell's boot, so it
10
+ * is the natural place to probe: after the page is served, a short
11
+ * cross-origin `fetch` per LAN candidate (a no-cors GET at
12
+ * {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
13
+ * and the first one that does becomes the new origin. A probe that finds
14
+ * nothing, or that runs out of budget, changes nothing — the page the phone
15
+ * already holds stays paired and usable, so the probe can never block pairing.
16
+ *
17
+ * Browsers do not block this probe. An https page may not normally fetch
18
+ * `http://` subresources, but a host that is an IP literal is exempt from both
19
+ * the block and the automatic upgrade (verified against Chrome: the console
20
+ * reports "This request was not upgraded to HTTPS because its URL's host is an
21
+ * IP address" and delivers the response), which is exactly the shape every LAN
22
+ * candidate has.
23
+ *
24
+ * @module @linxin666/dsh-remote-web-ui/lan-source
25
+ */
26
+ /** sessionStorage key holding this session's source decision. */
27
+ export const LAN_SOURCE_STORAGE_KEY = 'dsh-remote-lan-source';
28
+ /**
29
+ * Probe budget for the whole candidate set. Long enough for a LAN round trip,
30
+ * short enough that a phone on another network never waits on it — the probe
31
+ * is fire-and-forget and the page it runs on is already usable.
32
+ */
33
+ export const LAN_SOURCE_PROBE_TIMEOUT_MS = 800;
34
+ /**
35
+ * How recently a LAN hop must have been taken for a return to a non-LAN origin
36
+ * to read as "the hop did not stick". A phone that fails to load the LAN page
37
+ * re-scans the QR (which always names the public base) within seconds, and the
38
+ * landing then has to choose the public source instead of hopping again into
39
+ * the same black hole; a deliberate return much later is a fresh decision and
40
+ * re-probes.
41
+ */
42
+ export const LAN_SOURCE_RETRY_WINDOW_MS = 120_000;
43
+ /**
44
+ * Build the inline script that runs the source decision on the landing page.
45
+ * Reads every browser global through `window`, so a test can run it against
46
+ * one fake object (the same seam the parse-time boot patch uses).
47
+ * @param hop - the probe payload for this landing render.
48
+ * @returns the `<script>` element to splice into the landing's head.
49
+ */
50
+ export function appShellLanSwitchScript(hop) {
51
+ const payload = JSON.stringify({
52
+ key: LAN_SOURCE_STORAGE_KEY,
53
+ candidates: hop.candidates,
54
+ path: hop.path,
55
+ ping: hop.pingPath,
56
+ grant: hop.grant,
57
+ timeout: LAN_SOURCE_PROBE_TIMEOUT_MS,
58
+ retry: LAN_SOURCE_RETRY_WINDOW_MS,
59
+ });
60
+ const body = '(function(){try{' +
61
+ 'var w=window,loc=w.location;' +
62
+ 'var P=' + payload + ';' +
63
+ 'var S=w.sessionStorage;' +
64
+ // The candidate set is the decision's identity: a host whose interfaces
65
+ // changed hands the landing a different signature, which forces a re-probe.
66
+ 'P.sig=P.candidates.join(",");' +
67
+ // The stored decision: which source this session settled on, the candidate
68
+ // set it was made against (so a host whose interfaces changed re-decides),
69
+ // and when. Anything unreadable is treated as "no decision".
70
+ 'function rd(){try{' +
71
+ 'var raw=S.getItem(P.key);' +
72
+ 'if(typeof raw!=="string"||raw==="")return undefined;' +
73
+ 'var d=JSON.parse(raw);' +
74
+ 'if(typeof d!=="object"||d===null)return undefined;' +
75
+ 'if(d.src!=="lan"&&d.src!=="public")return undefined;' +
76
+ 'if(typeof d.sig!=="string"||typeof d.at!=="number")return undefined;' +
77
+ 'return d' +
78
+ '}catch(e){return undefined}}' +
79
+ 'function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}' +
80
+ // Already on a LAN candidate: nothing to decide, and a hop back to it
81
+ // would be the same origin. Recording the fact keeps a reload from probing.
82
+ 'function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}' +
83
+ 'function go(base){try{loc.replace(base+P.path+"?grant="+encodeURIComponent(P.grant))}catch(e){}}' +
84
+ 'function probe(){' +
85
+ 'if(typeof w.fetch!=="function")return;' +
86
+ 'var settled=false;' +
87
+ 'var timer=w.setTimeout(function(){finish(undefined)},P.timeout);' +
88
+ 'function finish(base){' +
89
+ 'if(settled)return;' +
90
+ 'settled=true;' +
91
+ 'try{w.clearTimeout(timer)}catch(e){}' +
92
+ // The first answer wins: one reachable candidate is all the hop needs, and
93
+ // the losers keep running their own no-cors GETs harmlessly.
94
+ 'if(base===undefined){wr({src:"public",sig:P.sig,at:Date.now()});return}' +
95
+ 'wr({src:"lan",sig:P.sig,base:base,at:Date.now()});' +
96
+ 'go(base)}' +
97
+ 'for(var i=0;i<P.candidates.length;i++){(function(base){' +
98
+ 'var sent;' +
99
+ 'try{sent=w.fetch(base+P.ping,{mode:"no-cors",cache:"no-store",credentials:"omit",referrerPolicy:"no-referrer"})}catch(e){sent=undefined}' +
100
+ 'if(sent===undefined||typeof sent.then!=="function")return;' +
101
+ 'sent.then(function(){finish(base)},function(){})' +
102
+ '})(P.candidates[i])}}' +
103
+ 'var d=rd();' +
104
+ 'if(own()){if(d===undefined||d.sig!==P.sig)wr({src:"lan",sig:P.sig,base:loc.origin,at:Date.now()});return}' +
105
+ 'if(d!==undefined&&d.sig===P.sig){' +
106
+ 'if(d.src==="public")return;' +
107
+ // A LAN decision taken moments ago, on a page that is not a LAN candidate,
108
+ // is a hop that did not stick. Fall back rather than hop again.
109
+ 'if(Date.now()-d.at<P.retry){wr({src:"public",sig:P.sig,at:Date.now()});return}}' +
110
+ 'probe()' +
111
+ '}catch(e){}})();';
112
+ return `<script>${body}</script>`;
113
+ }