@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.1

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  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 +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
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,49 @@ 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
+ };
574
669
  //#endregion
575
670
  //#region src/http.ts
576
671
  /** One JSON response. */
@@ -773,7 +868,9 @@ function makeRoutes(deps) {
773
868
  const ACCEPT_MAX_ATTEMPTS = 10;
774
869
  const ACCEPT_WINDOW_MS = 3e4;
775
870
  const rateLimitAccept = (req) => {
776
- const ip = req.socket?.remoteAddress ?? "unknown";
871
+ const socketIp = req.socket?.remoteAddress ?? "unknown";
872
+ const forwarded = typeof req.headers["x-forwarded-for"] === "string" ? (req.headers["x-forwarded-for"].split(",")[0] ?? "").trim() : void 0;
873
+ const ip = forwarded === void 0 || forwarded === "" ? socketIp : socketIp + "|" + forwarded;
777
874
  const nowMs = Date.now();
778
875
  if (acceptAttempts.size > 256) {
779
876
  for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
@@ -814,7 +911,7 @@ function makeRoutes(deps) {
814
911
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
815
912
  writeJson(res, 200, {
816
913
  ok: true,
817
- url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
914
+ url: `${base}/m/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
818
915
  token,
819
916
  expiresAt,
820
917
  lanAddresses: service.lanAddresses,
@@ -927,10 +1024,16 @@ function makeRoutes(deps) {
927
1024
  return;
928
1025
  }
929
1026
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1027
+ const paired = deviceId !== void 0 && service.hasDevice(deviceId);
1028
+ const snapshot = service.snapshot();
930
1029
  writeJson(res, 200, {
931
1030
  ok: true,
932
- paired: deviceId !== void 0 && service.hasDevice(deviceId),
933
- ...service.snapshot()
1031
+ paired,
1032
+ ...paired ? snapshot : {
1033
+ phase: snapshot.phase,
1034
+ lanAvailable: snapshot.lanAvailable,
1035
+ lanAddresses: snapshot.lanAddresses
1036
+ }
934
1037
  });
935
1038
  };
936
1039
  const handleEvents = (req, res) => {
@@ -981,23 +1084,25 @@ function makeRoutes(deps) {
981
1084
  //#endregion
982
1085
  //#region src/mobile-routes.ts
983
1086
  /**
984
- * The mobile surface's page routes: `/m` serves the standalone phone UI
985
- * (an independent bundle, built to lib/mobile.js by the mobile tsdown
986
- * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
987
- * host exclusively through the shared /api transport (paired-device cookie
988
- * already crosses the api/gate fence), so no host-side data plumbing is
989
- * needed here — only static serving, loopback+paired-fence via the normal
990
- * webserver route registration.
1087
+ * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
1088
+ * standalone phone UI. The page and worker live in the /m/ scope so the PWA
1089
+ * can cache only its static shell; the paired-device data channel remains at
1090
+ * /m/api and is never handled by the worker.
991
1091
  */
1092
+ const MOBILE_ROOT = "/m/";
1093
+ const MOBILE_BUNDLE_URL = "/m/mobile.js";
1094
+ const MOBILE_MANIFEST_URL = "/m/manifest.webmanifest";
1095
+ const MOBILE_WORKER_URL = "/m/service-worker.js";
1096
+ const MOBILE_OFFLINE_URL = "/m/offline.html";
992
1097
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
993
1098
  function mobileBundlePath() {
994
1099
  return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
995
1100
  }
996
- /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
997
- function appleTouchIconPath() {
998
- return fileURLToPath(new URL("../assets/apple-touch-icon.png", import.meta.url));
1101
+ /** A package asset available to the host after a registry or git install. */
1102
+ function mobileAssetPath(name) {
1103
+ return fileURLToPath(new URL("../assets/" + name, import.meta.url));
999
1104
  }
1000
- /** The mobile page shell: minimal, offline-safe, no external assets. */
1105
+ /** The mobile page shell: self-contained, with no document-level user data. */
1001
1106
  function pageHtml(bundleUrl) {
1002
1107
  return [
1003
1108
  "<!doctype html>",
@@ -1007,26 +1112,80 @@ function pageHtml(bundleUrl) {
1007
1112
  "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
1008
1113
  "<meta name=\"theme-color\" content=\"#f3f5f9\">",
1009
1114
  "<meta name=\"referrer\" content=\"no-referrer\">",
1115
+ "<meta name=\"mobile-web-app-capable\" content=\"yes\">",
1116
+ "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
1117
+ "<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
1118
+ "<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
1010
1119
  "<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
1011
1120
  "<title>移动端远程控制</title>",
1012
1121
  "</head>",
1013
1122
  "<body>",
1014
1123
  "<div id=\"root\"></div>",
1015
- `<script type="module" src="${bundleUrl}"><\/script>`,
1124
+ "<script type=\"module\" src=\"" + bundleUrl + "\"><\/script>",
1016
1125
  "</body>",
1017
1126
  "</html>"
1018
1127
  ].join("");
1019
1128
  }
1020
- /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
1129
+ /** The installable app identity and icon declarations for the /m scope. */
1130
+ function manifestJson() {
1131
+ return JSON.stringify({
1132
+ id: MOBILE_ROOT,
1133
+ name: "DSH Remote",
1134
+ short_name: "DSH Remote",
1135
+ start_url: MOBILE_ROOT,
1136
+ scope: MOBILE_ROOT,
1137
+ display: "standalone",
1138
+ background_color: "#151424",
1139
+ theme_color: "#f3f5f9",
1140
+ icons: [{
1141
+ src: "/m/icon-192.png",
1142
+ sizes: "192x192",
1143
+ type: "image/png",
1144
+ purpose: "any"
1145
+ }, {
1146
+ src: "/m/icon-512.png",
1147
+ sizes: "512x512",
1148
+ type: "image/png",
1149
+ purpose: "any maskable"
1150
+ }]
1151
+ }, null, 2);
1152
+ }
1153
+ /** A static fallback that contains no paired-device, session, or workspace data. */
1154
+ function offlineHtml() {
1155
+ return [
1156
+ "<!doctype html>",
1157
+ "<html lang=\"zh-CN\">",
1158
+ "<head>",
1159
+ "<meta charset=\"utf-8\">",
1160
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">",
1161
+ "<meta name=\"theme-color\" content=\"#151424\">",
1162
+ "<title>DSH Remote</title>",
1163
+ "<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>",
1164
+ "</head>",
1165
+ "<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>",
1166
+ "</html>"
1167
+ ].join("");
1168
+ }
1169
+ /** Send a small static UTF-8 body with revalidation headers. */
1021
1170
  function writeStatic(res, status, type, body) {
1022
1171
  res.writeHead(status, {
1023
- "content-type": `${type}; charset=utf-8`,
1172
+ "content-type": type + "; charset=utf-8",
1024
1173
  "cache-control": "no-cache",
1025
1174
  "referrer-policy": "no-referrer"
1026
1175
  });
1027
1176
  res.end(body);
1028
1177
  }
1029
- /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
1178
+ /** Send the worker with the same scope it registers for, without HTTP caching. */
1179
+ function writeServiceWorker(res, body) {
1180
+ res.writeHead(200, {
1181
+ "content-type": "text/javascript; charset=utf-8",
1182
+ "cache-control": "no-cache",
1183
+ "referrer-policy": "no-referrer",
1184
+ "service-worker-allowed": MOBILE_ROOT
1185
+ });
1186
+ res.end(body);
1187
+ }
1188
+ /** Send a PNG body without decoding it to UTF-8. */
1030
1189
  function writePng(res, status, body) {
1031
1190
  res.writeHead(status, {
1032
1191
  "content-type": "image/png",
@@ -1035,13 +1194,29 @@ function writePng(res, status, body) {
1035
1194
  });
1036
1195
  res.end(body);
1037
1196
  }
1197
+ /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
1198
+ function redirectToMobileRoot(req, res) {
1199
+ const url = new URL(req.url ?? "/m", "http://x");
1200
+ res.writeHead(308, {
1201
+ location: MOBILE_ROOT + url.search,
1202
+ "cache-control": "no-store",
1203
+ "referrer-policy": "no-referrer"
1204
+ });
1205
+ res.end();
1206
+ }
1038
1207
  /**
1039
1208
  * Build the mobile page routes.
1040
- * @returns the two exact routes to register on webServer.
1209
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
1041
1210
  */
1042
1211
  function makeMobileRoutes() {
1043
1212
  const handlePage = (_req, res) => {
1044
- writeStatic(res, 200, "text/html", pageHtml("/m/mobile.js"));
1213
+ writeStatic(res, 200, "text/html", pageHtml(MOBILE_BUNDLE_URL));
1214
+ };
1215
+ const handleManifest = (_req, res) => {
1216
+ writeStatic(res, 200, "application/manifest+json", manifestJson());
1217
+ };
1218
+ const handleOffline = (_req, res) => {
1219
+ writeStatic(res, 200, "text/html", offlineHtml());
1045
1220
  };
1046
1221
  let bundleBody;
1047
1222
  const handleBundle = async (_req, res) => {
@@ -1060,41 +1235,95 @@ function makeMobileRoutes() {
1060
1235
  }
1061
1236
  writeStatic(res, 200, "text/javascript", bundleBody);
1062
1237
  };
1063
- const handleIcon = async (_req, res) => {
1064
- const path = appleTouchIconPath();
1238
+ let workerBody;
1239
+ const handleWorker = async (_req, res) => {
1240
+ if (workerBody === void 0) {
1241
+ const path = mobileAssetPath("mobile-service-worker.js");
1242
+ if (!existsSync(path)) {
1243
+ writeStatic(res, 503, "text/plain", "mobile service worker not found");
1244
+ return;
1245
+ }
1246
+ try {
1247
+ workerBody = await readFile(path, "utf8");
1248
+ } catch {
1249
+ writeStatic(res, 500, "text/plain", "failed to read the mobile service worker");
1250
+ return;
1251
+ }
1252
+ }
1253
+ writeServiceWorker(res, workerBody);
1254
+ };
1255
+ const makeIconHandler = (asset) => async (_req, res) => {
1256
+ const path = mobileAssetPath(asset);
1065
1257
  if (!existsSync(path)) {
1066
- writeStatic(res, 404, "text/plain", "apple-touch-icon not found");
1258
+ writeStatic(res, 404, "text/plain", asset + " not found");
1067
1259
  return;
1068
1260
  }
1069
1261
  try {
1070
1262
  writePng(res, 200, await readFile(path));
1071
1263
  } catch {
1072
- writeStatic(res, 500, "text/plain", "failed to read the apple-touch-icon");
1264
+ writeStatic(res, 500, "text/plain", "failed to read " + asset);
1073
1265
  }
1074
1266
  };
1075
1267
  return [
1076
1268
  {
1077
1269
  kind: "exact",
1078
1270
  path: "/m",
1271
+ handler: redirectToMobileRoot
1272
+ },
1273
+ {
1274
+ kind: "exact",
1275
+ path: MOBILE_ROOT,
1079
1276
  handler: handlePage
1080
1277
  },
1081
1278
  {
1082
1279
  kind: "exact",
1083
- path: "/m/mobile.js",
1280
+ path: MOBILE_BUNDLE_URL,
1084
1281
  handler: handleBundle
1085
1282
  },
1283
+ {
1284
+ kind: "exact",
1285
+ path: MOBILE_MANIFEST_URL,
1286
+ handler: handleManifest
1287
+ },
1288
+ {
1289
+ kind: "exact",
1290
+ path: MOBILE_WORKER_URL,
1291
+ handler: handleWorker
1292
+ },
1293
+ {
1294
+ kind: "exact",
1295
+ path: MOBILE_OFFLINE_URL,
1296
+ handler: handleOffline
1297
+ },
1086
1298
  {
1087
1299
  kind: "exact",
1088
1300
  path: "/m/apple-touch-icon.png",
1089
- handler: handleIcon
1301
+ handler: makeIconHandler("apple-touch-icon.png")
1302
+ },
1303
+ {
1304
+ kind: "exact",
1305
+ path: "/m/icon-192.png",
1306
+ handler: makeIconHandler("mobile-icon-192.png")
1307
+ },
1308
+ {
1309
+ kind: "exact",
1310
+ path: "/m/icon-512.png",
1311
+ handler: makeIconHandler("mobile-icon-512.png")
1090
1312
  }
1091
1313
  ];
1092
1314
  }
1093
1315
  //#endregion
1094
1316
  //#region src/mobile-api.ts
1095
- /** Methods the phone surface may call. Everything else is refused. */
1317
+ /**
1318
+ * Methods the phone surface may call. Everything else is refused HERE — but
1319
+ * note the paired-device cookie also passes the global api/gate for the full
1320
+ * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
1321
+ * the allowlist only constrains this /m/api proxy, not the cookie's reach.
1322
+ * stop() revokes every device; there is no per-device revocation yet.
1323
+ */
1096
1324
  const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
1097
1325
  "workspace.list",
1326
+ "agentPreset.list",
1098
1327
  "session.create",
1099
1328
  "session.list",
1100
1329
  "session.history",
@@ -1359,6 +1588,7 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1359
1588
  result: response.result
1360
1589
  });
1361
1590
  if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
1591
+ if (method === "agentPreset.list") return wrap(await apiProxy.agentPresets.list(request));
1362
1592
  if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
1363
1593
  if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
1364
1594
  if (method === "session.search") return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
@@ -1436,6 +1666,7 @@ var TunnelManager = class {
1436
1666
  urlTimer;
1437
1667
  restartTimer;
1438
1668
  attempts = 0;
1669
+ generation = 0;
1439
1670
  stopping = false;
1440
1671
  urlListeners = /* @__PURE__ */ new Set();
1441
1672
  phaseListeners = /* @__PURE__ */ new Set();
@@ -1470,6 +1701,7 @@ var TunnelManager = class {
1470
1701
  this.stopping = false;
1471
1702
  this.targetUrl = targetUrl;
1472
1703
  this.attempts = 0;
1704
+ this.generation += 1;
1473
1705
  this.attempt();
1474
1706
  }
1475
1707
  /** Stop the tunnel for good: no restarts, no state. */
@@ -1499,12 +1731,13 @@ var TunnelManager = class {
1499
1731
  }
1500
1732
  attempt() {
1501
1733
  if (this.stopping || this.targetUrl === void 0) return;
1734
+ const gen = this.generation;
1502
1735
  this.setPhase("starting");
1503
1736
  this.handle = void 0;
1504
1737
  this.url = void 0;
1505
1738
  this.error = void 0;
1506
1739
  this.ensureBinary().then(() => {
1507
- if (this.stopping || this.targetUrl === void 0) return;
1740
+ if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
1508
1741
  const handle = this.factory(this.targetUrl);
1509
1742
  this.handle = handle;
1510
1743
  this.urlTimer = this.timer.setTimeout(() => {
@@ -1523,7 +1756,7 @@ var TunnelManager = class {
1523
1756
  this.error = value instanceof Error ? value.message : String(value);
1524
1757
  });
1525
1758
  }).catch((value) => {
1526
- if (this.stopping || this.targetUrl === void 0) return;
1759
+ if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
1527
1760
  const message = value instanceof Error ? value.message : String(value);
1528
1761
  this.fail(`could not obtain the cloudflared binary: ${message}`);
1529
1762
  });
@@ -1715,12 +1948,13 @@ function resolveUpdateTarget(deps) {
1715
1948
  if (anchor === void 0) return { error: "not-found" };
1716
1949
  const profile = findProfile(manifestPath);
1717
1950
  if (profile === void 0) return { error: "link" };
1718
- const spec = (readManifest(join(profile.dir, "package.json"))?.dependencies)?.[anchor];
1951
+ const profileManifest = readManifest(join(profile.dir, "package.json"));
1952
+ const spec = (profileManifest?.dependencies)?.[anchor];
1719
1953
  if (isLinkedSpec(spec)) return { error: "link" };
1720
1954
  return {
1721
1955
  profileName: profile.name,
1722
1956
  profileDir: profile.dir,
1723
- packages: [anchor, ...familyChildren(manifest)]
1957
+ packages: familyUpdatePackages(anchor, manifest, profileManifest)
1724
1958
  };
1725
1959
  }
1726
1960
  /** Family children of the anchor: its dependencies under the family scope. */
@@ -1731,6 +1965,14 @@ function familyChildren(anchorManifest) {
1731
1965
  for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
1732
1966
  return names;
1733
1967
  }
1968
+ /** Registry-managed family packages covered by one update operation. */
1969
+ function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
1970
+ const names = /* @__PURE__ */ new Set([anchor, ...familyChildren(anchorManifest)]);
1971
+ const dependencies = profileManifest?.dependencies;
1972
+ if (typeof dependencies !== "object" || dependencies === null) return [...names];
1973
+ for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string" && !isLinkedSpec(spec)) names.add(name);
1974
+ return [...names];
1975
+ }
1734
1976
  /**
1735
1977
  * Probe the npm registry for one package's latest release.
1736
1978
  * @param name - the package name (scope slash URL-encoded).
@@ -1745,7 +1987,7 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
1745
1987
  controller.abort();
1746
1988
  }, timeoutMs);
1747
1989
  try {
1748
- const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest");
1990
+ const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", { signal: controller.signal });
1749
1991
  if (!response.ok) return void 0;
1750
1992
  const body = await response.json();
1751
1993
  if (typeof body !== "object" || body === null) return void 0;
@@ -1766,14 +2008,15 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
1766
2008
  */
1767
2009
  const VERSION_UNKNOWN = "0.0.0";
1768
2010
  /** The resolved current version of one family package (probe failure tolerated). */
1769
- function readInstalledVersion(resolve, name) {
2011
+ function readInstalledVersion(resolve, name, profileDir) {
1770
2012
  try {
1771
2013
  const path = resolve(name + "/package.json");
1772
2014
  const version = path === void 0 ? void 0 : readManifest(path)?.version;
1773
- return typeof version === "string" ? version : VERSION_UNKNOWN;
1774
- } catch {
1775
- return VERSION_UNKNOWN;
1776
- }
2015
+ if (typeof version === "string") return version;
2016
+ } catch {}
2017
+ if (profileDir === void 0 || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN;
2018
+ const version = readManifest(join(profileDir, "node_modules", ...name.split("/"), "package.json"))?.version;
2019
+ return typeof version === "string" ? version : VERSION_UNKNOWN;
1777
2020
  }
1778
2021
  /**
1779
2022
  * Build the update status: locate the anchor, detect the install mode, and
@@ -1808,14 +2051,14 @@ async function checkUpdates(deps) {
1808
2051
  packages: [],
1809
2052
  outdated: false
1810
2053
  };
1811
- const names = [anchor, ...familyChildren(manifest)];
2054
+ const names = familyUpdatePackages(anchor, manifest, profileManifest);
1812
2055
  const latestList = await Promise.all(names.map((name) => deps.fetchLatest(name)));
1813
2056
  const packages = [];
1814
2057
  let probeFailures = 0;
1815
2058
  names.forEach((name, index) => {
1816
2059
  const latest = latestList[index];
1817
2060
  if (latest === void 0) probeFailures++;
1818
- const current = readInstalledVersion(deps.resolve, name);
2061
+ const current = readInstalledVersion(deps.resolve, name, profile.dir);
1819
2062
  packages.push({
1820
2063
  name,
1821
2064
  current,
@@ -2027,7 +2270,7 @@ function runUpdate(deps) {
2027
2270
  async function runUpdateVerified(deps) {
2028
2271
  const before = /* @__PURE__ */ new Map();
2029
2272
  for (const name of deps.run.packages) {
2030
- const version = readInstalledVersion(deps.check.resolve, name);
2273
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
2031
2274
  if (version !== VERSION_UNKNOWN) before.set(name, version);
2032
2275
  }
2033
2276
  const result = await runUpdate(deps.run);
@@ -2184,12 +2427,28 @@ const Config = z.object({
2184
2427
  cookieName: z.string().min(1).default("dsh_pair"),
2185
2428
  requirePairingForLan: z.boolean().default(true),
2186
2429
  publicBaseUrl: z.string(),
2430
+ devicesFile: z.string(),
2187
2431
  autoTunnel: z.boolean().default(false),
2188
2432
  mobileEnterToSend: z.boolean().default(true),
2189
2433
  enabled: z.boolean().default(true)
2190
2434
  });
2191
2435
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
2192
2436
  const SWEEP_INTERVAL_MS = 1e4;
2437
+ /**
2438
+ * The single mapping from resolved plugin config to the pairing service
2439
+ * config. Both the constructed service and every live settings sync reuse
2440
+ * it, so no field can be silently dropped when the web settings surface
2441
+ * pushes a new value into the running service.
2442
+ */
2443
+ function pairingConfigOf(resolved) {
2444
+ return {
2445
+ tokenTtlMs: resolved.tokenTtlMs,
2446
+ offlineAfterMs: resolved.offlineAfterMs,
2447
+ maxDevices: resolved.maxDevices,
2448
+ cookieName: resolved.cookieName,
2449
+ devicesFile: resolved.devicesFile
2450
+ };
2451
+ }
2193
2452
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
2194
2453
  const DEFAULTS = {
2195
2454
  tokenTtlMs: 10 * 6e4,
@@ -2198,6 +2457,7 @@ const DEFAULTS = {
2198
2457
  cookieName: "dsh_pair",
2199
2458
  requirePairingForLan: true,
2200
2459
  publicBaseUrl: void 0,
2460
+ devicesFile: void 0,
2201
2461
  autoTunnel: false,
2202
2462
  mobileEnterToSend: true,
2203
2463
  enabled: true
@@ -2216,6 +2476,7 @@ function applyImpl(ctx, config) {
2216
2476
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
2217
2477
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2218
2478
  publicBaseUrl: config?.publicBaseUrl,
2479
+ devicesFile: config?.devicesFile,
2219
2480
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
2220
2481
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2221
2482
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -2230,17 +2491,13 @@ function applyImpl(ctx, config) {
2230
2491
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
2231
2492
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2232
2493
  publicBaseUrl: value.publicBaseUrl,
2494
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
2233
2495
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
2234
2496
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2235
2497
  enabled: value.enabled ?? DEFAULTS.enabled
2236
2498
  };
2237
2499
  };
2238
- const service = new PairingService({
2239
- tokenTtlMs: resolved.tokenTtlMs,
2240
- offlineAfterMs: resolved.offlineAfterMs,
2241
- maxDevices: resolved.maxDevices,
2242
- cookieName: resolved.cookieName
2243
- });
2500
+ const service = new PairingService(pairingConfigOf(resolved));
2244
2501
  const tunnel = new TunnelManager();
2245
2502
  let autoTunnel = resolved.autoTunnel;
2246
2503
  tunnel.onPhase((info) => {
@@ -2342,14 +2599,13 @@ function applyImpl(ctx, config) {
2342
2599
  ];
2343
2600
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
2344
2601
  ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
2602
+ new RemoteWebUiPairing(ctx, (request) => {
2603
+ if (!resolve().enabled) return false;
2604
+ return isPairedDeviceRequest(service, request);
2605
+ });
2345
2606
  const sync = () => {
2346
2607
  const value = resolve();
2347
- service.config = {
2348
- tokenTtlMs: value.tokenTtlMs,
2349
- offlineAfterMs: value.offlineAfterMs,
2350
- maxDevices: value.maxDevices,
2351
- cookieName: value.cookieName
2352
- };
2608
+ service.config = pairingConfigOf(value);
2353
2609
  autoTunnel = value.autoTunnel === true;
2354
2610
  if (autoTunnel) {
2355
2611
  if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
@@ -2406,4 +2662,4 @@ function isHttpUrl(value) {
2406
2662
  }
2407
2663
  }
2408
2664
  //#endregion
2409
- export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name };
2665
+ export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name, pairingConfigOf };