@linxin666/dsh-remote-web-ui 0.2.8 → 0.3.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/LICENSE +202 -28
- package/README.i18n.yaml +2 -2
- package/README.md +51 -15
- package/README.zh.md +9 -4
- package/lib/client.js +65 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -77
- package/lib/mobile.js +874 -64
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +35 -5
- package/lib/types/client/remote-channel.d.ts +4 -5
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +13 -28
- package/lib/types/http.d.ts +37 -8
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +76 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +40 -5
- package/lib/types/mobile/api.d.ts +57 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +29 -0
- package/lib/types/mobile/markdown.d.ts +7 -1
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +16 -7
- package/lib/types/mobile/markdown.test.js +8 -0
- 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 +325 -6
- package/lib/types/mobile/views/ChatView.d.ts +10 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +205 -8
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +46 -5
- package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
- package/lib/types/mobile-api.d.ts +9 -3
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +61 -16
- package/lib/types/mobile-pending.d.ts +38 -0
- package/lib/types/mobile-pending.d.ts.map +1 -0
- package/lib/types/mobile-pending.js +59 -0
- package/lib/types/paired-model-catalog.d.ts +17 -0
- package/lib/types/paired-model-catalog.d.ts.map +1 -0
- package/lib/types/paired-model-catalog.js +252 -0
- package/lib/types/pairing.d.ts +13 -4
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -5
- package/lib/types/remote-api.d.ts +13 -4
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +27 -17
- package/lib/types/remote-channel-boot.d.ts +26 -0
- package/lib/types/remote-channel-boot.d.ts.map +1 -0
- package/lib/types/remote-channel-boot.js +128 -0
- package/lib/types/remote-channel-rules.d.ts +40 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -0
- package/lib/types/remote-channel-rules.js +32 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +12 -23
- package/lib/types/uuid-polyfill.d.ts +10 -0
- package/lib/types/uuid-polyfill.d.ts.map +1 -0
- package/lib/types/uuid-polyfill.js +27 -0
- package/package.json +1 -1
- package/src/client/index.ts +41 -5
- package/src/client/remote-channel.ts +18 -28
- package/src/http.ts +87 -12
- package/src/index.ts +42 -6
- package/src/mobile/api.ts +91 -0
- package/src/mobile/markdown.test.ts +9 -0
- package/src/mobile/markdown.ts +17 -7
- package/src/mobile/mobile-styles.ts +326 -6
- package/src/mobile/views/ChatView.stream.test.tsx +231 -0
- package/src/mobile/views/ChatView.tsx +328 -10
- package/src/mobile/views/WorkspaceView.test.tsx +163 -2
- package/src/mobile/views/WorkspaceView.tsx +152 -26
- package/src/mobile-api.ts +59 -17
- package/src/mobile-pending.ts +88 -0
- package/src/paired-model-catalog.ts +224 -0
- package/src/pairing.ts +13 -5
- package/src/remote-api.ts +34 -18
- package/src/remote-channel-boot.ts +131 -0
- package/src/remote-channel-rules.ts +62 -0
- package/src/routes.ts +13 -24
- package/src/uuid-polyfill.ts +28 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote-channel rewrite contract as pure data (issue #987): both the
|
|
3
|
+
* browser patch (client/remote-channel.ts) and the parse-time boot patch
|
|
4
|
+
* (remote-channel-boot.ts, inlined into index.html by the host) decide from
|
|
5
|
+
* these tables, so the two can never drift apart.
|
|
6
|
+
* @module @linxin666/dsh-remote-web-ui/remote-channel-rules
|
|
7
|
+
*/
|
|
8
|
+
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
9
|
+
export declare const REMOTE_PREFIX = "/remote";
|
|
10
|
+
/** Connection-plugin method prefix under the gated channel. */
|
|
11
|
+
export declare const REMOTE_API_PREFIX = "/remote/api";
|
|
12
|
+
/** Every decision input of the remote-channel rewrite, JSON-serializable. */
|
|
13
|
+
export interface RemoteChannelRules {
|
|
14
|
+
readonly remotePrefix: string;
|
|
15
|
+
readonly apiPrefix: string;
|
|
16
|
+
readonly pairPrefix: string;
|
|
17
|
+
readonly updatePrefix: string;
|
|
18
|
+
readonly desktopLauncherPrefix: string;
|
|
19
|
+
readonly settingsBridgePrefix: string;
|
|
20
|
+
readonly sidebarPrefix: string;
|
|
21
|
+
readonly gitPrefix: string;
|
|
22
|
+
readonly petPrefix: string;
|
|
23
|
+
readonly wsPaths: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/** The live rule set. */
|
|
26
|
+
export declare const REMOTE_CHANNEL_RULES: RemoteChannelRules;
|
|
27
|
+
/** The window global the boot patch publishes its seat under. */
|
|
28
|
+
export declare const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
29
|
+
/**
|
|
30
|
+
* The seat the parse-time boot patch installs: hook seats the plugin's
|
|
31
|
+
* client apply adopts, a pending-unpaired flag for signals raised before
|
|
32
|
+
* adoption, and restore() retiring the patch (also removes the global).
|
|
33
|
+
*/
|
|
34
|
+
export interface RemoteChannelBootSeat {
|
|
35
|
+
onUnpaired: (() => void) | null;
|
|
36
|
+
onPaired: (() => void) | null;
|
|
37
|
+
pendingUnpaired: boolean;
|
|
38
|
+
restore(): void;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=remote-channel-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAA;IACtC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CACpC;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBAiBlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote-channel rewrite contract as pure data (issue #987): both the
|
|
3
|
+
* browser patch (client/remote-channel.ts) and the parse-time boot patch
|
|
4
|
+
* (remote-channel-boot.ts, inlined into index.html by the host) decide from
|
|
5
|
+
* these tables, so the two can never drift apart.
|
|
6
|
+
* @module @linxin666/dsh-remote-web-ui/remote-channel-rules
|
|
7
|
+
*/
|
|
8
|
+
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
9
|
+
export const REMOTE_PREFIX = '/remote';
|
|
10
|
+
/** Connection-plugin method prefix under the gated channel. */
|
|
11
|
+
export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
|
|
12
|
+
/** The live rule set. */
|
|
13
|
+
export const REMOTE_CHANNEL_RULES = {
|
|
14
|
+
remotePrefix: REMOTE_PREFIX,
|
|
15
|
+
apiPrefix: '/api/',
|
|
16
|
+
pairPrefix: '/api/pair/',
|
|
17
|
+
updatePrefix: '/api/update/',
|
|
18
|
+
desktopLauncherPrefix: '/api/dsh-desktop-launcher',
|
|
19
|
+
settingsBridgePrefix: '/api/dsh-web-ui-settings',
|
|
20
|
+
sidebarPrefix: '/sidebar/',
|
|
21
|
+
gitPrefix: '/git/',
|
|
22
|
+
petPrefix: '/pet/',
|
|
23
|
+
wsPaths: [
|
|
24
|
+
'/api/events.mux',
|
|
25
|
+
'/api/events.host',
|
|
26
|
+
'/sidebar/ws/terminal',
|
|
27
|
+
'/sidebar/ws/agent-terminals',
|
|
28
|
+
'/api/dsh-ssh/terminal',
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
/** The window global the boot patch publishes its seat under. */
|
|
32
|
+
export const REMOTE_CHANNEL_BOOT_GLOBAL = '__DSH_REMOTE_CHANNEL_BOOT__';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AAoBjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,sEAAsE;IACtE,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;CACjD;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAkO3D"}
|
package/lib/types/routes.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { UnknownLanAddressError } from "./pairing.js";
|
|
12
12
|
import { isLoopbackClient, readCookie } from "./gate.js";
|
|
13
|
-
import {
|
|
13
|
+
import { readJsonBody, writeJson } from "./http.js";
|
|
14
14
|
/**
|
|
15
15
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
16
16
|
* package's internal fence semantics (Host/Origin based, DNS-rebinding and
|
|
@@ -113,25 +113,16 @@ export const revokePayloadSchema = z.object({
|
|
|
113
113
|
});
|
|
114
114
|
export const pairActionPayloadSchema = z.object({}).passthrough();
|
|
115
115
|
/**
|
|
116
|
-
* Parse a pair request body through schema. A missing/empty
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
116
|
+
* Parse a pair request body through schema. A missing/empty, unparseable
|
|
117
|
+
* or non-object body (shared readJsonBody with objectOnly yields null for
|
|
118
|
+
* all of them) is treated as an empty object — the desktop stop/heartbeat
|
|
119
|
+
* send no body — and a value that fails the schema returns `undefined` so
|
|
120
|
+
* the caller can answer with the existing error shape.
|
|
120
121
|
*/
|
|
121
122
|
function parsePairPayload(schema, body) {
|
|
122
123
|
const result = schema.safeParse(body ?? {});
|
|
123
124
|
return result.success ? result.data : undefined;
|
|
124
125
|
}
|
|
125
|
-
/** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
|
|
126
|
-
async function readJsonBody(req) {
|
|
127
|
-
try {
|
|
128
|
-
const parsed = await readBoundedJson(req, MAX_BODY_BYTES);
|
|
129
|
-
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
126
|
/** The SSE fan-out for desktop panel status. */
|
|
136
127
|
export class PairingEventsStream {
|
|
137
128
|
streams = new Set();
|
|
@@ -242,7 +233,7 @@ export function makeRoutes(deps) {
|
|
|
242
233
|
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
243
234
|
return;
|
|
244
235
|
}
|
|
245
|
-
const body = await readJsonBody(req);
|
|
236
|
+
const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
|
|
246
237
|
const payload = parsePairPayload(issuePayloadSchema, body);
|
|
247
238
|
if (payload === undefined) {
|
|
248
239
|
writeJson(res, 400, { ok: false, code: 'bad-payload' });
|
|
@@ -292,7 +283,7 @@ export function makeRoutes(deps) {
|
|
|
292
283
|
writeJson(res, 429, { ok: false, code: 'rate-limited' });
|
|
293
284
|
return;
|
|
294
285
|
}
|
|
295
|
-
const body = await readJsonBody(req);
|
|
286
|
+
const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
|
|
296
287
|
const payload = parsePairPayload(acceptPayloadSchema, body);
|
|
297
288
|
if (payload === undefined) {
|
|
298
289
|
writeJson(res, 400, { ok: false, code: 'bad-payload' });
|
|
@@ -307,13 +298,11 @@ export function makeRoutes(deps) {
|
|
|
307
298
|
// No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
|
|
308
299
|
// work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
|
|
309
300
|
// top-level navigations working while blocking cross-site subrequests.
|
|
310
|
-
res
|
|
311
|
-
'content-type': 'application/json; charset=utf-8',
|
|
301
|
+
writeJson(res, 200, { ok: true, deviceId: result.deviceId }, {
|
|
312
302
|
'set-cookie': [
|
|
313
303
|
`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
314
304
|
],
|
|
315
305
|
});
|
|
316
|
-
res.end(JSON.stringify({ ok: true, deviceId: result.deviceId }));
|
|
317
306
|
};
|
|
318
307
|
const handleStop = async (req, res) => {
|
|
319
308
|
if (!requireMethod(req, res, 'POST'))
|
|
@@ -322,7 +311,7 @@ export function makeRoutes(deps) {
|
|
|
322
311
|
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
323
312
|
return;
|
|
324
313
|
}
|
|
325
|
-
const body = await readJsonBody(req);
|
|
314
|
+
const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
|
|
326
315
|
if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
|
|
327
316
|
writeJson(res, 400, { ok: false, code: 'bad-payload' });
|
|
328
317
|
return;
|
|
@@ -337,7 +326,7 @@ export function makeRoutes(deps) {
|
|
|
337
326
|
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
338
327
|
return;
|
|
339
328
|
}
|
|
340
|
-
const body = await readJsonBody(req);
|
|
329
|
+
const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
|
|
341
330
|
const payload = parsePairPayload(revokePayloadSchema, body);
|
|
342
331
|
if (payload === undefined) {
|
|
343
332
|
writeJson(res, 400, { ok: false, code: 'bad-payload' });
|
|
@@ -357,7 +346,7 @@ export function makeRoutes(deps) {
|
|
|
357
346
|
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
358
347
|
return;
|
|
359
348
|
}
|
|
360
|
-
const body = await readJsonBody(req);
|
|
349
|
+
const body = await readJsonBody(req, { maxBytes: MAX_BODY_BYTES, objectOnly: true });
|
|
361
350
|
if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
|
|
362
351
|
writeJson(res, 400, { ok: false, code: 'bad-payload' });
|
|
363
352
|
return;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polyfill for crypto.randomUUID for non-secure contexts (LAN HTTP)
|
|
3
|
+
* where the browser does not expose crypto.randomUUID.
|
|
4
|
+
*
|
|
5
|
+
* The script string is injected as an inline <script> in the desktop HTML
|
|
6
|
+
* head so the SDK's mintRpcId() sees a working randomUUID even on plain
|
|
7
|
+
* http:// origins (#1024).
|
|
8
|
+
*/
|
|
9
|
+
export declare const UUID_POLYFILL_SCRIPT: string;
|
|
10
|
+
//# sourceMappingURL=uuid-polyfill.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uuid-polyfill.d.ts","sourceRoot":"","sources":["../../src/uuid-polyfill.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,eAAO,MAAM,oBAAoB,QAkBvB,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polyfill for crypto.randomUUID for non-secure contexts (LAN HTTP)
|
|
3
|
+
* where the browser does not expose crypto.randomUUID.
|
|
4
|
+
*
|
|
5
|
+
* The script string is injected as an inline <script> in the desktop HTML
|
|
6
|
+
* head so the SDK's mintRpcId() sees a working randomUUID even on plain
|
|
7
|
+
* http:// origins (#1024).
|
|
8
|
+
*/
|
|
9
|
+
export const UUID_POLYFILL_SCRIPT = [
|
|
10
|
+
'(function(){',
|
|
11
|
+
'var g=typeof globalThis!=="undefined"?globalThis:typeof window!=="undefined"?window:self;',
|
|
12
|
+
'if(typeof g.crypto==="undefined"){g.crypto={};}',
|
|
13
|
+
'if(typeof g.crypto.randomUUID!=="function"){',
|
|
14
|
+
'g.crypto.randomUUID=function(){',
|
|
15
|
+
'var a=new Uint8Array(16);',
|
|
16
|
+
'if(g.crypto.getRandomValues){g.crypto.getRandomValues(a);}',
|
|
17
|
+
'else{for(var j=0;j<16;j++){a[j]=Math.random()*256|0;}}',
|
|
18
|
+
'a[6]=(a[6]&0x0f)|0x40;',
|
|
19
|
+
'a[8]=(a[8]&0x3f)|0x80;',
|
|
20
|
+
'var h="",x="0123456789abcdef";',
|
|
21
|
+
'for(var i=0;i<16;i++){h+=x[a[i]>>4]+x[a[i]&0x0f];',
|
|
22
|
+
'if(i===3||i===5||i===7||i===9)h+="-";}',
|
|
23
|
+
'return h;',
|
|
24
|
+
'};',
|
|
25
|
+
'}',
|
|
26
|
+
'})();',
|
|
27
|
+
].join('');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings }
|
|
|
24
24
|
import { en, zh, type RemoteKey } from './locales.ts'
|
|
25
25
|
import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
|
|
26
26
|
import { readPairGatePolicy, sendHeartbeat } from './pair-api.ts'
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
channelTransition,
|
|
29
|
+
installRemoteChannel,
|
|
30
|
+
isLoopbackHostname,
|
|
31
|
+
remoteChannelRequired,
|
|
32
|
+
REMOTE_CHANNEL_BOOT_GLOBAL,
|
|
33
|
+
type RemoteChannelBootSeat,
|
|
34
|
+
} from './remote-channel.ts'
|
|
28
35
|
import { FenceNotice } from './FenceNotice.tsx'
|
|
29
36
|
|
|
30
37
|
export type { RemoteEntryProps } from './RemoteEntry.tsx'
|
|
@@ -228,16 +235,41 @@ export function apply(ctx: ClientContext): void {
|
|
|
228
235
|
settingsScope.getSnapshot(),
|
|
229
236
|
hostPairingPolicy,
|
|
230
237
|
)
|
|
238
|
+
// The parse-time boot patch (issue #987), when the served index carried
|
|
239
|
+
// it: already installed before any boot entry ran, so adopting its seat
|
|
240
|
+
// beats patching a second time (which would double-rewrite onto
|
|
241
|
+
// /remote/remote/...).
|
|
242
|
+
const bootSeat = (): RemoteChannelBootSeat | undefined =>
|
|
243
|
+
(window as unknown as Record<string, RemoteChannelBootSeat | undefined>)[REMOTE_CHANNEL_BOOT_GLOBAL]
|
|
231
244
|
const syncChannel = (): void => {
|
|
232
245
|
const transition = channelTransition(channelActive(), disposeChannel !== undefined)
|
|
233
246
|
if (transition === 'install') {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
247
|
+
const seat = bootSeat()
|
|
248
|
+
if (seat !== undefined) {
|
|
249
|
+
seat.onUnpaired = handleUnpaired
|
|
250
|
+
seat.onPaired = hideFenceNotice
|
|
251
|
+
// Replay a signal raised before adoption (early unpaired responses).
|
|
252
|
+
if (seat.pendingUnpaired) {
|
|
253
|
+
seat.pendingUnpaired = false
|
|
254
|
+
handleUnpaired()
|
|
255
|
+
}
|
|
256
|
+
disposeChannel = ctx.effect(() => () => {
|
|
257
|
+
seat.onUnpaired = null
|
|
258
|
+
seat.onPaired = null
|
|
259
|
+
}, 'remote-web-ui: remote desktop channel (boot patch)')
|
|
260
|
+
} else {
|
|
261
|
+
disposeChannel = ctx.effect(() => {
|
|
262
|
+
const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
|
|
263
|
+
return restore
|
|
264
|
+
}, 'remote-web-ui: remote desktop channel')
|
|
265
|
+
}
|
|
238
266
|
} else if (transition === 'retire' && disposeChannel !== undefined) {
|
|
239
267
|
disposeChannel()
|
|
240
268
|
disposeChannel = undefined
|
|
269
|
+
// Retire the provisional parse-time install with the channel: the
|
|
270
|
+
// desktop now rides plain /api, so the rewrite must go (its seat
|
|
271
|
+
// removes the global; a later re-activation patches afresh).
|
|
272
|
+
bootSeat()?.restore()
|
|
241
273
|
// Retire the notice with the channel: once requirePairingForLan turns
|
|
242
274
|
// off (or the plugin is disabled) the desktop rides plain /api again,
|
|
243
275
|
// so an unpaired notice raised while the channel was briefly active
|
|
@@ -245,6 +277,10 @@ export function apply(ctx: ClientContext): void {
|
|
|
245
277
|
// installed channel is the only path that raises the notice, so with
|
|
246
278
|
// the channel gone nothing can re-raise it (issue #808).
|
|
247
279
|
hideFenceNotice()
|
|
280
|
+
} else if (transition === 'none' && !channelActive()) {
|
|
281
|
+
// The channel was never adopted (policy settled to off before apply
|
|
282
|
+
// ran): the provisional boot patch still retires.
|
|
283
|
+
bootSeat()?.restore()
|
|
248
284
|
}
|
|
249
285
|
}
|
|
250
286
|
settingsScope.subscribe(syncChannel)
|
|
@@ -24,27 +24,17 @@
|
|
|
24
24
|
* the given window and returns their restore.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
import {
|
|
28
|
+
REMOTE_API_PREFIX,
|
|
29
|
+
REMOTE_CHANNEL_RULES,
|
|
30
|
+
REMOTE_PREFIX,
|
|
31
|
+
} from '../remote-channel-rules.ts'
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
export
|
|
33
|
+
export { REMOTE_API_PREFIX, REMOTE_PREFIX }
|
|
34
|
+
export type { RemoteChannelBootSeat } from '../remote-channel-rules.ts'
|
|
35
|
+
export { REMOTE_CHANNEL_BOOT_GLOBAL } from '../remote-channel-rules.ts'
|
|
32
36
|
|
|
33
|
-
const
|
|
34
|
-
const PAIR_PREFIX = '/api/pair/'
|
|
35
|
-
const UPDATE_PREFIX = '/api/update/'
|
|
36
|
-
const DESKTOP_LAUNCHER_PREFIX = '/api/dsh-desktop-launcher'
|
|
37
|
-
const SETTINGS_BRIDGE_PREFIX = '/api/dsh-web-ui-settings'
|
|
38
|
-
const SIDEBAR_PREFIX = '/sidebar/'
|
|
39
|
-
const GIT_PREFIX = '/git/'
|
|
40
|
-
const PET_PREFIX = '/pet/'
|
|
41
|
-
const WS_PATHS = new Set([
|
|
42
|
-
'/api/events.mux',
|
|
43
|
-
'/api/events.host',
|
|
44
|
-
'/sidebar/ws/terminal',
|
|
45
|
-
'/sidebar/ws/agent-terminals',
|
|
46
|
-
'/api/dsh-ssh/terminal',
|
|
47
|
-
])
|
|
37
|
+
const RULES = REMOTE_CHANNEL_RULES
|
|
48
38
|
|
|
49
39
|
/** Minimal settings snapshot used by the remote channel decision. */
|
|
50
40
|
export interface RemoteChannelSettingsSnapshot {
|
|
@@ -85,14 +75,14 @@ export function isLoopbackHostname(hostname: string): boolean {
|
|
|
85
75
|
* @param pathname - the request URL pathname.
|
|
86
76
|
*/
|
|
87
77
|
export function shouldRewriteFetchPath(pathname: string): boolean {
|
|
88
|
-
if (pathname.startsWith(
|
|
89
|
-
if (pathname.startsWith(
|
|
90
|
-
if (pathname ===
|
|
91
|
-
if (pathname ===
|
|
92
|
-
if (pathname.startsWith(
|
|
93
|
-
if (pathname.startsWith(
|
|
94
|
-
if (pathname.startsWith(
|
|
95
|
-
if (pathname.startsWith(
|
|
78
|
+
if (pathname.startsWith(RULES.pairPrefix)) return false
|
|
79
|
+
if (pathname.startsWith(RULES.updatePrefix)) return false
|
|
80
|
+
if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false
|
|
81
|
+
if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false
|
|
82
|
+
if (pathname.startsWith(RULES.apiPrefix)) return true
|
|
83
|
+
if (pathname.startsWith(RULES.sidebarPrefix) || pathname === '/sidebar') return true
|
|
84
|
+
if (pathname.startsWith(RULES.gitPrefix) || pathname === '/git') return true
|
|
85
|
+
if (pathname.startsWith(RULES.petPrefix) || pathname === '/pet') return true
|
|
96
86
|
return false
|
|
97
87
|
}
|
|
98
88
|
|
|
@@ -101,7 +91,7 @@ export function shouldRewriteFetchPath(pathname: string): boolean {
|
|
|
101
91
|
* @param pathname - the WebSocket URL pathname.
|
|
102
92
|
*/
|
|
103
93
|
export function shouldRewriteWsPath(pathname: string): boolean {
|
|
104
|
-
return
|
|
94
|
+
return RULES.wsPaths.includes(pathname)
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
/** The gated twin of one fenced path (`/remote` + original pathname). */
|
package/src/http.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
|
-
* Shared
|
|
3
|
-
* bounded
|
|
4
|
-
*
|
|
3
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
6
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
9
|
+
*
|
|
10
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
11
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
|
+
* belong in the same change as its introduction.
|
|
14
|
+
* @module dsh-web-ui-shared/host/http
|
|
5
15
|
*/
|
|
6
16
|
|
|
7
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
8
18
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
20
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
23
|
+
const JSON_HEADERS = {
|
|
24
|
+
'content-type': 'application/json; charset=utf-8',
|
|
25
|
+
'referrer-policy': 'no-referrer',
|
|
26
|
+
} satisfies OutgoingHttpHeaders
|
|
15
27
|
|
|
16
28
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
29
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
30
|
+
* JSON.
|
|
31
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
32
|
+
* invalid or empty payload.
|
|
19
33
|
*/
|
|
20
34
|
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
21
35
|
const chunks: Buffer[] = []
|
|
@@ -28,3 +42,64 @@ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): P
|
|
|
28
42
|
}
|
|
29
43
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
30
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
48
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
49
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
50
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
51
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
52
|
+
* also yield null.
|
|
53
|
+
*/
|
|
54
|
+
export async function readJsonBody(
|
|
55
|
+
req: IncomingMessage,
|
|
56
|
+
opts: { maxBytes?: number; objectOnly?: boolean } = {},
|
|
57
|
+
): Promise<unknown | null> {
|
|
58
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
|
|
59
|
+
const chunks: Buffer[] = []
|
|
60
|
+
let size = 0
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
const buffer = chunk as Buffer
|
|
63
|
+
size += buffer.length
|
|
64
|
+
if (size > maxBytes) {
|
|
65
|
+
req.destroy()
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
chunks.push(buffer)
|
|
69
|
+
}
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
71
|
+
if (text === '') return null
|
|
72
|
+
try {
|
|
73
|
+
const parsed: unknown = JSON.parse(text)
|
|
74
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null
|
|
75
|
+
return parsed
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
82
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
87
|
+
export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
|
|
88
|
+
return isJsonObject(value) ? value : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write one JSON response. Default headers are the family defaults
|
|
93
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
94
|
+
* override them.
|
|
95
|
+
*/
|
|
96
|
+
export function writeJson(
|
|
97
|
+
res: ServerResponse,
|
|
98
|
+
status: number,
|
|
99
|
+
body: unknown,
|
|
100
|
+
headers: OutgoingHttpHeaders = {},
|
|
101
|
+
): void {
|
|
102
|
+
const payload = JSON.stringify(body)
|
|
103
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers })
|
|
104
|
+
res.end(payload)
|
|
105
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,8 +23,10 @@ import { RemoteWebUiPairing } from './pairing-access.ts'
|
|
|
23
23
|
import { isTrustedApiRequest, makeRoutes } from './routes.ts'
|
|
24
24
|
import { makeMobileRoutes } from './mobile-routes.ts'
|
|
25
25
|
import { makeMobileApiRoutes } from './mobile-api.ts'
|
|
26
|
+
import { PendingTracker } from './mobile-pending.ts'
|
|
27
|
+
import { makePairedModelCatalogRoutes } from './paired-model-catalog.ts'
|
|
26
28
|
import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
|
|
27
|
-
import {
|
|
29
|
+
import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
|
|
28
30
|
import { lanIPv4Addresses } from './lan.ts'
|
|
29
31
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
30
32
|
import {
|
|
@@ -37,6 +39,8 @@ import {
|
|
|
37
39
|
} from './update.ts'
|
|
38
40
|
import { makeUpdateRoutes } from './update-routes.ts'
|
|
39
41
|
import { mountOnce } from './mount-once.ts'
|
|
42
|
+
import { REMOTE_CHANNEL_BOOT_SCRIPT } from './remote-channel-boot.ts'
|
|
43
|
+
import { UUID_POLYFILL_SCRIPT } from './uuid-polyfill.ts'
|
|
40
44
|
|
|
41
45
|
declare module '@deepseek-ai/cordis' {
|
|
42
46
|
interface Events {
|
|
@@ -356,14 +360,26 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
356
360
|
...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
|
|
357
361
|
...makeMobileRoutes(),
|
|
358
362
|
...(apiProxy !== undefined
|
|
359
|
-
? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
|
|
363
|
+
? makeMobileApiRoutes({ service, apiProxy, pendingTracker: new PendingTracker(), mobileEnterToSend: () => resolve().mobileEnterToSend })
|
|
360
364
|
: []),
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
|
|
365
|
+
...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
|
|
366
|
+
// The remote desktop channel: policy-gated `/remote` prefix that
|
|
367
|
+
// re-issues fenced paths to loopback (see remote-api.ts). The live
|
|
368
|
+
// requirePairingForLan is re-read per request, same as the gate listener
|
|
369
|
+
// and routes above, so a stale client rewrite on an open-LAN deployment
|
|
370
|
+
// proxies instead of 403ing.
|
|
371
|
+
...makeRemoteApiRoutes({
|
|
372
|
+
service,
|
|
373
|
+
port: ctx.webServer.port,
|
|
374
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
375
|
+
}),
|
|
364
376
|
...updateRoutes,
|
|
365
377
|
]
|
|
366
|
-
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
378
|
+
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
379
|
+
service,
|
|
380
|
+
port: ctx.webServer.port,
|
|
381
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
382
|
+
})
|
|
367
383
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
368
384
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
|
|
369
385
|
|
|
@@ -471,6 +487,26 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
471
487
|
// re-probe unless the target set is unchanged.
|
|
472
488
|
runPostureProbe()
|
|
473
489
|
}
|
|
490
|
+
// Inject the crypto.randomUUID polyfill before any other script runs, so that
|
|
491
|
+
// the main bundle doesn't crash on non-secure contexts (LAN HTTP)
|
|
492
|
+
ctx.effect(() => ctx.on('webserver/index-inject', (table) => {
|
|
493
|
+
table.push({ kind: 'script', placement: 'head', text: UUID_POLYFILL_SCRIPT })
|
|
494
|
+
}), 'remote-web-ui: uuid polyfill')
|
|
495
|
+
|
|
496
|
+
// Issue #987: the browser-half channel patch installs at this plugin's
|
|
497
|
+
// boot entry, but dsh-client-connection boots earlier and opens its event
|
|
498
|
+
// streams unrewritten — on a non-loopback origin the SDK fence rejects
|
|
499
|
+
// them and the workspace list never loads. Contribute the rewrite as a
|
|
500
|
+
// parse-time head script so it is active before ANY boot entry runs; the
|
|
501
|
+
// client apply adopts the installed seat instead of patching twice. The
|
|
502
|
+
// row follows the live steady-state decision (enabled + pairing gate); the
|
|
503
|
+
// script itself skips loopback origins.
|
|
504
|
+
ctx.effect(() => ctx.on('webserver/index-inject', (table) => {
|
|
505
|
+
const value = resolve()
|
|
506
|
+
if (!value.enabled || !value.requirePairingForLan) return
|
|
507
|
+
table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
|
|
508
|
+
}), 'remote-web-ui: remote channel boot patch')
|
|
509
|
+
|
|
474
510
|
installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
475
511
|
setSource: (source) => {
|
|
476
512
|
current = source
|