@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.
Files changed (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
@@ -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
- * Decode process output with tolerant decoding:
436
- * Defaults to UTF-8; if invalid byte sequences are encountered (common on
437
- * Windows consoles using code page 936/GBK), falls back to GBK via TextDecoder.
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 decodeProcessChunk(chunk) {
440
- try {
441
- return new TextDecoder('utf-8', { fatal: true }).decode(chunk);
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 currentOutput = '';
504
- const append = (chunk) => {
505
- const text = decodeProcessChunk(chunk);
506
- output += text;
507
- if (output.length > OUTPUT_CAP)
508
- output = output.slice(output.length - OUTPUT_CAP);
509
- currentOutput += text;
510
- if (currentOutput.length > OUTPUT_CAP)
511
- currentOutput = currentOutput.slice(currentOutput.length - OUTPUT_CAP);
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
- spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
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
- currentChild?.kill('SIGTERM');
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
- currentOutput = '';
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(currentOutput)))) {
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.3.23",
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.5-rc.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.2",
66
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
67
- "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
68
- "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
69
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.5-rc.1",
70
- "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
71
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
72
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
73
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
74
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
75
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
76
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.1",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
78
- "@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
79
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
80
- "@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
81
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.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
- // A failed stop request is harmless: the optimistic phase flip below
185
- // keeps the UI honest, and the status stream confirms the stopped phase.
186
- void stopPair().catch(() => {})
187
- // Optimistic fallback; the status stream confirms with the stopped phase.
188
- setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' as PairingPhase, devices: [] } : previous)
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
- void revokePair(deviceId).catch(() => {})
193
- setState(previous => previous.kind === 'ready'
194
- ? { ...previous, devices: previous.devices.filter(device => device.id !== deviceId) }
195
- : previous)
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
- IconCloseOutline16, IconCopyOutline16, IconRefreshOutline16, IconStopFill16,
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
- <IconCloseOutline16 size={14} />
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
- <QRCodeSVG value={state.url} size={184} level="M" marginSize={1} className={css.qr} />
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
- <IconCopyOutline16 size={14} />
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
- <IconCopyOutline16 size={14} />
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
- <IconStopFill16 size={14} />
259
+ <IconStopFillRegular size={14} />
250
260
  {t('action.stop')}
251
261
  </button>
252
262
  <button type="button" className={css.action} onClick={onRefresh}>
253
- <IconRefreshOutline16 size={14} />
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` settings namespace.
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 { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
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` scope onto the card's staged form. */
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 bound settings scope for the `remote-web-ui` namespace. */
97
- constructor(scope: SettingsScope<RemoteSettings>) {
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 scope subscription and bound stores; the slot
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 { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
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
- <IconDownloadOutline16 size={wide ? 16 : 18} />
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 { IconCloseOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
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
- <IconCloseOutline16 />
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
- <IconRefreshOutline16 /> {t("update.retry")}
89
+ <IconRefreshOutlineRegular /> {t("update.retry")}
90
90
  </button>
91
91
  </div>
92
92
  )}
@@ -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: an accepted token is consumed
74
- // (a re-scan would 409), and a failed one must not loop.
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}`)