@linxin666/dsh-remote-web-ui 0.2.6 → 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 (86) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +8 -1
  3. package/README.zh.md +2 -1
  4. package/lib/client.js +55 -10
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +12 -8
  7. package/lib/mobile.js +37 -18
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +3 -2
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  11. package/lib/types/client/FooterRemoteEntry.js +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +8 -8
  15. package/lib/types/client/RemotePanel.js +3 -3
  16. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/RemoteSettingsCard.js +2 -2
  18. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  19. package/lib/types/client/UpdateEntry.js +2 -2
  20. package/lib/types/client/UpdatePanel.js +1 -1
  21. package/lib/types/client/deep-link.js +1 -1
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +45 -23
  24. package/lib/types/client/pair-api.d.ts +6 -0
  25. package/lib/types/client/pair-api.d.ts.map +1 -1
  26. package/lib/types/client/pair-api.js +11 -0
  27. package/lib/types/client/remote-channel.d.ts +25 -0
  28. package/lib/types/client/remote-channel.d.ts.map +1 -1
  29. package/lib/types/client/remote-channel.js +24 -0
  30. package/lib/types/client/settings-form.d.ts.map +1 -1
  31. package/lib/types/client/update-api.d.ts.map +1 -1
  32. package/lib/types/gate.js +1 -1
  33. package/lib/types/index.js +15 -15
  34. package/lib/types/invariant.d.ts.map +1 -1
  35. package/lib/types/loopback-proxy.js +1 -1
  36. package/lib/types/mobile/PairRequiredView.js +1 -1
  37. package/lib/types/mobile/PairRequiredView.test.js +1 -1
  38. package/lib/types/mobile/api.js +1 -1
  39. package/lib/types/mobile/index.js +5 -5
  40. package/lib/types/mobile/markdown.test.js +1 -1
  41. package/lib/types/mobile/messages.d.ts.map +1 -1
  42. package/lib/types/mobile/messages.js +5 -3
  43. package/lib/types/mobile/messages.test.js +21 -1
  44. package/lib/types/mobile/mobile-theme.test.js +2 -2
  45. package/lib/types/mobile/mux.d.ts +7 -4
  46. package/lib/types/mobile/mux.d.ts.map +1 -1
  47. package/lib/types/mobile/mux.js +44 -16
  48. package/lib/types/mobile/mux.test.js +58 -1
  49. package/lib/types/mobile/rpc.d.ts.map +1 -1
  50. package/lib/types/mobile/theme-toggle.js +1 -1
  51. package/lib/types/mobile/views/App.js +7 -7
  52. package/lib/types/mobile/views/App.test.js +2 -2
  53. package/lib/types/mobile/views/ChatView.js +6 -6
  54. package/lib/types/mobile/views/ChatView.test.js +3 -3
  55. package/lib/types/mobile/views/SessionListView.js +3 -3
  56. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  57. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  58. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  59. package/lib/types/mobile-api.d.ts +1 -1
  60. package/lib/types/mobile-api.d.ts.map +1 -1
  61. package/lib/types/mobile-api.js +2 -2
  62. package/lib/types/pairing-access.d.ts.map +1 -1
  63. package/lib/types/pairing.d.ts.map +1 -1
  64. package/lib/types/pairing.persist.test.js +2 -2
  65. package/lib/types/remote-api.js +6 -6
  66. package/lib/types/remote-methods.d.ts.map +1 -1
  67. package/lib/types/routes.d.ts +9 -7
  68. package/lib/types/routes.d.ts.map +1 -1
  69. package/lib/types/routes.js +8 -5
  70. package/lib/types/tunnel.d.ts.map +1 -1
  71. package/lib/types/update-routes.d.ts +2 -2
  72. package/lib/types/update-routes.d.ts.map +1 -1
  73. package/lib/types/update-routes.js +1 -1
  74. package/package.json +15 -12
  75. package/src/client/FenceNotice.tsx +3 -2
  76. package/src/client/RemoteEntry.tsx +3 -2
  77. package/src/client/UpdateEntry.tsx +1 -0
  78. package/src/client/index.ts +40 -15
  79. package/src/client/pair-api.ts +16 -0
  80. package/src/client/remote-channel.ts +42 -0
  81. package/src/index.ts +1 -1
  82. package/src/mobile/messages.test.ts +23 -0
  83. package/src/mobile/messages.ts +5 -3
  84. package/src/mobile/mux.test.ts +64 -0
  85. package/src/mobile/mux.ts +44 -17
  86. package/src/routes.ts +7 -2
@@ -37,13 +37,13 @@ export declare function isTrustedApiRequest(request: IncomingMessage, trustedHos
37
37
  export declare function publicHostOf(url: string | undefined): string | undefined;
38
38
  /** Route paths (exact matches under /api). */
39
39
  export declare const PAIR_PATHS: {
40
- readonly issue: '/api/pair/issue';
41
- readonly accept: '/api/pair/accept';
42
- readonly stop: '/api/pair/stop';
43
- readonly revoke: '/api/pair/revoke';
44
- readonly heartbeat: '/api/pair/heartbeat';
45
- readonly status: '/api/pair/status';
46
- readonly events: '/api/pair/events';
40
+ readonly issue: "/api/pair/issue";
41
+ readonly accept: "/api/pair/accept";
42
+ readonly stop: "/api/pair/stop";
43
+ readonly revoke: "/api/pair/revoke";
44
+ readonly heartbeat: "/api/pair/heartbeat";
45
+ readonly status: "/api/pair/status";
46
+ readonly events: "/api/pair/events";
47
47
  };
48
48
  /**
49
49
  * /api/pair request payload contracts. Each POST endpoint validates its body
@@ -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;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,MAAM,EAAE,kBAAkB;aAC1B,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,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;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,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"}
@@ -8,9 +8,9 @@
8
8
  * on every other /api request.
9
9
  */
10
10
  import { z } from 'zod';
11
- import { UnknownLanAddressError } from './pairing.js';
12
- import { isLoopbackClient, readCookie } from './gate.js';
13
- import { readBoundedJson, writeJson } from './http.js';
11
+ import { UnknownLanAddressError } from "./pairing.js";
12
+ import { isLoopbackClient, readCookie } from "./gate.js";
13
+ import { readBoundedJson, writeJson } from "./http.js";
14
14
  /**
15
15
  * Browser-trust fence for the /api/pair routes, mirroring the connection
16
16
  * package's internal fence semantics (Host/Origin based, DNS-rebinding and
@@ -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'))
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
@@ -9,8 +9,8 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
9
9
  import type { UpdateRunResult, UpdateStatus } from './update.ts';
10
10
  /** Route paths (exact matches under /api). */
11
11
  export declare const UPDATE_PATHS: {
12
- readonly status: '/api/update/status';
13
- readonly run: '/api/update/run';
12
+ readonly status: "/api/update/status";
13
+ readonly run: "/api/update/run";
14
14
  };
15
15
  /** Route-family dependencies (test seam). */
16
16
  export interface UpdateRoutesDeps {
@@ -1 +1 @@
1
- {"version":3,"file":"update-routes.d.ts","sourceRoot":"","sources":["../../src/update-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAGhE,8CAA8C;AAC9C,eAAO,MAAM,YAAY;aACvB,MAAM,EAAE,oBAAoB;aAC5B,GAAG,EAAE,iBAAiB;CACd,CAAA;AAEV,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,KAAK,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAA;IACxC,yDAAyD;IACzD,KAAK,IAAI,OAAO,CAAC,YAAY,CAAC,CAAA;IAC9B,sDAAsD;IACtD,GAAG,IAAI,OAAO,CAAC,eAAe,CAAC,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,QAAQ,EAAE,CA+BnE"}
1
+ {"version":3,"file":"update-routes.d.ts","sourceRoot":"","sources":["../../src/update-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAGhE,8CAA8C;AAC9C,eAAO,MAAM,YAAY;;;CAGf,CAAA;AAEV,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,KAAK,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAA;IACxC,yDAAyD;IACzD,KAAK,IAAI,OAAO,CAAC,YAAY,CAAC,CAAA;IAC9B,sDAAsD;IACtD,GAAG,IAAI,OAAO,CAAC,eAAe,CAAC,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,QAAQ,EAAE,CA+BnE"}
@@ -4,7 +4,7 @@
4
4
  * pnpm install inside the owning profile, so it must never be reachable
5
5
  * from a LAN/phone origin.
6
6
  */
7
- import { writeJson } from './http.js';
7
+ import { writeJson } from "./http.js";
8
8
  /** Route paths (exact matches under /api). */
9
9
  export const UPDATE_PATHS = {
10
10
  status: '/api/update/status',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.2.6",
4
+ "version": "0.2.8",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -35,6 +35,9 @@
35
35
  ],
36
36
  "license": "Apache-2.0",
37
37
  "dsh": {
38
+ "engines": {
39
+ "dsh": ">=0.1.1-rc.1"
40
+ },
38
41
  "bundle": {
39
42
  "patch": "./cordis.patch.yml"
40
43
  },
@@ -61,17 +64,17 @@
61
64
  },
62
65
  "devDependencies": {
63
66
  "@deepseek-ai/cordis": "^4.0.1",
64
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
65
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
66
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
67
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
68
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
69
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.8",
70
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
71
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.8",
72
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
73
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
74
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
67
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
71
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
72
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2",
73
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
74
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
75
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
76
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
77
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
75
78
  "@testing-library/dom": "^10.4.1",
76
79
  "@testing-library/react": "^16.3.2",
77
80
  "@types/node": "^22.20.0",
@@ -2,8 +2,9 @@
2
2
  * Unpaired-desktop notice: a full-page blocking surface rendered when the remote channel
3
3
  * (see remote-channel.ts) refuses a call because this desktop browser has no
4
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.
5
+ * succeeds (the channel reports pairing) or when the channel itself is
6
+ * torn down (requirePairingForLan off / plugin disabled), so it never
7
+ * outlives the unpaired state it describes.
7
8
  */
8
9
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
9
10
  import css from './remote.module.css'
@@ -219,7 +219,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
219
219
  <>
220
220
  <div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
221
221
  <UpdateEntry wide={wide} t={t} />
222
- <TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} />
222
+ <TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} expanded={open} />
223
223
  </div>
224
224
  {open && createPortal((
225
225
  <div className={css.overlay} role="presentation">
@@ -243,13 +243,14 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
243
243
  }
244
244
 
245
245
  /** The trigger: an icon-only control with a persistent accessible label. */
246
- function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
246
+ function TooltipAnchor({ wide, label, onClick, expanded }: { wide: boolean; label: string; onClick: () => void; expanded: boolean }) {
247
247
  return (
248
248
  <button
249
249
  type="button"
250
250
  className={css.trigger}
251
251
  data-wide={wide ? 'wide' : 'rail'}
252
252
  aria-label={label}
253
+ aria-expanded={expanded}
253
254
  title={label}
254
255
  onClick={onClick}
255
256
  >
@@ -124,6 +124,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
124
124
  data-wide={wide ? undefined : "rail"}
125
125
  data-update-available={updateAvailable ? "true" : undefined}
126
126
  aria-label={updateLabel}
127
+ aria-expanded={open}
127
128
  title={updateLabel}
128
129
  onClick={openPanel}
129
130
  >
@@ -23,8 +23,8 @@ import { PairFailedNotice } from './PairFailedNotice.tsx'
23
23
  import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings } from './RemoteSettingsCard.tsx'
24
24
  import { en, zh, type RemoteKey } from './locales.ts'
25
25
  import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
26
- import { sendHeartbeat } from './pair-api.ts'
27
- import { installRemoteChannel, isLoopbackHostname } from './remote-channel.ts'
26
+ import { readPairGatePolicy, sendHeartbeat } from './pair-api.ts'
27
+ import { channelTransition, installRemoteChannel, isLoopbackHostname, remoteChannelRequired } from './remote-channel.ts'
28
28
  import { FenceNotice } from './FenceNotice.tsx'
29
29
 
30
30
  export type { RemoteEntryProps } from './RemoteEntry.tsx'
@@ -201,6 +201,8 @@ export function apply(ctx: ClientContext): void {
201
201
  // rewritten onto this plugin's gated /remote/api prefix (remote-channel.ts)
202
202
  // while the fence setting demands it. Loopback origins are untouched.
203
203
  let disposeChannel: (() => void) | undefined
204
+ let hostPairingPolicy: boolean | undefined
205
+ let unpairedWhilePolicyPending = false
204
206
  let fenceNotice: { unmount: () => void; node: HTMLElement } | undefined
205
207
  const showFenceNotice = (): void => {
206
208
  if (fenceNotice !== undefined) return
@@ -214,30 +216,53 @@ export function apply(ctx: ClientContext): void {
214
216
  fenceNotice?.unmount()
215
217
  fenceNotice = undefined
216
218
  }
217
- const channelActive = (): boolean => {
218
- if (isLoopbackHostname(window.location.hostname)) return false
219
- // A not-yet-loaded (or unavailable) settings snapshot falls back to the
220
- // schema defaults — enabled and requirePairingForLan are both true, and
221
- // on a remote origin the snapshot may never load (its own transport is
222
- // what the channel gates), so waiting for 'ready' here would deadlock
223
- // the channel off exactly where it is needed most.
224
- const snapshot = settingsScope.getSnapshot()
225
- const value = snapshot.status === 'ready' ? snapshot.value : undefined
226
- return (value?.enabled ?? true) && (value?.requirePairingForLan ?? true)
219
+ const handleUnpaired = (): void => {
220
+ if (settingsScope.getSnapshot().status !== 'ready' && hostPairingPolicy === undefined) {
221
+ unpairedWhilePolicyPending = true
222
+ return
223
+ }
224
+ showFenceNotice()
227
225
  }
226
+ const channelActive = (): boolean => remoteChannelRequired(
227
+ window.location.hostname,
228
+ settingsScope.getSnapshot(),
229
+ hostPairingPolicy,
230
+ )
228
231
  const syncChannel = (): void => {
229
- if (channelActive() && disposeChannel === undefined) {
232
+ const transition = channelTransition(channelActive(), disposeChannel !== undefined)
233
+ if (transition === 'install') {
230
234
  disposeChannel = ctx.effect(() => {
231
- const restore = installRemoteChannel(window, { onUnpaired: showFenceNotice, onPaired: hideFenceNotice })
235
+ const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
232
236
  return restore
233
237
  }, 'remote-web-ui: remote desktop channel')
234
- } else if (!channelActive() && disposeChannel !== undefined) {
238
+ } else if (transition === 'retire' && disposeChannel !== undefined) {
235
239
  disposeChannel()
236
240
  disposeChannel = undefined
241
+ // Retire the notice with the channel: once requirePairingForLan turns
242
+ // off (or the plugin is disabled) the desktop rides plain /api again,
243
+ // so an unpaired notice raised while the channel was briefly active
244
+ // (the settings snapshot loads after boot) must not outlive it. The
245
+ // installed channel is the only path that raises the notice, so with
246
+ // the channel gone nothing can re-raise it (issue #808).
247
+ hideFenceNotice()
237
248
  }
238
249
  }
239
250
  settingsScope.subscribe(syncChannel)
240
251
  syncChannel()
252
+ if (!isLoopbackHostname(window.location.hostname) && settingsScope.getSnapshot().status !== 'ready') {
253
+ void readPairGatePolicy().then((policy) => {
254
+ hostPairingPolicy = policy.requirePairingForLan
255
+ syncChannel()
256
+ if (hostPairingPolicy && unpairedWhilePolicyPending) showFenceNotice()
257
+ unpairedWhilePolicyPending = false
258
+ }).catch(() => {
259
+ // Fail closed when the policy endpoint is unavailable or malformed.
260
+ hostPairingPolicy = true
261
+ syncChannel()
262
+ if (unpairedWhilePolicyPending) showFenceNotice()
263
+ unpairedWhilePolicyPending = false
264
+ })
265
+ }
241
266
 
242
267
  // One-time failed-pair toast. The accept result lands asynchronously, so
243
268
  // the marker check is deferred past the accept round trip.
@@ -36,6 +36,22 @@ export interface IssueLoopbackRequired {
36
36
 
37
37
  export type IssueResponse = IssueResult | IssueLanRequired | IssueUnknownAddress | IssueLoopbackRequired
38
38
 
39
+ /** Public policy fields returned by the pairing status endpoint. */
40
+ export interface PairGatePolicy {
41
+ requirePairingForLan: boolean
42
+ }
43
+
44
+ /** Read the host-authoritative desktop pairing policy. */
45
+ export async function readPairGatePolicy(): Promise<PairGatePolicy> {
46
+ const response = await fetch('/api/pair/status')
47
+ if (!response.ok) throw new Error(`remote-web-ui: status failed with ${String(response.status)}`)
48
+ const value = await response.json() as { requirePairingForLan?: unknown }
49
+ if (typeof value.requirePairingForLan !== 'boolean') {
50
+ throw new Error('remote-web-ui: status omitted requirePairingForLan')
51
+ }
52
+ return { requirePairingForLan: value.requirePairingForLan }
53
+ }
54
+
39
55
  /** accept() refusal codes. */
40
56
  export type AcceptFailure = { ok: false; code: 'invalid' | 'used' | 'forbidden' }
41
57
 
@@ -46,6 +46,27 @@ const WS_PATHS = new Set([
46
46
  '/api/dsh-ssh/terminal',
47
47
  ])
48
48
 
49
+ /** Minimal settings snapshot used by the remote channel decision. */
50
+ export interface RemoteChannelSettingsSnapshot {
51
+ status: 'ready' | 'loading' | 'unavailable' | string
52
+ value?: { enabled?: boolean; requirePairingForLan?: boolean }
53
+ }
54
+
55
+ /** Decide whether a remote desktop channel is required from local or host policy. */
56
+ export function remoteChannelRequired(
57
+ hostname: string,
58
+ snapshot: RemoteChannelSettingsSnapshot,
59
+ hostPairingPolicy: boolean | undefined,
60
+ ): boolean {
61
+ if (isLoopbackHostname(hostname)) return false
62
+ if (snapshot.status === 'ready') {
63
+ return (snapshot.value?.enabled ?? true) && (snapshot.value?.requirePairingForLan ?? true)
64
+ }
65
+ // Install provisionally while the host probe is pending so early SDK calls
66
+ // cannot escape onto the plain remote origin. A confirmed false retires it.
67
+ return hostPairingPolicy !== false
68
+ }
69
+
49
70
  /**
50
71
  * Browser-safe loopback classification for the page origin (the SDK client
51
72
  * exports its own; this copy keeps the module dependency-free).
@@ -267,3 +288,24 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
267
288
  for (const restore of restoreSrc) restore()
268
289
  }
269
290
  }
291
+
292
+ /**
293
+ * The remote-channel lifecycle transition between two steady states:
294
+ * running (active + installed) and retired (inactive + not installed).
295
+ * The client apply drives the channel with this decision and retires the
296
+ * unpaired fence notice together with the channel itself — a notice raised
297
+ * while the channel was briefly active must not outlive it (issue #808).
298
+ */
299
+ export type ChannelTransition = 'install' | 'retire' | 'none'
300
+
301
+ /**
302
+ * Decide what the channel lifecycle must do next.
303
+ * @param active - whether the gated remote channel should be running now.
304
+ * @param installed - whether it currently is (disposer !== undefined).
305
+ * @returns the transition to apply.
306
+ */
307
+ export function channelTransition(active: boolean, installed: boolean): ChannelTransition {
308
+ if (active && !installed) return 'install'
309
+ if (!active && installed) return 'retire'
310
+ return 'none'
311
+ }
package/src/index.ts CHANGED
@@ -353,7 +353,7 @@ function applyImpl(ctx: Context, config?: Config): void {
353
353
  },
354
354
  })
355
355
  const routes = [
356
- ...makeRoutes({ service, lanAddresses }),
356
+ ...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
357
357
  ...makeMobileRoutes(),
358
358
  ...(apiProxy !== undefined
359
359
  ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
@@ -183,6 +183,29 @@ describe('foldEvents', () => {
183
183
  expect(assistant?.pending).toBeFalsy()
184
184
  })
185
185
 
186
+ it('keeps same-turn steps in final-event order after turn/end', () => {
187
+ const result = foldEvents([
188
+ makeEvent('user/message', userMessageData('u-1', 'multi-step'), 0),
189
+ makeEvent('assistant/message', assistantMessageData('step-z', 0, 0, 'first'), 2),
190
+ makeEvent('assistant/message', assistantMessageData('step-a', 0, 1, 'second'), 4),
191
+ makeEvent('assistant/message', assistantMessageData('step-m', 0, 2, 'third'), 6),
192
+ makeEvent('turn/end', { turn: 0, reason: { kind: 'completed' } }, 7),
193
+ ])
194
+
195
+ const assistants = result.filter(message => message.kind === 'assistant')
196
+ expect(assistants.map(message => message.id)).toEqual(['step-z', 'step-a', 'step-m'])
197
+ expect(assistants.map(message => message.seq)).toEqual([2, 4, 6])
198
+ expect(assistants.every(message => message.pending !== true)).toBe(true)
199
+ })
200
+
201
+ it('keeps stable insertion order when legacy rows share a seq', () => {
202
+ const folder = new EventFolder([
203
+ { id: 'z-last-lexically', kind: 'assistant', text: 'first', seq: 5, time: 5_000 },
204
+ { id: 'a-first-lexically', kind: 'assistant', text: 'second', seq: 5, time: 5_000 },
205
+ ])
206
+ expect(folder.snapshot().map(message => message.id)).toEqual(['z-last-lexically', 'a-first-lexically'])
207
+ })
208
+
186
209
  it('is idempotent: applying the same batch twice yields an identical list', () => {
187
210
  const events: WireEvent[] = [
188
211
  makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
@@ -589,7 +589,8 @@ function applyTurnEnd(state: FoldState, event: WireEvent): void {
589
589
  ...message,
590
590
  ...(wasPending ? { pending: false } : {}),
591
591
  ...(failed ? { failed: true } : {}),
592
- seq: Math.max(message.seq, event.seq),
592
+ // Preserve each step's own final-event seq. Collapsing every message
593
+ // onto turn/end makes same-turn ordering depend on arbitrary ids.
593
594
  time: event.time,
594
595
  })
595
596
  }
@@ -666,10 +667,11 @@ function snapshotOf(state: FoldState): RenderMessage[] {
666
667
  for (let index = 1; index < out.length; index += 1) {
667
668
  const prev = out[index - 1]!
668
669
  const current = out[index]!
669
- if (prev.seq > current.seq || (prev.seq === current.seq && prev.id >= current.id)) {
670
+ if (prev.seq > current.seq) {
670
671
  ordered = false
671
672
  break
672
673
  }
673
674
  }
674
- return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
675
+ // Array.sort is stable: equal-seq rows keep their event insertion order.
676
+ return ordered ? out : out.sort((a, b) => a.seq - b.seq)
675
677
  }
@@ -95,6 +95,22 @@ describe('MuxClient polling fallback', () => {
95
95
  client.stop()
96
96
  })
97
97
 
98
+ it('sorts an out-of-order history page before advancing the watermark', async () => {
99
+ const { factory } = makeSources()
100
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([2, 1, 3]))
101
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
102
+ const seqs: number[] = []
103
+ client.onFrame((frame) => {
104
+ if (frame.type === 'session/event' && typeof frame.event.seq === 'number') seqs.push(frame.event.seq)
105
+ })
106
+ client.start()
107
+ client.observe('s1')
108
+
109
+ await vi.advanceTimersByTimeAsync(1200)
110
+ expect(seqs).toEqual([1, 2, 3])
111
+ client.stop()
112
+ })
113
+
98
114
  it('keeps the watermark so a repeated page never re-emits old events', async () => {
99
115
  const { factory } = makeSources()
100
116
  // Two calls return the same page: the second must emit nothing.
@@ -171,6 +187,54 @@ describe('MuxClient polling fallback', () => {
171
187
  client.stop()
172
188
  })
173
189
 
190
+ it('recovers when a previously-live SSE stream becomes silently stalled', async () => {
191
+ const { factory, sources } = makeSources()
192
+ const pollLatest = vi.fn(async (_sessionId: string) => pageOf([5]))
193
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
194
+ const frames: Array<{ type: string; event?: { seq: number } }> = []
195
+ client.onFrame(frame => { frames.push(frame as never) })
196
+ client.start()
197
+ client.observe('s1')
198
+
199
+ sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
200
+ await vi.advanceTimersByTimeAsync(2400)
201
+ expect(pollLatest).not.toHaveBeenCalled()
202
+
203
+ // A once-live stream gets three stall windows; the next scheduler tick
204
+ // crosses that boundary and starts the ordinary-HTTP recovery path.
205
+ await vi.advanceTimersByTimeAsync(400)
206
+ expect(pollLatest).toHaveBeenCalledTimes(1)
207
+ expect(frames.at(-1)?.event).toMatchObject({ seq: 5 })
208
+ client.stop()
209
+ })
210
+
211
+ it('backs empty polls off and resets to the base cadence after progress', async () => {
212
+ const { factory } = makeSources()
213
+ const pages = [pageOf([]), pageOf([1]), pageOf([1, 2])]
214
+ const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
215
+ const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
216
+ client.start()
217
+ client.observe('s1')
218
+
219
+ await vi.advanceTimersByTimeAsync(1200)
220
+ expect(pollLatest).toHaveBeenCalledTimes(1)
221
+ await vi.advanceTimersByTimeAsync(400)
222
+ expect(pollLatest).toHaveBeenCalledTimes(1)
223
+ await vi.advanceTimersByTimeAsync(400)
224
+ expect(pollLatest).toHaveBeenCalledTimes(2)
225
+
226
+ // The productive second poll resets the next delay from 800 ms to 400 ms.
227
+ await vi.advanceTimersByTimeAsync(400)
228
+ expect(pollLatest).toHaveBeenCalledTimes(3)
229
+ await vi.advanceTimersByTimeAsync(400)
230
+ expect(pollLatest).toHaveBeenCalledTimes(4)
231
+ await vi.advanceTimersByTimeAsync(400)
232
+ expect(pollLatest).toHaveBeenCalledTimes(4)
233
+ await vi.advanceTimersByTimeAsync(400)
234
+ expect(pollLatest).toHaveBeenCalledTimes(5)
235
+ client.stop()
236
+ })
237
+
174
238
  it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
175
239
  const { factory } = makeSources()
176
240
  const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')