@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.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 +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
package/lib/types/update.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
|
+
import { createOutputCapture } from "./console-output.js";
|
|
15
|
+
import { withIdentityEncoding } from "./http.js";
|
|
14
16
|
/** npm registry base used for version probes. */
|
|
15
17
|
export const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
16
18
|
/** The family scope every dsh-web package is published under. */
|
|
@@ -229,7 +231,7 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
|
|
|
229
231
|
const controller = new AbortController();
|
|
230
232
|
const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
|
|
231
233
|
try {
|
|
232
|
-
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', { signal: controller.signal });
|
|
234
|
+
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', withIdentityEncoding({ signal: controller.signal }));
|
|
233
235
|
if (!response.ok)
|
|
234
236
|
return undefined;
|
|
235
237
|
const body = await response.json();
|
|
@@ -312,10 +314,10 @@ export async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeou
|
|
|
312
314
|
const controller = new AbortController();
|
|
313
315
|
const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
|
|
314
316
|
try {
|
|
315
|
-
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, {
|
|
317
|
+
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
|
|
316
318
|
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-web-update' },
|
|
317
319
|
signal: controller.signal,
|
|
318
|
-
});
|
|
320
|
+
}));
|
|
319
321
|
if (!response.ok)
|
|
320
322
|
return undefined;
|
|
321
323
|
const body = await response.json();
|
|
@@ -424,6 +426,13 @@ export async function checkUpdates(deps) {
|
|
|
424
426
|
}
|
|
425
427
|
/** Cap on captured pnpm output (keeps error payloads bounded). */
|
|
426
428
|
const OUTPUT_CAP = 16 * 1024;
|
|
429
|
+
/**
|
|
430
|
+
* Characters a package name may contain before it is handed to the update
|
|
431
|
+
* command line. Deliberately looser than the strict family shape (the tests
|
|
432
|
+
* and future callers use short names) but it admits no whitespace, quotes,
|
|
433
|
+
* shell metacharacters, or path separators beyond the scope slash.
|
|
434
|
+
*/
|
|
435
|
+
const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
427
436
|
/**
|
|
428
437
|
* Windows cmd command-not-found stderr. With shell:true a missing shim
|
|
429
438
|
* exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
|
|
@@ -432,22 +441,14 @@ const OUTPUT_CAP = 16 * 1024;
|
|
|
432
441
|
*/
|
|
433
442
|
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
|
|
434
443
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
444
|
+
* Fold one decoded candidate output into the running total under the display
|
|
445
|
+
* cap (the tail carries the failure reason).
|
|
446
|
+
* @param output - the accumulated text.
|
|
447
|
+
* @param text - the candidate's decoded text.
|
|
438
448
|
*/
|
|
439
|
-
function
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
catch {
|
|
444
|
-
try {
|
|
445
|
-
return new TextDecoder('gbk').decode(chunk);
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
return chunk.toString('utf8');
|
|
449
|
-
}
|
|
450
|
-
}
|
|
449
|
+
function capOutput(output, text) {
|
|
450
|
+
const combined = output + text;
|
|
451
|
+
return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
|
|
451
452
|
}
|
|
452
453
|
/**
|
|
453
454
|
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
@@ -469,16 +470,24 @@ const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
|
|
|
469
470
|
export function runUpdate(deps) {
|
|
470
471
|
return new Promise((resolve) => {
|
|
471
472
|
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
472
|
-
const packages = deps.packages;
|
|
473
473
|
const platform = deps.platform ?? process.platform;
|
|
474
|
+
// The names come from the profile/anchor manifests and the win32 candidate
|
|
475
|
+
// runs through a shell, so anything outside the npm name shape is dropped
|
|
476
|
+
// instead of being concatenated into a command line (a dependency key
|
|
477
|
+
// containing shell metacharacters would otherwise run as a second command).
|
|
478
|
+
const packages = deps.packages.filter(name => SHELL_SAFE_PACKAGE_RE.test(name));
|
|
474
479
|
// Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
|
|
475
480
|
// start .cmd files directly (ENOENT even when installed), so route
|
|
476
481
|
// them through cmd.exe there. POSIX spawns stay shell-free.
|
|
482
|
+
// POSIX: the child leads its own process group (detached) so the timeout
|
|
483
|
+
// can signal pnpm's whole tree — pnpm spawns lifecycle-script children that
|
|
484
|
+
// a direct SIGTERM to the wrapper leaves running against the profile.
|
|
477
485
|
const spawnOptions = {
|
|
478
486
|
cwd: deps.profileDir,
|
|
479
487
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
480
|
-
...(platform === 'win32' ? { shell: true } : {}),
|
|
488
|
+
...(platform === 'win32' ? { shell: true } : { detached: true }),
|
|
481
489
|
};
|
|
490
|
+
const killImpl = deps.killImpl ?? process.kill;
|
|
482
491
|
// Ordered fallback chain: each is tried only when the previous one is
|
|
483
492
|
// missing on PATH (ENOENT on the spawn error event, never on close).
|
|
484
493
|
// `--latest` is required: dsh plugin writes exact-version specs (e.g.
|
|
@@ -499,16 +508,22 @@ export function runUpdate(deps) {
|
|
|
499
508
|
// (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
|
|
500
509
|
// run against `currentOutput` so a previous candidate's 'not recognized'
|
|
501
510
|
// stderr cannot misclassify a real failure in the next one.
|
|
511
|
+
// Each candidate accumulates raw bytes and is decoded exactly once
|
|
512
|
+
// (console-output.ts): decoding per `data` event splits a multi-byte
|
|
513
|
+
// character that straddles two reads into replacement characters, which is
|
|
514
|
+
// what the panel then shows as garbled pnpm diagnostics.
|
|
502
515
|
let output = '';
|
|
503
|
-
let
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
516
|
+
let capture = createOutputCapture(OUTPUT_CAP);
|
|
517
|
+
let candidateFolded = false;
|
|
518
|
+
const append = (chunk) => { capture.push(chunk); };
|
|
519
|
+
/** This candidate's decoded text (idempotent). */
|
|
520
|
+
const candidateText = () => capture.read();
|
|
521
|
+
/** Fold this candidate's text into the running output exactly once. */
|
|
522
|
+
const foldCandidate = () => {
|
|
523
|
+
if (candidateFolded)
|
|
524
|
+
return;
|
|
525
|
+
candidateFolded = true;
|
|
526
|
+
output = capOutput(output, capture.read());
|
|
512
527
|
};
|
|
513
528
|
let currentChild;
|
|
514
529
|
// Terminal guard: once a result is produced the promise is settled and no
|
|
@@ -531,7 +546,11 @@ export function runUpdate(deps) {
|
|
|
531
546
|
const pid = currentChild?.pid;
|
|
532
547
|
if (pid !== undefined && pid > 0) {
|
|
533
548
|
try {
|
|
534
|
-
|
|
549
|
+
// A spawn failure arrives as an asynchronous 'error' event, which
|
|
550
|
+
// the try/catch cannot see; an emitter with no error listener throws
|
|
551
|
+
// and would take the host process down on this path.
|
|
552
|
+
const killer = spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
|
|
553
|
+
killer.on?.('error', () => { });
|
|
535
554
|
}
|
|
536
555
|
catch {
|
|
537
556
|
// Best-effort kill; fall through to the timeout result.
|
|
@@ -539,8 +558,26 @@ export function runUpdate(deps) {
|
|
|
539
558
|
}
|
|
540
559
|
}
|
|
541
560
|
else {
|
|
542
|
-
|
|
561
|
+
// Detached child: its process-group id is its pid, so the negative pid
|
|
562
|
+
// reaches pnpm's grandchildren too. A group kill throws when the child
|
|
563
|
+
// already exited; fall back to the direct kill.
|
|
564
|
+
const pid = currentChild?.pid;
|
|
565
|
+
if (pid !== undefined && pid > 0) {
|
|
566
|
+
try {
|
|
567
|
+
killImpl(-pid, 'SIGTERM');
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
try {
|
|
571
|
+
currentChild?.kill('SIGTERM');
|
|
572
|
+
}
|
|
573
|
+
catch { /* already gone */ }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
currentChild?.kill('SIGTERM');
|
|
578
|
+
}
|
|
543
579
|
}
|
|
580
|
+
foldCandidate();
|
|
544
581
|
finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' });
|
|
545
582
|
}, deps.timeoutMs ?? 10 * 60_000);
|
|
546
583
|
const runCandidate = (index) => {
|
|
@@ -557,7 +594,8 @@ export function runUpdate(deps) {
|
|
|
557
594
|
return;
|
|
558
595
|
}
|
|
559
596
|
const candidate = candidates[index];
|
|
560
|
-
|
|
597
|
+
capture = createOutputCapture(OUTPUT_CAP);
|
|
598
|
+
candidateFolded = false;
|
|
561
599
|
const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
|
|
562
600
|
currentChild = child;
|
|
563
601
|
let settled = false;
|
|
@@ -571,6 +609,7 @@ export function runUpdate(deps) {
|
|
|
571
609
|
child.stderr?.on('data', append);
|
|
572
610
|
child.on('error', (error) => {
|
|
573
611
|
once(() => {
|
|
612
|
+
foldCandidate();
|
|
574
613
|
if (error.code === 'ENOENT') {
|
|
575
614
|
// Command missing: try the next candidate in the chain.
|
|
576
615
|
runCandidate(index + 1);
|
|
@@ -587,11 +626,13 @@ export function runUpdate(deps) {
|
|
|
587
626
|
// 'not recognized' or localized message instead of ENOENT — keep the
|
|
588
627
|
// chain going. Checked against this candidate's own output so a prior
|
|
589
628
|
// fallback's stderr cannot misclassify a real failure here.
|
|
590
|
-
if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(
|
|
629
|
+
if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(candidateText())))) {
|
|
630
|
+
foldCandidate();
|
|
591
631
|
runCandidate(index + 1);
|
|
592
632
|
return;
|
|
593
633
|
}
|
|
594
634
|
settled = true;
|
|
635
|
+
foldCandidate();
|
|
595
636
|
finish({
|
|
596
637
|
ok: code === 0,
|
|
597
638
|
exitCode: code,
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"icon": "icon.svg",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"engines": {
|
|
7
8
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -31,12 +32,13 @@
|
|
|
31
32
|
"lib/**/*.d.ts.map",
|
|
32
33
|
"src",
|
|
33
34
|
"assets",
|
|
34
|
-
"cordis.patch.yml"
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"icon.svg"
|
|
35
37
|
],
|
|
36
38
|
"license": "Apache-2.0",
|
|
37
39
|
"dsh": {
|
|
38
40
|
"engines": {
|
|
39
|
-
"dsh": ">=0.1.
|
|
41
|
+
"dsh": ">=0.1.7-rc.1"
|
|
40
42
|
},
|
|
41
43
|
"bundle": {
|
|
42
44
|
"patch": "./cordis.patch.yml"
|
|
@@ -58,27 +60,28 @@
|
|
|
58
60
|
"zod": "^4.4.3"
|
|
59
61
|
},
|
|
60
62
|
"peerDependencies": {
|
|
63
|
+
"@deepseek-ai/dsh": ">=0.1.7-rc.1",
|
|
61
64
|
"react": "^18.2.0",
|
|
62
65
|
"react-dom": "^18.2.0"
|
|
63
66
|
},
|
|
64
67
|
"devDependencies": {
|
|
65
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
66
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.
|
|
68
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.
|
|
69
|
-
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
72
|
-
"@deepseek-ai/dsh-client-store": "^0.1.
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
78
|
-
"@deepseek-ai/dsh-commands": "^0.1.
|
|
79
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
80
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
81
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
68
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
69
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-commands": "^0.1.7-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-invariants": "^0.1.7-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
|
|
82
85
|
"@testing-library/dom": "^10.4.1",
|
|
83
86
|
"@testing-library/react": "^16.3.2",
|
|
84
87
|
"@types/node": "^22.20.0",
|
|
@@ -62,8 +62,6 @@ export function FenceNotice({ t, onRetry, onAccept = acceptPair }: FenceNoticePr
|
|
|
62
62
|
}
|
|
63
63
|
if (result.code === 'invalid') {
|
|
64
64
|
setErrorMsg(t('fence.unpaired.tokenInvalid'))
|
|
65
|
-
} else if (result.code === 'used') {
|
|
66
|
-
setErrorMsg(t('fence.unpaired.tokenUsed'))
|
|
67
65
|
} else {
|
|
68
66
|
setErrorMsg(t('fence.unpaired.tokenFailed'))
|
|
69
67
|
}
|
|
@@ -64,6 +64,10 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
64
64
|
* edits, so the footer would sit there permanently disabled.
|
|
65
65
|
*/
|
|
66
66
|
hideFooter?: boolean
|
|
67
|
+
/** Render children even when the settings namespace is not exposed by the host. */
|
|
68
|
+
renderChildrenWhenNotExposed?: boolean
|
|
69
|
+
/** Hide the 'not exposed' warning banner when the settings namespace is not exposed. */
|
|
70
|
+
hideNotExposedNotice?: boolean
|
|
67
71
|
/** The plugin's controls. */
|
|
68
72
|
children: ReactNode
|
|
69
73
|
}
|
|
@@ -126,14 +130,15 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
126
130
|
// official settings allowlist omits third-party namespaces): show a card
|
|
127
131
|
// that explains the gap instead of vanishing, so a missing card never
|
|
128
132
|
// reads as a missing plugin.
|
|
129
|
-
if (!state.exposed) {
|
|
133
|
+
if (!state.exposed && props.renderChildrenWhenNotExposed !== true) {
|
|
134
|
+
const showNotice = props.hideNotExposedNotice !== true
|
|
130
135
|
return (
|
|
131
136
|
<li className={cardClass}>
|
|
132
137
|
{header}
|
|
133
138
|
{expanded
|
|
134
139
|
? (
|
|
135
140
|
<div className={css.body}>
|
|
136
|
-
<p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
|
|
141
|
+
{showNotice ? <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p> : null}
|
|
137
142
|
</div>
|
|
138
143
|
)
|
|
139
144
|
: null}
|
|
@@ -181,18 +181,23 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
181
181
|
useEffect(() => closeEventSource, [closeEventSource])
|
|
182
182
|
|
|
183
183
|
const handleStop = useCallback(() => {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
184
|
+
// The phase flip lands only once the server accepted the stop. A refused
|
|
185
|
+
// stop changes nothing server-side and the service emits no state change
|
|
186
|
+
// (notify dedupes identical snapshots), so an optimistic flip would stick
|
|
187
|
+
// and claim a revocation that never happened.
|
|
188
|
+
void stopPair().then(() => {
|
|
189
|
+
setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' as PairingPhase, devices: [] } : previous)
|
|
190
|
+
}).catch(() => {})
|
|
189
191
|
}, [])
|
|
190
192
|
|
|
191
193
|
const handleRevoke = useCallback((deviceId: string) => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// Same rule as stop: a refused unpair must leave the row, or the panel
|
|
195
|
+
// claims a still-live session is gone.
|
|
196
|
+
void revokePair(deviceId).then(() => {
|
|
197
|
+
setState(previous => previous.kind === 'ready'
|
|
198
|
+
? { ...previous, devices: previous.devices.filter(device => device.id !== deviceId) }
|
|
199
|
+
: previous)
|
|
200
|
+
}).catch(() => {})
|
|
196
201
|
}, [])
|
|
197
202
|
|
|
198
203
|
const handleRefresh = useCallback(() => {
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* actions arrive through props from the entry's behavior component.
|
|
6
6
|
*/
|
|
7
7
|
import clsx from 'clsx'
|
|
8
|
+
import { memo } from 'react'
|
|
8
9
|
import { QRCodeSVG } from 'qrcode.react'
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
+
IconCloseOutlineRegular, IconCopyOutlineRegular, IconRefreshOutlineRegular, IconStopFillRegular,
|
|
11
12
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
12
13
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
13
14
|
import type { PairingPhase } from '../pairing.ts'
|
|
@@ -22,6 +23,15 @@ import {
|
|
|
22
23
|
import { deviceNameFromUserAgent } from './device-name.ts'
|
|
23
24
|
import css from './remote.module.css'
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The QR symbol, memoized on the link: the panel re-renders on every SSE state
|
|
28
|
+
* frame (a paired phone heartbeats every 10s), and qrcode.react rebuilds the
|
|
29
|
+
* SVG path and element tree on each render.
|
|
30
|
+
*/
|
|
31
|
+
const PairQrCode = memo(function PairQrCode({ url, className }: { url: string; className: string }) {
|
|
32
|
+
return <QRCodeSVG value={url} size={184} level="M" marginSize={1} className={className} />
|
|
33
|
+
})
|
|
34
|
+
|
|
25
35
|
/** The panel's view state, owned by the entry component. */
|
|
26
36
|
export type PanelState =
|
|
27
37
|
| { kind: 'lan-required'; tunnel?: TunnelStatusFrame }
|
|
@@ -115,7 +125,7 @@ export function RemotePanel({
|
|
|
115
125
|
<p className={css.subtitle}>{t('subtitle')}</p>
|
|
116
126
|
</div>
|
|
117
127
|
<button type="button" className={css.close} aria-label={t('close.label')} onClick={onClose}>
|
|
118
|
-
<
|
|
128
|
+
<IconCloseOutlineRegular size={14} />
|
|
119
129
|
</button>
|
|
120
130
|
</div>
|
|
121
131
|
|
|
@@ -155,7 +165,7 @@ export function RemotePanel({
|
|
|
155
165
|
</span>
|
|
156
166
|
</div>
|
|
157
167
|
<div className={css.qrWrap} data-testid="remote-qr">
|
|
158
|
-
<
|
|
168
|
+
<PairQrCode url={state.url} className={css.qr} />
|
|
159
169
|
</div>
|
|
160
170
|
{state.expired
|
|
161
171
|
? <p className={css.expired}>{t('pair.expired')}</p>
|
|
@@ -170,7 +180,7 @@ export function RemotePanel({
|
|
|
170
180
|
<code className={css.link} title={state.url}>{state.url}</code>
|
|
171
181
|
</div>
|
|
172
182
|
<button type="button" className={css.copyLink} onClick={() => onCopy(state.url)}>
|
|
173
|
-
<
|
|
183
|
+
<IconCopyOutlineRegular size={14} />
|
|
174
184
|
{copied ? t('action.copied') : t('action.copyLink')}
|
|
175
185
|
</button>
|
|
176
186
|
</div>
|
|
@@ -185,7 +195,7 @@ export function RemotePanel({
|
|
|
185
195
|
className={css.copyLink}
|
|
186
196
|
onClick={() => onCopyToken ? onCopyToken(state.token!) : onCopy(state.token!)}
|
|
187
197
|
>
|
|
188
|
-
<
|
|
198
|
+
<IconCopyOutlineRegular size={14} />
|
|
189
199
|
{copiedToken ? t('action.copiedToken') : t('action.copyToken')}
|
|
190
200
|
</button>
|
|
191
201
|
</div>
|
|
@@ -246,11 +256,11 @@ export function RemotePanel({
|
|
|
246
256
|
|
|
247
257
|
<div className={css.actions}>
|
|
248
258
|
<button type="button" className={css.action} onClick={onStop}>
|
|
249
|
-
<
|
|
259
|
+
<IconStopFillRegular size={14} />
|
|
250
260
|
{t('action.stop')}
|
|
251
261
|
</button>
|
|
252
262
|
<button type="button" className={css.action} onClick={onRefresh}>
|
|
253
|
-
<
|
|
263
|
+
<IconRefreshOutlineRegular size={14} />
|
|
254
264
|
{t('action.refresh')}
|
|
255
265
|
</button>
|
|
256
266
|
</div>
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The remote-control settings card: pairing security and device limits.
|
|
3
3
|
* Registers into the `web-ui.plugin.item` child slot the Web UI plugin group
|
|
4
|
-
* renders, bound to the `remote-web-ui`
|
|
4
|
+
* renders, bound to the `remote-web-ui` profile entry's configuration form.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useEffect, useState } from 'react'
|
|
8
8
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
9
|
-
import type {
|
|
9
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
10
10
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
11
11
|
import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
|
|
12
12
|
import { CardForm, booleanField, numberField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
13
|
-
import { readLanBindStatus, type LanBindFrame } from './pair-api.ts'
|
|
13
|
+
import { LanBindStatusError, readLanBindStatus, shouldStopLanBindPoll, type LanBindFrame } from './pair-api.ts'
|
|
14
14
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
15
|
|
|
16
16
|
/** The remote-control fields this card edits (the namespace's full schema). */
|
|
@@ -88,13 +88,13 @@ export interface RemoteSettingsCardFace extends CardActions {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** Bridges the `remote-web-ui`
|
|
91
|
+
/** Bridges the `remote-web-ui` form onto the card's staged form. */
|
|
92
92
|
export class RemoteSettingsCardController {
|
|
93
93
|
private readonly form: CardForm<RemoteSettings>
|
|
94
94
|
private readonly store: SnapshotStore<RemoteSettingsCardState>
|
|
95
95
|
|
|
96
|
-
/** @param scope - the
|
|
97
|
-
constructor(scope:
|
|
96
|
+
/** @param scope - the configuration form for the `remote-web-ui` entry. */
|
|
97
|
+
constructor(scope: ConfigForm<RemoteSettings>) {
|
|
98
98
|
this.form = new CardForm(scope, [
|
|
99
99
|
booleanField('enabled'),
|
|
100
100
|
numberField('tokenTtlMs'),
|
|
@@ -139,7 +139,7 @@ export class RemoteSettingsCardController {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* Release the card's
|
|
142
|
+
* Release the card's form subscription and bound stores; the slot
|
|
143
143
|
* disposer calls this on teardown.
|
|
144
144
|
*/
|
|
145
145
|
dispose(): void {
|
|
@@ -175,6 +175,8 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
|
|
|
175
175
|
descriptionKey="settings.description"
|
|
176
176
|
defaultOpen={false}
|
|
177
177
|
state={state}
|
|
178
|
+
renderChildrenWhenNotExposed
|
|
179
|
+
hideNotExposedNotice
|
|
178
180
|
onSave={props.save}
|
|
179
181
|
onDiscard={props.discard}
|
|
180
182
|
>
|
|
@@ -325,10 +327,15 @@ function LanBindStatus({ t }: { t: TranslateNS<'remote'> }) {
|
|
|
325
327
|
const read = (): void => {
|
|
326
328
|
void readLanBindStatus().then((value) => {
|
|
327
329
|
if (alive) setFrame(value)
|
|
328
|
-
}).catch(() => {
|
|
330
|
+
}).catch((error: unknown) => {
|
|
331
|
+
// The endpoint is loopback-only: a 401/403 means this origin (a paired
|
|
332
|
+
// phone) can never read it, so the 10s poll stops instead of retrying a
|
|
333
|
+
// known refusal for as long as the card stays expanded.
|
|
334
|
+
if (error instanceof LanBindStatusError && shouldStopLanBindPoll(error.status)) window.clearInterval(timer)
|
|
335
|
+
})
|
|
329
336
|
}
|
|
330
|
-
read()
|
|
331
337
|
const timer = window.setInterval(read, 10_000)
|
|
338
|
+
read()
|
|
332
339
|
return () => {
|
|
333
340
|
alive = false
|
|
334
341
|
window.clearInterval(timer)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
10
10
|
import { createPortal } from "react-dom"
|
|
11
11
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
-
import {
|
|
12
|
+
import { IconDownloadOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
13
|
import type { UpdateStatus } from '../update.ts'
|
|
14
14
|
import { fetchUpdateStatus, runUpdate, UpdateStatusError } from "./update-api.ts"
|
|
15
15
|
import { UpdatePanel, type UpdateView } from "./UpdatePanel.tsx"
|
|
@@ -128,7 +128,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
128
128
|
title={updateLabel}
|
|
129
129
|
onClick={openPanel}
|
|
130
130
|
>
|
|
131
|
-
<
|
|
131
|
+
<IconDownloadOutlineRegular size={wide ? 16 : 18} />
|
|
132
132
|
{updateAvailable && wide && (
|
|
133
133
|
<span className={css.updateBadgeText}>{t("update.badge")}</span>
|
|
134
134
|
)}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* through props from the entry behavior component.
|
|
6
6
|
*/
|
|
7
7
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
8
|
-
import {
|
|
8
|
+
import { IconCloseOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
9
|
import type { UpdateReleaseNotes, UpdateRunResult, UpdateStatus } from '../update.ts'
|
|
10
10
|
import css from "./remote.module.css"
|
|
11
11
|
|
|
@@ -55,7 +55,7 @@ export function UpdatePanel({ t, view, onClose, onRecheck, onStartUpdate }: Upda
|
|
|
55
55
|
{subtitle !== undefined && <p className={css.subtitle}>{subtitle}</p>}
|
|
56
56
|
</div>
|
|
57
57
|
<button type="button" className={css.close} aria-label={t("update.close")} onClick={onClose}>
|
|
58
|
-
<
|
|
58
|
+
<IconCloseOutlineRegular />
|
|
59
59
|
</button>
|
|
60
60
|
</div>
|
|
61
61
|
{view.kind === "checking" && <p className={css.updateStatus}>{t("update.checking")}</p>}
|
|
@@ -86,7 +86,7 @@ export function UpdatePanel({ t, view, onClose, onRecheck, onStartUpdate }: Upda
|
|
|
86
86
|
{(view.kind === "done" || view.kind === "error") && (
|
|
87
87
|
<div className={css.updateActions}>
|
|
88
88
|
<button type="button" className={css.updateRetry} onClick={onRecheck}>
|
|
89
|
-
<
|
|
89
|
+
<IconRefreshOutlineRegular /> {t("update.retry")}
|
|
90
90
|
</button>
|
|
91
91
|
</div>
|
|
92
92
|
)}
|
package/src/client/deep-link.ts
CHANGED
|
@@ -70,8 +70,8 @@ async function runAccept(token: string, page: PageSurface): Promise<void> {
|
|
|
70
70
|
} catch {
|
|
71
71
|
sessionStorage.setItem(PAIR_FAILED_MARKER, 'failed')
|
|
72
72
|
}
|
|
73
|
-
// Drop the token from the URL either way:
|
|
74
|
-
//
|
|
73
|
+
// Drop the token from the URL either way: it is spent for this tab (the
|
|
74
|
+
// session is established), and a failed one must not loop.
|
|
75
75
|
const url = new URL(page.href)
|
|
76
76
|
url.searchParams.delete('pair')
|
|
77
77
|
page.replaceState(`${url.pathname}${url.search}${url.hash}`)
|