@linxin666/dsh-remote-web-ui 0.1.19 → 0.2.0

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 (144) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +57 -13
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +408 -130
  10. package/lib/mobile.js +296 -22
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/FooterRemoteEntry.js +1 -1
  13. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  14. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PluginSettingsCard.js +5 -3
  16. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  17. package/lib/types/client/RemoteEntry.js +43 -13
  18. package/lib/types/client/RemotePanel.d.ts +1 -0
  19. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  20. package/lib/types/client/RemotePanel.js +2 -2
  21. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/RemoteSettingsCard.js +3 -3
  23. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  24. package/lib/types/client/UpdateEntry.js +37 -4
  25. package/lib/types/client/UpdatePanel.js +1 -1
  26. package/lib/types/client/deep-link.js +2 -2
  27. package/lib/types/client/index.js +7 -7
  28. package/lib/types/client/locales.d.ts +2 -0
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/locales.js +2 -0
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/update-api.d.ts.map +1 -1
  33. package/lib/types/gate.d.ts +9 -0
  34. package/lib/types/gate.d.ts.map +1 -1
  35. package/lib/types/gate.js +16 -5
  36. package/lib/types/http.d.ts +14 -0
  37. package/lib/types/http.d.ts.map +1 -0
  38. package/lib/types/http.js +27 -0
  39. package/lib/types/index.d.ts +28 -1
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +41 -22
  42. package/lib/types/invariant.d.ts.map +1 -1
  43. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  44. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  45. package/lib/types/mobile/PairRequiredView.js +28 -0
  46. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  47. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  48. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  49. package/lib/types/mobile/api.js +1 -1
  50. package/lib/types/mobile/index.js +17 -4
  51. package/lib/types/mobile/markdown.d.ts +6 -4
  52. package/lib/types/mobile/markdown.d.ts.map +1 -1
  53. package/lib/types/mobile/markdown.js +8 -4
  54. package/lib/types/mobile/markdown.test.js +5 -1
  55. package/lib/types/mobile/messages.d.ts.map +1 -1
  56. package/lib/types/mobile/messages.js +14 -1
  57. package/lib/types/mobile/messages.test.js +1 -1
  58. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  59. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  60. package/lib/types/mobile/mobile-styles.js +48 -0
  61. package/lib/types/mobile/mobile-theme.test.js +2 -2
  62. package/lib/types/mobile/mux.d.ts.map +1 -1
  63. package/lib/types/mobile/mux.js +1 -1
  64. package/lib/types/mobile/mux.test.js +1 -1
  65. package/lib/types/mobile/pairing.d.ts +31 -0
  66. package/lib/types/mobile/pairing.d.ts.map +1 -0
  67. package/lib/types/mobile/pairing.js +62 -0
  68. package/lib/types/mobile/rpc.d.ts.map +1 -1
  69. package/lib/types/mobile/theme-toggle.js +1 -1
  70. package/lib/types/mobile/views/App.d.ts +12 -16
  71. package/lib/types/mobile/views/App.d.ts.map +1 -1
  72. package/lib/types/mobile/views/App.js +39 -12
  73. package/lib/types/mobile/views/App.test.d.ts +2 -0
  74. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  75. package/lib/types/mobile/views/App.test.js +52 -0
  76. package/lib/types/mobile/views/ChatView.js +6 -6
  77. package/lib/types/mobile/views/ChatView.test.js +3 -3
  78. package/lib/types/mobile/views/SessionListView.js +3 -3
  79. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  80. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  81. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  82. package/lib/types/mobile-api.d.ts +1 -1
  83. package/lib/types/mobile-api.d.ts.map +1 -1
  84. package/lib/types/mobile-api.js +11 -18
  85. package/lib/types/mobile-pwa.d.ts +6 -0
  86. package/lib/types/mobile-pwa.d.ts.map +1 -0
  87. package/lib/types/mobile-pwa.js +17 -0
  88. package/lib/types/mobile-routes.d.ts +5 -8
  89. package/lib/types/mobile-routes.d.ts.map +1 -1
  90. package/lib/types/mobile-routes.js +130 -39
  91. package/lib/types/mount-once.d.ts +25 -0
  92. package/lib/types/mount-once.d.ts.map +1 -0
  93. package/lib/types/mount-once.js +42 -0
  94. package/lib/types/pairing-access.d.ts +28 -0
  95. package/lib/types/pairing-access.d.ts.map +1 -0
  96. package/lib/types/pairing-access.js +26 -0
  97. package/lib/types/pairing.d.ts +27 -0
  98. package/lib/types/pairing.d.ts.map +1 -1
  99. package/lib/types/pairing.js +68 -0
  100. package/lib/types/pairing.persist.test.d.ts +2 -0
  101. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  102. package/lib/types/pairing.persist.test.js +128 -0
  103. package/lib/types/routes.d.ts +6 -6
  104. package/lib/types/routes.d.ts.map +1 -1
  105. package/lib/types/routes.js +15 -20
  106. package/lib/types/tunnel.d.ts.map +1 -1
  107. package/lib/types/update-routes.d.ts +2 -2
  108. package/lib/types/update-routes.d.ts.map +1 -1
  109. package/lib/types/update-routes.js +1 -6
  110. package/lib/types/update.d.ts +3 -3
  111. package/lib/types/update.d.ts.map +1 -1
  112. package/lib/types/update.js +40 -14
  113. package/package.json +12 -12
  114. package/src/client/PluginSettingsCard.tsx +10 -0
  115. package/src/client/RemoteEntry.tsx +40 -8
  116. package/src/client/RemotePanel.tsx +1 -1
  117. package/src/client/RemoteSettingsCard.tsx +1 -0
  118. package/src/client/UpdateEntry.tsx +36 -4
  119. package/src/client/deep-link.ts +1 -1
  120. package/src/client/locales.ts +2 -0
  121. package/src/client/remote.module.css +26 -0
  122. package/src/gate.ts +15 -3
  123. package/src/http.ts +30 -0
  124. package/src/index.ts +50 -16
  125. package/src/mobile/PairRequiredView.test.tsx +28 -0
  126. package/src/mobile/PairRequiredView.tsx +58 -0
  127. package/src/mobile/index.tsx +15 -1
  128. package/src/mobile/markdown.test.ts +5 -0
  129. package/src/mobile/markdown.ts +7 -4
  130. package/src/mobile/messages.ts +14 -1
  131. package/src/mobile/mobile-styles.ts +48 -0
  132. package/src/mobile/pairing.ts +77 -0
  133. package/src/mobile/views/App.test.tsx +62 -0
  134. package/src/mobile/views/App.tsx +53 -18
  135. package/src/mobile-api.ts +9 -17
  136. package/src/mobile-pwa.ts +19 -0
  137. package/src/mobile-routes.ts +137 -38
  138. package/src/mount-once.ts +48 -0
  139. package/src/pairing-access.ts +36 -0
  140. package/src/pairing.persist.test.ts +145 -0
  141. package/src/pairing.ts +70 -1
  142. package/src/routes.ts +12 -18
  143. package/src/update-routes.ts +1 -7
  144. package/src/update.ts +46 -15
package/lib/index.js CHANGED
@@ -3,16 +3,16 @@ import { setInterval as setInterval$1 } from "node:timers";
3
3
  import { Service } from "@deepseek-ai/cordis";
4
4
  import z from "schemastery";
5
5
  import { randomBytes } from "node:crypto";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
6
8
  import { z as z$1 } from "zod";
7
9
  import { readFile } from "node:fs/promises";
8
- import { existsSync, readFileSync } from "node:fs";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
11
12
  import { networkInterfaces } from "node:os";
12
13
  import { Tunnel, bin, install } from "cloudflared";
13
- import { dirname, join } from "node:path";
14
14
  import { spawn } from "node:child_process";
15
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.6_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_52495af71738a1095726249ceff4fd7b/node_modules/@deepseek-ai/dsh-settings/lib/index.js
15
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.7_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_6b0eb868fb92d854799855c05c5fa43c/node_modules/@deepseek-ai/dsh-settings/lib/index.js
16
16
  /**
17
17
  * Structural secret redaction for settings values. `role('secret')` fields are
18
18
  * removed from a value before it crosses a wire boundary; a sidecar records
@@ -271,6 +271,61 @@ var PairingService = class {
271
271
  constructor(config, clock = defaultClock) {
272
272
  this.config = config;
273
273
  this.clock = clock;
274
+ this.loadPersisted();
275
+ }
276
+ /**
277
+ * Restore device sessions persisted by a previous process run. A corrupt
278
+ * or missing file is tolerated (an empty device table, never a throw) —
279
+ * persistence is an availability convenience, not a security boundary.
280
+ */
281
+ loadPersisted() {
282
+ const file = this.config.devicesFile;
283
+ if (file === void 0) return;
284
+ try {
285
+ const saved = JSON.parse(readFileSync(file, "utf8"));
286
+ if (typeof saved !== "object" || saved === null) return;
287
+ for (const [deviceId, session] of Object.entries(saved)) {
288
+ if (typeof deviceId !== "string") continue;
289
+ if (typeof session !== "object" || session === null) continue;
290
+ const { createdAt, lastSeenAt } = session;
291
+ if (typeof createdAt !== "number" || typeof lastSeenAt !== "number") continue;
292
+ this.devices.set(deviceId, {
293
+ createdAt,
294
+ lastSeenAt
295
+ });
296
+ }
297
+ this.clampToMaxDevices();
298
+ } catch {}
299
+ }
300
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
301
+ clampToMaxDevices() {
302
+ if (this.devices.size <= this.config.maxDevices) return;
303
+ const overflow = this.devices.size - this.config.maxDevices;
304
+ const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
305
+ for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id);
306
+ }
307
+ /**
308
+ * Write the current device table to the configured file. Called on the
309
+ * mutation boundaries that change the set of live sessions (accept and
310
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
311
+ * per-request write storm is avoided — after a restart the phone's first
312
+ * heartbeat re-warms its own session.
313
+ *
314
+ * Device ids are session credentials (the gate authorizes requests by the
315
+ * cookie's device id), so the file is written 0600 via a temp file and
316
+ * atomic rename; a crash mid-write can never leave a half-written store.
317
+ */
318
+ persist() {
319
+ const file = this.config.devicesFile;
320
+ if (file === void 0) return;
321
+ try {
322
+ mkdirSync(dirname(file), { recursive: true });
323
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
324
+ writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 384 });
325
+ renameSync(temp, file);
326
+ } catch (error) {
327
+ console.error("remote-web-ui: failed to persist paired devices", error);
328
+ }
274
329
  }
275
330
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
276
331
  get lanBaseUrl() {
@@ -365,6 +420,7 @@ var PairingService = class {
365
420
  createdAt: now,
366
421
  lastSeenAt: now
367
422
  });
423
+ this.persist();
368
424
  this.notify();
369
425
  return {
370
426
  ok: true,
@@ -379,6 +435,7 @@ var PairingService = class {
379
435
  stop() {
380
436
  this.tokens.clear();
381
437
  this.devices.clear();
438
+ this.persist();
382
439
  this.stopped = true;
383
440
  this.notify();
384
441
  }
@@ -566,11 +623,75 @@ function makeGateListener(service, requirePairingForLan = true, enabled = true)
566
623
  if (isLoopbackClient(request)) return next();
567
624
  if (!(typeof enabled === "function" ? enabled() : enabled)) return false;
568
625
  if (!(typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan)) return next();
569
- const deviceId = readCookie(request.headers.cookie, service.config.cookieName);
570
- if (deviceId === void 0) return false;
571
- return service.touchDevice(deviceId) ? next() : false;
626
+ return isPairedDeviceRequest(service, request) ? next() : false;
572
627
  };
573
628
  }
629
+ /**
630
+ * Whether a request carries a live, non-revoked paired-device cookie for
631
+ * this service. Sibling host routes outside /api (aionui-panel, etc.) use
632
+ * the same check via the remoteWebUiPairing service.
633
+ * @param service - the pairing service that owns the device table.
634
+ * @param request - the incoming HTTP request.
635
+ * @returns true when the cookie names a live session (and lastSeenAt was refreshed).
636
+ */
637
+ function isPairedDeviceRequest(service, request) {
638
+ const deviceId = readCookie(request.headers.cookie, service.config.cookieName);
639
+ if (deviceId === void 0) return false;
640
+ return service.touchDevice(deviceId);
641
+ }
642
+ //#endregion
643
+ //#region src/pairing-access.ts
644
+ /** Named lookup key sibling plugins pass to ctx.get. */
645
+ const REMOTE_WEB_UI_PAIRING = "remoteWebUiPairing";
646
+ /**
647
+ * Pairing identity for one HTTP request. Structural: consumers must not
648
+ * import this class, only the method shape.
649
+ */
650
+ var RemoteWebUiPairing = class extends Service {
651
+ check;
652
+ /**
653
+ * @param ctx - host plugin context.
654
+ * @param check - live cookie + session predicate (re-read per request).
655
+ */
656
+ constructor(ctx, check) {
657
+ super(ctx, REMOTE_WEB_UI_PAIRING);
658
+ this.check = check;
659
+ }
660
+ /**
661
+ * Whether the request carries a live paired-device cookie.
662
+ * @param request - the incoming HTTP request.
663
+ * @returns true when the session is live and was refreshed.
664
+ */
665
+ isPairedDevice(request) {
666
+ return this.check(request);
667
+ }
668
+ };
669
+ //#endregion
670
+ //#region src/http.ts
671
+ /** One JSON response. */
672
+ function writeJson(res, status, body) {
673
+ const payload = JSON.stringify(body);
674
+ res.writeHead(status, {
675
+ "content-type": "application/json; charset=utf-8",
676
+ "referrer-policy": "no-referrer"
677
+ });
678
+ res.end(payload);
679
+ }
680
+ /**
681
+ * Read a request body up to maxBytes and parse it as JSON.
682
+ * @throws 'body too large' beyond the cap, or the JSON.parse error.
683
+ */
684
+ async function readBoundedJson(req, maxBytes) {
685
+ const chunks = [];
686
+ let size = 0;
687
+ for await (const chunk of req) {
688
+ const buffer = chunk;
689
+ size += buffer.length;
690
+ if (size > maxBytes) throw new Error("body too large");
691
+ chunks.push(buffer);
692
+ }
693
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
694
+ }
574
695
  //#endregion
575
696
  //#region src/routes.ts
576
697
  /**
@@ -663,27 +784,10 @@ function parsePairPayload(schema, body) {
663
784
  const result = schema.safeParse(body ?? {});
664
785
  return result.success ? result.data : void 0;
665
786
  }
666
- /** One JSON response. */
667
- function writeJson$1(res, status, body) {
668
- const payload = JSON.stringify(body);
669
- res.writeHead(status, {
670
- "content-type": "application/json; charset=utf-8",
671
- "referrer-policy": "no-referrer"
672
- });
673
- res.end(payload);
674
- }
675
- /** Read a request body up to MAX_BODY_BYTES and parse it as JSON. */
787
+ /** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
676
788
  async function readJsonBody$1(req) {
677
- const chunks = [];
678
- let size = 0;
679
- for await (const chunk of req) {
680
- const buffer = chunk;
681
- size += buffer.length;
682
- if (size > MAX_BODY_BYTES) return void 0;
683
- chunks.push(buffer);
684
- }
685
789
  try {
686
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
790
+ const parsed = await readBoundedJson(req, MAX_BODY_BYTES);
687
791
  return typeof parsed === "object" && parsed !== null ? parsed : void 0;
688
792
  } catch {
689
793
  return;
@@ -766,6 +870,9 @@ function makeRoutes(deps) {
766
870
  const rateLimitAccept = (req) => {
767
871
  const ip = req.socket?.remoteAddress ?? "unknown";
768
872
  const nowMs = Date.now();
873
+ if (acceptAttempts.size > 256) {
874
+ for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
875
+ }
769
876
  const entry = acceptAttempts.get(ip);
770
877
  if (entry === void 0 || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
771
878
  acceptAttempts.set(ip, {
@@ -780,7 +887,7 @@ function makeRoutes(deps) {
780
887
  const handleIssue = async (req, res) => {
781
888
  if (!requireMethod(req, res, "POST")) return;
782
889
  if (!loopbackFence(req)) {
783
- writeJson$1(res, 403, {
890
+ writeJson(res, 403, {
784
891
  ok: false,
785
892
  code: "forbidden"
786
893
  });
@@ -789,7 +896,7 @@ function makeRoutes(deps) {
789
896
  const body = await readJsonBody$1(req);
790
897
  const payload = parsePairPayload(issuePayloadSchema, body);
791
898
  if (payload === void 0) {
792
- writeJson$1(res, 400, {
899
+ writeJson(res, 400, {
793
900
  ok: false,
794
901
  code: "bad-payload"
795
902
  });
@@ -800,9 +907,9 @@ function makeRoutes(deps) {
800
907
  const { token, expiresAt } = service.issue(workspaceId, address);
801
908
  const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
802
909
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
803
- writeJson$1(res, 200, {
910
+ writeJson(res, 200, {
804
911
  ok: true,
805
- url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
912
+ url: `${base}/m/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
806
913
  token,
807
914
  expiresAt,
808
915
  lanAddresses: service.lanAddresses,
@@ -810,7 +917,7 @@ function makeRoutes(deps) {
810
917
  });
811
918
  } catch (error) {
812
919
  const unknownAddress = error instanceof UnknownLanAddressError;
813
- writeJson$1(res, unknownAddress ? 400 : 409, {
920
+ writeJson(res, unknownAddress ? 400 : 409, {
814
921
  ok: false,
815
922
  code: unknownAddress ? "unknown-address" : "lan-required"
816
923
  });
@@ -819,14 +926,14 @@ function makeRoutes(deps) {
819
926
  const handleAccept = async (req, res) => {
820
927
  if (!requireMethod(req, res, "POST")) return;
821
928
  if (!lanFence(req)) {
822
- writeJson$1(res, 403, {
929
+ writeJson(res, 403, {
823
930
  ok: false,
824
931
  code: "forbidden"
825
932
  });
826
933
  return;
827
934
  }
828
935
  if (rateLimitAccept(req)) {
829
- writeJson$1(res, 429, {
936
+ writeJson(res, 429, {
830
937
  ok: false,
831
938
  code: "rate-limited"
832
939
  });
@@ -835,7 +942,7 @@ function makeRoutes(deps) {
835
942
  const body = await readJsonBody$1(req);
836
943
  const payload = parsePairPayload(acceptPayloadSchema, body);
837
944
  if (payload === void 0) {
838
- writeJson$1(res, 400, {
945
+ writeJson(res, 400, {
839
946
  ok: false,
840
947
  code: "bad-payload"
841
948
  });
@@ -843,7 +950,7 @@ function makeRoutes(deps) {
843
950
  }
844
951
  const result = service.accept(payload.token);
845
952
  if (!result.ok) {
846
- writeJson$1(res, result.code === "used" ? 409 : 404, {
953
+ writeJson(res, result.code === "used" ? 409 : 404, {
847
954
  ok: false,
848
955
  code: result.code
849
956
  });
@@ -861,7 +968,7 @@ function makeRoutes(deps) {
861
968
  const handleStop = async (req, res) => {
862
969
  if (!requireMethod(req, res, "POST")) return;
863
970
  if (!loopbackFence(req)) {
864
- writeJson$1(res, 403, {
971
+ writeJson(res, 403, {
865
972
  ok: false,
866
973
  code: "forbidden"
867
974
  });
@@ -869,19 +976,19 @@ function makeRoutes(deps) {
869
976
  }
870
977
  const body = await readJsonBody$1(req);
871
978
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
872
- writeJson$1(res, 400, {
979
+ writeJson(res, 400, {
873
980
  ok: false,
874
981
  code: "bad-payload"
875
982
  });
876
983
  return;
877
984
  }
878
985
  service.stop();
879
- writeJson$1(res, 200, { ok: true });
986
+ writeJson(res, 200, { ok: true });
880
987
  };
881
988
  const handleHeartbeat = async (req, res) => {
882
989
  if (!requireMethod(req, res, "POST")) return;
883
990
  if (!lanFence(req)) {
884
- writeJson$1(res, 403, {
991
+ writeJson(res, 403, {
885
992
  ok: false,
886
993
  code: "forbidden"
887
994
  });
@@ -889,7 +996,7 @@ function makeRoutes(deps) {
889
996
  }
890
997
  const body = await readJsonBody$1(req);
891
998
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
892
- writeJson$1(res, 400, {
999
+ writeJson(res, 400, {
893
1000
  ok: false,
894
1001
  code: "bad-payload"
895
1002
  });
@@ -897,25 +1004,25 @@ function makeRoutes(deps) {
897
1004
  }
898
1005
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
899
1006
  if (deviceId === void 0 || !service.heartbeat(deviceId)) {
900
- writeJson$1(res, 401, {
1007
+ writeJson(res, 401, {
901
1008
  ok: false,
902
1009
  code: "unpaired"
903
1010
  });
904
1011
  return;
905
1012
  }
906
- writeJson$1(res, 200, { ok: true });
1013
+ writeJson(res, 200, { ok: true });
907
1014
  };
908
1015
  const handleStatus = async (req, res) => {
909
1016
  if (!requireMethod(req, res, "GET")) return;
910
1017
  if (!lanFence(req)) {
911
- writeJson$1(res, 403, {
1018
+ writeJson(res, 403, {
912
1019
  ok: false,
913
1020
  code: "forbidden"
914
1021
  });
915
1022
  return;
916
1023
  }
917
1024
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
918
- writeJson$1(res, 200, {
1025
+ writeJson(res, 200, {
919
1026
  ok: true,
920
1027
  paired: deviceId !== void 0 && service.hasDevice(deviceId),
921
1028
  ...service.snapshot()
@@ -924,7 +1031,7 @@ function makeRoutes(deps) {
924
1031
  const handleEvents = (req, res) => {
925
1032
  if (!requireMethod(req, res, "GET")) return;
926
1033
  if (!loopbackFence(req)) {
927
- writeJson$1(res, 403, {
1034
+ writeJson(res, 403, {
928
1035
  ok: false,
929
1036
  code: "forbidden"
930
1037
  });
@@ -969,23 +1076,25 @@ function makeRoutes(deps) {
969
1076
  //#endregion
970
1077
  //#region src/mobile-routes.ts
971
1078
  /**
972
- * The mobile surface's page routes: `/m` serves the standalone phone UI
973
- * (an independent bundle, built to lib/mobile.js by the mobile tsdown
974
- * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
975
- * host exclusively through the shared /api transport (paired-device cookie
976
- * already crosses the api/gate fence), so no host-side data plumbing is
977
- * needed here — only static serving, loopback+paired-fence via the normal
978
- * webserver route registration.
1079
+ * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
1080
+ * standalone phone UI. The page and worker live in the /m/ scope so the PWA
1081
+ * can cache only its static shell; the paired-device data channel remains at
1082
+ * /m/api and is never handled by the worker.
979
1083
  */
1084
+ const MOBILE_ROOT = "/m/";
1085
+ const MOBILE_BUNDLE_URL = "/m/mobile.js";
1086
+ const MOBILE_MANIFEST_URL = "/m/manifest.webmanifest";
1087
+ const MOBILE_WORKER_URL = "/m/service-worker.js";
1088
+ const MOBILE_OFFLINE_URL = "/m/offline.html";
980
1089
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
981
1090
  function mobileBundlePath() {
982
1091
  return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
983
1092
  }
984
- /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
985
- function appleTouchIconPath() {
986
- return fileURLToPath(new URL("../assets/apple-touch-icon.png", import.meta.url));
1093
+ /** A package asset available to the host after a registry or git install. */
1094
+ function mobileAssetPath(name) {
1095
+ return fileURLToPath(new URL("../assets/" + name, import.meta.url));
987
1096
  }
988
- /** The mobile page shell: minimal, offline-safe, no external assets. */
1097
+ /** The mobile page shell: self-contained, with no document-level user data. */
989
1098
  function pageHtml(bundleUrl) {
990
1099
  return [
991
1100
  "<!doctype html>",
@@ -995,26 +1104,80 @@ function pageHtml(bundleUrl) {
995
1104
  "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
996
1105
  "<meta name=\"theme-color\" content=\"#f3f5f9\">",
997
1106
  "<meta name=\"referrer\" content=\"no-referrer\">",
1107
+ "<meta name=\"mobile-web-app-capable\" content=\"yes\">",
1108
+ "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
1109
+ "<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
1110
+ "<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
998
1111
  "<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
999
1112
  "<title>移动端远程控制</title>",
1000
1113
  "</head>",
1001
1114
  "<body>",
1002
1115
  "<div id=\"root\"></div>",
1003
- `<script type="module" src="${bundleUrl}"><\/script>`,
1116
+ "<script type=\"module\" src=\"" + bundleUrl + "\"><\/script>",
1004
1117
  "</body>",
1005
1118
  "</html>"
1006
1119
  ].join("");
1007
1120
  }
1008
- /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
1121
+ /** The installable app identity and icon declarations for the /m scope. */
1122
+ function manifestJson() {
1123
+ return JSON.stringify({
1124
+ id: MOBILE_ROOT,
1125
+ name: "DSH Remote",
1126
+ short_name: "DSH Remote",
1127
+ start_url: MOBILE_ROOT,
1128
+ scope: MOBILE_ROOT,
1129
+ display: "standalone",
1130
+ background_color: "#151424",
1131
+ theme_color: "#f3f5f9",
1132
+ icons: [{
1133
+ src: "/m/icon-192.png",
1134
+ sizes: "192x192",
1135
+ type: "image/png",
1136
+ purpose: "any"
1137
+ }, {
1138
+ src: "/m/icon-512.png",
1139
+ sizes: "512x512",
1140
+ type: "image/png",
1141
+ purpose: "any maskable"
1142
+ }]
1143
+ }, null, 2);
1144
+ }
1145
+ /** A static fallback that contains no paired-device, session, or workspace data. */
1146
+ function offlineHtml() {
1147
+ return [
1148
+ "<!doctype html>",
1149
+ "<html lang=\"zh-CN\">",
1150
+ "<head>",
1151
+ "<meta charset=\"utf-8\">",
1152
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">",
1153
+ "<meta name=\"theme-color\" content=\"#151424\">",
1154
+ "<title>DSH Remote</title>",
1155
+ "<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>",
1156
+ "</head>",
1157
+ "<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>",
1158
+ "</html>"
1159
+ ].join("");
1160
+ }
1161
+ /** Send a small static UTF-8 body with revalidation headers. */
1009
1162
  function writeStatic(res, status, type, body) {
1010
1163
  res.writeHead(status, {
1011
- "content-type": `${type}; charset=utf-8`,
1164
+ "content-type": type + "; charset=utf-8",
1012
1165
  "cache-control": "no-cache",
1013
1166
  "referrer-policy": "no-referrer"
1014
1167
  });
1015
1168
  res.end(body);
1016
1169
  }
1017
- /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
1170
+ /** Send the worker with the same scope it registers for, without HTTP caching. */
1171
+ function writeServiceWorker(res, body) {
1172
+ res.writeHead(200, {
1173
+ "content-type": "text/javascript; charset=utf-8",
1174
+ "cache-control": "no-cache",
1175
+ "referrer-policy": "no-referrer",
1176
+ "service-worker-allowed": MOBILE_ROOT
1177
+ });
1178
+ res.end(body);
1179
+ }
1180
+ /** Send a PNG body without decoding it to UTF-8. */
1018
1181
  function writePng(res, status, body) {
1019
1182
  res.writeHead(status, {
1020
1183
  "content-type": "image/png",
@@ -1023,53 +1186,121 @@ function writePng(res, status, body) {
1023
1186
  });
1024
1187
  res.end(body);
1025
1188
  }
1189
+ /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
1190
+ function redirectToMobileRoot(req, res) {
1191
+ const url = new URL(req.url ?? "/m", "http://x");
1192
+ res.writeHead(308, {
1193
+ location: MOBILE_ROOT + url.search,
1194
+ "cache-control": "no-store",
1195
+ "referrer-policy": "no-referrer"
1196
+ });
1197
+ res.end();
1198
+ }
1026
1199
  /**
1027
1200
  * Build the mobile page routes.
1028
- * @returns the two exact routes to register on webServer.
1201
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
1029
1202
  */
1030
1203
  function makeMobileRoutes() {
1031
1204
  const handlePage = (_req, res) => {
1032
- writeStatic(res, 200, "text/html", pageHtml("/m/mobile.js"));
1205
+ writeStatic(res, 200, "text/html", pageHtml(MOBILE_BUNDLE_URL));
1206
+ };
1207
+ const handleManifest = (_req, res) => {
1208
+ writeStatic(res, 200, "application/manifest+json", manifestJson());
1033
1209
  };
1210
+ const handleOffline = (_req, res) => {
1211
+ writeStatic(res, 200, "text/html", offlineHtml());
1212
+ };
1213
+ let bundleBody;
1034
1214
  const handleBundle = async (_req, res) => {
1035
- const path = mobileBundlePath();
1036
- if (!existsSync(path)) {
1037
- writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
1038
- return;
1215
+ if (bundleBody === void 0) {
1216
+ const path = mobileBundlePath();
1217
+ if (!existsSync(path)) {
1218
+ writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
1219
+ return;
1220
+ }
1221
+ try {
1222
+ bundleBody = await readFile(path, "utf8");
1223
+ } catch {
1224
+ writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
1225
+ return;
1226
+ }
1039
1227
  }
1040
- try {
1041
- writeStatic(res, 200, "text/javascript", await readFile(path, "utf8"));
1042
- } catch {
1043
- writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
1228
+ writeStatic(res, 200, "text/javascript", bundleBody);
1229
+ };
1230
+ let workerBody;
1231
+ const handleWorker = async (_req, res) => {
1232
+ if (workerBody === void 0) {
1233
+ const path = mobileAssetPath("mobile-service-worker.js");
1234
+ if (!existsSync(path)) {
1235
+ writeStatic(res, 503, "text/plain", "mobile service worker not found");
1236
+ return;
1237
+ }
1238
+ try {
1239
+ workerBody = await readFile(path, "utf8");
1240
+ } catch {
1241
+ writeStatic(res, 500, "text/plain", "failed to read the mobile service worker");
1242
+ return;
1243
+ }
1044
1244
  }
1245
+ writeServiceWorker(res, workerBody);
1045
1246
  };
1046
- const handleIcon = async (_req, res) => {
1047
- const path = appleTouchIconPath();
1247
+ const makeIconHandler = (asset) => async (_req, res) => {
1248
+ const path = mobileAssetPath(asset);
1048
1249
  if (!existsSync(path)) {
1049
- writeStatic(res, 404, "text/plain", "apple-touch-icon not found");
1250
+ writeStatic(res, 404, "text/plain", asset + " not found");
1050
1251
  return;
1051
1252
  }
1052
1253
  try {
1053
1254
  writePng(res, 200, await readFile(path));
1054
1255
  } catch {
1055
- writeStatic(res, 500, "text/plain", "failed to read the apple-touch-icon");
1256
+ writeStatic(res, 500, "text/plain", "failed to read " + asset);
1056
1257
  }
1057
1258
  };
1058
1259
  return [
1059
1260
  {
1060
1261
  kind: "exact",
1061
1262
  path: "/m",
1263
+ handler: redirectToMobileRoot
1264
+ },
1265
+ {
1266
+ kind: "exact",
1267
+ path: MOBILE_ROOT,
1062
1268
  handler: handlePage
1063
1269
  },
1064
1270
  {
1065
1271
  kind: "exact",
1066
- path: "/m/mobile.js",
1272
+ path: MOBILE_BUNDLE_URL,
1067
1273
  handler: handleBundle
1068
1274
  },
1275
+ {
1276
+ kind: "exact",
1277
+ path: MOBILE_MANIFEST_URL,
1278
+ handler: handleManifest
1279
+ },
1280
+ {
1281
+ kind: "exact",
1282
+ path: MOBILE_WORKER_URL,
1283
+ handler: handleWorker
1284
+ },
1285
+ {
1286
+ kind: "exact",
1287
+ path: MOBILE_OFFLINE_URL,
1288
+ handler: handleOffline
1289
+ },
1069
1290
  {
1070
1291
  kind: "exact",
1071
1292
  path: "/m/apple-touch-icon.png",
1072
- handler: handleIcon
1293
+ handler: makeIconHandler("apple-touch-icon.png")
1294
+ },
1295
+ {
1296
+ kind: "exact",
1297
+ path: "/m/icon-192.png",
1298
+ handler: makeIconHandler("mobile-icon-192.png")
1299
+ },
1300
+ {
1301
+ kind: "exact",
1302
+ path: "/m/icon-512.png",
1303
+ handler: makeIconHandler("mobile-icon-512.png")
1073
1304
  }
1074
1305
  ];
1075
1306
  }
@@ -1147,10 +1378,6 @@ function makeMobileApiRoutes(deps) {
1147
1378
  const gateOk = (req) => {
1148
1379
  return touchDeviceFor(req);
1149
1380
  };
1150
- const writeJson = (res, status, body) => {
1151
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1152
- res.end(JSON.stringify(body));
1153
- };
1154
1381
  const handleMethod = async (req, res) => {
1155
1382
  if (req.method !== "POST") {
1156
1383
  res.writeHead(405);
@@ -1227,10 +1454,12 @@ function makeMobileApiRoutes(deps) {
1227
1454
  return;
1228
1455
  }
1229
1456
  try {
1230
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId);
1231
- writeJson(res, 200, response);
1457
+ const abort = new AbortController();
1458
+ res.on("close", () => {
1459
+ if (!res.writableEnded) abort.abort();
1460
+ });
1461
+ writeJson(res, 200, await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal));
1232
1462
  } catch (error) {
1233
- const message = error instanceof Error ? error.message : String(error);
1234
1463
  writeJson(res, 200, {
1235
1464
  type: "server-response",
1236
1465
  rpcId,
@@ -1238,7 +1467,7 @@ function makeMobileApiRoutes(deps) {
1238
1467
  ok: false,
1239
1468
  error: {
1240
1469
  code: "internal",
1241
- message
1470
+ message: error instanceof Error ? error.message : String(error)
1242
1471
  }
1243
1472
  }
1244
1473
  });
@@ -1305,18 +1534,10 @@ function makeMobileApiRoutes(deps) {
1305
1534
  }
1306
1535
  /** Read a request body as JSON (bounded). */
1307
1536
  async function readJsonBody(req) {
1308
- const chunks = [];
1309
- let size = 0;
1310
- for await (const chunk of req) {
1311
- const buffer = chunk;
1312
- size += buffer.length;
1313
- if (size > 64 * 1024) throw new Error("body too large");
1314
- chunks.push(buffer);
1315
- }
1316
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1537
+ return readBoundedJson(req, 64 * 1024);
1317
1538
  }
1318
1539
  /** Dispatch one allowlisted method through the host ApiProxy. */
1319
- async function dispatch(apiProxy, method, payload, rpcId) {
1540
+ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1320
1541
  const request = {
1321
1542
  rpcId: RpcId(rpcId),
1322
1543
  payload
@@ -1354,7 +1575,7 @@ async function dispatch(apiProxy, method, payload, rpcId) {
1354
1575
  if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
1355
1576
  if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
1356
1577
  if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
1357
- if (method === "session.search") return wrap(await apiProxy.sessions.search(request, new AbortController().signal));
1578
+ if (method === "session.search") return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
1358
1579
  if (method === "session.prompt") return wrap(await apiProxy.sessions.prompt(request));
1359
1580
  if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
1360
1581
  if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
@@ -1708,12 +1929,13 @@ function resolveUpdateTarget(deps) {
1708
1929
  if (anchor === void 0) return { error: "not-found" };
1709
1930
  const profile = findProfile(manifestPath);
1710
1931
  if (profile === void 0) return { error: "link" };
1711
- const spec = (readManifest(join(profile.dir, "package.json"))?.dependencies)?.[anchor];
1932
+ const profileManifest = readManifest(join(profile.dir, "package.json"));
1933
+ const spec = (profileManifest?.dependencies)?.[anchor];
1712
1934
  if (isLinkedSpec(spec)) return { error: "link" };
1713
1935
  return {
1714
1936
  profileName: profile.name,
1715
1937
  profileDir: profile.dir,
1716
- packages: [anchor, ...familyChildren(manifest)]
1938
+ packages: familyUpdatePackages(anchor, manifest, profileManifest)
1717
1939
  };
1718
1940
  }
1719
1941
  /** Family children of the anchor: its dependencies under the family scope. */
@@ -1724,6 +1946,14 @@ function familyChildren(anchorManifest) {
1724
1946
  for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
1725
1947
  return names;
1726
1948
  }
1949
+ /** Registry-managed family packages covered by one update operation. */
1950
+ function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
1951
+ const names = /* @__PURE__ */ new Set([anchor, ...familyChildren(anchorManifest)]);
1952
+ const dependencies = profileManifest?.dependencies;
1953
+ if (typeof dependencies !== "object" || dependencies === null) return [...names];
1954
+ for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string" && !isLinkedSpec(spec)) names.add(name);
1955
+ return [...names];
1956
+ }
1727
1957
  /**
1728
1958
  * Probe the npm registry for one package's latest release.
1729
1959
  * @param name - the package name (scope slash URL-encoded).
@@ -1759,14 +1989,15 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
1759
1989
  */
1760
1990
  const VERSION_UNKNOWN = "0.0.0";
1761
1991
  /** The resolved current version of one family package (probe failure tolerated). */
1762
- function readInstalledVersion(resolve, name) {
1992
+ function readInstalledVersion(resolve, name, profileDir) {
1763
1993
  try {
1764
1994
  const path = resolve(name + "/package.json");
1765
1995
  const version = path === void 0 ? void 0 : readManifest(path)?.version;
1766
- return typeof version === "string" ? version : VERSION_UNKNOWN;
1767
- } catch {
1768
- return VERSION_UNKNOWN;
1769
- }
1996
+ if (typeof version === "string") return version;
1997
+ } catch {}
1998
+ if (profileDir === void 0 || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN;
1999
+ const version = readManifest(join(profileDir, "node_modules", ...name.split("/"), "package.json"))?.version;
2000
+ return typeof version === "string" ? version : VERSION_UNKNOWN;
1770
2001
  }
1771
2002
  /**
1772
2003
  * Build the update status: locate the anchor, detect the install mode, and
@@ -1801,20 +2032,21 @@ async function checkUpdates(deps) {
1801
2032
  packages: [],
1802
2033
  outdated: false
1803
2034
  };
1804
- const names = [anchor, ...familyChildren(manifest)];
2035
+ const names = familyUpdatePackages(anchor, manifest, profileManifest);
2036
+ const latestList = await Promise.all(names.map((name) => deps.fetchLatest(name)));
1805
2037
  const packages = [];
1806
2038
  let probeFailures = 0;
1807
- for (const name of names) {
1808
- const latest = await deps.fetchLatest(name);
2039
+ names.forEach((name, index) => {
2040
+ const latest = latestList[index];
1809
2041
  if (latest === void 0) probeFailures++;
1810
- const current = readInstalledVersion(deps.resolve, name);
2042
+ const current = readInstalledVersion(deps.resolve, name, profile.dir);
1811
2043
  packages.push({
1812
2044
  name,
1813
2045
  current,
1814
2046
  latest,
1815
2047
  outdated: latest !== void 0 && latest !== current && compareVersions(latest, current) > 0
1816
2048
  });
1817
- }
2049
+ });
1818
2050
  const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
1819
2051
  return {
1820
2052
  mode: linked ? "link" : "npm",
@@ -2019,7 +2251,7 @@ function runUpdate(deps) {
2019
2251
  async function runUpdateVerified(deps) {
2020
2252
  const before = /* @__PURE__ */ new Map();
2021
2253
  for (const name of deps.run.packages) {
2022
- const version = readInstalledVersion(deps.check.resolve, name);
2254
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
2023
2255
  if (version !== VERSION_UNKNOWN) before.set(name, version);
2024
2256
  }
2025
2257
  const result = await runUpdate(deps.run);
@@ -2061,15 +2293,6 @@ const UPDATE_PATHS = {
2061
2293
  status: "/api/update/status",
2062
2294
  run: "/api/update/run"
2063
2295
  };
2064
- /** One JSON response. */
2065
- function writeJson(res, status, body) {
2066
- const payload = JSON.stringify(body);
2067
- res.writeHead(status, {
2068
- "content-type": "application/json; charset=utf-8",
2069
- "referrer-policy": "no-referrer"
2070
- });
2071
- res.end(payload);
2072
- }
2073
2296
  /**
2074
2297
  * Build the /api/update route family.
2075
2298
  * @param deps - fence + check/run seams.
@@ -2117,6 +2340,47 @@ function makeUpdateRoutes(deps) {
2117
2340
  }];
2118
2341
  }
2119
2342
  //#endregion
2343
+ //#region src/mount-once.ts
2344
+ /**
2345
+ * Host single-instance guard shared by the plugin family. The family bundle
2346
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
2347
+ * the loader accepts a standalone install of the same package side by side;
2348
+ * without this guard the second instance would still re-register the same
2349
+ * webserver routes, tools, settings namespaces, and system-prompt sections
2350
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
2351
+ * lifetime of the first instance (the browser half is already deduped by
2352
+ * package name in the client module host).
2353
+ *
2354
+ * The registry rides a global symbol so two module instances of the same
2355
+ * package (npm copy vs repository link) still share one verdict. cordis
2356
+ * `ctx.effect` runs its callback immediately and treats the callback's
2357
+ * return value as the fiber disposer, so the unmarker is returned, not run.
2358
+ */
2359
+ const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
2360
+ function mountedSet() {
2361
+ const registry = globalThis;
2362
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
2363
+ }
2364
+ /**
2365
+ * Wrap a cordis plugin apply so the package runs at most once per process.
2366
+ * The first mount registers normally and unmarks when its fiber disposes;
2367
+ * any later mount of the same package name is a no-op.
2368
+ * @param packageName - npm package identity shared by every install source.
2369
+ * @param fn - the original plugin apply.
2370
+ * @returns an apply of the same shape.
2371
+ */
2372
+ function mountOnce(packageName, fn) {
2373
+ return ((...args) => {
2374
+ const mounted = mountedSet();
2375
+ if (mounted.has(packageName)) return;
2376
+ mounted.add(packageName);
2377
+ args[0]?.effect?.(() => () => {
2378
+ mounted.delete(packageName);
2379
+ });
2380
+ return fn(...args);
2381
+ });
2382
+ }
2383
+ //#endregion
2120
2384
  //#region src/index.ts
2121
2385
  /**
2122
2386
  * Mobile remote control for the dsh web GUI — host half. Mounts the pairing
@@ -2144,12 +2408,28 @@ const Config = z.object({
2144
2408
  cookieName: z.string().min(1).default("dsh_pair"),
2145
2409
  requirePairingForLan: z.boolean().default(true),
2146
2410
  publicBaseUrl: z.string(),
2411
+ devicesFile: z.string(),
2147
2412
  autoTunnel: z.boolean().default(false),
2148
2413
  mobileEnterToSend: z.boolean().default(true),
2149
2414
  enabled: z.boolean().default(true)
2150
2415
  });
2151
2416
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
2152
2417
  const SWEEP_INTERVAL_MS = 1e4;
2418
+ /**
2419
+ * The single mapping from resolved plugin config to the pairing service
2420
+ * config. Both the constructed service and every live settings sync reuse
2421
+ * it, so no field can be silently dropped when the web settings surface
2422
+ * pushes a new value into the running service.
2423
+ */
2424
+ function pairingConfigOf(resolved) {
2425
+ return {
2426
+ tokenTtlMs: resolved.tokenTtlMs,
2427
+ offlineAfterMs: resolved.offlineAfterMs,
2428
+ maxDevices: resolved.maxDevices,
2429
+ cookieName: resolved.cookieName,
2430
+ devicesFile: resolved.devicesFile
2431
+ };
2432
+ }
2153
2433
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
2154
2434
  const DEFAULTS = {
2155
2435
  tokenTtlMs: 10 * 6e4,
@@ -2158,6 +2438,7 @@ const DEFAULTS = {
2158
2438
  cookieName: "dsh_pair",
2159
2439
  requirePairingForLan: true,
2160
2440
  publicBaseUrl: void 0,
2441
+ devicesFile: void 0,
2161
2442
  autoTunnel: false,
2162
2443
  mobileEnterToSend: true,
2163
2444
  enabled: true
@@ -2167,7 +2448,8 @@ const DEFAULTS = {
2167
2448
  * @param ctx - host plugin context carrying webServer.
2168
2449
  * @param config - resolved plugin config (schema defaults applied by the loader).
2169
2450
  */
2170
- function apply(ctx, config) {
2451
+ const apply = mountOnce("@linxin666/dsh-remote-web-ui", applyImpl);
2452
+ function applyImpl(ctx, config) {
2171
2453
  const resolved = {
2172
2454
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
2173
2455
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
@@ -2175,6 +2457,7 @@ function apply(ctx, config) {
2175
2457
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
2176
2458
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2177
2459
  publicBaseUrl: config?.publicBaseUrl,
2460
+ devicesFile: config?.devicesFile,
2178
2461
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
2179
2462
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2180
2463
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -2189,17 +2472,13 @@ function apply(ctx, config) {
2189
2472
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
2190
2473
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2191
2474
  publicBaseUrl: value.publicBaseUrl,
2475
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
2192
2476
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
2193
2477
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2194
2478
  enabled: value.enabled ?? DEFAULTS.enabled
2195
2479
  };
2196
2480
  };
2197
- const service = new PairingService({
2198
- tokenTtlMs: resolved.tokenTtlMs,
2199
- offlineAfterMs: resolved.offlineAfterMs,
2200
- maxDevices: resolved.maxDevices,
2201
- cookieName: resolved.cookieName
2202
- });
2481
+ const service = new PairingService(pairingConfigOf(resolved));
2203
2482
  const tunnel = new TunnelManager();
2204
2483
  let autoTunnel = resolved.autoTunnel;
2205
2484
  tunnel.onPhase((info) => {
@@ -2301,14 +2580,13 @@ function apply(ctx, config) {
2301
2580
  ];
2302
2581
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
2303
2582
  ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
2583
+ new RemoteWebUiPairing(ctx, (request) => {
2584
+ if (!resolve().enabled) return false;
2585
+ return isPairedDeviceRequest(service, request);
2586
+ });
2304
2587
  const sync = () => {
2305
2588
  const value = resolve();
2306
- service.config = {
2307
- tokenTtlMs: value.tokenTtlMs,
2308
- offlineAfterMs: value.offlineAfterMs,
2309
- maxDevices: value.maxDevices,
2310
- cookieName: value.cookieName
2311
- };
2589
+ service.config = pairingConfigOf(value);
2312
2590
  autoTunnel = value.autoTunnel === true;
2313
2591
  if (autoTunnel) {
2314
2592
  if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
@@ -2365,4 +2643,4 @@ function isHttpUrl(value) {
2365
2643
  }
2366
2644
  }
2367
2645
  //#endregion
2368
- export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name };
2646
+ export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name, pairingConfigOf };