@linxin666/dsh-remote-web-ui 0.1.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 (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
package/lib/index.js ADDED
@@ -0,0 +1,1455 @@
1
+ import { setInterval as setInterval$1 } from "node:timers";
2
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import z from "schemastery";
4
+ import { randomBytes } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
9
+ import { networkInterfaces } from "node:os";
10
+ import { Tunnel, bin, install } from "cloudflared";
11
+ //#region src/pairing.ts
12
+ /**
13
+ * Pairing state machine: one active one-time token, a device-session table,
14
+ * and presence tracking. Pure TypeScript with injected clock/randomness so
15
+ * the whole security semantics are unit-testable without cordis. The
16
+ * cordis-facing surfaces (routes, the api/gate listener) live next door.
17
+ *
18
+ * Security invariants:
19
+ * - One active token at a time; `issue()` replaces it, so a refreshed QR
20
+ * immediately invalidates the previous link.
21
+ * - A token is consumed by the first successful `accept()` — reuse is
22
+ * refused with `'used'`.
23
+ * - Tokens expire; `accept()` on an expired token is refused like an
24
+ * unknown one (no oracle for validity).
25
+ * - `stop()` revokes every device session and clears the token, so paired
26
+ * devices are cut off on their next gated request.
27
+ */
28
+ /** Thrown by issue() for an address outside the sampled LAN literals. */
29
+ var UnknownLanAddressError = class extends Error {
30
+ /**
31
+ * @param address - the offending literal.
32
+ */
33
+ constructor(address) {
34
+ super(`remote-web-ui: unknown LAN address ${JSON.stringify(address)}`);
35
+ this.name = "UnknownLanAddressError";
36
+ }
37
+ };
38
+ /** Real clock/entropy: 32 random hex chars per token. */
39
+ const defaultClock = {
40
+ now: () => Date.now(),
41
+ randomToken: () => randomBytes(16).toString("hex")
42
+ };
43
+ /**
44
+ * The pairing state machine. All mutations notify state listeners after the
45
+ * commit point that makes them true, and notification dedupes against the
46
+ * last emitted snapshot — time-driven transitions (a device aging offline)
47
+ * surface on the next sweep without any mutation.
48
+ */
49
+ var PairingService = class {
50
+ config;
51
+ clock;
52
+ tokens = /* @__PURE__ */ new Map();
53
+ devices = /* @__PURE__ */ new Map();
54
+ listeners = /* @__PURE__ */ new Set();
55
+ lastEmitted;
56
+ stopped = false;
57
+ tokenSerial = 0;
58
+ /** LAN base URLs keyed by the advertised IP literal (interface order). */
59
+ lanBases = /* @__PURE__ */ new Map();
60
+ /** Public (tunneled) base URL, e.g. a Cloudflare Tunnel quick URL. */
61
+ publicBase;
62
+ /** Auto-tunnel status, while the auto-tunnel feature is active. */
63
+ tunnelStatus;
64
+ /**
65
+ * @param config - tunables. The settings surface replaces the object (a
66
+ * fresh literal) when a committed section changes; every operation reads
67
+ * the current one.
68
+ * @param clock - clock/entropy source (injectable for tests).
69
+ */
70
+ constructor(config, clock = defaultClock) {
71
+ this.config = config;
72
+ this.clock = clock;
73
+ }
74
+ /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
75
+ get lanBaseUrl() {
76
+ return this.lanBases.values().next().value;
77
+ }
78
+ /** The LAN base URL for one specific literal (undefined when not constructible). */
79
+ lanBaseUrlFor(address) {
80
+ return this.lanBases.get(address);
81
+ }
82
+ /** The LAN IP literals QR links can be built from (interface order). */
83
+ get lanAddresses() {
84
+ return [...this.lanBases.keys()];
85
+ }
86
+ /** Set the LAN base URLs once the server bind is known (interface order). */
87
+ setLanBases(entries) {
88
+ this.lanBases = new Map(entries.map((entry) => [entry.address, entry.base]));
89
+ this.notify();
90
+ }
91
+ /** The configured public (tunneled) base URL, when present. */
92
+ get publicBaseUrl() {
93
+ return this.publicBase;
94
+ }
95
+ /** Set or clear the public base URL (a tunnel in front of this server). */
96
+ setPublicBaseUrl(url) {
97
+ this.publicBase = url;
98
+ this.notify();
99
+ }
100
+ /** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
101
+ setTunnelStatus(status) {
102
+ this.tunnelStatus = status;
103
+ this.notify();
104
+ }
105
+ /**
106
+ * Issue a fresh token, replacing (invalidating) any previous one. A
107
+ * stopped service re-arms through this call (the panel's refresh button).
108
+ * @param workspaceId - optional workspace the QR link should land in.
109
+ * @param address - optional LAN IP literal the QR must be built from; the
110
+ * default is the public base (when configured) or the first interface.
111
+ * Unknown addresses are refused.
112
+ * @returns the token secret and its expiry.
113
+ * @throws {Error} when no reachable base exists (no all-interfaces bind and
114
+ * no public base) — callers surface this as the lan-required state instead
115
+ * of minting an unusable QR.
116
+ */
117
+ issue(workspaceId, address) {
118
+ if (this.lanBases.size === 0 && this.publicBase === void 0) throw new Error("remote-web-ui: pairing requires a reachable bind (--host 0.0.0.0 or publicBaseUrl)");
119
+ if (address !== void 0 && !this.lanBases.has(address)) throw new UnknownLanAddressError(address);
120
+ const now = this.clock.now();
121
+ const token = this.clock.randomToken();
122
+ this.tokens.clear();
123
+ this.stopped = false;
124
+ this.tokenSerial += 1;
125
+ this.tokens.set(token, {
126
+ id: `t${this.tokenSerial}`,
127
+ issuedAt: now,
128
+ expiresAt: now + this.config.tokenTtlMs,
129
+ consumed: false,
130
+ ...workspaceId !== void 0 ? { workspaceId } : {},
131
+ ...address !== void 0 ? { address } : {}
132
+ });
133
+ this.notify();
134
+ return {
135
+ token,
136
+ expiresAt: now + this.config.tokenTtlMs
137
+ };
138
+ }
139
+ /**
140
+ * Consume a token and bind a device session. One-time: the second
141
+ * successful call for the same token is impossible because the first
142
+ * consumes it.
143
+ * @param token - the token secret from the QR link.
144
+ * @returns the new device id, or a refusal code.
145
+ */
146
+ accept(token) {
147
+ const record = this.tokens.get(token);
148
+ if (record === void 0 || record.consumed || this.stopped || this.clock.now() > record.expiresAt) return {
149
+ ok: false,
150
+ code: record?.consumed === true ? "used" : "invalid"
151
+ };
152
+ record.consumed = true;
153
+ const deviceId = this.clock.randomToken();
154
+ const now = this.clock.now();
155
+ if (this.devices.size >= this.config.maxDevices) {
156
+ let oldest;
157
+ for (const [id, session] of this.devices) if (oldest === void 0 || session.createdAt < oldest.createdAt) oldest = {
158
+ id,
159
+ createdAt: session.createdAt
160
+ };
161
+ if (oldest !== void 0) this.devices.delete(oldest.id);
162
+ }
163
+ this.devices.set(deviceId, {
164
+ createdAt: now,
165
+ lastSeenAt: now
166
+ });
167
+ this.notify();
168
+ return {
169
+ ok: true,
170
+ deviceId
171
+ };
172
+ }
173
+ /**
174
+ * Stop remote control: revoke every device session and clear the token.
175
+ * The phone's next gated /api request 403s; the panel falls back to
176
+ * stopped until a fresh QR is issued.
177
+ */
178
+ stop() {
179
+ this.tokens.clear();
180
+ this.devices.clear();
181
+ this.stopped = true;
182
+ this.notify();
183
+ }
184
+ /**
185
+ * The api/gate path: record activity for a device id and report whether
186
+ * the request may proceed. Unknown or revoked ids (including any device
187
+ * after stop()) are refused.
188
+ * @param deviceId - the cookie value of the requesting device.
189
+ * @returns true when the device session is live and was refreshed.
190
+ */
191
+ touchDevice(deviceId) {
192
+ const session = this.devices.get(deviceId);
193
+ if (session === void 0 || this.stopped) return false;
194
+ session.lastSeenAt = this.clock.now();
195
+ this.notify();
196
+ return true;
197
+ }
198
+ /** Explicit presence heartbeat (the phone's client sends these). */
199
+ heartbeat(deviceId) {
200
+ return this.touchDevice(deviceId);
201
+ }
202
+ /**
203
+ * Periodic sweep: re-evaluate the derived snapshot (a device aging past
204
+ * the offline window flips the phase to disconnected). Emits only when
205
+ * the snapshot actually changed.
206
+ */
207
+ sweep() {
208
+ this.notify();
209
+ }
210
+ /** The current snapshot (fresh object per call — stable between emits). */
211
+ snapshot() {
212
+ const now = this.clock.now();
213
+ const onlineCount = [...this.devices.values()].filter((session) => this.isOnlineAt(session, now)).length;
214
+ const token = this.activeToken();
215
+ return {
216
+ phase: this.derivePhase(onlineCount, token !== void 0),
217
+ lanAvailable: this.lanBases.size > 0,
218
+ lanAddresses: [...this.lanBases.keys()],
219
+ ...this.publicBase !== void 0 ? { publicUrl: this.publicBase } : {},
220
+ ...this.tunnelStatus !== void 0 ? { tunnel: this.tunnelStatus } : {},
221
+ ...token !== void 0 ? {
222
+ tokenId: token.record.id,
223
+ tokenExpiresAt: token.record.expiresAt
224
+ } : {},
225
+ deviceCount: this.devices.size,
226
+ onlineCount
227
+ };
228
+ }
229
+ /** Whether a cookie value names a currently live device session. */
230
+ hasDevice(deviceId) {
231
+ return this.devices.get(deviceId) !== void 0 && !this.stopped;
232
+ }
233
+ /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
234
+ onState(listener) {
235
+ this.listeners.add(listener);
236
+ return () => {
237
+ this.listeners.delete(listener);
238
+ };
239
+ }
240
+ activeToken() {
241
+ for (const [token, record] of this.tokens) {
242
+ if (this.stopped) return void 0;
243
+ if (this.clock.now() > record.expiresAt) continue;
244
+ return {
245
+ token,
246
+ record
247
+ };
248
+ }
249
+ }
250
+ derivePhase(onlineCount, hasToken) {
251
+ if (this.lanBases.size === 0 && this.publicBase === void 0) return "lan-required";
252
+ if (this.stopped) return "stopped";
253
+ if (onlineCount > 0) return "connected";
254
+ if (this.devices.size > 0) return "disconnected";
255
+ if (hasToken) return "waiting";
256
+ return "stopped";
257
+ }
258
+ isOnlineAt(session, now) {
259
+ return now - session.lastSeenAt <= this.config.offlineAfterMs;
260
+ }
261
+ notify() {
262
+ const snapshot = this.snapshot();
263
+ if (this.lastEmitted !== void 0 && snapshotsEqual(this.lastEmitted, snapshot)) return;
264
+ this.lastEmitted = snapshot;
265
+ for (const listener of this.listeners) try {
266
+ listener(snapshot);
267
+ } catch (error) {
268
+ console.error("remote-web-ui: pairing state listener failed", error);
269
+ }
270
+ }
271
+ };
272
+ /** Structural equality over the snapshot's wire fields. */
273
+ function snapshotsEqual(a, b) {
274
+ return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount;
275
+ }
276
+ /** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
277
+ function tunnelEqual(a, b) {
278
+ return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
279
+ }
280
+ /** Element-wise string list equality (interface order is meaningful). */
281
+ function sameStrings(a, b) {
282
+ return a.length === b.length && a.every((value, index) => value === b[index]);
283
+ }
284
+ //#endregion
285
+ //#region src/gate.ts
286
+ /**
287
+ * Whether a normalized URL hostname names the local loopback authority.
288
+ * Semantics mirror the connection package's internal predicate (localhost,
289
+ * IPv6 loopback, any IPv4 address in 127/8); it is reimplemented here because
290
+ * the connection package no longer exports it — the fence now lives inside
291
+ * the connection plugin, and external host plugins only need the
292
+ * classification, not the whole trust decision.
293
+ * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
294
+ * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
295
+ */
296
+ function isLoopbackHostname(hostname) {
297
+ if (hostname === "localhost" || hostname === "[::1]") return true;
298
+ const parts = hostname.split(".");
299
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
300
+ }
301
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
302
+ function isLoopbackAddress(address) {
303
+ if (address === void 0) return false;
304
+ const normalized = address.toLowerCase();
305
+ if (normalized === "::1") return true;
306
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
307
+ return isIPv4Loopback(normalized);
308
+ }
309
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
310
+ function isIPv4Loopback(v4) {
311
+ const parts = v4.split(".");
312
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
313
+ }
314
+ /**
315
+ * Read one cookie value from a Cookie header.
316
+ * @param header - the raw Cookie header value (or undefined).
317
+ * @param name - the cookie name.
318
+ * @returns the value, or undefined when absent.
319
+ */
320
+ function readCookie(header, name) {
321
+ if (header === void 0) return void 0;
322
+ for (const part of header.split(";")) {
323
+ const eq = part.indexOf("=");
324
+ if (eq < 0) continue;
325
+ if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
326
+ }
327
+ }
328
+ /**
329
+ * The effective Host hostname of a request.
330
+ * @param request - node HTTP request.
331
+ * @returns the normalized hostname, or undefined when unparsable.
332
+ */
333
+ function hostnameOf(request) {
334
+ const host = request.headers.host;
335
+ if (typeof host !== "string") return void 0;
336
+ try {
337
+ return new URL(`http://${host}`).hostname;
338
+ } catch {
339
+ return;
340
+ }
341
+ }
342
+ /** Whether a request comes from the desktop loopback client (loopback socket AND loopback Host). */
343
+ function isLoopbackClient(request) {
344
+ const hostname = hostnameOf(request);
345
+ if (hostname === void 0 || !isLoopbackHostname(hostname)) return false;
346
+ const socket = request.socket;
347
+ return isLoopbackAddress(socket?.remoteAddress);
348
+ }
349
+ /**
350
+ * Build the api/gate listener for one pairing service.
351
+ * @param service - the pairing service.
352
+ * @param requirePairingForLan - when false, non-loopback requests pass
353
+ * without a device cookie (the feature then only manages tokens/status;
354
+ * revocation of paired devices still holds). A function is re-read per
355
+ * request, so a settings edit takes effect without a restart. Defaults to true.
356
+ * @param enabled - when false, every non-loopback request is vetoed while
357
+ * loopback stays available. A function is re-read per request so the fence
358
+ * stays mounted for the plugin lifetime and disabling the plugin cannot open
359
+ * a LAN-exposed /api. Defaults to true.
360
+ * @returns the cordis waterfall listener: call `next()` to delegate,
361
+ * return false (without calling it) to veto with 403.
362
+ */
363
+ function makeGateListener(service, requirePairingForLan = true, enabled = true) {
364
+ return (request, _method, next) => {
365
+ if (isLoopbackClient(request)) return next();
366
+ if (!(typeof enabled === "function" ? enabled() : enabled)) return false;
367
+ if (!(typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan)) return next();
368
+ const deviceId = readCookie(request.headers.cookie, service.config.cookieName);
369
+ if (deviceId === void 0) return false;
370
+ return service.touchDevice(deviceId) ? next() : false;
371
+ };
372
+ }
373
+ //#endregion
374
+ //#region src/routes.ts
375
+ /**
376
+ * Browser-trust fence for the /api/pair routes, mirroring the connection
377
+ * package's internal fence semantics (Host/Origin based, DNS-rebinding and
378
+ * cross-site defense). The connection package no longer exports its trust
379
+ * predicate — the fence for the /api prefix lives inside the connection
380
+ * plugin — so the pairing routes, which must stay reachable from LAN phones
381
+ * ahead of the connection prefix route (exact routes match first), carry
382
+ * their own copy scoped to the literals the QR links advertise.
383
+ * @param request - the node HTTP request.
384
+ * @param trustedHosts - non-loopback authorities this surface serves: exact
385
+ * `host:port`, or port-less `host` matching any port.
386
+ * @returns true when the Host is ours (loopback or trusted) and any attached
387
+ * browser markers are same-origin.
388
+ */
389
+ function isTrustedApiRequest(request, trustedHosts) {
390
+ const host = request.headers.host;
391
+ if (typeof host !== "string") return false;
392
+ let hostUrl;
393
+ try {
394
+ hostUrl = new URL(`http://${host}`);
395
+ } catch {
396
+ return false;
397
+ }
398
+ const hostname = hostUrl.hostname;
399
+ if (!(isLoopbackClient(request) || trustedHosts.some((entry) => {
400
+ const entryUrl = new URL(`http://${entry}`);
401
+ return entryUrl.port === "" ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host;
402
+ }))) return false;
403
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
404
+ const origin = request.headers.origin;
405
+ if (origin === void 0) return true;
406
+ try {
407
+ return new URL(origin).host === hostUrl.host;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+ /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
413
+ const MAX_BODY_BYTES = 4096;
414
+ /**
415
+ * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
416
+ * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
417
+ * a malformed config then simply contributes no fence entry (and the panel
418
+ * falls back to LAN-only URLs).
419
+ * @param url - the configured public base URL (or undefined).
420
+ * @returns the `host[:port]` authority the fence should trust.
421
+ */
422
+ function publicHostOf(url) {
423
+ if (url === void 0) return void 0;
424
+ try {
425
+ return new URL(url).host;
426
+ } catch {
427
+ return;
428
+ }
429
+ }
430
+ /** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
431
+ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
432
+ /** Route paths (exact matches under /api). */
433
+ const PAIR_PATHS = {
434
+ issue: "/api/pair/issue",
435
+ accept: "/api/pair/accept",
436
+ stop: "/api/pair/stop",
437
+ heartbeat: "/api/pair/heartbeat",
438
+ status: "/api/pair/status",
439
+ events: "/api/pair/events"
440
+ };
441
+ /** One JSON response. */
442
+ function writeJson(res, status, body) {
443
+ const payload = JSON.stringify(body);
444
+ res.writeHead(status, {
445
+ "content-type": "application/json; charset=utf-8",
446
+ "referrer-policy": "no-referrer"
447
+ });
448
+ res.end(payload);
449
+ }
450
+ /** Read a request body up to MAX_BODY_BYTES and parse it as JSON. */
451
+ async function readJsonBody$1(req) {
452
+ const chunks = [];
453
+ let size = 0;
454
+ for await (const chunk of req) {
455
+ const buffer = chunk;
456
+ size += buffer.length;
457
+ if (size > MAX_BODY_BYTES) return void 0;
458
+ chunks.push(buffer);
459
+ }
460
+ try {
461
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
462
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
463
+ } catch {
464
+ return;
465
+ }
466
+ }
467
+ /** The SSE fan-out for desktop panel status. */
468
+ var PairingEventsStream = class {
469
+ streams = /* @__PURE__ */ new Set();
470
+ /**
471
+ * @param service - the pairing service whose snapshots are fanned out.
472
+ */
473
+ constructor(service) {
474
+ service.onState((snapshot) => {
475
+ this.push(snapshot);
476
+ });
477
+ }
478
+ /** Open one stream; the response is owned to completion. */
479
+ open(req, res) {
480
+ res.writeHead(200, {
481
+ "content-type": "text/event-stream; charset=utf-8",
482
+ "cache-control": "no-cache",
483
+ connection: "keep-alive"
484
+ });
485
+ const stream = {
486
+ res,
487
+ closed: false
488
+ };
489
+ this.streams.add(stream);
490
+ const close = () => {
491
+ if (stream.closed) return;
492
+ stream.closed = true;
493
+ this.streams.delete(stream);
494
+ };
495
+ res.on("close", close);
496
+ req.on("close", close);
497
+ }
498
+ /** Push one frame to every open stream (contained per stream). */
499
+ push(snapshot) {
500
+ const frame = `data: ${JSON.stringify({
501
+ type: "state",
502
+ ...snapshot
503
+ })}\n\n`;
504
+ for (const stream of this.streams) try {
505
+ stream.res.write(frame);
506
+ } catch {
507
+ stream.closed = true;
508
+ this.streams.delete(stream);
509
+ }
510
+ }
511
+ /** Stream count (tests/diagnostics). */
512
+ get size() {
513
+ return this.streams.size;
514
+ }
515
+ };
516
+ /**
517
+ * Build the /api/pair route family.
518
+ * @param deps - service + fence inputs.
519
+ * @returns the exact routes to register on webServer.
520
+ */
521
+ function makeRoutes(deps) {
522
+ const { service, lanAddresses } = deps;
523
+ const events = new PairingEventsStream(service);
524
+ /** Loopback-only fence: the desktop panel's control endpoints. */
525
+ const loopbackFence = (req) => isTrustedApiRequest(req, []);
526
+ /** Phone-facing fence: loopback, the derived LAN literals, or the configured public host. */
527
+ const lanFence = (req) => {
528
+ const publicHost = publicHostOf(service.publicBaseUrl);
529
+ return isTrustedApiRequest(req, publicHost === void 0 ? lanAddresses : [...lanAddresses, publicHost]);
530
+ };
531
+ const requireMethod = (req, res, method) => {
532
+ if (req.method === method) return true;
533
+ res.writeHead(405);
534
+ res.end();
535
+ return false;
536
+ };
537
+ /** Per-source-IP accept rate limit (brute-force defense in depth). */
538
+ const acceptAttempts = /* @__PURE__ */ new Map();
539
+ const ACCEPT_MAX_ATTEMPTS = 10;
540
+ const ACCEPT_WINDOW_MS = 3e4;
541
+ const rateLimitAccept = (req) => {
542
+ const ip = req.socket?.remoteAddress ?? "unknown";
543
+ const nowMs = Date.now();
544
+ const entry = acceptAttempts.get(ip);
545
+ if (entry === void 0 || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
546
+ acceptAttempts.set(ip, {
547
+ count: 1,
548
+ windowStart: nowMs
549
+ });
550
+ return false;
551
+ }
552
+ entry.count += 1;
553
+ return entry.count > ACCEPT_MAX_ATTEMPTS;
554
+ };
555
+ const handleIssue = async (req, res) => {
556
+ if (!requireMethod(req, res, "POST")) return;
557
+ if (!loopbackFence(req)) {
558
+ writeJson(res, 403, {
559
+ ok: false,
560
+ code: "forbidden"
561
+ });
562
+ return;
563
+ }
564
+ const body = await readJsonBody$1(req);
565
+ const workspaceId = body === void 0 || typeof body.workspaceId !== "string" || body.workspaceId === "" ? void 0 : body.workspaceId;
566
+ const address = body === void 0 || typeof body.address !== "string" || body.address === "" ? void 0 : body.address;
567
+ try {
568
+ const { token, expiresAt } = service.issue(workspaceId, address);
569
+ const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
570
+ if (base === void 0) throw new Error("remote-web-ui: base unavailable");
571
+ writeJson(res, 200, {
572
+ ok: true,
573
+ url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
574
+ token,
575
+ expiresAt,
576
+ lanAddresses: service.lanAddresses,
577
+ ...service.publicBaseUrl !== void 0 ? { publicBaseUrl: service.publicBaseUrl } : {}
578
+ });
579
+ } catch (error) {
580
+ const unknownAddress = error instanceof UnknownLanAddressError;
581
+ writeJson(res, unknownAddress ? 400 : 409, {
582
+ ok: false,
583
+ code: unknownAddress ? "unknown-address" : "lan-required"
584
+ });
585
+ }
586
+ };
587
+ const handleAccept = async (req, res) => {
588
+ if (!requireMethod(req, res, "POST")) return;
589
+ if (!lanFence(req)) {
590
+ writeJson(res, 403, {
591
+ ok: false,
592
+ code: "forbidden"
593
+ });
594
+ return;
595
+ }
596
+ if (rateLimitAccept(req)) {
597
+ writeJson(res, 429, {
598
+ ok: false,
599
+ code: "rate-limited"
600
+ });
601
+ return;
602
+ }
603
+ const body = await readJsonBody$1(req);
604
+ const token = typeof body?.token === "string" ? body.token : "";
605
+ const result = service.accept(token);
606
+ if (!result.ok) {
607
+ writeJson(res, result.code === "used" ? 409 : 404, {
608
+ ok: false,
609
+ code: result.code
610
+ });
611
+ return;
612
+ }
613
+ res.writeHead(200, {
614
+ "content-type": "application/json; charset=utf-8",
615
+ "set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
616
+ });
617
+ res.end(JSON.stringify({
618
+ ok: true,
619
+ deviceId: result.deviceId
620
+ }));
621
+ };
622
+ const handleStop = async (req, res) => {
623
+ if (!requireMethod(req, res, "POST")) return;
624
+ if (!loopbackFence(req)) {
625
+ writeJson(res, 403, {
626
+ ok: false,
627
+ code: "forbidden"
628
+ });
629
+ return;
630
+ }
631
+ await readJsonBody$1(req);
632
+ service.stop();
633
+ writeJson(res, 200, { ok: true });
634
+ };
635
+ const handleHeartbeat = async (req, res) => {
636
+ if (!requireMethod(req, res, "POST")) return;
637
+ if (!lanFence(req)) {
638
+ writeJson(res, 403, {
639
+ ok: false,
640
+ code: "forbidden"
641
+ });
642
+ return;
643
+ }
644
+ await readJsonBody$1(req);
645
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
646
+ if (deviceId === void 0 || !service.heartbeat(deviceId)) {
647
+ writeJson(res, 401, {
648
+ ok: false,
649
+ code: "unpaired"
650
+ });
651
+ return;
652
+ }
653
+ writeJson(res, 200, { ok: true });
654
+ };
655
+ const handleStatus = async (req, res) => {
656
+ if (!requireMethod(req, res, "GET")) return;
657
+ if (!lanFence(req)) {
658
+ writeJson(res, 403, {
659
+ ok: false,
660
+ code: "forbidden"
661
+ });
662
+ return;
663
+ }
664
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
665
+ writeJson(res, 200, {
666
+ ok: true,
667
+ paired: deviceId !== void 0 && service.hasDevice(deviceId),
668
+ ...service.snapshot()
669
+ });
670
+ };
671
+ const handleEvents = (req, res) => {
672
+ if (!requireMethod(req, res, "GET")) return;
673
+ if (!loopbackFence(req)) {
674
+ writeJson(res, 403, {
675
+ ok: false,
676
+ code: "forbidden"
677
+ });
678
+ return;
679
+ }
680
+ events.open(req, res);
681
+ events.push(service.snapshot());
682
+ };
683
+ return [
684
+ {
685
+ kind: "exact",
686
+ path: PAIR_PATHS.issue,
687
+ handler: handleIssue
688
+ },
689
+ {
690
+ kind: "exact",
691
+ path: PAIR_PATHS.accept,
692
+ handler: handleAccept
693
+ },
694
+ {
695
+ kind: "exact",
696
+ path: PAIR_PATHS.stop,
697
+ handler: handleStop
698
+ },
699
+ {
700
+ kind: "exact",
701
+ path: PAIR_PATHS.heartbeat,
702
+ handler: handleHeartbeat
703
+ },
704
+ {
705
+ kind: "exact",
706
+ path: PAIR_PATHS.status,
707
+ handler: handleStatus
708
+ },
709
+ {
710
+ kind: "exact",
711
+ path: PAIR_PATHS.events,
712
+ handler: handleEvents
713
+ }
714
+ ];
715
+ }
716
+ //#endregion
717
+ //#region src/mobile-routes.ts
718
+ /**
719
+ * The mobile surface's page routes: `/m` serves the standalone phone UI
720
+ * (an independent bundle, built to lib/mobile.js by the mobile tsdown
721
+ * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
722
+ * host exclusively through the shared /api transport (paired-device cookie
723
+ * already crosses the api/gate fence), so no host-side data plumbing is
724
+ * needed here — only static serving, loopback+paired-fence via the normal
725
+ * webserver route registration.
726
+ */
727
+ /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
728
+ function mobileBundlePath() {
729
+ return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
730
+ }
731
+ /** The mobile page shell: minimal, offline-safe, no external assets. */
732
+ function pageHtml(bundleUrl) {
733
+ return [
734
+ "<!doctype html>",
735
+ "<html lang=\"zh-CN\">",
736
+ "<head>",
737
+ "<meta charset=\"utf-8\">",
738
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
739
+ "<meta name=\"theme-color\" content=\"#f3f5f9\">",
740
+ "<meta name=\"referrer\" content=\"no-referrer\">",
741
+ "<title>移动端远程控制</title>",
742
+ "</head>",
743
+ "<body>",
744
+ "<div id=\"root\"></div>",
745
+ `<script type="module" src="${bundleUrl}"><\/script>`,
746
+ "</body>",
747
+ "</html>"
748
+ ].join("");
749
+ }
750
+ /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
751
+ function writeStatic(res, status, type, body) {
752
+ res.writeHead(status, {
753
+ "content-type": `${type}; charset=utf-8`,
754
+ "cache-control": "no-cache",
755
+ "referrer-policy": "no-referrer"
756
+ });
757
+ res.end(body);
758
+ }
759
+ /**
760
+ * Build the mobile page routes.
761
+ * @returns the two exact routes to register on webServer.
762
+ */
763
+ function makeMobileRoutes() {
764
+ const handlePage = (_req, res) => {
765
+ writeStatic(res, 200, "text/html", pageHtml("/m/mobile.js"));
766
+ };
767
+ const handleBundle = async (_req, res) => {
768
+ const path = mobileBundlePath();
769
+ if (!existsSync(path)) {
770
+ writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
771
+ return;
772
+ }
773
+ try {
774
+ writeStatic(res, 200, "text/javascript", await readFile(path, "utf8"));
775
+ } catch {
776
+ writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
777
+ }
778
+ };
779
+ return [{
780
+ kind: "exact",
781
+ path: "/m",
782
+ handler: handlePage
783
+ }, {
784
+ kind: "exact",
785
+ path: "/m/mobile.js",
786
+ handler: handleBundle
787
+ }];
788
+ }
789
+ //#endregion
790
+ //#region src/mobile-api.ts
791
+ /** Methods the phone surface may call. Everything else is refused. */
792
+ const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
793
+ "workspace.list",
794
+ "session.create",
795
+ "session.list",
796
+ "session.history",
797
+ "session.search",
798
+ "session.prompt",
799
+ "session.models",
800
+ "session.selectModel",
801
+ "session.rename"
802
+ ]);
803
+ /** One session.list page (thin phones load incrementally). */
804
+ const SESSION_PAGE_SIZE = 20;
805
+ /** Encode one list position as an opaque continuation cursor. */
806
+ function sessionListCursor(updatedAt, sessionId) {
807
+ return `${updatedAt}:${sessionId}`;
808
+ }
809
+ /** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
810
+ function parseSessionListCursor(cursor) {
811
+ const separator = cursor.indexOf(":");
812
+ if (separator < 0) return void 0;
813
+ const updatedAt = Number(cursor.slice(0, separator));
814
+ if (!Number.isFinite(updatedAt)) return void 0;
815
+ return {
816
+ updatedAt,
817
+ sessionId: cursor.slice(separator + 1)
818
+ };
819
+ }
820
+ /** Whether a row comes strictly after the cursor position. */
821
+ function afterCursor(row, position) {
822
+ return row.updatedAt < position.updatedAt || row.updatedAt === position.updatedAt && row.sessionId > position.sessionId;
823
+ }
824
+ /** Mobile API route paths. */
825
+ const MOBILE_API_PATHS = { events: "/m/api/events.mux" };
826
+ /** The mobile-api prefix (every other path under it is a method name). */
827
+ const MOBILE_API_PREFIX = "/m/api";
828
+ /** Method extraction: the prefix plus one slash. */
829
+ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
830
+ /**
831
+ * Build the mobile data-channel routes.
832
+ * @param deps - pairing service + apiProxy.
833
+ * @returns the routes to register on webServer.
834
+ */
835
+ function makeMobileApiRoutes(deps) {
836
+ const { service, apiProxy } = deps;
837
+ /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
838
+ const gateOk = (req) => {
839
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
840
+ return deviceId !== void 0 && service.hasDevice(deviceId);
841
+ };
842
+ const writeJson = (res, status, body) => {
843
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
844
+ res.end(JSON.stringify(body));
845
+ };
846
+ const handleMethod = async (req, res) => {
847
+ if (req.method !== "POST") {
848
+ res.writeHead(405);
849
+ res.end();
850
+ return;
851
+ }
852
+ if (!gateOk(req)) {
853
+ writeJson(res, 403, {
854
+ ok: false,
855
+ error: {
856
+ code: "unpaired",
857
+ message: "mobile session is not paired"
858
+ }
859
+ });
860
+ return;
861
+ }
862
+ const pathname = new URL(req.url ?? "/", "http://x").pathname;
863
+ if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
864
+ writeJson(res, 404, {
865
+ ok: false,
866
+ error: {
867
+ code: "not-found",
868
+ message: "unknown mobile api path"
869
+ }
870
+ });
871
+ return;
872
+ }
873
+ const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
874
+ if (!MOBILE_ALLOWLIST.has(method)) {
875
+ writeJson(res, 403, {
876
+ ok: false,
877
+ error: {
878
+ code: "forbidden",
879
+ message: `method ${method} is not exposed to the mobile surface`
880
+ }
881
+ });
882
+ return;
883
+ }
884
+ let envelope;
885
+ try {
886
+ envelope = await readJsonBody(req);
887
+ } catch {
888
+ writeJson(res, 400, {
889
+ ok: false,
890
+ error: {
891
+ code: "bad-request",
892
+ message: "invalid json body"
893
+ }
894
+ });
895
+ return;
896
+ }
897
+ const parsed = envelope;
898
+ const rpcId = typeof parsed?.rpcId === "string" ? parsed.rpcId : "";
899
+ if (rpcId === "") {
900
+ writeJson(res, 400, {
901
+ ok: false,
902
+ error: {
903
+ code: "bad-request",
904
+ message: "missing rpcId"
905
+ }
906
+ });
907
+ return;
908
+ }
909
+ try {
910
+ const response = await dispatch(apiProxy, method, parsed?.payload, rpcId);
911
+ writeJson(res, 200, response);
912
+ } catch (error) {
913
+ const message = error instanceof Error ? error.message : String(error);
914
+ writeJson(res, 200, {
915
+ type: "server-response",
916
+ rpcId,
917
+ result: {
918
+ ok: false,
919
+ error: {
920
+ code: "internal",
921
+ message
922
+ }
923
+ }
924
+ });
925
+ }
926
+ };
927
+ /** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
928
+ const handleEvents = async (req, res) => {
929
+ if (req.method !== "GET") {
930
+ res.writeHead(405);
931
+ res.end();
932
+ return;
933
+ }
934
+ if (!gateOk(req)) {
935
+ res.writeHead(403);
936
+ res.end("forbidden");
937
+ return;
938
+ }
939
+ res.writeHead(200, {
940
+ "content-type": "text/event-stream; charset=utf-8",
941
+ "cache-control": "no-cache",
942
+ connection: "keep-alive"
943
+ });
944
+ const controller = new AbortController();
945
+ let closed = false;
946
+ const heartbeat = setInterval(() => {
947
+ if (closed) return;
948
+ try {
949
+ res.write(": ping\n\n");
950
+ } catch {}
951
+ }, 15e3);
952
+ const onClose = () => {
953
+ if (closed) return;
954
+ closed = true;
955
+ controller.abort();
956
+ clearInterval(heartbeat);
957
+ };
958
+ res.on("close", onClose);
959
+ req.on("close", onClose);
960
+ try {
961
+ const frames = apiProxy.events.mux({
962
+ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`),
963
+ payload: {}
964
+ }, controller.signal);
965
+ for await (const frame of frames) {
966
+ if (closed) break;
967
+ res.write(`data: ${JSON.stringify(frame)}\n\n`);
968
+ }
969
+ } catch {} finally {
970
+ controller.abort();
971
+ clearInterval(heartbeat);
972
+ }
973
+ if (!closed) res.end();
974
+ };
975
+ return [{
976
+ kind: "prefix",
977
+ path: MOBILE_API_PREFIX,
978
+ handler: handleMethod
979
+ }, {
980
+ kind: "exact",
981
+ path: MOBILE_API_PATHS.events,
982
+ handler: handleEvents
983
+ }];
984
+ }
985
+ /** Read a request body as JSON (bounded). */
986
+ async function readJsonBody(req) {
987
+ const chunks = [];
988
+ let size = 0;
989
+ for await (const chunk of req) {
990
+ const buffer = chunk;
991
+ size += buffer.length;
992
+ if (size > 64 * 1024) throw new Error("body too large");
993
+ chunks.push(buffer);
994
+ }
995
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
996
+ }
997
+ /** Dispatch one allowlisted method through the host ApiProxy. */
998
+ async function dispatch(apiProxy, method, payload, rpcId) {
999
+ const request = {
1000
+ rpcId: RpcId(rpcId),
1001
+ payload
1002
+ };
1003
+ if (method === "session.list") {
1004
+ const full = await apiProxy.sessions.list(request);
1005
+ if (!full.result.ok) return full;
1006
+ const items = full.result.value.items;
1007
+ const cursor = payload?.cursor;
1008
+ items.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
1009
+ const position = cursor === void 0 ? void 0 : parseSessionListCursor(cursor);
1010
+ const from = position === void 0 ? 0 : items.findIndex((row) => afterCursor(row, position));
1011
+ const start = from < 0 ? items.length : from;
1012
+ const page = items.slice(start, start + SESSION_PAGE_SIZE);
1013
+ const last = page[page.length - 1];
1014
+ const nextCursor = last !== void 0 && start + page.length < items.length ? sessionListCursor(last.updatedAt, last.sessionId) : void 0;
1015
+ return {
1016
+ type: "server-response",
1017
+ rpcId,
1018
+ result: {
1019
+ ok: true,
1020
+ value: {
1021
+ items: page,
1022
+ hasMore: nextCursor !== void 0,
1023
+ ...nextCursor !== void 0 ? { nextCursor } : {}
1024
+ }
1025
+ }
1026
+ };
1027
+ }
1028
+ const wrap = (response) => ({
1029
+ type: "server-response",
1030
+ rpcId,
1031
+ result: response.result
1032
+ });
1033
+ if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
1034
+ if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
1035
+ if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
1036
+ if (method === "session.search") return wrap(await apiProxy.sessions.search(request, new AbortController().signal));
1037
+ if (method === "session.prompt") return wrap(await apiProxy.sessions.prompt(request));
1038
+ if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
1039
+ if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
1040
+ if (method === "session.rename") return wrap(await apiProxy.sessions.rename(request));
1041
+ throw new Error(`unhandled allowlisted method ${method}`);
1042
+ }
1043
+ //#endregion
1044
+ //#region src/lan.ts
1045
+ /**
1046
+ * LAN address derivation for the pairing URLs. Mirrors the dsh CLI's
1047
+ * boot-time sampling (apps/cli/src/app-cli-entry.ts `resolveLanTrust`): the
1048
+ * pairing links may only name addresses the /api trust fence was configured
1049
+ * with, so the same non-internal IPv4 derivation applies here — an external
1050
+ * plugin cannot read the CLI's sampled snapshot, but the fence accepts
1051
+ * exactly these literals, which is the property that matters.
1052
+ */
1053
+ /**
1054
+ * Non-internal IPv4 interface addresses of this machine — the IP-literal
1055
+ * authorities an all-interfaces bind is reachable by on the LAN.
1056
+ * @returns the addresses in interface order (possibly empty).
1057
+ */
1058
+ function lanIPv4Addresses() {
1059
+ return Object.values(networkInterfaces()).flat().filter((iface) => {
1060
+ return iface !== void 0 && iface.family === "IPv4" && !iface.internal;
1061
+ }).map((iface) => iface.address);
1062
+ }
1063
+ //#endregion
1064
+ //#region src/tunnel.ts
1065
+ /**
1066
+ * Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
1067
+ * tunnel --url <local>`) through the `cloudflared` npm package — its
1068
+ * postinstall downloads the platform binary, so no user-side tooling is
1069
+ * involved — surfaces the minted `https://xxx.trycloudflare.com` URL, and
1070
+ * restarts the process after unexpected exits with exponential backoff.
1071
+ *
1072
+ * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
1073
+ * owns the lifecycle policy (binary readiness, URL timeout, restart
1074
+ * backoff) around it. All seams — the tunnel factory, binary readiness,
1075
+ * timers — are injectable so the whole lifecycle is unit-testable without
1076
+ * a real binary or network.
1077
+ */
1078
+ /** Default binary readiness: download the platform binary on first use. */
1079
+ async function defaultEnsureBinary() {
1080
+ if (existsSync(bin)) return;
1081
+ await install(bin);
1082
+ }
1083
+ /** Default factory: the cloudflared package's quick tunnel (no account). */
1084
+ function defaultFactory(targetUrl) {
1085
+ return Tunnel.quick(targetUrl, { "--no-autoupdate": true });
1086
+ }
1087
+ /** Node timers. */
1088
+ const nodeTimer = {
1089
+ setTimeout,
1090
+ clearTimeout
1091
+ };
1092
+ /**
1093
+ * Own the lifecycle of one auto-tunnel: start/stop, URL surfacing, and
1094
+ * crash-restart backoff.
1095
+ */
1096
+ var TunnelManager = class {
1097
+ factory;
1098
+ ensureBinary;
1099
+ urlTimeoutMs;
1100
+ restartBaseMs;
1101
+ restartMaxMs;
1102
+ timer;
1103
+ phase = "stopped";
1104
+ url;
1105
+ error;
1106
+ targetUrl;
1107
+ handle;
1108
+ urlTimer;
1109
+ restartTimer;
1110
+ attempts = 0;
1111
+ stopping = false;
1112
+ urlListeners = /* @__PURE__ */ new Set();
1113
+ phaseListeners = /* @__PURE__ */ new Set();
1114
+ /**
1115
+ * @param options - seams; defaults spawn the real quick tunnel.
1116
+ */
1117
+ constructor(options = {}) {
1118
+ this.factory = options.factory ?? defaultFactory;
1119
+ this.ensureBinary = options.ensureBinary ?? defaultEnsureBinary;
1120
+ this.urlTimeoutMs = options.urlTimeoutMs ?? 3e4;
1121
+ this.restartBaseMs = options.restartBaseMs ?? 5e3;
1122
+ this.restartMaxMs = options.restartMaxMs ?? 6e4;
1123
+ this.timer = options.timer ?? nodeTimer;
1124
+ }
1125
+ /** The current status frame. */
1126
+ get info() {
1127
+ return {
1128
+ phase: this.phase,
1129
+ ...this.url !== void 0 ? { url: this.url } : {},
1130
+ ...this.error !== void 0 ? { error: this.error } : {}
1131
+ };
1132
+ }
1133
+ /**
1134
+ * Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
1135
+ * different target tears the old tunnel down first; restarting with the
1136
+ * same target while running is a no-op.
1137
+ * @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
1138
+ */
1139
+ start(targetUrl) {
1140
+ if (this.targetUrl === targetUrl && (this.phase === "starting" || this.phase === "running")) return;
1141
+ this.teardown();
1142
+ this.stopping = false;
1143
+ this.targetUrl = targetUrl;
1144
+ this.attempts = 0;
1145
+ this.attempt();
1146
+ }
1147
+ /** Stop the tunnel for good: no restarts, no state. */
1148
+ stop() {
1149
+ this.teardown();
1150
+ this.stopping = false;
1151
+ this.targetUrl = void 0;
1152
+ this.setPhase("stopped");
1153
+ }
1154
+ /** Alias of {@link stop} for plugin-effect disposal. */
1155
+ dispose() {
1156
+ this.stop();
1157
+ }
1158
+ /** Subscribe to minted tunnel URLs (fire-and-forget duplicates dropped). */
1159
+ onUrl(listener) {
1160
+ this.urlListeners.add(listener);
1161
+ return () => {
1162
+ this.urlListeners.delete(listener);
1163
+ };
1164
+ }
1165
+ /** Subscribe to every phase change. */
1166
+ onPhase(listener) {
1167
+ this.phaseListeners.add(listener);
1168
+ return () => {
1169
+ this.phaseListeners.delete(listener);
1170
+ };
1171
+ }
1172
+ attempt() {
1173
+ if (this.stopping || this.targetUrl === void 0) return;
1174
+ this.setPhase("starting");
1175
+ this.handle = void 0;
1176
+ this.url = void 0;
1177
+ this.error = void 0;
1178
+ this.ensureBinary().then(() => {
1179
+ if (this.stopping || this.targetUrl === void 0) return;
1180
+ const handle = this.factory(this.targetUrl);
1181
+ this.handle = handle;
1182
+ this.urlTimer = this.timer.setTimeout(() => {
1183
+ this.fail("timed out waiting for the tunnel URL");
1184
+ }, this.urlTimeoutMs);
1185
+ handle.on("url", (value) => {
1186
+ if (this.handle !== handle) return;
1187
+ this.handleUrl(value);
1188
+ });
1189
+ handle.on("exit", () => {
1190
+ if (this.handle !== handle) return;
1191
+ this.handleExit();
1192
+ });
1193
+ handle.on("error", (value) => {
1194
+ if (this.handle !== handle || this.phase !== "starting") return;
1195
+ this.error = value instanceof Error ? value.message : String(value);
1196
+ });
1197
+ }).catch((value) => {
1198
+ if (this.stopping || this.targetUrl === void 0) return;
1199
+ const message = value instanceof Error ? value.message : String(value);
1200
+ this.fail(`could not obtain the cloudflared binary: ${message}`);
1201
+ });
1202
+ }
1203
+ handleUrl(value) {
1204
+ if (this.urlTimer !== void 0) {
1205
+ this.timer.clearTimeout(this.urlTimer);
1206
+ this.urlTimer = void 0;
1207
+ }
1208
+ this.url = value;
1209
+ this.error = void 0;
1210
+ this.attempts = 0;
1211
+ this.setPhase("running");
1212
+ for (const listener of this.urlListeners) try {
1213
+ listener(value);
1214
+ } catch {}
1215
+ }
1216
+ handleExit() {
1217
+ if (this.stopping) return;
1218
+ this.fail("the tunnel process exited unexpectedly");
1219
+ }
1220
+ fail(message) {
1221
+ if (this.stopping) return;
1222
+ this.url = void 0;
1223
+ this.error = message;
1224
+ if (this.handle !== void 0) {
1225
+ this.handle.stop();
1226
+ this.handle = void 0;
1227
+ }
1228
+ if (this.urlTimer !== void 0) {
1229
+ this.timer.clearTimeout(this.urlTimer);
1230
+ this.urlTimer = void 0;
1231
+ }
1232
+ this.setPhase("failed");
1233
+ this.attempts += 1;
1234
+ const delay = Math.min(this.restartBaseMs * 2 ** (this.attempts - 1), this.restartMaxMs);
1235
+ this.restartTimer = this.timer.setTimeout(() => {
1236
+ this.restartTimer = void 0;
1237
+ this.attempt();
1238
+ }, delay);
1239
+ }
1240
+ /** Stop the current process and cancel every pending timer (no phase change). */
1241
+ teardown() {
1242
+ this.stopping = true;
1243
+ if (this.urlTimer !== void 0) {
1244
+ this.timer.clearTimeout(this.urlTimer);
1245
+ this.urlTimer = void 0;
1246
+ }
1247
+ if (this.restartTimer !== void 0) {
1248
+ this.timer.clearTimeout(this.restartTimer);
1249
+ this.restartTimer = void 0;
1250
+ }
1251
+ if (this.handle !== void 0) {
1252
+ this.handle.stop();
1253
+ this.handle = void 0;
1254
+ }
1255
+ }
1256
+ setPhase(phase) {
1257
+ this.phase = phase;
1258
+ const info = this.info;
1259
+ for (const listener of this.phaseListeners) try {
1260
+ listener(info);
1261
+ } catch {}
1262
+ }
1263
+ };
1264
+ //#endregion
1265
+ //#region src/index.ts
1266
+ /**
1267
+ * Mobile remote control for the dsh web GUI — host half. Mounts the pairing
1268
+ * service (one-time tokens, device sessions, revocation), the /api/pair
1269
+ * route family (issue/accept/stop/heartbeat/status/events), the api/gate
1270
+ * listener that enforces pairing on every other /api request from
1271
+ * non-loopback hosts, and the presence sweep. The browser half (the
1272
+ * `./client` entry) renders the sidebar entry, the pairing panel, and the
1273
+ * phone-side pair/accept + deep-link flow.
1274
+ */
1275
+ /** Stable cordis plugin name. */
1276
+ const name = "remote-web-ui";
1277
+ /** Services required before the pairing surfaces can mount. */
1278
+ const inject = ["webServer", "apiProxy"];
1279
+ /**
1280
+ * Settings namespace of the remote-control capability — the section the web
1281
+ * settings surface edits. Spelled here rather than imported: the browser
1282
+ * half spells the same value and must not depend on a Host package.
1283
+ */
1284
+ const REMOTE_WEB_UI_SETTINGS_NAMESPACE = settingsNamespace("remote-web-ui");
1285
+ const Config = z.object({
1286
+ tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
1287
+ offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
1288
+ maxDevices: z.number().step(1).min(1).max(64).default(4),
1289
+ cookieName: z.string().min(1).default("dsh_pair"),
1290
+ requirePairingForLan: z.boolean().default(true),
1291
+ publicBaseUrl: z.string(),
1292
+ autoTunnel: z.boolean().default(false),
1293
+ enabled: z.boolean().default(true)
1294
+ });
1295
+ /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
1296
+ const SWEEP_INTERVAL_MS = 1e4;
1297
+ /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
1298
+ const DEFAULTS = {
1299
+ tokenTtlMs: 10 * 6e4,
1300
+ offlineAfterMs: 25e3,
1301
+ maxDevices: 4,
1302
+ cookieName: "dsh_pair",
1303
+ requirePairingForLan: true,
1304
+ publicBaseUrl: void 0,
1305
+ autoTunnel: false,
1306
+ enabled: true
1307
+ };
1308
+ /**
1309
+ * Mount the pairing service, routes, gate listener, and presence sweep.
1310
+ * @param ctx - host plugin context carrying webServer.
1311
+ * @param config - resolved plugin config (schema defaults applied by the loader).
1312
+ */
1313
+ function apply(ctx, config) {
1314
+ const resolved = {
1315
+ tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
1316
+ offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
1317
+ maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
1318
+ cookieName: config?.cookieName ?? DEFAULTS.cookieName,
1319
+ requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
1320
+ publicBaseUrl: config?.publicBaseUrl,
1321
+ autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
1322
+ enabled: config?.enabled ?? DEFAULTS.enabled
1323
+ };
1324
+ let current = () => config ?? {};
1325
+ const resolve = () => {
1326
+ const value = current();
1327
+ return {
1328
+ tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
1329
+ offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
1330
+ maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
1331
+ cookieName: value.cookieName ?? DEFAULTS.cookieName,
1332
+ requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
1333
+ publicBaseUrl: value.publicBaseUrl,
1334
+ autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
1335
+ enabled: value.enabled ?? DEFAULTS.enabled
1336
+ };
1337
+ };
1338
+ const service = new PairingService({
1339
+ tokenTtlMs: resolved.tokenTtlMs,
1340
+ offlineAfterMs: resolved.offlineAfterMs,
1341
+ maxDevices: resolved.maxDevices,
1342
+ cookieName: resolved.cookieName
1343
+ });
1344
+ const tunnel = new TunnelManager();
1345
+ let autoTunnel = resolved.autoTunnel;
1346
+ tunnel.onPhase((info) => {
1347
+ if (!autoTunnel) return;
1348
+ if (info.phase === "running" && info.url !== void 0) {
1349
+ service.setPublicBaseUrl(info.url);
1350
+ service.setTunnelStatus({
1351
+ state: "running",
1352
+ url: info.url
1353
+ });
1354
+ } else if (info.phase === "starting") {
1355
+ service.setPublicBaseUrl(void 0);
1356
+ service.setTunnelStatus({ state: "starting" });
1357
+ } else if (info.phase === "failed") {
1358
+ service.setPublicBaseUrl(void 0);
1359
+ service.setTunnelStatus(info.error === void 0 ? { state: "failed" } : {
1360
+ state: "failed",
1361
+ error: info.error
1362
+ });
1363
+ }
1364
+ });
1365
+ ctx.effect(() => () => {
1366
+ tunnel.dispose();
1367
+ }, "remote-web-ui: auto tunnel");
1368
+ const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
1369
+ address,
1370
+ base: `http://${address}:${String(ctx.webServer.port)}`
1371
+ })) : [];
1372
+ service.setLanBases(lanBases);
1373
+ const lanAddresses = lanBases.map((entry) => entry.address);
1374
+ let disposeRoutes;
1375
+ let disposeSweep;
1376
+ const apiProxy = ctx.get("apiProxy");
1377
+ if (apiProxy === void 0) console.warn("remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled");
1378
+ const routes = [
1379
+ ...makeRoutes({
1380
+ service,
1381
+ lanAddresses
1382
+ }),
1383
+ ...makeMobileRoutes(),
1384
+ ...apiProxy !== void 0 ? makeMobileApiRoutes({
1385
+ service,
1386
+ apiProxy
1387
+ }) : []
1388
+ ];
1389
+ const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
1390
+ ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
1391
+ const sync = () => {
1392
+ const value = resolve();
1393
+ service.config = {
1394
+ tokenTtlMs: value.tokenTtlMs,
1395
+ offlineAfterMs: value.offlineAfterMs,
1396
+ maxDevices: value.maxDevices,
1397
+ cookieName: value.cookieName
1398
+ };
1399
+ autoTunnel = value.autoTunnel === true;
1400
+ if (autoTunnel) {
1401
+ if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
1402
+ tunnel.start(`http://127.0.0.1:${String(ctx.webServer.port)}`);
1403
+ } else {
1404
+ tunnel.stop();
1405
+ if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
1406
+ console.warn(`remote-web-ui: ignoring malformed publicBaseUrl ${JSON.stringify(value.publicBaseUrl)} (expected https://host[:port])`);
1407
+ service.setPublicBaseUrl(void 0);
1408
+ } else service.setPublicBaseUrl(value.publicBaseUrl);
1409
+ }
1410
+ const enabled = value.enabled;
1411
+ if (!enabled) service.stop();
1412
+ if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
1413
+ const disposers = routes.map((route) => ctx.webServer.register(route));
1414
+ return () => {
1415
+ for (const dispose of disposers) dispose();
1416
+ };
1417
+ }, "remote-web-ui: pairing routes");
1418
+ else if (disposeRoutes !== void 0 && !enabled) {
1419
+ disposeRoutes();
1420
+ disposeRoutes = void 0;
1421
+ }
1422
+ if (disposeSweep === void 0 && enabled) disposeSweep = ctx.effect(() => {
1423
+ const timer = setInterval$1(() => {
1424
+ service.sweep();
1425
+ }, SWEEP_INTERVAL_MS);
1426
+ timer.unref();
1427
+ return () => {
1428
+ clearInterval(timer);
1429
+ };
1430
+ }, "remote-web-ui: presence sweep");
1431
+ else if (disposeSweep !== void 0 && !enabled) {
1432
+ disposeSweep();
1433
+ disposeSweep = void 0;
1434
+ }
1435
+ };
1436
+ installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
1437
+ setSource: (source) => {
1438
+ current = source;
1439
+ sync();
1440
+ },
1441
+ onChange: sync
1442
+ });
1443
+ sync();
1444
+ }
1445
+ /** Whether a configured public base is a parseable http(s) URL with a host. */
1446
+ function isHttpUrl(value) {
1447
+ try {
1448
+ const url = new URL(value);
1449
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
1450
+ } catch {
1451
+ return false;
1452
+ }
1453
+ }
1454
+ //#endregion
1455
+ export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name };