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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/lib/client.js +20 -3
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +119 -78
  4. package/lib/mobile.js +18 -5
  5. package/lib/mobile.js.map +1 -1
  6. package/lib/types/client/FooterRemoteEntry.js +1 -1
  7. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  10. package/lib/types/client/RemoteEntry.js +41 -11
  11. package/lib/types/client/RemotePanel.d.ts +1 -0
  12. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  13. package/lib/types/client/RemotePanel.js +2 -2
  14. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/RemoteSettingsCard.js +2 -2
  16. package/lib/types/client/UpdateEntry.js +1 -1
  17. package/lib/types/client/UpdatePanel.js +1 -1
  18. package/lib/types/client/deep-link.js +1 -1
  19. package/lib/types/client/index.js +7 -7
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/update-api.d.ts.map +1 -1
  22. package/lib/types/gate.js +1 -1
  23. package/lib/types/http.d.ts +14 -0
  24. package/lib/types/http.d.ts.map +1 -0
  25. package/lib/types/http.js +27 -0
  26. package/lib/types/index.d.ts +3 -1
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +12 -10
  29. package/lib/types/invariant.d.ts.map +1 -1
  30. package/lib/types/mobile/api.js +1 -1
  31. package/lib/types/mobile/index.js +3 -3
  32. package/lib/types/mobile/markdown.d.ts +6 -4
  33. package/lib/types/mobile/markdown.d.ts.map +1 -1
  34. package/lib/types/mobile/markdown.js +8 -4
  35. package/lib/types/mobile/markdown.test.js +5 -1
  36. package/lib/types/mobile/messages.d.ts.map +1 -1
  37. package/lib/types/mobile/messages.js +14 -1
  38. package/lib/types/mobile/messages.test.js +1 -1
  39. package/lib/types/mobile/mobile-theme.test.js +2 -2
  40. package/lib/types/mobile/mux.d.ts.map +1 -1
  41. package/lib/types/mobile/mux.js +1 -1
  42. package/lib/types/mobile/mux.test.js +1 -1
  43. package/lib/types/mobile/rpc.d.ts.map +1 -1
  44. package/lib/types/mobile/theme-toggle.js +1 -1
  45. package/lib/types/mobile/views/App.d.ts +0 -11
  46. package/lib/types/mobile/views/App.d.ts.map +1 -1
  47. package/lib/types/mobile/views/App.js +6 -6
  48. package/lib/types/mobile/views/ChatView.js +6 -6
  49. package/lib/types/mobile/views/ChatView.test.js +3 -3
  50. package/lib/types/mobile/views/SessionListView.js +3 -3
  51. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  52. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  53. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  54. package/lib/types/mobile-api.d.ts +1 -1
  55. package/lib/types/mobile-api.d.ts.map +1 -1
  56. package/lib/types/mobile-api.js +11 -18
  57. package/lib/types/mobile-routes.d.ts.map +1 -1
  58. package/lib/types/mobile-routes.js +17 -11
  59. package/lib/types/mount-once.d.ts +25 -0
  60. package/lib/types/mount-once.d.ts.map +1 -0
  61. package/lib/types/mount-once.js +42 -0
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/routes.d.ts +6 -6
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +14 -19
  66. package/lib/types/tunnel.d.ts.map +1 -1
  67. package/lib/types/update-routes.d.ts +2 -2
  68. package/lib/types/update-routes.d.ts.map +1 -1
  69. package/lib/types/update-routes.js +1 -6
  70. package/lib/types/update.d.ts.map +1 -1
  71. package/lib/types/update.js +6 -3
  72. package/package.json +1 -1
  73. package/src/client/RemoteEntry.tsx +38 -6
  74. package/src/client/RemotePanel.tsx +1 -1
  75. package/src/http.ts +30 -0
  76. package/src/index.ts +4 -1
  77. package/src/mobile/markdown.test.ts +5 -0
  78. package/src/mobile/markdown.ts +7 -4
  79. package/src/mobile/messages.ts +14 -1
  80. package/src/mobile/views/App.tsx +0 -12
  81. package/src/mobile-api.ts +9 -17
  82. package/src/mobile-routes.ts +16 -10
  83. package/src/mount-once.ts +48 -0
  84. package/src/routes.ts +11 -17
  85. package/src/update-routes.ts +1 -7
  86. package/src/update.ts +6 -3
package/lib/index.js CHANGED
@@ -572,6 +572,32 @@ function makeGateListener(service, requirePairingForLan = true, enabled = true)
572
572
  };
573
573
  }
574
574
  //#endregion
575
+ //#region src/http.ts
576
+ /** One JSON response. */
577
+ function writeJson(res, status, body) {
578
+ const payload = JSON.stringify(body);
579
+ res.writeHead(status, {
580
+ "content-type": "application/json; charset=utf-8",
581
+ "referrer-policy": "no-referrer"
582
+ });
583
+ res.end(payload);
584
+ }
585
+ /**
586
+ * Read a request body up to maxBytes and parse it as JSON.
587
+ * @throws 'body too large' beyond the cap, or the JSON.parse error.
588
+ */
589
+ async function readBoundedJson(req, maxBytes) {
590
+ const chunks = [];
591
+ let size = 0;
592
+ for await (const chunk of req) {
593
+ const buffer = chunk;
594
+ size += buffer.length;
595
+ if (size > maxBytes) throw new Error("body too large");
596
+ chunks.push(buffer);
597
+ }
598
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
599
+ }
600
+ //#endregion
575
601
  //#region src/routes.ts
576
602
  /**
577
603
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -663,27 +689,10 @@ function parsePairPayload(schema, body) {
663
689
  const result = schema.safeParse(body ?? {});
664
690
  return result.success ? result.data : void 0;
665
691
  }
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. */
692
+ /** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
676
693
  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
694
  try {
686
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
695
+ const parsed = await readBoundedJson(req, MAX_BODY_BYTES);
687
696
  return typeof parsed === "object" && parsed !== null ? parsed : void 0;
688
697
  } catch {
689
698
  return;
@@ -766,6 +775,9 @@ function makeRoutes(deps) {
766
775
  const rateLimitAccept = (req) => {
767
776
  const ip = req.socket?.remoteAddress ?? "unknown";
768
777
  const nowMs = Date.now();
778
+ if (acceptAttempts.size > 256) {
779
+ for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
780
+ }
769
781
  const entry = acceptAttempts.get(ip);
770
782
  if (entry === void 0 || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
771
783
  acceptAttempts.set(ip, {
@@ -780,7 +792,7 @@ function makeRoutes(deps) {
780
792
  const handleIssue = async (req, res) => {
781
793
  if (!requireMethod(req, res, "POST")) return;
782
794
  if (!loopbackFence(req)) {
783
- writeJson$1(res, 403, {
795
+ writeJson(res, 403, {
784
796
  ok: false,
785
797
  code: "forbidden"
786
798
  });
@@ -789,7 +801,7 @@ function makeRoutes(deps) {
789
801
  const body = await readJsonBody$1(req);
790
802
  const payload = parsePairPayload(issuePayloadSchema, body);
791
803
  if (payload === void 0) {
792
- writeJson$1(res, 400, {
804
+ writeJson(res, 400, {
793
805
  ok: false,
794
806
  code: "bad-payload"
795
807
  });
@@ -800,7 +812,7 @@ function makeRoutes(deps) {
800
812
  const { token, expiresAt } = service.issue(workspaceId, address);
801
813
  const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
802
814
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
803
- writeJson$1(res, 200, {
815
+ writeJson(res, 200, {
804
816
  ok: true,
805
817
  url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
806
818
  token,
@@ -810,7 +822,7 @@ function makeRoutes(deps) {
810
822
  });
811
823
  } catch (error) {
812
824
  const unknownAddress = error instanceof UnknownLanAddressError;
813
- writeJson$1(res, unknownAddress ? 400 : 409, {
825
+ writeJson(res, unknownAddress ? 400 : 409, {
814
826
  ok: false,
815
827
  code: unknownAddress ? "unknown-address" : "lan-required"
816
828
  });
@@ -819,14 +831,14 @@ function makeRoutes(deps) {
819
831
  const handleAccept = async (req, res) => {
820
832
  if (!requireMethod(req, res, "POST")) return;
821
833
  if (!lanFence(req)) {
822
- writeJson$1(res, 403, {
834
+ writeJson(res, 403, {
823
835
  ok: false,
824
836
  code: "forbidden"
825
837
  });
826
838
  return;
827
839
  }
828
840
  if (rateLimitAccept(req)) {
829
- writeJson$1(res, 429, {
841
+ writeJson(res, 429, {
830
842
  ok: false,
831
843
  code: "rate-limited"
832
844
  });
@@ -835,7 +847,7 @@ function makeRoutes(deps) {
835
847
  const body = await readJsonBody$1(req);
836
848
  const payload = parsePairPayload(acceptPayloadSchema, body);
837
849
  if (payload === void 0) {
838
- writeJson$1(res, 400, {
850
+ writeJson(res, 400, {
839
851
  ok: false,
840
852
  code: "bad-payload"
841
853
  });
@@ -843,7 +855,7 @@ function makeRoutes(deps) {
843
855
  }
844
856
  const result = service.accept(payload.token);
845
857
  if (!result.ok) {
846
- writeJson$1(res, result.code === "used" ? 409 : 404, {
858
+ writeJson(res, result.code === "used" ? 409 : 404, {
847
859
  ok: false,
848
860
  code: result.code
849
861
  });
@@ -861,7 +873,7 @@ function makeRoutes(deps) {
861
873
  const handleStop = async (req, res) => {
862
874
  if (!requireMethod(req, res, "POST")) return;
863
875
  if (!loopbackFence(req)) {
864
- writeJson$1(res, 403, {
876
+ writeJson(res, 403, {
865
877
  ok: false,
866
878
  code: "forbidden"
867
879
  });
@@ -869,19 +881,19 @@ function makeRoutes(deps) {
869
881
  }
870
882
  const body = await readJsonBody$1(req);
871
883
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
872
- writeJson$1(res, 400, {
884
+ writeJson(res, 400, {
873
885
  ok: false,
874
886
  code: "bad-payload"
875
887
  });
876
888
  return;
877
889
  }
878
890
  service.stop();
879
- writeJson$1(res, 200, { ok: true });
891
+ writeJson(res, 200, { ok: true });
880
892
  };
881
893
  const handleHeartbeat = async (req, res) => {
882
894
  if (!requireMethod(req, res, "POST")) return;
883
895
  if (!lanFence(req)) {
884
- writeJson$1(res, 403, {
896
+ writeJson(res, 403, {
885
897
  ok: false,
886
898
  code: "forbidden"
887
899
  });
@@ -889,7 +901,7 @@ function makeRoutes(deps) {
889
901
  }
890
902
  const body = await readJsonBody$1(req);
891
903
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
892
- writeJson$1(res, 400, {
904
+ writeJson(res, 400, {
893
905
  ok: false,
894
906
  code: "bad-payload"
895
907
  });
@@ -897,25 +909,25 @@ function makeRoutes(deps) {
897
909
  }
898
910
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
899
911
  if (deviceId === void 0 || !service.heartbeat(deviceId)) {
900
- writeJson$1(res, 401, {
912
+ writeJson(res, 401, {
901
913
  ok: false,
902
914
  code: "unpaired"
903
915
  });
904
916
  return;
905
917
  }
906
- writeJson$1(res, 200, { ok: true });
918
+ writeJson(res, 200, { ok: true });
907
919
  };
908
920
  const handleStatus = async (req, res) => {
909
921
  if (!requireMethod(req, res, "GET")) return;
910
922
  if (!lanFence(req)) {
911
- writeJson$1(res, 403, {
923
+ writeJson(res, 403, {
912
924
  ok: false,
913
925
  code: "forbidden"
914
926
  });
915
927
  return;
916
928
  }
917
929
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
918
- writeJson$1(res, 200, {
930
+ writeJson(res, 200, {
919
931
  ok: true,
920
932
  paired: deviceId !== void 0 && service.hasDevice(deviceId),
921
933
  ...service.snapshot()
@@ -924,7 +936,7 @@ function makeRoutes(deps) {
924
936
  const handleEvents = (req, res) => {
925
937
  if (!requireMethod(req, res, "GET")) return;
926
938
  if (!loopbackFence(req)) {
927
- writeJson$1(res, 403, {
939
+ writeJson(res, 403, {
928
940
  ok: false,
929
941
  code: "forbidden"
930
942
  });
@@ -1031,17 +1043,22 @@ function makeMobileRoutes() {
1031
1043
  const handlePage = (_req, res) => {
1032
1044
  writeStatic(res, 200, "text/html", pageHtml("/m/mobile.js"));
1033
1045
  };
1046
+ let bundleBody;
1034
1047
  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;
1039
- }
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");
1048
+ if (bundleBody === void 0) {
1049
+ const path = mobileBundlePath();
1050
+ if (!existsSync(path)) {
1051
+ writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
1052
+ return;
1053
+ }
1054
+ try {
1055
+ bundleBody = await readFile(path, "utf8");
1056
+ } catch {
1057
+ writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
1058
+ return;
1059
+ }
1044
1060
  }
1061
+ writeStatic(res, 200, "text/javascript", bundleBody);
1045
1062
  };
1046
1063
  const handleIcon = async (_req, res) => {
1047
1064
  const path = appleTouchIconPath();
@@ -1147,10 +1164,6 @@ function makeMobileApiRoutes(deps) {
1147
1164
  const gateOk = (req) => {
1148
1165
  return touchDeviceFor(req);
1149
1166
  };
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
1167
  const handleMethod = async (req, res) => {
1155
1168
  if (req.method !== "POST") {
1156
1169
  res.writeHead(405);
@@ -1227,10 +1240,12 @@ function makeMobileApiRoutes(deps) {
1227
1240
  return;
1228
1241
  }
1229
1242
  try {
1230
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId);
1231
- writeJson(res, 200, response);
1243
+ const abort = new AbortController();
1244
+ res.on("close", () => {
1245
+ if (!res.writableEnded) abort.abort();
1246
+ });
1247
+ writeJson(res, 200, await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal));
1232
1248
  } catch (error) {
1233
- const message = error instanceof Error ? error.message : String(error);
1234
1249
  writeJson(res, 200, {
1235
1250
  type: "server-response",
1236
1251
  rpcId,
@@ -1238,7 +1253,7 @@ function makeMobileApiRoutes(deps) {
1238
1253
  ok: false,
1239
1254
  error: {
1240
1255
  code: "internal",
1241
- message
1256
+ message: error instanceof Error ? error.message : String(error)
1242
1257
  }
1243
1258
  }
1244
1259
  });
@@ -1305,18 +1320,10 @@ function makeMobileApiRoutes(deps) {
1305
1320
  }
1306
1321
  /** Read a request body as JSON (bounded). */
1307
1322
  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"));
1323
+ return readBoundedJson(req, 64 * 1024);
1317
1324
  }
1318
1325
  /** Dispatch one allowlisted method through the host ApiProxy. */
1319
- async function dispatch(apiProxy, method, payload, rpcId) {
1326
+ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1320
1327
  const request = {
1321
1328
  rpcId: RpcId(rpcId),
1322
1329
  payload
@@ -1354,7 +1361,7 @@ async function dispatch(apiProxy, method, payload, rpcId) {
1354
1361
  if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
1355
1362
  if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
1356
1363
  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));
1364
+ if (method === "session.search") return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
1358
1365
  if (method === "session.prompt") return wrap(await apiProxy.sessions.prompt(request));
1359
1366
  if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
1360
1367
  if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
@@ -1802,10 +1809,11 @@ async function checkUpdates(deps) {
1802
1809
  outdated: false
1803
1810
  };
1804
1811
  const names = [anchor, ...familyChildren(manifest)];
1812
+ const latestList = await Promise.all(names.map((name) => deps.fetchLatest(name)));
1805
1813
  const packages = [];
1806
1814
  let probeFailures = 0;
1807
- for (const name of names) {
1808
- const latest = await deps.fetchLatest(name);
1815
+ names.forEach((name, index) => {
1816
+ const latest = latestList[index];
1809
1817
  if (latest === void 0) probeFailures++;
1810
1818
  const current = readInstalledVersion(deps.resolve, name);
1811
1819
  packages.push({
@@ -1814,7 +1822,7 @@ async function checkUpdates(deps) {
1814
1822
  latest,
1815
1823
  outdated: latest !== void 0 && latest !== current && compareVersions(latest, current) > 0
1816
1824
  });
1817
- }
1825
+ });
1818
1826
  const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
1819
1827
  return {
1820
1828
  mode: linked ? "link" : "npm",
@@ -2061,15 +2069,6 @@ const UPDATE_PATHS = {
2061
2069
  status: "/api/update/status",
2062
2070
  run: "/api/update/run"
2063
2071
  };
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
2072
  /**
2074
2073
  * Build the /api/update route family.
2075
2074
  * @param deps - fence + check/run seams.
@@ -2117,6 +2116,47 @@ function makeUpdateRoutes(deps) {
2117
2116
  }];
2118
2117
  }
2119
2118
  //#endregion
2119
+ //#region src/mount-once.ts
2120
+ /**
2121
+ * Host single-instance guard shared by the plugin family. The family bundle
2122
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
2123
+ * the loader accepts a standalone install of the same package side by side;
2124
+ * without this guard the second instance would still re-register the same
2125
+ * webserver routes, tools, settings namespaces, and system-prompt sections
2126
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
2127
+ * lifetime of the first instance (the browser half is already deduped by
2128
+ * package name in the client module host).
2129
+ *
2130
+ * The registry rides a global symbol so two module instances of the same
2131
+ * package (npm copy vs repository link) still share one verdict. cordis
2132
+ * `ctx.effect` runs its callback immediately and treats the callback's
2133
+ * return value as the fiber disposer, so the unmarker is returned, not run.
2134
+ */
2135
+ const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
2136
+ function mountedSet() {
2137
+ const registry = globalThis;
2138
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
2139
+ }
2140
+ /**
2141
+ * Wrap a cordis plugin apply so the package runs at most once per process.
2142
+ * The first mount registers normally and unmarks when its fiber disposes;
2143
+ * any later mount of the same package name is a no-op.
2144
+ * @param packageName - npm package identity shared by every install source.
2145
+ * @param fn - the original plugin apply.
2146
+ * @returns an apply of the same shape.
2147
+ */
2148
+ function mountOnce(packageName, fn) {
2149
+ return ((...args) => {
2150
+ const mounted = mountedSet();
2151
+ if (mounted.has(packageName)) return;
2152
+ mounted.add(packageName);
2153
+ args[0]?.effect?.(() => () => {
2154
+ mounted.delete(packageName);
2155
+ });
2156
+ return fn(...args);
2157
+ });
2158
+ }
2159
+ //#endregion
2120
2160
  //#region src/index.ts
2121
2161
  /**
2122
2162
  * Mobile remote control for the dsh web GUI — host half. Mounts the pairing
@@ -2167,7 +2207,8 @@ const DEFAULTS = {
2167
2207
  * @param ctx - host plugin context carrying webServer.
2168
2208
  * @param config - resolved plugin config (schema defaults applied by the loader).
2169
2209
  */
2170
- function apply(ctx, config) {
2210
+ const apply = mountOnce("@linxin666/dsh-remote-web-ui", applyImpl);
2211
+ function applyImpl(ctx, config) {
2171
2212
  const resolved = {
2172
2213
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
2173
2214
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
package/lib/mobile.js CHANGED
@@ -13429,7 +13429,17 @@ function foldEvents(events, existing) {
13429
13429
  if (event.seq <= watermark) continue;
13430
13430
  applyEvent(state, event);
13431
13431
  }
13432
- return [...state.messages].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
13432
+ const out = state.messages;
13433
+ let ordered = true;
13434
+ for (let index = 1; index < out.length; index += 1) {
13435
+ const prev = out[index - 1];
13436
+ const current = out[index];
13437
+ if (prev.seq > current.seq || prev.seq === current.seq && prev.id >= current.id) {
13438
+ ordered = false;
13439
+ break;
13440
+ }
13441
+ }
13442
+ return ordered ? out : [...out].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
13433
13443
  }
13434
13444
  //#endregion
13435
13445
  //#region src/mobile/markdown.ts
@@ -13450,15 +13460,18 @@ function escapeHtml(text) {
13450
13460
  }
13451
13461
  /**
13452
13462
  * Guard a raw link/image target against dangerous protocols. Returns the
13453
- * (trimmed) raw string when safe, else null. Only http:, https:, mailto:
13454
- * and fragment anchors (#...) are allowed; anything with another scheme
13455
- * — javascript:, data:, vbscript:, etc. — is rejected so the value never
13456
- * reaches dangerouslySetInnerHTML.
13463
+ * (trimmed) raw string when safe, else null. Only http:, https:, mailto:,
13464
+ * fragment anchors (#...) and strictly relative paths are allowed; anything
13465
+ * with another scheme — javascript:, data:, vbscript:, etc. — or a
13466
+ * protocol-relative //host target (the browser resolves it against the
13467
+ * current scheme, reaching an arbitrary origin) is rejected so the value
13468
+ * never reaches dangerouslySetInnerHTML.
13457
13469
  */
13458
13470
  function safeUrl(raw) {
13459
13471
  const trimmed = raw.trim();
13460
13472
  if (trimmed === "") return null;
13461
13473
  if (trimmed.startsWith("#")) return trimmed;
13474
+ if (trimmed.startsWith("//")) return null;
13462
13475
  const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed);
13463
13476
  if (scheme === null) return trimmed;
13464
13477
  const name = scheme[1].toLowerCase();