@linxin666/dsh-remote-web-ui 0.2.7 → 0.2.8

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 (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +1 -0
  4. package/lib/client.js +40 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +12 -8
  7. package/lib/mobile.js +39 -21
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/RemoteEntry.js +3 -3
  10. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  11. package/lib/types/client/UpdateEntry.js +1 -1
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +28 -14
  14. package/lib/types/client/pair-api.d.ts +6 -0
  15. package/lib/types/client/pair-api.d.ts.map +1 -1
  16. package/lib/types/client/pair-api.js +11 -0
  17. package/lib/types/client/remote-channel.d.ts +10 -0
  18. package/lib/types/client/remote-channel.d.ts.map +1 -1
  19. package/lib/types/client/remote-channel.js +11 -0
  20. package/lib/types/index.js +1 -1
  21. package/lib/types/mobile/messages.d.ts.map +1 -1
  22. package/lib/types/mobile/messages.js +5 -3
  23. package/lib/types/mobile/messages.test.js +20 -0
  24. package/lib/types/mobile/mux.d.ts +7 -4
  25. package/lib/types/mobile/mux.d.ts.map +1 -1
  26. package/lib/types/mobile/mux.js +43 -15
  27. package/lib/types/mobile/mux.test.js +57 -0
  28. package/lib/types/routes.d.ts +2 -0
  29. package/lib/types/routes.d.ts.map +1 -1
  30. package/lib/types/routes.js +5 -2
  31. package/package.json +15 -12
  32. package/src/client/RemoteEntry.tsx +3 -2
  33. package/src/client/UpdateEntry.tsx +1 -0
  34. package/src/client/index.ts +30 -13
  35. package/src/client/pair-api.ts +16 -0
  36. package/src/client/remote-channel.ts +21 -0
  37. package/src/index.ts +1 -1
  38. package/src/mobile/messages.test.ts +23 -0
  39. package/src/mobile/messages.ts +5 -3
  40. package/src/mobile/mux.test.ts +64 -0
  41. package/src/mobile/mux.ts +44 -17
  42. package/src/routes.ts +7 -2
@@ -204,9 +204,9 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
204
204
  window.setTimeout(() => { setCopied(undefined); }, 1500);
205
205
  });
206
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)] }));
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, 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, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic, onRevoke: handleRevoke })] })), document.body)] }));
208
208
  }
209
209
  /** The trigger: an icon-only control with a persistent accessible label. */
210
- function TooltipAnchor({ wide, label, onClick }) {
211
- return (_jsx("button", { type: "button", className: css.trigger, "data-wide": wide ? 'wide' : 'rail', "aria-label": label, title: label, onClick: onClick, children: _jsx(PhoneIcon, { size: wide ? 16 : 18 }) }));
210
+ function TooltipAnchor({ wide, label, onClick, expanded }) {
211
+ return (_jsx("button", { type: "button", className: css.trigger, "data-wide": wide ? 'wide' : 'rail', "aria-label": label, "aria-expanded": expanded, title: label, onClick: onClick, children: _jsx(PhoneIcon, { size: wide ? 16 : 18 }) }));
212
212
  }
@@ -1 +1 @@
1
- {"version":3,"file":"UpdateEntry.d.ts","sourceRoot":"","sources":["../../../src/client/UpdateEntry.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAOnE,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;IACb,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BA8GxD"}
1
+ {"version":3,"file":"UpdateEntry.d.ts","sourceRoot":"","sources":["../../../src/client/UpdateEntry.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAOnE,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;IACb,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BA+GxD"}
@@ -108,5 +108,5 @@ export function UpdateEntry({ wide, t }) {
108
108
  };
109
109
  }, [probeAvailability]);
110
110
  const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label");
111
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.trigger, "data-wide": wide ? undefined : "rail", "data-update-available": updateAvailable ? "true" : undefined, "aria-label": updateLabel, title: updateLabel, onClick: openPanel, children: _jsx(IconDownloadOutline16, { size: wide ? 16 : 18 }) }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(UpdatePanel, { t: t, view: view, onClose: closePanel, onRecheck: () => { void check(); }, onStartUpdate: (status) => { void startUpdate(status); } })] })), document.body)] }));
111
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.trigger, "data-wide": wide ? undefined : "rail", "data-update-available": updateAvailable ? "true" : undefined, "aria-label": updateLabel, "aria-expanded": open, title: updateLabel, onClick: openPanel, children: _jsx(IconDownloadOutline16, { size: wide ? 16 : 18 }) }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(UpdatePanel, { t: t, view: view, onClose: closePanel, onRecheck: () => { void check(); }, onStartUpdate: (status) => { void startUpdate(status); } })] })), document.body)] }));
112
112
  }
@@ -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;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,CAoK9C"}
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,CAqL9C"}
@@ -14,8 +14,8 @@ import { PairFailedNotice } from "./PairFailedNotice.js";
14
14
  import { RemoteSettingsCard, RemoteSettingsCardController } from "./RemoteSettingsCard.js";
15
15
  import { en, zh } from "./locales.js";
16
16
  import { PAIR_FAILED_MARKER, runPairBootFlow } from "./deep-link.js";
17
- import { sendHeartbeat } from "./pair-api.js";
18
- import { channelTransition, installRemoteChannel, isLoopbackHostname } from "./remote-channel.js";
17
+ import { readPairGatePolicy, sendHeartbeat } from "./pair-api.js";
18
+ import { channelTransition, installRemoteChannel, isLoopbackHostname, remoteChannelRequired } from "./remote-channel.js";
19
19
  import { FenceNotice } from "./FenceNotice.js";
20
20
  /** Dictionary namespace owned by this plugin. */
21
21
  const NS = 'remote';
@@ -130,6 +130,8 @@ export function apply(ctx) {
130
130
  // rewritten onto this plugin's gated /remote/api prefix (remote-channel.ts)
131
131
  // while the fence setting demands it. Loopback origins are untouched.
132
132
  let disposeChannel;
133
+ let hostPairingPolicy;
134
+ let unpairedWhilePolicyPending = false;
133
135
  let fenceNotice;
134
136
  const showFenceNotice = () => {
135
137
  if (fenceNotice !== undefined)
@@ -144,23 +146,19 @@ export function apply(ctx) {
144
146
  fenceNotice?.unmount();
145
147
  fenceNotice = undefined;
146
148
  };
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);
149
+ const handleUnpaired = () => {
150
+ if (settingsScope.getSnapshot().status !== 'ready' && hostPairingPolicy === undefined) {
151
+ unpairedWhilePolicyPending = true;
152
+ return;
153
+ }
154
+ showFenceNotice();
158
155
  };
156
+ const channelActive = () => remoteChannelRequired(window.location.hostname, settingsScope.getSnapshot(), hostPairingPolicy);
159
157
  const syncChannel = () => {
160
158
  const transition = channelTransition(channelActive(), disposeChannel !== undefined);
161
159
  if (transition === 'install') {
162
160
  disposeChannel = ctx.effect(() => {
163
- const restore = installRemoteChannel(window, { onUnpaired: showFenceNotice, onPaired: hideFenceNotice });
161
+ const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice });
164
162
  return restore;
165
163
  }, 'remote-web-ui: remote desktop channel');
166
164
  }
@@ -178,6 +176,22 @@ export function apply(ctx) {
178
176
  };
179
177
  settingsScope.subscribe(syncChannel);
180
178
  syncChannel();
179
+ if (!isLoopbackHostname(window.location.hostname) && settingsScope.getSnapshot().status !== 'ready') {
180
+ void readPairGatePolicy().then((policy) => {
181
+ hostPairingPolicy = policy.requirePairingForLan;
182
+ syncChannel();
183
+ if (hostPairingPolicy && unpairedWhilePolicyPending)
184
+ showFenceNotice();
185
+ unpairedWhilePolicyPending = false;
186
+ }).catch(() => {
187
+ // Fail closed when the policy endpoint is unavailable or malformed.
188
+ hostPairingPolicy = true;
189
+ syncChannel();
190
+ if (unpairedWhilePolicyPending)
191
+ showFenceNotice();
192
+ unpairedWhilePolicyPending = false;
193
+ });
194
+ }
181
195
  // One-time failed-pair toast. The accept result lands asynchronously, so
182
196
  // the marker check is deferred past the accept round trip.
183
197
  ctx.effect(() => {
@@ -30,6 +30,12 @@ export interface IssueLoopbackRequired {
30
30
  code: 'forbidden';
31
31
  }
32
32
  export type IssueResponse = IssueResult | IssueLanRequired | IssueUnknownAddress | IssueLoopbackRequired;
33
+ /** Public policy fields returned by the pairing status endpoint. */
34
+ export interface PairGatePolicy {
35
+ requirePairingForLan: boolean;
36
+ }
37
+ /** Read the host-authoritative desktop pairing policy. */
38
+ export declare function readPairGatePolicy(): Promise<PairGatePolicy>;
33
39
  /** accept() refusal codes. */
34
40
  export type AcceptFailure = {
35
41
  ok: false;
@@ -1 +1 @@
1
- {"version":3,"file":"pair-api.d.ts","sourceRoot":"","sources":["../../../src/client/pair-api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pair-api.d.ts","sourceRoot":"","sources":["../../../src/client/pair-api.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,17 @@
3
3
  * same-origin /api (like the connection client); JSON bodies, string
4
4
  * responses for the error codes.
5
5
  */
6
+ /** Read the host-authoritative desktop pairing policy. */
7
+ export async function readPairGatePolicy() {
8
+ const response = await fetch('/api/pair/status');
9
+ if (!response.ok)
10
+ throw new Error(`remote-web-ui: status failed with ${String(response.status)}`);
11
+ const value = await response.json();
12
+ if (typeof value.requirePairingForLan !== 'boolean') {
13
+ throw new Error('remote-web-ui: status omitted requirePairingForLan');
14
+ }
15
+ return { requirePairingForLan: value.requirePairingForLan };
16
+ }
6
17
  /**
7
18
  * Mint a fresh pairing token (one active token at a time — this invalidates
8
19
  * any previous link).
@@ -27,6 +27,16 @@
27
27
  export declare const REMOTE_PREFIX = "/remote";
28
28
  /** Connection-plugin method prefix under the gated channel. */
29
29
  export declare const REMOTE_API_PREFIX = "/remote/api";
30
+ /** Minimal settings snapshot used by the remote channel decision. */
31
+ export interface RemoteChannelSettingsSnapshot {
32
+ status: 'ready' | 'loading' | 'unavailable' | string;
33
+ value?: {
34
+ enabled?: boolean;
35
+ requirePairingForLan?: boolean;
36
+ };
37
+ }
38
+ /** Decide whether a remote desktop channel is required from local or host policy. */
39
+ export declare function remoteChannelRequired(hostname: string, snapshot: RemoteChannelSettingsSnapshot, hostPairingPolicy: boolean | undefined): boolean;
30
40
  /**
31
41
  * Browser-safe loopback classification for the page origin (the SDK client
32
42
  * exports its own; this copy keeps the module dependency-free).
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel.d.ts","sourceRoot":"","sources":["../../../src/client/remote-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAkBvD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAUhE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAcnF;AAED,wEAAwE;AACxE,UAAU,cAAc;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;IAC9B,SAAS,EAAE,OAAO,SAAS,CAAA;IAC3B,WAAW,CAAC,EAAE,OAAO,WAAW,CAAA;IAChC,gBAAgB,CAAC,EAAE,cAAc,CAAA;IACjC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAC3C;AAED,gDAAgD;AAChD,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAoBD;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3E;AA0BD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,IAAI,CAwE1G;AAED;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;AAE7D;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAIxF"}
1
+ {"version":3,"file":"remote-channel.d.ts","sourceRoot":"","sources":["../../../src/client/remote-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAkBvD,qEAAqE;AACrE,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,aAAa,GAAG,MAAM,CAAA;IACpD,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,oBAAoB,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAC9D;AAED,qFAAqF;AACrF,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,6BAA6B,EACvC,iBAAiB,EAAE,OAAO,GAAG,SAAS,GACrC,OAAO,CAQT;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAUhE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAcnF;AAED,wEAAwE;AACxE,UAAU,cAAc;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;IAC9B,SAAS,EAAE,OAAO,SAAS,CAAA;IAC3B,WAAW,CAAC,EAAE,OAAO,WAAW,CAAA;IAChC,gBAAgB,CAAC,EAAE,cAAc,CAAA;IACjC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAC3C;AAED,gDAAgD;AAChD,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAoBD;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3E;AA0BD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,IAAI,CAwE1G;AAED;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;AAE7D;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAIxF"}
@@ -42,6 +42,17 @@ const WS_PATHS = new Set([
42
42
  '/sidebar/ws/agent-terminals',
43
43
  '/api/dsh-ssh/terminal',
44
44
  ]);
45
+ /** Decide whether a remote desktop channel is required from local or host policy. */
46
+ export function remoteChannelRequired(hostname, snapshot, hostPairingPolicy) {
47
+ if (isLoopbackHostname(hostname))
48
+ return false;
49
+ if (snapshot.status === 'ready') {
50
+ return (snapshot.value?.enabled ?? true) && (snapshot.value?.requirePairingForLan ?? true);
51
+ }
52
+ // Install provisionally while the host probe is pending so early SDK calls
53
+ // cannot escape onto the plain remote origin. A confirmed false retires it.
54
+ return hostPairingPolicy !== false;
55
+ }
45
56
  /**
46
57
  * Browser-safe loopback classification for the page origin (the SDK client
47
58
  * exports its own; this copy keeps the module dependency-free).
@@ -248,7 +248,7 @@ function applyImpl(ctx, config) {
248
248
  },
249
249
  });
250
250
  const routes = [
251
- ...makeRoutes({ service, lanAddresses }),
251
+ ...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
252
252
  ...makeMobileRoutes(),
253
253
  ...(apiProxy !== undefined
254
254
  ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
@@ -1 +1 @@
1
- {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAweD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
@@ -470,7 +470,8 @@ function applyTurnEnd(state, event) {
470
470
  ...message,
471
471
  ...(wasPending ? { pending: false } : {}),
472
472
  ...(failed ? { failed: true } : {}),
473
- seq: Math.max(message.seq, event.seq),
473
+ // Preserve each step's own final-event seq. Collapsing every message
474
+ // onto turn/end makes same-turn ordering depend on arbitrary ids.
474
475
  time: event.time,
475
476
  });
476
477
  }
@@ -542,10 +543,11 @@ function snapshotOf(state) {
542
543
  for (let index = 1; index < out.length; index += 1) {
543
544
  const prev = out[index - 1];
544
545
  const current = out[index];
545
- if (prev.seq > current.seq || (prev.seq === current.seq && prev.id >= current.id)) {
546
+ if (prev.seq > current.seq) {
546
547
  ordered = false;
547
548
  break;
548
549
  }
549
550
  }
550
- return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
551
+ // Array.sort is stable: equal-seq rows keep their event insertion order.
552
+ return ordered ? out : out.sort((a, b) => a.seq - b.seq);
551
553
  }
@@ -152,6 +152,26 @@ describe('foldEvents', () => {
152
152
  expect(assistant?.failed).toBeFalsy();
153
153
  expect(assistant?.pending).toBeFalsy();
154
154
  });
155
+ it('keeps same-turn steps in final-event order after turn/end', () => {
156
+ const result = foldEvents([
157
+ makeEvent('user/message', userMessageData('u-1', 'multi-step'), 0),
158
+ makeEvent('assistant/message', assistantMessageData('step-z', 0, 0, 'first'), 2),
159
+ makeEvent('assistant/message', assistantMessageData('step-a', 0, 1, 'second'), 4),
160
+ makeEvent('assistant/message', assistantMessageData('step-m', 0, 2, 'third'), 6),
161
+ makeEvent('turn/end', { turn: 0, reason: { kind: 'completed' } }, 7),
162
+ ]);
163
+ const assistants = result.filter(message => message.kind === 'assistant');
164
+ expect(assistants.map(message => message.id)).toEqual(['step-z', 'step-a', 'step-m']);
165
+ expect(assistants.map(message => message.seq)).toEqual([2, 4, 6]);
166
+ expect(assistants.every(message => message.pending !== true)).toBe(true);
167
+ });
168
+ it('keeps stable insertion order when legacy rows share a seq', () => {
169
+ const folder = new EventFolder([
170
+ { id: 'z-last-lexically', kind: 'assistant', text: 'first', seq: 5, time: 5_000 },
171
+ { id: 'a-first-lexically', kind: 'assistant', text: 'second', seq: 5, time: 5_000 },
172
+ ]);
173
+ expect(folder.snapshot().map(message => message.id)).toEqual(['z-last-lexically', 'a-first-lexically']);
174
+ });
155
175
  it('is idempotent: applying the same batch twice yields an identical list', () => {
156
176
  const events = [
157
177
  makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
@@ -35,9 +35,9 @@ export interface MuxClientOptions {
35
35
  * the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
36
36
  */
37
37
  pollLatest?: (sessionId: string) => Promise<HistoryPage>;
38
- /** Poll cadence while SSE is stalled (default 3000 ms). */
38
+ /** Initial poll cadence while SSE is stalled (default 3000 ms). Empty polls back off to 60000 ms. */
39
39
  pollIntervalMs?: number;
40
- /** How long SSE must go without a frame before fallback kicks in (default 12000 ms). */
40
+ /** Initial SSE stall window (default 12000 ms); a previously-live stream gets three windows before fallback. */
41
41
  stallThresholdMs?: number;
42
42
  /** Clock seam for tests (defaults to Date.now). */
43
43
  now?: () => number;
@@ -72,8 +72,8 @@ export declare class MuxClient {
72
72
  private lastDataAt;
73
73
  /**
74
74
  * Whether the SSE channel has ever delivered a frame in this stream (a
75
- * delivered frame proves the tunnel forwards SSE; silence alone then means
76
- * the agent idle, not a dead channel — only an onerror re-arms fallback).
75
+ * delivered frame proves the tunnel can forward SSE; sustained silence may
76
+ * still mean a suspended mobile tunnel, so polling re-arms after 3 windows).
77
77
  */
78
78
  private sseAlive;
79
79
  /** Per-session highest event seq already emitted, for poll dedup. */
@@ -83,6 +83,8 @@ export declare class MuxClient {
83
83
  private polling;
84
84
  /** Epoch ms of the next due poll while polling (kept on the same tick timer). */
85
85
  private nextPollAt;
86
+ /** Adaptive delay: productive polls reset it; empty/error polls add one base interval up to one minute. */
87
+ private pollDelayMs;
86
88
  /**
87
89
  * @param url - the mobile events endpoint (browser-relative).
88
90
  * @param options - seams.
@@ -110,6 +112,7 @@ export declare class MuxClient {
110
112
  private startTick;
111
113
  private stopTick;
112
114
  private tick;
115
+ private isSseStalled;
113
116
  private startPolling;
114
117
  private stopPolling;
115
118
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAA;AAGzE,OAAO,EAA2B,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpE,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAA;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAA;IACxD,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,wFAAwF;IACxF,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,mDAAmD;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACrD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,KAAK,IAAI,IAAI,CAAA;CACd;AAuBD;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAChE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6C;IACxE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuC;IACjE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAE5B,wEAAwE;IACxE,OAAO,CAAC,gBAAgB,CAAoB;IAC5C,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAI;IACtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAQ;IACxB,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,+FAA+F;IAC/F,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,OAAO,CAAQ;IACvB,iFAAiF;IACjF,OAAO,CAAC,UAAU,CAAI;IAEtB;;;OAGG;gBACS,GAAG,SAAsB,EAAE,OAAO,GAAE,gBAAqB;IASrE,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAOb,sBAAsB;IACtB,IAAI,IAAI,IAAI;IASZ,sEAAsE;IACtE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAY5C,OAAO,CAAC,OAAO;IAqBf;;;;;OAKG;IACH,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,QAAQ;IAOhB,OAAO,CAAC,IAAI;IAkBZ,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,WAAW;IAInB;;;;OAIG;YACW,QAAQ;IAsBtB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,IAAI;IAUZ,OAAO,CAAC,WAAW;CAapB"}
1
+ {"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAA;AAGzE,OAAO,EAA2B,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpE,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAA;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAA;IACxD,qGAAqG;IACrG,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gHAAgH;IAChH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,mDAAmD;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACrD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,KAAK,IAAI,IAAI,CAAA;CACd;AAyBD;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAChE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6C;IACxE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuC;IACjE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAE5B,wEAAwE;IACxE,OAAO,CAAC,gBAAgB,CAAoB;IAC5C,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAI;IACtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAQ;IACxB,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,+FAA+F;IAC/F,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,OAAO,CAAQ;IACvB,iFAAiF;IACjF,OAAO,CAAC,UAAU,CAAI;IACtB,2GAA2G;IAC3G,OAAO,CAAC,WAAW,CAAQ;IAE3B;;;OAGG;gBACS,GAAG,SAAsB,EAAE,OAAO,GAAE,gBAAqB;IAUrE,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAOb,sBAAsB;IACtB,IAAI,IAAI,IAAI;IASZ,sEAAsE;IACtE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAU5C,OAAO,CAAC,OAAO;IAqBf;;;;;OAKG;IACH,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,QAAQ;IAOhB,OAAO,CAAC,IAAI;IAgBZ,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,WAAW;IAMnB;;;;OAIG;YACW,QAAQ;IAsCtB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,IAAI;IAUZ,OAAO,CAAC,WAAW;CAapB"}
@@ -34,6 +34,8 @@ function browserSource(url) {
34
34
  }
35
35
  const DEFAULT_POLL_INTERVAL_MS = 3000;
36
36
  const DEFAULT_STALL_THRESHOLD_MS = 12000;
37
+ const LIVE_SSE_STALL_MULTIPLIER = 3;
38
+ const MAX_POLL_BACKOFF_MS = 60000;
37
39
  /**
38
40
  * Stall-check granularity: the single scheduler tick runs at least this
39
41
  * often so fallback arms within a second of the stall threshold passing,
@@ -64,8 +66,8 @@ export class MuxClient {
64
66
  lastDataAt = 0;
65
67
  /**
66
68
  * Whether the SSE channel has ever delivered a frame in this stream (a
67
- * delivered frame proves the tunnel forwards SSE; silence alone then means
68
- * the agent idle, not a dead channel — only an onerror re-arms fallback).
69
+ * delivered frame proves the tunnel can forward SSE; sustained silence may
70
+ * still mean a suspended mobile tunnel, so polling re-arms after 3 windows).
69
71
  */
70
72
  sseAlive = false;
71
73
  /** Per-session highest event seq already emitted, for poll dedup. */
@@ -75,6 +77,8 @@ export class MuxClient {
75
77
  polling = false;
76
78
  /** Epoch ms of the next due poll while polling (kept on the same tick timer). */
77
79
  nextPollAt = 0;
80
+ /** Adaptive delay: productive polls reset it; empty/error polls add one base interval up to one minute. */
81
+ pollDelayMs;
78
82
  /**
79
83
  * @param url - the mobile events endpoint (browser-relative).
80
84
  * @param options - seams.
@@ -84,6 +88,7 @@ export class MuxClient {
84
88
  this.sourceFactory = options.sourceFactory ?? browserSource;
85
89
  this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE));
86
90
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
91
+ this.pollDelayMs = this.pollIntervalMs;
87
92
  this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
88
93
  this.now = options.now ?? (() => Date.now());
89
94
  }
@@ -121,9 +126,8 @@ export class MuxClient {
121
126
  return;
122
127
  }
123
128
  // If SSE is already stalled for this session, start patching right away.
124
- if (!this.polling && !this.stopped && !this.sseAlive && (this.now() - this.lastDataAt) > this.stallThresholdMs) {
129
+ if (!this.polling && !this.stopped && this.isSseStalled())
125
130
  this.startPolling();
126
- }
127
131
  }
128
132
  connect() {
129
133
  // A fresh stream starts unknown; only a delivered frame proves it works.
@@ -170,30 +174,36 @@ export class MuxClient {
170
174
  if (this.observeSessionId === undefined)
171
175
  return;
172
176
  if (this.polling) {
173
- // The stall phase has ended; the same tick now paces the polls.
177
+ // The stall phase has ended; the same tick now paces the adaptive polls.
174
178
  if (this.now() >= this.nextPollAt) {
175
- this.nextPollAt = this.now() + this.pollIntervalMs;
179
+ // pollTick schedules the next run after it settles, so slow requests
180
+ // cannot overlap with another scheduler tick.
181
+ this.nextPollAt = Number.POSITIVE_INFINITY;
176
182
  void this.pollTick();
177
183
  }
178
184
  return;
179
185
  }
180
- // A live SSE channel only goes silent while the agent idles; never poll
181
- // against it. Fallback arms again only via onerror or a stream that has
182
- // never delivered.
183
- if (this.sseAlive)
184
- return;
185
- if ((this.now() - this.lastDataAt) > this.stallThresholdMs)
186
+ if (this.isSseStalled())
186
187
  this.startPolling();
187
188
  }
189
+ isSseStalled() {
190
+ const windowMs = this.sseAlive
191
+ ? this.stallThresholdMs * LIVE_SSE_STALL_MULTIPLIER
192
+ : this.stallThresholdMs;
193
+ return (this.now() - this.lastDataAt) > windowMs;
194
+ }
188
195
  startPolling() {
189
196
  if (this.polling || this.stopped)
190
197
  return;
191
198
  this.polling = true;
192
- this.nextPollAt = this.now() + this.pollIntervalMs;
199
+ this.pollDelayMs = this.pollIntervalMs;
200
+ this.nextPollAt = Number.POSITIVE_INFINITY;
193
201
  void this.pollTick();
194
202
  }
195
203
  stopPolling() {
196
204
  this.polling = false;
205
+ this.pollDelayMs = this.pollIntervalMs;
206
+ this.nextPollAt = 0;
197
207
  }
198
208
  /**
199
209
  * Fetch the latest history page for the observed session and re-emit any
@@ -206,21 +216,39 @@ export class MuxClient {
206
216
  this.stopPolling();
207
217
  return;
208
218
  }
219
+ let emitted = 0;
209
220
  try {
210
221
  const page = await this.pollLatest(sessionId);
211
222
  let maxSeq = this.pollWatermark.get(sessionId) ?? -1;
212
- for (const entry of page.events) {
223
+ const ordered = [...page.events].sort((left, right) => {
224
+ const leftSeq = typeof left.event?.seq === 'number' ? left.event.seq : -1;
225
+ const rightSeq = typeof right.event?.seq === 'number' ? right.event.seq : -1;
226
+ return leftSeq - rightSeq;
227
+ });
228
+ for (const entry of ordered) {
213
229
  const event = entry.event;
214
230
  const seq = typeof event?.seq === 'number' ? event.seq : -1;
215
231
  if (seq <= maxSeq)
216
232
  continue;
217
233
  maxSeq = seq;
234
+ emitted += 1;
218
235
  this.emit({ type: 'session/event', sessionId: sessionId, event });
219
236
  }
220
237
  this.pollWatermark.set(sessionId, maxSeq);
221
238
  }
222
239
  catch {
223
- // Transient (network, pairing, history paging); the next tick retries.
240
+ // Transient (network, pairing, history paging); retry with backoff.
241
+ }
242
+ finally {
243
+ if (emitted > 0) {
244
+ this.pollDelayMs = this.pollIntervalMs;
245
+ }
246
+ else {
247
+ this.pollDelayMs = Math.min(MAX_POLL_BACKOFF_MS, this.pollDelayMs + this.pollIntervalMs);
248
+ }
249
+ if (this.polling && this.observeSessionId === sessionId) {
250
+ this.nextPollAt = this.now() + this.pollDelayMs;
251
+ }
224
252
  }
225
253
  }
226
254
  handleMessage(data) {
@@ -76,6 +76,21 @@ describe('MuxClient polling fallback', () => {
76
76
  expect(frames[1]?.event).toMatchObject({ seq: 1 });
77
77
  client.stop();
78
78
  });
79
+ it('sorts an out-of-order history page before advancing the watermark', async () => {
80
+ const { factory } = makeSources();
81
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([2, 1, 3]));
82
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
83
+ const seqs = [];
84
+ client.onFrame((frame) => {
85
+ if (frame.type === 'session/event' && typeof frame.event.seq === 'number')
86
+ seqs.push(frame.event.seq);
87
+ });
88
+ client.start();
89
+ client.observe('s1');
90
+ await vi.advanceTimersByTimeAsync(1200);
91
+ expect(seqs).toEqual([1, 2, 3]);
92
+ client.stop();
93
+ });
79
94
  it('keeps the watermark so a repeated page never re-emits old events', async () => {
80
95
  const { factory } = makeSources();
81
96
  // Two calls return the same page: the second must emit nothing.
@@ -139,6 +154,48 @@ describe('MuxClient polling fallback', () => {
139
154
  expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' });
140
155
  client.stop();
141
156
  });
157
+ it('recovers when a previously-live SSE stream becomes silently stalled', async () => {
158
+ const { factory, sources } = makeSources();
159
+ const pollLatest = vi.fn(async (_sessionId) => pageOf([5]));
160
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
161
+ const frames = [];
162
+ client.onFrame(frame => { frames.push(frame); });
163
+ client.start();
164
+ client.observe('s1');
165
+ sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) });
166
+ await vi.advanceTimersByTimeAsync(2400);
167
+ expect(pollLatest).not.toHaveBeenCalled();
168
+ // A once-live stream gets three stall windows; the next scheduler tick
169
+ // crosses that boundary and starts the ordinary-HTTP recovery path.
170
+ await vi.advanceTimersByTimeAsync(400);
171
+ expect(pollLatest).toHaveBeenCalledTimes(1);
172
+ expect(frames.at(-1)?.event).toMatchObject({ seq: 5 });
173
+ client.stop();
174
+ });
175
+ it('backs empty polls off and resets to the base cadence after progress', async () => {
176
+ const { factory } = makeSources();
177
+ const pages = [pageOf([]), pageOf([1]), pageOf([1, 2])];
178
+ const pollLatest = vi.fn(async (_sessionId) => pages.shift() ?? pageOf([]));
179
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
180
+ client.start();
181
+ client.observe('s1');
182
+ await vi.advanceTimersByTimeAsync(1200);
183
+ expect(pollLatest).toHaveBeenCalledTimes(1);
184
+ await vi.advanceTimersByTimeAsync(400);
185
+ expect(pollLatest).toHaveBeenCalledTimes(1);
186
+ await vi.advanceTimersByTimeAsync(400);
187
+ expect(pollLatest).toHaveBeenCalledTimes(2);
188
+ // The productive second poll resets the next delay from 800 ms to 400 ms.
189
+ await vi.advanceTimersByTimeAsync(400);
190
+ expect(pollLatest).toHaveBeenCalledTimes(3);
191
+ await vi.advanceTimersByTimeAsync(400);
192
+ expect(pollLatest).toHaveBeenCalledTimes(4);
193
+ await vi.advanceTimersByTimeAsync(400);
194
+ expect(pollLatest).toHaveBeenCalledTimes(4);
195
+ await vi.advanceTimersByTimeAsync(400);
196
+ expect(pollLatest).toHaveBeenCalledTimes(5);
197
+ client.stop();
198
+ });
142
199
  it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
143
200
  const { factory } = makeSources();
144
201
  const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
@@ -84,6 +84,8 @@ export interface PairRoutesDeps {
84
84
  service: PairingService;
85
85
  /** The LAN IP literals the fence accepts (derived from the bind host). */
86
86
  lanAddresses: readonly string[];
87
+ /** Current desktop gate policy, re-read for every status response. */
88
+ requirePairingForLan?: boolean | (() => boolean);
87
89
  }
88
90
  /**
89
91
  * Build the /api/pair route family.
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAiO3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,sEAAsE;IACtE,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;CACjD;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoO3D"}
@@ -183,7 +183,10 @@ export class PairingEventsStream {
183
183
  * @returns the exact routes to register on webServer.
184
184
  */
185
185
  export function makeRoutes(deps) {
186
- const { service, lanAddresses } = deps;
186
+ const { service, lanAddresses, requirePairingForLan = true } = deps;
187
+ const pairingRequired = () => typeof requirePairingForLan === 'function'
188
+ ? requirePairingForLan()
189
+ : requirePairingForLan;
187
190
  const events = new PairingEventsStream(service);
188
191
  /** Loopback-only fence: the desktop panel's control endpoints. */
189
192
  const loopbackFence = (req) => isTrustedApiRequest(req, []);
@@ -385,7 +388,7 @@ export function makeRoutes(deps) {
385
388
  const visible = paired
386
389
  ? rest
387
390
  : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses };
388
- writeJson(res, 200, { ok: true, paired, ...visible });
391
+ writeJson(res, 200, { ok: true, paired, requirePairingForLan: pairingRequired(), ...visible });
389
392
  };
390
393
  const handleEvents = (req, res) => {
391
394
  if (!requireMethod(req, res, 'GET'))