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