@linxin666/dsh-remote-web-ui 0.2.4 → 0.2.6

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +603 -68
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +667 -32
  7. package/lib/mobile.js +3 -1
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +79 -29
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/dsh-home.d.ts +17 -0
  35. package/lib/types/dsh-home.d.ts.map +1 -0
  36. package/lib/types/dsh-home.js +34 -0
  37. package/lib/types/gate.d.ts.map +1 -1
  38. package/lib/types/gate.js +5 -2
  39. package/lib/types/index.d.ts +19 -12
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +73 -5
  42. package/lib/types/loopback-proxy.d.ts +28 -0
  43. package/lib/types/loopback-proxy.d.ts.map +1 -0
  44. package/lib/types/loopback-proxy.js +112 -0
  45. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  46. package/lib/types/mobile/views/SessionListView.js +6 -1
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +6 -2
  49. package/lib/types/mobile-routes.js +1 -1
  50. package/lib/types/pairing.d.ts +63 -11
  51. package/lib/types/pairing.d.ts.map +1 -1
  52. package/lib/types/pairing.js +137 -20
  53. package/lib/types/pairing.persist.test.js +60 -5
  54. package/lib/types/posture.d.ts +75 -0
  55. package/lib/types/posture.d.ts.map +1 -0
  56. package/lib/types/posture.js +116 -0
  57. package/lib/types/remote-api.d.ts +60 -0
  58. package/lib/types/remote-api.d.ts.map +1 -0
  59. package/lib/types/remote-api.js +181 -0
  60. package/lib/types/remote-methods.d.ts +33 -0
  61. package/lib/types/remote-methods.d.ts.map +1 -0
  62. package/lib/types/remote-methods.js +54 -0
  63. package/lib/types/routes.d.ts +4 -0
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +33 -4
  66. package/package.json +2 -2
  67. package/src/client/FenceNotice.tsx +43 -0
  68. package/src/client/RemoteEntry.tsx +19 -9
  69. package/src/client/RemotePanel.tsx +83 -10
  70. package/src/client/RemoteSettingsCard.tsx +16 -0
  71. package/src/client/device-name.ts +31 -0
  72. package/src/client/index.ts +46 -0
  73. package/src/client/locales.ts +79 -29
  74. package/src/client/pair-api.ts +60 -0
  75. package/src/client/remote-channel.ts +269 -0
  76. package/src/client/remote.module.css +240 -16
  77. package/src/client/settings-card.module.css +2 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/gate.ts +5 -2
  80. package/src/index.ts +89 -17
  81. package/src/loopback-proxy.ts +126 -0
  82. package/src/mobile/views/SessionListView.tsx +6 -1
  83. package/src/mobile-api.ts +6 -2
  84. package/src/mobile-routes.ts +1 -1
  85. package/src/pairing.persist.test.ts +64 -5
  86. package/src/pairing.ts +167 -20
  87. package/src/posture.ts +154 -0
  88. package/src/remote-api.ts +217 -0
  89. package/src/remote-methods.ts +62 -0
  90. package/src/routes.ts +33 -4
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Unpaired-desktop notice: a full-page blocking surface rendered when the remote channel
3
+ * (see remote-channel.ts) refuses a call because this desktop browser has no
4
+ * live paired-device cookie. Retires automatically once a gated call
5
+ * succeeds (the channel reports pairing), so it never outlives the
6
+ * unpaired state it describes.
7
+ */
8
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
9
+ /** Notice props: localized copy. */
10
+ export interface FenceNoticeProps {
11
+ t: TranslateNS<'remote'>;
12
+ /** Retry after the user has opened a freshly issued computer pairing link. */
13
+ onRetry: () => void;
14
+ }
15
+ /**
16
+ * Render the unpaired blocking page.
17
+ * @param props - localized copy.
18
+ * @returns the notice element.
19
+ */
20
+ export declare function FenceNotice({ t, onRetry }: FenceNoticeProps): import("react").JSX.Element;
21
+ //# sourceMappingURL=FenceNotice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FenceNotice.d.ts","sourceRoot":"","sources":["../../../src/client/FenceNotice.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAGnE,oCAAoC;AACpC,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAoB3D"}
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import css from './remote.module.css';
3
+ /**
4
+ * Render the unpaired blocking page.
5
+ * @param props - localized copy.
6
+ * @returns the notice element.
7
+ */
8
+ export function FenceNotice({ t, onRetry }) {
9
+ return (_jsx("div", { className: css.fencePage, role: "dialog", "aria-modal": "true", "aria-labelledby": "remote-fence-title", children: _jsxs("main", { className: css.fenceCard, "data-dsh-plugin": "remote-web-ui", children: [_jsx("div", { className: css.fenceMark, "aria-hidden": "true", children: "\u00D7" }), _jsx("p", { className: css.fenceEyebrow, children: t('fence.unpaired.eyebrow') }), _jsx("h1", { id: "remote-fence-title", className: css.fenceTitle, children: t('fence.unpaired.title') }), _jsx("p", { className: css.fenceDetail, children: t('fence.unpaired.hint') }), _jsxs("ol", { className: css.fenceSteps, children: [_jsx("li", { children: t('fence.unpaired.stepDesktop') }), _jsx("li", { children: t('fence.unpaired.stepLink') }), _jsx("li", { children: t('fence.unpaired.stepOpen') })] }), _jsx("button", { className: css.fenceRetry, type: "button", onClick: onRetry, children: t('fence.unpaired.retry') }), _jsx("p", { className: css.fenceFootnote, children: t('fence.unpaired.footnote') })] }) }));
10
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAQjF,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,gBAAgB,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAwBrF;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BAuLvE"}
1
+ {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAQjF,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,gBAAgB,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AA0BrF;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BA+LvE"}
@@ -11,7 +11,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  import { useCallback, useEffect, useRef, useState } from 'react';
12
12
  import { createPortal } from 'react-dom';
13
13
  import { RemotePanel } from './RemotePanel.js';
14
- import { copyText, issuePair, stopPair } from './pair-api.js';
14
+ import { copyText, issuePair, revokePair, stopPair } from './pair-api.js';
15
15
  import { PhoneIcon } from './PhoneIcon.js';
16
16
  import { UpdateEntry } from './UpdateEntry.js';
17
17
  import css from './remote.module.css';
@@ -34,7 +34,9 @@ function mergeFrame(state, frame) {
34
34
  phase: frame.phase,
35
35
  deviceCount: frame.deviceCount,
36
36
  onlineCount: frame.onlineCount,
37
+ devices: frame.devices ?? [],
37
38
  ...(frame.tunnel !== undefined ? { tunnel: frame.tunnel } : {}),
39
+ ...(frame.posture !== undefined ? { posture: frame.posture } : {}),
38
40
  };
39
41
  }
40
42
  /**
@@ -50,7 +52,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
50
52
  // pure), so mint decisions read this ref instead.
51
53
  const stateRef = useRef(state);
52
54
  useEffect(() => { stateRef.current = state; }, [state]);
53
- const [copied, setCopied] = useState(false);
55
+ const [copied, setCopied] = useState(undefined);
54
56
  const eventSource = useRef(undefined);
55
57
  // Generation counter for the open flow: closing (or re-opening) the panel
56
58
  // bumps it, so an in-flight issue() that resolves after a close does not
@@ -92,6 +94,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
92
94
  phase: 'waiting',
93
95
  deviceCount: 0,
94
96
  onlineCount: 0,
97
+ devices: [],
95
98
  // Whether this QR is built on the configured public (tunneled) base.
96
99
  public: publicBaseUrl !== undefined && result.url.startsWith(publicBaseUrl),
97
100
  ...(publicBaseUrl !== undefined ? { publicBaseUrl } : {}),
@@ -174,7 +177,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
174
177
  // keeps the UI honest, and the status stream confirms the stopped phase.
175
178
  void stopPair().catch(() => { });
176
179
  // Optimistic fallback; the status stream confirms with the stopped phase.
177
- setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' } : previous);
180
+ setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped', devices: [] } : previous);
181
+ }, []);
182
+ const handleRevoke = useCallback((deviceId) => {
183
+ void revokePair(deviceId).catch(() => { });
184
+ setState(previous => previous.kind === 'ready'
185
+ ? { ...previous, devices: previous.devices.filter(device => device.id !== deviceId) }
186
+ : previous);
178
187
  }, []);
179
188
  const handleRefresh = useCallback(() => {
180
189
  void mint().then(setState);
@@ -187,17 +196,15 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
187
196
  const handlePickPublic = useCallback(() => {
188
197
  void mint().then(setState);
189
198
  }, [mint]);
190
- const handleCopy = useCallback(() => {
191
- if (state.kind !== 'ready')
192
- return;
193
- void copyText(state.url).then((ok) => {
199
+ const handleCopy = useCallback((target, url) => {
200
+ void copyText(url).then((ok) => {
194
201
  if (!ok)
195
202
  return;
196
- setCopied(true);
197
- window.setTimeout(() => { setCopied(false); }, 1500);
203
+ setCopied(target);
204
+ window.setTimeout(() => { setCopied(undefined); }, 1500);
198
205
  });
199
- }, [state]);
200
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.entryRow, "data-rail": wide ? undefined : 'rail', children: [_jsx(UpdateEntry, { wide: wide, t: t }), _jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel })] }), 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, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic })] })), document.body)] }));
206
+ }, []);
207
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.entryRow, "data-rail": wide ? undefined : 'rail', children: [_jsx(UpdateEntry, { wide: wide, t: t }), _jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel })] }), 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, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic, onRevoke: handleRevoke })] })), document.body)] }));
201
208
  }
202
209
  /** The trigger: an icon-only control with a persistent accessible label. */
203
210
  function TooltipAnchor({ wide, label, onClick }) {
@@ -1,6 +1,6 @@
1
1
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { PairingPhase } from '../pairing.ts';
3
- import { type TunnelStatusFrame } from './pair-api.ts';
3
+ import { type DeviceFrame, type PostureFrame, type TunnelStatusFrame } from './pair-api.ts';
4
4
  /** The panel's view state, owned by the entry component. */
5
5
  export type PanelState = {
6
6
  kind: 'lan-required';
@@ -17,6 +17,8 @@ export type PanelState = {
17
17
  phase: PairingPhase;
18
18
  deviceCount: number;
19
19
  onlineCount: number;
20
+ /** Authorized devices for the roster under the QR card. */
21
+ devices: DeviceFrame[];
20
22
  /** The LAN literal the current QR was built from. */
21
23
  address: string;
22
24
  /** Every constructible LAN literal (interface order). */
@@ -27,25 +29,29 @@ export type PanelState = {
27
29
  publicBaseUrl?: string;
28
30
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
29
31
  tunnel?: TunnelStatusFrame;
32
+ /** Latest /api posture probe, once a round has completed. */
33
+ posture?: PostureFrame;
30
34
  };
31
35
  /** Full panel props: copy + view state + actions. */
32
36
  export interface RemotePanelProps {
33
37
  t: TranslateNS<'remote'>;
34
38
  state: PanelState;
35
- copied: boolean;
39
+ copied: 'phone' | 'desktop' | undefined;
36
40
  onClose(): void;
37
41
  onStop(): void;
38
42
  onRefresh(): void;
39
- onCopy(): void;
43
+ onCopy(target: 'phone' | 'desktop', url: string): void;
40
44
  /** Re-mint the QR against a different LAN address. */
41
45
  onPickAddress(address: string): void;
42
46
  /** Re-mint the QR against the configured public (tunneled) base. */
43
47
  onPickPublic(): void;
48
+ /** Revoke one paired device. */
49
+ onRevoke(deviceId: string): void;
44
50
  }
45
51
  /**
46
52
  * Render the pairing panel.
47
53
  * @param props - copy, state, and actions.
48
54
  * @returns the panel element tree.
49
55
  */
50
- export declare function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic }: RemotePanelProps): import("react").JSX.Element;
56
+ export declare function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }: RemotePanelProps): import("react").JSX.Element;
51
57
  //# sourceMappingURL=RemotePanel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAAe,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAGnE,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,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAA;IACf,yDAAyD;IACzD,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAC3B,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,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,IAAI,IAAI,CAAA;IACd,sDAAsD;IACtD,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,oEAAoE;IACpE,YAAY,IAAI,IAAI,CAAA;CACrB;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,EAAE,gBAAgB,+BA8GlI"}
1
+ {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAItB,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,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,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,GAAG,SAAS,GAAG,SAAS,CAAA;IACvC,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACtD,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,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,gBAAgB,+BAyK5I"}
@@ -7,8 +7,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  */
8
8
  import clsx from 'clsx';
9
9
  import { QRCodeSVG } from 'qrcode.react';
10
- import { IconCloseOutline16, IconCopyOutline16, IconLinkOutline16, IconRefreshOutline16, IconStopFill16, } from '@deepseek-ai/dsh-client-ui-primitives';
11
- import { formatClock } from './pair-api.js';
10
+ import { IconCloseOutline16, IconCopyOutline16, IconRefreshOutline16, IconStopFill16, } from '@deepseek-ai/dsh-client-ui-primitives';
11
+ import { desktopPairUrl, formatClock, formatLastSeen, } from './pair-api.js';
12
+ import { deviceNameFromUserAgent } from './device-name.js';
12
13
  import css from './remote.module.css';
13
14
  /** Badge text + tone per phase (ready states only). */
14
15
  function statusOf(t, state) {
@@ -25,10 +26,10 @@ function statusOf(t, state) {
25
26
  * @param props - copy, state, and actions.
26
27
  * @returns the panel element tree.
27
28
  */
28
- export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic }) {
29
- 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(IconCloseOutline16, { 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: [_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(QRCodeSVG, { value: state.url, size: 184, level: "M", marginSize: 1, className: css.qr }) }), state.expired
29
+ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }) {
30
+ 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(IconCloseOutline16, { 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(QRCodeSVG, { value: state.url, size: 184, level: "M", marginSize: 1, className: css.qr }) }), state.expired
30
31
  ? _jsx("p", { className: css.expired, children: t('pair.expired') })
31
- : _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') }), _jsx("p", { className: css.link, title: state.url, children: state.url }), 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'
32
+ : _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.phoneLabel') }), _jsx("code", { className: css.link, title: state.url, children: state.url })] }), _jsxs("button", { type: "button", className: css.copyLink, onClick: () => onCopy('phone', state.url), children: [_jsx(IconCopyOutline16, { size: 14 }), copied === 'phone' ? t('action.copied') : t('action.copyPhone')] })] }), _jsxs("div", { className: css.pairLinkRow, children: [_jsxs("div", { className: css.pairLinkText, children: [_jsx("span", { className: css.pairLinkLabel, children: t('pair.desktopLabel') }), _jsx("code", { className: css.link, title: desktopPairUrl(state.url), children: desktopPairUrl(state.url) })] }), _jsxs("button", { type: "button", className: css.copyLink, onClick: () => onCopy('desktop', desktopPairUrl(state.url)), children: [_jsx(IconCopyOutline16, { size: 14 }), copied === 'desktop' ? t('action.copied') : t('action.copyDesktop')] })] })] }), _jsx("p", { className: css.oneTimeHint, children: t('pair.oneTimeHint') }), 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'
32
33
  ? t('tunnel.failed', { error: state.tunnel.error ?? t('tunnel.unknownError') })
33
- : t('tunnel.starting') })), (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(IconStopFill16, { size: 14 }), t('action.stop')] }), _jsxs("button", { type: "button", className: css.action, onClick: onRefresh, children: [_jsx(IconRefreshOutline16, { size: 14 }), t('action.refresh')] }), _jsxs("button", { type: "button", className: css.action, onClick: onCopy, children: [copied ? _jsx(IconCopyOutline16, { size: 14 }) : _jsx(IconLinkOutline16, { size: 14 }), copied ? t('action.copied') : t('action.copy')] })] })] }))] }));
34
+ : t('tunnel.starting') })), (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(IconStopFill16, { size: 14 }), t('action.stop')] }), _jsxs("button", { type: "button", className: css.action, onClick: onRefresh, children: [_jsx(IconRefreshOutline16, { 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))) }))] })] }))] }));
34
35
  }
@@ -16,6 +16,8 @@ export interface RemoteSettings {
16
16
  offlineAfterMs?: number;
17
17
  /** Hard cap on paired device sessions (oldest evicted when full). */
18
18
  maxDevices?: number;
19
+ /** Idle sessions older than this (ms) are deleted. */
20
+ idleExpireMs?: number;
19
21
  /** Cookie name carrying the paired device id. */
20
22
  cookieName?: string;
21
23
  /** Fence flag: whether non-loopback /api requests must carry a live paired-device cookie. */
@@ -37,6 +39,8 @@ export interface RemoteSettingsCardState extends CardShell {
37
39
  offlineAfterMs: CardFieldState;
38
40
  /** Paired-device cap. */
39
41
  maxDevices: CardFieldState;
42
+ /** Idle-expiry window. */
43
+ idleExpireMs: CardFieldState;
40
44
  /** Device cookie name. */
41
45
  cookieName: CardFieldState;
42
46
  /** LAN fence flag. */
@@ -1 +1 @@
1
- {"version":3,"file":"RemoteSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteSettingsCard.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAC7F,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAE1F,OAAO,EAAkD,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,IAAI,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAExJ,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6FAA6F;IAC7F,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,4CAA4C;AAC5C,MAAM,WAAW,uBAAwB,SAAQ,SAAS;IACxD,qBAAqB;IACrB,OAAO,EAAE,cAAc,CAAA;IACvB,sBAAsB;IACtB,UAAU,EAAE,cAAc,CAAA;IAC1B,gCAAgC;IAChC,cAAc,EAAE,cAAc,CAAA;IAC9B,yBAAyB;IACzB,UAAU,EAAE,cAAc,CAAA;IAC1B,0BAA0B;IAC1B,UAAU,EAAE,cAAc,CAAA;IAC1B,sBAAsB;IACtB,oBAAoB,EAAE,cAAc,CAAA;IACpC,kCAAkC;IAClC,aAAa,EAAE,cAAc,CAAA;IAC7B,iCAAiC;IACjC,UAAU,EAAE,cAAc,CAAA;IAC1B,4CAA4C;IAC5C,iBAAiB,EAAE,cAAc,CAAA;CAClC;AAED,gEAAgE;AAChE,MAAM,WAAW,sBAAuB,SAAQ,WAAW;IACzD,KAAK,EAAE;QACL,oEAAoE;QACpE,kBAAkB,EAAE,aAAa,CAAC,uBAAuB,CAAC,CAAA;KAC3D,CAAA;CACF;AAED,qEAAqE;AACrE,qBAAa,4BAA4B;IACvC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0B;IAC/C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwC;IAE9D,iFAAiF;IACjF,YAAY,KAAK,EAAE,aAAa,CAAC,cAAc,CAAC,EAa/C;IAED,OAAO,CAAC,UAAU;IAelB;;;OAGG;IACH,MAAM,IAAI,sBAAsB,CAE/B;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;CACF;AAED,4DAA4D;AAC5D,MAAM,MAAM,uBAAuB,GACjC,YAAY,CAAC,oBAAoB,CAAC,GAChC,WAAW,CAAC,QAAQ,CAAC,GACrB,UAAU,CAAC,sBAAsB,CAAC,CAAA;AAEtC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,+BAuHhE"}
1
+ {"version":3,"file":"RemoteSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteSettingsCard.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAC7F,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAE1F,OAAO,EAAkD,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,IAAI,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAExJ,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6FAA6F;IAC7F,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,4CAA4C;AAC5C,MAAM,WAAW,uBAAwB,SAAQ,SAAS;IACxD,qBAAqB;IACrB,OAAO,EAAE,cAAc,CAAA;IACvB,sBAAsB;IACtB,UAAU,EAAE,cAAc,CAAA;IAC1B,gCAAgC;IAChC,cAAc,EAAE,cAAc,CAAA;IAC9B,yBAAyB;IACzB,UAAU,EAAE,cAAc,CAAA;IAC1B,0BAA0B;IAC1B,YAAY,EAAE,cAAc,CAAA;IAC5B,0BAA0B;IAC1B,UAAU,EAAE,cAAc,CAAA;IAC1B,sBAAsB;IACtB,oBAAoB,EAAE,cAAc,CAAA;IACpC,kCAAkC;IAClC,aAAa,EAAE,cAAc,CAAA;IAC7B,iCAAiC;IACjC,UAAU,EAAE,cAAc,CAAA;IAC1B,4CAA4C;IAC5C,iBAAiB,EAAE,cAAc,CAAA;CAClC;AAED,gEAAgE;AAChE,MAAM,WAAW,sBAAuB,SAAQ,WAAW;IACzD,KAAK,EAAE;QACL,oEAAoE;QACpE,kBAAkB,EAAE,aAAa,CAAC,uBAAuB,CAAC,CAAA;KAC3D,CAAA;CACF;AAED,qEAAqE;AACrE,qBAAa,4BAA4B;IACvC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0B;IAC/C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwC;IAE9D,iFAAiF;IACjF,YAAY,KAAK,EAAE,aAAa,CAAC,cAAc,CAAC,EAc/C;IAED,OAAO,CAAC,UAAU;IAgBlB;;;OAGG;IACH,MAAM,IAAI,sBAAsB,CAE/B;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;CACF;AAED,4DAA4D;AAC5D,MAAM,MAAM,uBAAuB,GACjC,YAAY,CAAC,oBAAoB,CAAC,GAChC,WAAW,CAAC,QAAQ,CAAC,GACrB,UAAU,CAAC,sBAAsB,CAAC,CAAA;AAEtC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,+BAiIhE"}
@@ -12,6 +12,7 @@ export class RemoteSettingsCardController {
12
12
  numberField('tokenTtlMs'),
13
13
  numberField('offlineAfterMs'),
14
14
  numberField('maxDevices'),
15
+ numberField('idleExpireMs'),
15
16
  textField('cookieName'),
16
17
  booleanField('requirePairingForLan'),
17
18
  textField('publicBaseUrl'),
@@ -27,6 +28,7 @@ export class RemoteSettingsCardController {
27
28
  tokenTtlMs: this.form.field('tokenTtlMs'),
28
29
  offlineAfterMs: this.form.field('offlineAfterMs'),
29
30
  maxDevices: this.form.field('maxDevices'),
31
+ idleExpireMs: this.form.field('idleExpireMs'),
30
32
  cookieName: this.form.field('cookieName'),
31
33
  requirePairingForLan: this.form.field('requirePairingForLan'),
32
34
  publicBaseUrl: this.form.field('publicBaseUrl'),
@@ -64,5 +66,5 @@ export function RemoteSettingsCard(props) {
64
66
  invalidLabel: t('settings.invalidNumber'),
65
67
  disabled,
66
68
  };
67
- return (_jsxs(PluginSettingsCard, { t: t, titleKey: "settings.title", descriptionKey: "settings.description", defaultOpen: false, state: state, onSave: props.save, onDiscard: props.discard, children: [_jsx(BooleanField, { id: "settings-remote-enabled", label: t('settings.enabled'), hint: t('settings.enabledHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.enabled, onEdit: (text) => { props.edit('enabled', text); }, onReset: () => { props.resetField('enabled'); } }), _jsx(ValueField, { id: "settings-remote-token-ttl", label: t('settings.tokenTtlMs'), hint: t('settings.tokenTtlMsHint'), numeric: true, ...fieldProps, ...state.tokenTtlMs, onEdit: (text) => { props.edit('tokenTtlMs', text); }, onReset: () => { props.resetField('tokenTtlMs'); } }), _jsx(ValueField, { id: "settings-remote-offline", label: t('settings.offlineAfterMs'), hint: t('settings.offlineAfterMsHint'), numeric: true, ...fieldProps, ...state.offlineAfterMs, onEdit: (text) => { props.edit('offlineAfterMs', text); }, onReset: () => { props.resetField('offlineAfterMs'); } }), _jsx(ValueField, { id: "settings-remote-max-devices", label: t('settings.maxDevices'), hint: t('settings.maxDevicesHint'), numeric: true, ...fieldProps, ...state.maxDevices, onEdit: (text) => { props.edit('maxDevices', text); }, onReset: () => { props.resetField('maxDevices'); } }), _jsx(ValueField, { id: "settings-remote-cookie", label: t('settings.cookieName'), hint: t('settings.cookieNameHint'), ...fieldProps, ...state.cookieName, onEdit: (text) => { props.edit('cookieName', text); }, onReset: () => { props.resetField('cookieName'); } }), _jsx(BooleanField, { id: "settings-remote-fence", label: t('settings.requirePairingForLan'), hint: t('settings.requirePairingForLanHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.requirePairingForLan, onEdit: (text) => { props.edit('requirePairingForLan', text); }, onReset: () => { props.resetField('requirePairingForLan'); } }), _jsx(ValueField, { id: "settings-remote-public-base", label: t('settings.publicBaseUrl'), hint: t('settings.publicBaseUrlHint'), placeholder: "https://example.trycloudflare.com", ...fieldProps, ...state.publicBaseUrl, onEdit: (text) => { props.edit('publicBaseUrl', text); }, onReset: () => { props.resetField('publicBaseUrl'); } }), _jsx(BooleanField, { id: "settings-remote-auto-tunnel", label: t('settings.autoTunnel'), hint: t('settings.autoTunnelHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.autoTunnel, onEdit: (text) => { props.edit('autoTunnel', text); }, onReset: () => { props.resetField('autoTunnel'); } }), _jsx(BooleanField, { id: "settings-remote-mobile-enter", label: t('settings.mobileEnterToSend'), hint: t('settings.mobileEnterToSendHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.mobileEnterToSend, onEdit: (text) => { props.edit('mobileEnterToSend', text); }, onReset: () => { props.resetField('mobileEnterToSend'); } })] }));
69
+ return (_jsxs(PluginSettingsCard, { t: t, titleKey: "settings.title", descriptionKey: "settings.description", defaultOpen: false, state: state, onSave: props.save, onDiscard: props.discard, children: [_jsx(BooleanField, { id: "settings-remote-enabled", label: t('settings.enabled'), hint: t('settings.enabledHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.enabled, onEdit: (text) => { props.edit('enabled', text); }, onReset: () => { props.resetField('enabled'); } }), _jsx(ValueField, { id: "settings-remote-token-ttl", label: t('settings.tokenTtlMs'), hint: t('settings.tokenTtlMsHint'), numeric: true, ...fieldProps, ...state.tokenTtlMs, onEdit: (text) => { props.edit('tokenTtlMs', text); }, onReset: () => { props.resetField('tokenTtlMs'); } }), _jsx(ValueField, { id: "settings-remote-offline", label: t('settings.offlineAfterMs'), hint: t('settings.offlineAfterMsHint'), numeric: true, ...fieldProps, ...state.offlineAfterMs, onEdit: (text) => { props.edit('offlineAfterMs', text); }, onReset: () => { props.resetField('offlineAfterMs'); } }), _jsx(ValueField, { id: "settings-remote-max-devices", label: t('settings.maxDevices'), hint: t('settings.maxDevicesHint'), numeric: true, ...fieldProps, ...state.maxDevices, onEdit: (text) => { props.edit('maxDevices', text); }, onReset: () => { props.resetField('maxDevices'); } }), _jsx(ValueField, { id: "settings-remote-idle-expire", label: t('settings.idleExpireMs'), hint: t('settings.idleExpireMsHint'), numeric: true, ...fieldProps, ...state.idleExpireMs, onEdit: (text) => { props.edit('idleExpireMs', text); }, onReset: () => { props.resetField('idleExpireMs'); } }), _jsx(ValueField, { id: "settings-remote-cookie", label: t('settings.cookieName'), hint: t('settings.cookieNameHint'), ...fieldProps, ...state.cookieName, onEdit: (text) => { props.edit('cookieName', text); }, onReset: () => { props.resetField('cookieName'); } }), _jsx(BooleanField, { id: "settings-remote-fence", label: t('settings.requirePairingForLan'), hint: t('settings.requirePairingForLanHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.requirePairingForLan, onEdit: (text) => { props.edit('requirePairingForLan', text); }, onReset: () => { props.resetField('requirePairingForLan'); } }), _jsx(ValueField, { id: "settings-remote-public-base", label: t('settings.publicBaseUrl'), hint: t('settings.publicBaseUrlHint'), placeholder: "https://example.trycloudflare.com", ...fieldProps, ...state.publicBaseUrl, onEdit: (text) => { props.edit('publicBaseUrl', text); }, onReset: () => { props.resetField('publicBaseUrl'); } }), _jsx(BooleanField, { id: "settings-remote-auto-tunnel", label: t('settings.autoTunnel'), hint: t('settings.autoTunnelHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.autoTunnel, onEdit: (text) => { props.edit('autoTunnel', text); }, onReset: () => { props.resetField('autoTunnel'); } }), _jsx(BooleanField, { id: "settings-remote-mobile-enter", label: t('settings.mobileEnterToSend'), hint: t('settings.mobileEnterToSendHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.mobileEnterToSend, onEdit: (text) => { props.edit('mobileEnterToSend', text); }, onReset: () => { props.resetField('mobileEnterToSend'); } })] }));
68
70
  }
@@ -0,0 +1,3 @@
1
+ /** Derive a short, non-sensitive device label from a browser User-Agent. */
2
+ export declare function deviceNameFromUserAgent(userAgent?: string): string | undefined;
3
+ //# sourceMappingURL=device-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-name.d.ts","sourceRoot":"","sources":["../../../src/client/device-name.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA6B9E"}
@@ -0,0 +1,30 @@
1
+ /** Derive a short, non-sensitive device label from a browser User-Agent. */
2
+ export function deviceNameFromUserAgent(userAgent) {
3
+ if (userAgent === undefined || userAgent.trim() === '')
4
+ return undefined;
5
+ const os = /Windows NT/i.test(userAgent)
6
+ ? 'Windows'
7
+ : /Android/i.test(userAgent)
8
+ ? 'Android'
9
+ : /iPhone|iPad|iPod/i.test(userAgent)
10
+ ? 'iOS'
11
+ : /Macintosh|Mac OS X/i.test(userAgent)
12
+ ? 'macOS'
13
+ : /Linux/i.test(userAgent)
14
+ ? 'Linux'
15
+ : undefined;
16
+ const browser = /Edg(?:A|iOS)?\//i.test(userAgent)
17
+ ? 'Edge'
18
+ : /(?:OPR|Opera)\//i.test(userAgent)
19
+ ? 'Opera'
20
+ : /(?:Chrome|CriOS)\//i.test(userAgent)
21
+ ? 'Chrome'
22
+ : /(?:Firefox|FxiOS)\//i.test(userAgent)
23
+ ? 'Firefox'
24
+ : /Safari\//i.test(userAgent) && /Version\//i.test(userAgent)
25
+ ? 'Safari'
26
+ : undefined;
27
+ if (os !== undefined && browser !== undefined)
28
+ return `${os} · ${browser}`;
29
+ return os ?? browser;
30
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAa7G,OAAO,EAAU,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAIrD,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACrE,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACnE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,YAAY,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAC/F,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,0CAA0C;QAC1C,MAAM,EAAE,SAAS,CAAA;KAClB;IAED,UAAU,OAAO;QACf;;;;WAIG;QACH,gBAAgB,EAAE;YAAE,IAAI,EAAE,QAAQ,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,uBAAuB,CAAA;SAAE,CAAA;QACnF;;;;;WAKG;QACH,oBAAoB,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,4BAA4B,CAAA;SAAE,CAAA;KAC3F;CACF;AAED,4GAA4G;AAC5G,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd;AAED,mEAAmE;AACnE,MAAM,WAAW,4BAA4B;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,KAAK,CAAA;CACjB;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAYD,wCAAwC;AACxC,eAAO,MAAM,MAAM,UAA+D,CAAA;AAElF;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAgH9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAa7G,OAAO,EAAU,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAMrD,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACrE,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACnE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,YAAY,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAC/F,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,0CAA0C;QAC1C,MAAM,EAAE,SAAS,CAAA;KAClB;IAED,UAAU,OAAO;QACf;;;;WAIG;QACH,gBAAgB,EAAE;YAAE,IAAI,EAAE,QAAQ,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,uBAAuB,CAAA;SAAE,CAAA;QACnF;;;;;WAKG;QACH,oBAAoB,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,4BAA4B,CAAA;SAAE,CAAA;KAC3F;CACF;AAED,4GAA4G;AAC5G,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd;AAED,mEAAmE;AACnE,MAAM,WAAW,4BAA4B;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,KAAK,CAAA;CACjB;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAYD,wCAAwC;AACxC,eAAO,MAAM,MAAM,UAA+D,CAAA;AAElF;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA4J9C"}
@@ -15,6 +15,8 @@ import { RemoteSettingsCard, RemoteSettingsCardController } from './RemoteSettin
15
15
  import { en, zh } from './locales.js';
16
16
  import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.js';
17
17
  import { sendHeartbeat } from './pair-api.js';
18
+ import { installRemoteChannel, isLoopbackHostname } from './remote-channel.js';
19
+ import { FenceNotice } from './FenceNotice.js';
18
20
  /** Dictionary namespace owned by this plugin. */
19
21
  const NS = 'remote';
20
22
  /** Settings namespace the remote-control card edits (the Host plugin registers it). */
@@ -122,6 +124,52 @@ export function apply(ctx) {
122
124
  };
123
125
  settingsScope.subscribe(syncRuntime);
124
126
  syncRuntime();
127
+ // Remote desktop channel: on a non-loopback origin (LAN address or public
128
+ // tunnel) the connection plugin's /api fence refuses this desktop Web GUI,
129
+ // and pairing is the access control — so the SDK client's /api traffic is
130
+ // rewritten onto this plugin's gated /remote/api prefix (remote-channel.ts)
131
+ // while the fence setting demands it. Loopback origins are untouched.
132
+ let disposeChannel;
133
+ let fenceNotice;
134
+ const showFenceNotice = () => {
135
+ if (fenceNotice !== undefined)
136
+ return;
137
+ const node = document.createElement('div');
138
+ document.body.appendChild(node);
139
+ const root = createRoot(node);
140
+ root.render(createElement(FenceNotice, { t, onRetry: () => { window.location.reload(); } }));
141
+ fenceNotice = { unmount: () => { root.unmount(); node.remove(); }, node };
142
+ };
143
+ const hideFenceNotice = () => {
144
+ fenceNotice?.unmount();
145
+ fenceNotice = undefined;
146
+ };
147
+ const channelActive = () => {
148
+ if (isLoopbackHostname(window.location.hostname))
149
+ return false;
150
+ // A not-yet-loaded (or unavailable) settings snapshot falls back to the
151
+ // schema defaults — enabled and requirePairingForLan are both true, and
152
+ // on a remote origin the snapshot may never load (its own transport is
153
+ // what the channel gates), so waiting for 'ready' here would deadlock
154
+ // the channel off exactly where it is needed most.
155
+ const snapshot = settingsScope.getSnapshot();
156
+ const value = snapshot.status === 'ready' ? snapshot.value : undefined;
157
+ return (value?.enabled ?? true) && (value?.requirePairingForLan ?? true);
158
+ };
159
+ const syncChannel = () => {
160
+ if (channelActive() && disposeChannel === undefined) {
161
+ disposeChannel = ctx.effect(() => {
162
+ const restore = installRemoteChannel(window, { onUnpaired: showFenceNotice, onPaired: hideFenceNotice });
163
+ return restore;
164
+ }, 'remote-web-ui: remote desktop channel');
165
+ }
166
+ else if (!channelActive() && disposeChannel !== undefined) {
167
+ disposeChannel();
168
+ disposeChannel = undefined;
169
+ }
170
+ };
171
+ settingsScope.subscribe(syncChannel);
172
+ syncChannel();
125
173
  // One-time failed-pair toast. The accept result lands asynchronously, so
126
174
  // the marker check is deferred past the accept round trip.
127
175
  ctx.effect(() => {
@@ -1,4 +1,4 @@
1
- /** `remote` namespace dictionaries: the mobile remote-control surface copy. */
1
+ /** `remote` namespace dictionaries: the remote-access surface copy. */
2
2
  /** Simplified Chinese dictionary (the key-set source of truth). */
3
3
  export declare const zh: {
4
4
  'entry.label': string;
@@ -24,12 +24,35 @@ export declare const zh: {
24
24
  'address.hint': string;
25
25
  'pair.expires': string;
26
26
  'pair.expired': string;
27
+ 'pair.phoneLabel': string;
28
+ 'pair.desktopLabel': string;
29
+ 'pair.oneTimeHint': string;
27
30
  'pair.failed.title': string;
28
31
  'pair.failed.detail': string;
32
+ 'fence.unpaired.title': string;
33
+ 'fence.unpaired.eyebrow': string;
34
+ 'fence.unpaired.hint': string;
35
+ 'fence.unpaired.stepDesktop': string;
36
+ 'fence.unpaired.stepLink': string;
37
+ 'fence.unpaired.stepOpen': string;
38
+ 'fence.unpaired.retry': string;
39
+ 'fence.unpaired.footnote': string;
40
+ 'posture.exposed': string;
41
+ 'posture.exposedHint': string;
29
42
  'action.stop': string;
30
43
  'action.refresh': string;
31
44
  'action.copy': string;
45
+ 'action.copyPhone': string;
46
+ 'action.copyDesktop': string;
32
47
  'action.copied': string;
48
+ 'devices.title': string;
49
+ 'devices.empty': string;
50
+ 'devices.unknown': string;
51
+ 'devices.online': string;
52
+ 'devices.offline': string;
53
+ 'devices.lastSeen': string;
54
+ 'devices.revoke': string;
55
+ 'devices.revoke.label': string;
33
56
  'stopped.hint': string;
34
57
  'tunnel.starting': string;
35
58
  'tunnel.failed': string;
@@ -45,6 +68,8 @@ export declare const zh: {
45
68
  'settings.offlineAfterMsHint': string;
46
69
  'settings.maxDevices': string;
47
70
  'settings.maxDevicesHint': string;
71
+ 'settings.idleExpireMs': string;
72
+ 'settings.idleExpireMsHint': string;
48
73
  'settings.cookieName': string;
49
74
  'settings.cookieNameHint': string;
50
75
  'settings.requirePairingForLan': string;
@@ -135,12 +160,35 @@ export declare const en: {
135
160
  'address.hint': string;
136
161
  'pair.expires': string;
137
162
  'pair.expired': string;
163
+ 'pair.phoneLabel': string;
164
+ 'pair.desktopLabel': string;
165
+ 'pair.oneTimeHint': string;
138
166
  'pair.failed.title': string;
139
167
  'pair.failed.detail': string;
168
+ 'fence.unpaired.title': string;
169
+ 'fence.unpaired.eyebrow': string;
170
+ 'fence.unpaired.hint': string;
171
+ 'fence.unpaired.stepDesktop': string;
172
+ 'fence.unpaired.stepLink': string;
173
+ 'fence.unpaired.stepOpen': string;
174
+ 'fence.unpaired.retry': string;
175
+ 'fence.unpaired.footnote': string;
176
+ 'posture.exposed': string;
177
+ 'posture.exposedHint': string;
140
178
  'action.stop': string;
141
179
  'action.refresh': string;
142
180
  'action.copy': string;
181
+ 'action.copyPhone': string;
182
+ 'action.copyDesktop': string;
143
183
  'action.copied': string;
184
+ 'devices.title': string;
185
+ 'devices.empty': string;
186
+ 'devices.unknown': string;
187
+ 'devices.online': string;
188
+ 'devices.offline': string;
189
+ 'devices.lastSeen': string;
190
+ 'devices.revoke': string;
191
+ 'devices.revoke.label': string;
144
192
  'stopped.hint': string;
145
193
  'tunnel.starting': string;
146
194
  'tunnel.failed': string;
@@ -156,6 +204,8 @@ export declare const en: {
156
204
  'settings.offlineAfterMsHint': string;
157
205
  'settings.maxDevices': string;
158
206
  'settings.maxDevicesHint': string;
207
+ 'settings.idleExpireMs': string;
208
+ 'settings.idleExpireMsHint': string;
159
209
  'settings.cookieName': string;
160
210
  'settings.cookieNameHint': string;
161
211
  'settings.requirePairingForLan': string;
@@ -1 +1 @@
1
- {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAE/E,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2GmB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2GsB,CAAA"}
1
+ {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,uEAAuE;AAEvE,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoImB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoIsB,CAAA"}