@linxin666/dsh-remote-web-ui 0.1.20 → 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 (91) 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 +37 -10
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +290 -53
  10. package/lib/mobile.js +278 -17
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +5 -3
  15. package/lib/types/client/RemoteEntry.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/RemoteSettingsCard.js +1 -1
  18. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  19. package/lib/types/client/UpdateEntry.js +36 -3
  20. package/lib/types/client/deep-link.js +1 -1
  21. package/lib/types/client/locales.d.ts +2 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +2 -0
  24. package/lib/types/gate.d.ts +9 -0
  25. package/lib/types/gate.d.ts.map +1 -1
  26. package/lib/types/gate.js +15 -4
  27. package/lib/types/index.d.ts +25 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +30 -13
  30. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  31. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  32. package/lib/types/mobile/PairRequiredView.js +28 -0
  33. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  34. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  35. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  36. package/lib/types/mobile/index.js +14 -1
  37. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  38. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  39. package/lib/types/mobile/mobile-styles.js +48 -0
  40. package/lib/types/mobile/pairing.d.ts +31 -0
  41. package/lib/types/mobile/pairing.d.ts.map +1 -0
  42. package/lib/types/mobile/pairing.js +62 -0
  43. package/lib/types/mobile/views/App.d.ts +12 -5
  44. package/lib/types/mobile/views/App.d.ts.map +1 -1
  45. package/lib/types/mobile/views/App.js +34 -7
  46. package/lib/types/mobile/views/App.test.d.ts +2 -0
  47. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  48. package/lib/types/mobile/views/App.test.js +52 -0
  49. package/lib/types/mobile-pwa.d.ts +6 -0
  50. package/lib/types/mobile-pwa.d.ts.map +1 -0
  51. package/lib/types/mobile-pwa.js +17 -0
  52. package/lib/types/mobile-routes.d.ts +5 -8
  53. package/lib/types/mobile-routes.d.ts.map +1 -1
  54. package/lib/types/mobile-routes.js +115 -30
  55. package/lib/types/pairing-access.d.ts +28 -0
  56. package/lib/types/pairing-access.d.ts.map +1 -0
  57. package/lib/types/pairing-access.js +26 -0
  58. package/lib/types/pairing.d.ts +27 -0
  59. package/lib/types/pairing.d.ts.map +1 -1
  60. package/lib/types/pairing.js +68 -0
  61. package/lib/types/pairing.persist.test.d.ts +2 -0
  62. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  63. package/lib/types/pairing.persist.test.js +128 -0
  64. package/lib/types/routes.js +1 -1
  65. package/lib/types/update.d.ts +3 -3
  66. package/lib/types/update.d.ts.map +1 -1
  67. package/lib/types/update.js +34 -11
  68. package/package.json +12 -12
  69. package/src/client/PluginSettingsCard.tsx +10 -0
  70. package/src/client/RemoteEntry.tsx +2 -2
  71. package/src/client/RemoteSettingsCard.tsx +1 -0
  72. package/src/client/UpdateEntry.tsx +36 -4
  73. package/src/client/deep-link.ts +1 -1
  74. package/src/client/locales.ts +2 -0
  75. package/src/client/remote.module.css +26 -0
  76. package/src/gate.ts +15 -3
  77. package/src/index.ts +46 -15
  78. package/src/mobile/PairRequiredView.test.tsx +28 -0
  79. package/src/mobile/PairRequiredView.tsx +58 -0
  80. package/src/mobile/index.tsx +15 -1
  81. package/src/mobile/mobile-styles.ts +48 -0
  82. package/src/mobile/pairing.ts +77 -0
  83. package/src/mobile/views/App.test.tsx +62 -0
  84. package/src/mobile/views/App.tsx +53 -6
  85. package/src/mobile-pwa.ts +19 -0
  86. package/src/mobile-routes.ts +123 -30
  87. package/src/pairing-access.ts +36 -0
  88. package/src/pairing.persist.test.ts +145 -0
  89. package/src/pairing.ts +70 -1
  90. package/src/routes.ts +1 -1
  91. package/src/update.ts +40 -12
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. */
@@ -814,7 +909,7 @@ function makeRoutes(deps) {
814
909
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
815
910
  writeJson(res, 200, {
816
911
  ok: true,
817
- url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
912
+ url: `${base}/m/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
818
913
  token,
819
914
  expiresAt,
820
915
  lanAddresses: service.lanAddresses,
@@ -981,23 +1076,25 @@ function makeRoutes(deps) {
981
1076
  //#endregion
982
1077
  //#region src/mobile-routes.ts
983
1078
  /**
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.
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.
991
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";
992
1089
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
993
1090
  function mobileBundlePath() {
994
1091
  return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
995
1092
  }
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));
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));
999
1096
  }
1000
- /** The mobile page shell: minimal, offline-safe, no external assets. */
1097
+ /** The mobile page shell: self-contained, with no document-level user data. */
1001
1098
  function pageHtml(bundleUrl) {
1002
1099
  return [
1003
1100
  "<!doctype html>",
@@ -1007,26 +1104,80 @@ function pageHtml(bundleUrl) {
1007
1104
  "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
1008
1105
  "<meta name=\"theme-color\" content=\"#f3f5f9\">",
1009
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\">",
1010
1111
  "<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
1011
1112
  "<title>移动端远程控制</title>",
1012
1113
  "</head>",
1013
1114
  "<body>",
1014
1115
  "<div id=\"root\"></div>",
1015
- `<script type="module" src="${bundleUrl}"><\/script>`,
1116
+ "<script type=\"module\" src=\"" + bundleUrl + "\"><\/script>",
1016
1117
  "</body>",
1017
1118
  "</html>"
1018
1119
  ].join("");
1019
1120
  }
1020
- /** 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. */
1021
1162
  function writeStatic(res, status, type, body) {
1022
1163
  res.writeHead(status, {
1023
- "content-type": `${type}; charset=utf-8`,
1164
+ "content-type": type + "; charset=utf-8",
1024
1165
  "cache-control": "no-cache",
1025
1166
  "referrer-policy": "no-referrer"
1026
1167
  });
1027
1168
  res.end(body);
1028
1169
  }
1029
- /** 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. */
1030
1181
  function writePng(res, status, body) {
1031
1182
  res.writeHead(status, {
1032
1183
  "content-type": "image/png",
@@ -1035,13 +1186,29 @@ function writePng(res, status, body) {
1035
1186
  });
1036
1187
  res.end(body);
1037
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
+ }
1038
1199
  /**
1039
1200
  * Build the mobile page routes.
1040
- * @returns the two exact routes to register on webServer.
1201
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
1041
1202
  */
1042
1203
  function makeMobileRoutes() {
1043
1204
  const handlePage = (_req, res) => {
1044
- 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());
1209
+ };
1210
+ const handleOffline = (_req, res) => {
1211
+ writeStatic(res, 200, "text/html", offlineHtml());
1045
1212
  };
1046
1213
  let bundleBody;
1047
1214
  const handleBundle = async (_req, res) => {
@@ -1060,33 +1227,80 @@ function makeMobileRoutes() {
1060
1227
  }
1061
1228
  writeStatic(res, 200, "text/javascript", bundleBody);
1062
1229
  };
1063
- const handleIcon = async (_req, res) => {
1064
- const path = appleTouchIconPath();
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
+ }
1244
+ }
1245
+ writeServiceWorker(res, workerBody);
1246
+ };
1247
+ const makeIconHandler = (asset) => async (_req, res) => {
1248
+ const path = mobileAssetPath(asset);
1065
1249
  if (!existsSync(path)) {
1066
- writeStatic(res, 404, "text/plain", "apple-touch-icon not found");
1250
+ writeStatic(res, 404, "text/plain", asset + " not found");
1067
1251
  return;
1068
1252
  }
1069
1253
  try {
1070
1254
  writePng(res, 200, await readFile(path));
1071
1255
  } catch {
1072
- writeStatic(res, 500, "text/plain", "failed to read the apple-touch-icon");
1256
+ writeStatic(res, 500, "text/plain", "failed to read " + asset);
1073
1257
  }
1074
1258
  };
1075
1259
  return [
1076
1260
  {
1077
1261
  kind: "exact",
1078
1262
  path: "/m",
1263
+ handler: redirectToMobileRoot
1264
+ },
1265
+ {
1266
+ kind: "exact",
1267
+ path: MOBILE_ROOT,
1079
1268
  handler: handlePage
1080
1269
  },
1081
1270
  {
1082
1271
  kind: "exact",
1083
- path: "/m/mobile.js",
1272
+ path: MOBILE_BUNDLE_URL,
1084
1273
  handler: handleBundle
1085
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
+ },
1086
1290
  {
1087
1291
  kind: "exact",
1088
1292
  path: "/m/apple-touch-icon.png",
1089
- 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")
1090
1304
  }
1091
1305
  ];
1092
1306
  }
@@ -1715,12 +1929,13 @@ function resolveUpdateTarget(deps) {
1715
1929
  if (anchor === void 0) return { error: "not-found" };
1716
1930
  const profile = findProfile(manifestPath);
1717
1931
  if (profile === void 0) return { error: "link" };
1718
- 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];
1719
1934
  if (isLinkedSpec(spec)) return { error: "link" };
1720
1935
  return {
1721
1936
  profileName: profile.name,
1722
1937
  profileDir: profile.dir,
1723
- packages: [anchor, ...familyChildren(manifest)]
1938
+ packages: familyUpdatePackages(anchor, manifest, profileManifest)
1724
1939
  };
1725
1940
  }
1726
1941
  /** Family children of the anchor: its dependencies under the family scope. */
@@ -1731,6 +1946,14 @@ function familyChildren(anchorManifest) {
1731
1946
  for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
1732
1947
  return names;
1733
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
+ }
1734
1957
  /**
1735
1958
  * Probe the npm registry for one package's latest release.
1736
1959
  * @param name - the package name (scope slash URL-encoded).
@@ -1766,14 +1989,15 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
1766
1989
  */
1767
1990
  const VERSION_UNKNOWN = "0.0.0";
1768
1991
  /** The resolved current version of one family package (probe failure tolerated). */
1769
- function readInstalledVersion(resolve, name) {
1992
+ function readInstalledVersion(resolve, name, profileDir) {
1770
1993
  try {
1771
1994
  const path = resolve(name + "/package.json");
1772
1995
  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
- }
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;
1777
2001
  }
1778
2002
  /**
1779
2003
  * Build the update status: locate the anchor, detect the install mode, and
@@ -1808,14 +2032,14 @@ async function checkUpdates(deps) {
1808
2032
  packages: [],
1809
2033
  outdated: false
1810
2034
  };
1811
- const names = [anchor, ...familyChildren(manifest)];
2035
+ const names = familyUpdatePackages(anchor, manifest, profileManifest);
1812
2036
  const latestList = await Promise.all(names.map((name) => deps.fetchLatest(name)));
1813
2037
  const packages = [];
1814
2038
  let probeFailures = 0;
1815
2039
  names.forEach((name, index) => {
1816
2040
  const latest = latestList[index];
1817
2041
  if (latest === void 0) probeFailures++;
1818
- const current = readInstalledVersion(deps.resolve, name);
2042
+ const current = readInstalledVersion(deps.resolve, name, profile.dir);
1819
2043
  packages.push({
1820
2044
  name,
1821
2045
  current,
@@ -2027,7 +2251,7 @@ function runUpdate(deps) {
2027
2251
  async function runUpdateVerified(deps) {
2028
2252
  const before = /* @__PURE__ */ new Map();
2029
2253
  for (const name of deps.run.packages) {
2030
- const version = readInstalledVersion(deps.check.resolve, name);
2254
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
2031
2255
  if (version !== VERSION_UNKNOWN) before.set(name, version);
2032
2256
  }
2033
2257
  const result = await runUpdate(deps.run);
@@ -2184,12 +2408,28 @@ const Config = z.object({
2184
2408
  cookieName: z.string().min(1).default("dsh_pair"),
2185
2409
  requirePairingForLan: z.boolean().default(true),
2186
2410
  publicBaseUrl: z.string(),
2411
+ devicesFile: z.string(),
2187
2412
  autoTunnel: z.boolean().default(false),
2188
2413
  mobileEnterToSend: z.boolean().default(true),
2189
2414
  enabled: z.boolean().default(true)
2190
2415
  });
2191
2416
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
2192
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
+ }
2193
2433
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
2194
2434
  const DEFAULTS = {
2195
2435
  tokenTtlMs: 10 * 6e4,
@@ -2198,6 +2438,7 @@ const DEFAULTS = {
2198
2438
  cookieName: "dsh_pair",
2199
2439
  requirePairingForLan: true,
2200
2440
  publicBaseUrl: void 0,
2441
+ devicesFile: void 0,
2201
2442
  autoTunnel: false,
2202
2443
  mobileEnterToSend: true,
2203
2444
  enabled: true
@@ -2216,6 +2457,7 @@ function applyImpl(ctx, config) {
2216
2457
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
2217
2458
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2218
2459
  publicBaseUrl: config?.publicBaseUrl,
2460
+ devicesFile: config?.devicesFile,
2219
2461
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
2220
2462
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2221
2463
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -2230,17 +2472,13 @@ function applyImpl(ctx, config) {
2230
2472
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
2231
2473
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2232
2474
  publicBaseUrl: value.publicBaseUrl,
2475
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
2233
2476
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
2234
2477
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2235
2478
  enabled: value.enabled ?? DEFAULTS.enabled
2236
2479
  };
2237
2480
  };
2238
- const service = new PairingService({
2239
- tokenTtlMs: resolved.tokenTtlMs,
2240
- offlineAfterMs: resolved.offlineAfterMs,
2241
- maxDevices: resolved.maxDevices,
2242
- cookieName: resolved.cookieName
2243
- });
2481
+ const service = new PairingService(pairingConfigOf(resolved));
2244
2482
  const tunnel = new TunnelManager();
2245
2483
  let autoTunnel = resolved.autoTunnel;
2246
2484
  tunnel.onPhase((info) => {
@@ -2342,14 +2580,13 @@ function applyImpl(ctx, config) {
2342
2580
  ];
2343
2581
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
2344
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
+ });
2345
2587
  const sync = () => {
2346
2588
  const value = resolve();
2347
- service.config = {
2348
- tokenTtlMs: value.tokenTtlMs,
2349
- offlineAfterMs: value.offlineAfterMs,
2350
- maxDevices: value.maxDevices,
2351
- cookieName: value.cookieName
2352
- };
2589
+ service.config = pairingConfigOf(value);
2353
2590
  autoTunnel = value.autoTunnel === true;
2354
2591
  if (autoTunnel) {
2355
2592
  if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
@@ -2406,4 +2643,4 @@ function isHttpUrl(value) {
2406
2643
  }
2407
2644
  }
2408
2645
  //#endregion
2409
- export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name };
2646
+ export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name, pairingConfigOf };