@linxin666/dsh-remote-web-ui 0.4.3 → 0.4.5

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 (47) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +23 -8
  3. package/README.zh.md +22 -7
  4. package/lib/client.js +194 -140
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +231 -23
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.js +2 -2
  10. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/RemoteSettingsCard.js +2 -1
  12. package/lib/types/client/index.js +3 -3
  13. package/lib/types/client/locales.d.ts +0 -4
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +5 -9
  16. package/lib/types/client/mobile-adapt.d.ts +2 -2
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +163 -136
  19. package/lib/types/client/settings-form.d.ts +16 -0
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +27 -1
  22. package/lib/types/detached-work.d.ts +45 -0
  23. package/lib/types/detached-work.d.ts.map +1 -0
  24. package/lib/types/detached-work.js +54 -0
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +67 -12
  27. package/lib/types/lan-bind.d.ts +21 -4
  28. package/lib/types/lan-bind.d.ts.map +1 -1
  29. package/lib/types/lan-bind.js +103 -17
  30. package/lib/types/pairing.d.ts.map +1 -1
  31. package/lib/types/pairing.js +43 -0
  32. package/lib/types/remote-api.d.ts +13 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +14 -1
  35. package/package.json +19 -19
  36. package/src/client/PluginSettingsCard.tsx +9 -0
  37. package/src/client/RemoteSettingsCard.tsx +2 -0
  38. package/src/client/index.ts +3 -3
  39. package/src/client/locales.ts +5 -9
  40. package/src/client/mobile-adapt.ts +167 -138
  41. package/src/client/settings-card.module.css +31 -5
  42. package/src/client/settings-form.ts +27 -1
  43. package/src/detached-work.ts +57 -0
  44. package/src/index.ts +67 -12
  45. package/src/lan-bind.ts +111 -17
  46. package/src/pairing.ts +44 -1
  47. package/src/remote-api.ts +14 -1
@@ -29,6 +29,7 @@ import { withIdentityEncoding } from "./http.js";
29
29
  import { TunnelManager } from "./tunnel.js";
30
30
  import { PublicBaseKeeper } from "./public-base.js";
31
31
  import { mountOnce } from "./mount-once.js";
32
+ import { runDetached } from "./detached-work.js";
32
33
  import { REMOTE_CHANNEL_BOOT_SCRIPT } from "./remote-channel-boot.js";
33
34
  import { UUID_POLYFILL_SCRIPT } from "./uuid-polyfill.js";
34
35
  /** Stable cordis plugin name. */
@@ -536,18 +537,34 @@ function applyImpl(ctx, config) {
536
537
  if (ctx.webServer.host === '0.0.0.0') {
537
538
  console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)');
538
539
  }
539
- // Apply the effective configuration to the running surfaces once per
540
- // activation: the service tunables, the LAN-bind block/firewall, the tunnel
541
- // plan, the pairing routes, the presence sweep, and the posture probe.
542
- const sync = () => {
543
- const value = resolve();
544
- service.config = pairingConfigOf(value);
545
- // LAN bind toggle: only write the managed patch block once the user has
546
- // flipped it (undefined = untouched, never write). Desired host/port come
547
- // from the CLI flags when given (flags win), else from the toggle and
548
- // the currently bound port. The block takes effect on the next start, so
549
- // the re-assert at every boot keeps it in sync with both the toggle and
550
- // the flags.
540
+ // LAN bind toggle: only write the managed patch block once the user has
541
+ // flipped it (undefined = untouched, never write). Desired host/port come
542
+ // from the CLI flags when given (flags win), else from the toggle and
543
+ // the currently bound port. The block takes effect on the next start, so
544
+ // the re-assert at every boot keeps it in sync with both the toggle and
545
+ // the flags.
546
+ //
547
+ // cordis.patch.yml is exactly the file the Host's HMR config watcher
548
+ // refreshes from, and a settings save runs inside the Host's exclusive HMR
549
+ // transaction. Writing that file while the caller's transaction is open makes
550
+ // the watcher's refresh re-enter it, which the Host refuses with "HMR
551
+ // transactions cannot be nested" and which surfaces on the user's save
552
+ // (#1751, #1754).
553
+ //
554
+ // Deferring alone does NOT escape that context, and the earlier belt rested
555
+ // on a premise that measurement disproves: AsyncLocalStorage is propagated
556
+ // into setImmediate, into node:timers, and into AsyncResource scopes (a
557
+ // plain resource built while the transaction runs inherits it), so
558
+ // "setImmediate starts a fresh store" is false. What does start clean is an
559
+ // AsyncResource created at module scope, before any transaction existed -
560
+ // see detached-work.ts. scheduleLanBindWork runs the scheduling through it,
561
+ // so the write lands outside the caller's transaction.
562
+ //
563
+ // The assertion is also idempotent on its own: the write is guarded by the
564
+ // current-vs-desired comparison below, so once the profile carries the block
565
+ // a coalesced follow-up sync() finds nothing to do. That guard is a second
566
+ // belt, not the fix.
567
+ const applyLanBindWork = (value) => {
551
568
  if (value.lanBind !== undefined) {
552
569
  const startup = ctx.get('webStartup');
553
570
  const desiredHost = desiredBindHost(value.lanBind === true, startup?.host);
@@ -589,6 +606,44 @@ function applyImpl(ctx, config) {
589
606
  }
590
607
  }
591
608
  }
609
+ };
610
+ // The LAN bind assertion never blocks the caller: one pending work item is
611
+ // enough, and it always re-reads the config through `resolve()` at run time
612
+ // so a toggle flipped twice before the tick lands on the last value.
613
+ //
614
+ // The scheduling itself runs through runDetached: the settings save that
615
+ // drives sync() holds the Host's exclusive HMR transaction in its async
616
+ // context, and a `setImmediate` scheduled directly from here would inherit
617
+ // that mark (measured on Node 24), so the write would still land inside the
618
+ // transaction the watcher then tries to re-enter. Scheduling from the
619
+ // module-scope resource starts the timer - and therefore the write - with no
620
+ // store at all.
621
+ let lanBindWorkPending = false;
622
+ const scheduleLanBindWork = () => {
623
+ if (lanBindWorkPending)
624
+ return;
625
+ lanBindWorkPending = true;
626
+ runDetached(() => setImmediate(() => {
627
+ lanBindWorkPending = false;
628
+ try {
629
+ applyLanBindWork(resolve());
630
+ }
631
+ catch (error) {
632
+ // A deferred assertion must never surface as an unhandled rejection:
633
+ // the settings save that triggered it already answered, and the card
634
+ // reads the live block state back on its own poll.
635
+ console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`);
636
+ }
637
+ }));
638
+ };
639
+ ctx.effect(() => () => { lanBindWorkPending = false; }, 'remote-web-ui: lan-bind work');
640
+ // Apply the effective configuration to the running surfaces once per
641
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
642
+ // plan, the pairing routes, the presence sweep, and the posture probe.
643
+ const sync = () => {
644
+ const value = resolve();
645
+ service.config = pairingConfigOf(value);
646
+ scheduleLanBindWork();
592
647
  // The plugin-managed tunnels own the public base while one runs: the URL
593
648
  // lands in the service through the tunnel's phase listener (the minted
594
649
  // quick URL, or the named tunnel's fixed public hostname). The manual
@@ -30,7 +30,23 @@ export type LanBindHost = '0.0.0.0' | '127.0.0.1';
30
30
  * profiles directory.
31
31
  */
32
32
  export declare function profilePatchFile(profile: string, home?: string): string;
33
- /** Remove the managed block (and its markers) from patch content. */
33
+ /**
34
+ * Remove the managed block (its markers and the webserver row this plugin
35
+ * wrote) from patch content, keeping every other row.
36
+ *
37
+ * The markers travel with the file through writers that append rows through
38
+ * the YAML document API: the appended rows land at the end of the sequence,
39
+ * which is BEFORE a trailing END comment, so the region between the markers
40
+ * can hold rows this plugin never wrote. Deleting the whole region silently
41
+ * removed the user's model, plugin and remote-control rows on the next rewrite
42
+ * - the remote-control row carries the LAN toggle itself, so the feature reset
43
+ * to `undefined` and the phone was locked out again after a restart (DSH
44
+ * Desktop report, 2026-09-30). Only the managed item is removed here; every
45
+ * foreign row is kept in place and the fresh block is appended after them (the
46
+ * same-id row last wins).
47
+ * @param content - the patch file text.
48
+ * @returns the text with the markers and the managed row removed.
49
+ */
34
50
  export declare function stripManagedBlock(content: string): string;
35
51
  /**
36
52
  * Render the managed block for one bind state. The values are static: the
@@ -61,9 +77,10 @@ export declare function lanBindState(profile: string, home?: string): {
61
77
  * patch file is preserved; the block is rewritten atomically (unique temp
62
78
  * file + rename, so concurrent writers can never rename a half-written
63
79
  * file) with the original file's permissions preserved. A hand-truncated
64
- * unterminated block (BEGIN marker without END, which stripManagedBlock
65
- * cannot match) is truncated at its BEGIN marker first so the rewrite can
66
- * never stack a second webserver row onto the orphan.
80
+ * unterminated block (BEGIN marker without END) is consumed by
81
+ * stripManagedBlock as well - markers and managed row removed, foreign rows
82
+ * kept - so the rewrite can never stack a second webserver row onto the
83
+ * orphan.
67
84
  *
68
85
  * The written file is always exactly one valid YAML document: a base the
69
86
  * plugin manager left in flow style is re-emitted as a block sequence before
@@ -1 +1 @@
1
- {"version":3,"file":"lan-bind.d.ts","sourceRoot":"","sources":["../../src/lan-bind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAUH,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AACpG,eAAO,MAAM,kBAAkB,+CAA+C,CAAA;AAE9E,iDAAiD;AACjD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,CAAA;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,MAAM,CAUlF;AAYD,qEAAqE;AACrE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKzD;AA0DD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcpE;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;IACjC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAW3E;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG;IAAE,YAAY,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAI/H;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,IAAI,CAa7G"}
1
+ {"version":3,"file":"lan-bind.d.ts","sourceRoot":"","sources":["../../src/lan-bind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAUH,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AACpG,eAAO,MAAM,kBAAkB,+CAA+C,CAAA;AAE9E,iDAAiD;AACjD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,CAAA;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,MAAM,CAUlF;AAqED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoBzD;AA0DD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcpE;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;IACjC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAkB3E;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG;IAAE,YAAY,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAI/H;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,IAAI,CAW7G"}
@@ -49,16 +49,96 @@ function readPatchContent(file) {
49
49
  return '';
50
50
  return readFileSync(file, 'utf8');
51
51
  }
52
- /** Escape one literal string for embedding into a RegExp pattern. */
53
- function escapeRegex(text) {
54
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ /**
53
+ * Split a block-sequence region into its top-level items. Only a column-0 `-`
54
+ * starts an item: that is the shape every writer of this file produces - the
55
+ * managed block itself and the YAML document API's block-style re-emission.
56
+ * @param region - text between two line boundaries.
57
+ * @returns the items in source order; text outside them is not returned.
58
+ */
59
+ function blockItems(region) {
60
+ const items = [];
61
+ let start = -1;
62
+ let offset = 0;
63
+ for (const line of region.split('\n')) {
64
+ if (line.startsWith('- ') || line === '-') {
65
+ if (start !== -1)
66
+ items.push({ start, end: offset, text: region.slice(start, offset) });
67
+ start = offset;
68
+ }
69
+ offset += line.length + 1;
70
+ }
71
+ if (start !== -1)
72
+ items.push({ start, end: region.length, text: region.slice(start) });
73
+ return items;
55
74
  }
56
- /** Remove the managed block (and its markers) from patch content. */
75
+ /**
76
+ * Whether one top-level item is the webserver row this plugin's block owns.
77
+ * The row id is the whole signal: the markers declare everything inside them
78
+ * managed, our block always writes `- id: webserver`, and a same-id row later
79
+ * in the patch replaces this one wholesale anyway - so an id-webserver row
80
+ * found inside our markers is this plugin's row (a hand-truncated block may
81
+ * have lost its `name` line).
82
+ * @param item - one top-level item's text.
83
+ * @returns true when the item is the managed webserver row.
84
+ */
85
+ function isManagedWebserverItem(item) {
86
+ return /^- id:\s*['"]?webserver['"]?[ \t]*(?:\r?\n|$)/.test(item);
87
+ }
88
+ /**
89
+ * The text between a block's BEGIN and END markers: from the character after
90
+ * the BEGIN line's terminator to the start of the END marker's line (or to
91
+ * the end of the content without an END marker).
92
+ * @param content - the patch file text.
93
+ * @param begin - index of the BEGIN marker.
94
+ * @param end - index of the END marker, or -1 when the block is unterminated.
95
+ * @returns the region's text.
96
+ */
97
+ function managedRegion(content, begin, end) {
98
+ const newlineAfterBegin = content.indexOf('\n', begin);
99
+ const innerStart = newlineAfterBegin === -1 ? content.length : newlineAfterBegin + 1;
100
+ const endLineStart = end === -1 ? content.length : content.lastIndexOf('\n', end - 1) + 1;
101
+ return content.slice(innerStart, Math.max(innerStart, endLineStart));
102
+ }
103
+ /**
104
+ * Remove the managed block (its markers and the webserver row this plugin
105
+ * wrote) from patch content, keeping every other row.
106
+ *
107
+ * The markers travel with the file through writers that append rows through
108
+ * the YAML document API: the appended rows land at the end of the sequence,
109
+ * which is BEFORE a trailing END comment, so the region between the markers
110
+ * can hold rows this plugin never wrote. Deleting the whole region silently
111
+ * removed the user's model, plugin and remote-control rows on the next rewrite
112
+ * - the remote-control row carries the LAN toggle itself, so the feature reset
113
+ * to `undefined` and the phone was locked out again after a restart (DSH
114
+ * Desktop report, 2026-09-30). Only the managed item is removed here; every
115
+ * foreign row is kept in place and the fresh block is appended after them (the
116
+ * same-id row last wins).
117
+ * @param content - the patch file text.
118
+ * @returns the text with the markers and the managed row removed.
119
+ */
57
120
  export function stripManagedBlock(content) {
58
- const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN);
59
- const end = escapeRegex(LAN_BIND_BLOCK_END);
60
- const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, 'g');
61
- return content.replace(pattern, '\n');
121
+ let text = content;
122
+ for (;;) {
123
+ const begin = text.indexOf(LAN_BIND_BLOCK_BEGIN);
124
+ if (begin === -1)
125
+ return text;
126
+ const end = text.indexOf(LAN_BIND_BLOCK_END, begin);
127
+ const blockStart = text.lastIndexOf('\n', begin - 1) + 1;
128
+ const newlineAfterEnd = end === -1 ? -1 : text.indexOf('\n', end);
129
+ const resumeAt = end === -1 ? text.length : (newlineAfterEnd === -1 ? text.length : newlineAfterEnd + 1);
130
+ const region = managedRegion(text, begin, end);
131
+ let kept = '';
132
+ let cursor = 0;
133
+ for (const item of blockItems(region)) {
134
+ if (!isManagedWebserverItem(item.text))
135
+ continue;
136
+ kept += region.slice(cursor, item.start);
137
+ cursor = item.end;
138
+ }
139
+ kept += region.slice(cursor);
140
+ text = text.slice(0, blockStart) + kept + text.slice(resumeAt);
141
+ }
62
142
  }
63
143
  /**
64
144
  * Render the managed block for one bind state. The values are static: the
@@ -149,9 +229,16 @@ export function managedBindOf(content) {
149
229
  if (begin === -1)
150
230
  return undefined;
151
231
  const end = content.indexOf(LAN_BIND_BLOCK_END, begin);
152
- const block = end === -1 ? content.slice(begin) : content.slice(begin, end);
153
- const hostMatch = /host:\s*'([^']+)'/.exec(block);
154
- const portMatch = /port:\s*(\d+)/.exec(block);
232
+ const region = managedRegion(content, begin, end);
233
+ // Read the bind out of the managed row itself. A region a YAML writer
234
+ // displaced holds foreign rows too, and their `host`/`port` keys are not
235
+ // this block's bind - reading them made the boot re-assert
236
+ // believe the block was already applied. A region without a recognizable
237
+ // managed row (hand-edited literals) keeps the historical whole-region read.
238
+ const items = blockItems(region).filter(item => isManagedWebserverItem(item.text));
239
+ const scope = items.length > 0 ? items.map(item => item.text).join('') : region;
240
+ const hostMatch = /host:\s*'([^']+)'/.exec(scope);
241
+ const portMatch = /port:\s*(\d+)/.exec(scope);
155
242
  return {
156
243
  host: hostMatch?.[1] ?? '',
157
244
  port: portMatch !== null ? Number(portMatch[1]) : undefined,
@@ -169,9 +256,10 @@ export function lanBindState(profile, home = dshHome()) {
169
256
  * patch file is preserved; the block is rewritten atomically (unique temp
170
257
  * file + rename, so concurrent writers can never rename a half-written
171
258
  * file) with the original file's permissions preserved. A hand-truncated
172
- * unterminated block (BEGIN marker without END, which stripManagedBlock
173
- * cannot match) is truncated at its BEGIN marker first so the rewrite can
174
- * never stack a second webserver row onto the orphan.
259
+ * unterminated block (BEGIN marker without END) is consumed by
260
+ * stripManagedBlock as well - markers and managed row removed, foreign rows
261
+ * kept - so the rewrite can never stack a second webserver row onto the
262
+ * orphan.
175
263
  *
176
264
  * The written file is always exactly one valid YAML document: a base the
177
265
  * plugin manager left in flow style is re-emitted as a block sequence before
@@ -179,9 +267,7 @@ export function lanBindState(profile, home = dshHome()) {
179
267
  */
180
268
  export function writeLanBind(host, port, profile, home = dshHome()) {
181
269
  const file = profilePatchFile(profile, home);
182
- const stripped = stripManagedBlock(readPatchContent(file));
183
- const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN);
184
- const rawBase = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd();
270
+ const rawBase = stripManagedBlock(readPatchContent(file)).trimEnd();
185
271
  const base = rawBase.replace(/\[\s*\]\s*$/, '').trimEnd();
186
272
  const block = managedBlock(host, port);
187
273
  const content = mergePatchContent(base, block, file);
@@ -1 +1 @@
1
- {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAAe,eAAe,EAAE,MAAM,cAAc,CAAA;AAEhE,uDAAuD;AACvD,MAAM,MAAM,YAAY;AACtB,mEAAmE;AACjE,cAAc;AAChB,oEAAoE;GAClE,SAAS;AACX,4DAA4D;GAC1D,SAAS;AACX,sDAAsD;GACpD,WAAW;AACb,6EAA6E;GAC3E,cAAc,CAAA;AAElB,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAA;IACV,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAA2B,CAAA;AAK9D,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAA;IACjB,gEAAgE;IAChE,UAAU,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,wDAAwD;IACxD,EAAE,EAAE,MAAM,CAAA;IACV,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAA;IACjB,kDAAkD;IAClD,UAAU,EAAE,MAAM,CAAA;IAClB,uDAAuD;IACvD,MAAM,EAAE,OAAO,CAAA;IACf,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAA;IACxC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,KAAK,EAAE,KAAK,GAAG,aAAa,GAAG,SAAS,GAAG,QAAQ,CAAA;IACnD,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,YAAY,CAAA;IACnB,yEAAyE;IACzE,YAAY,EAAE,OAAO,CAAA;IACrB,oEAAoE;IACpE,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,uEAAuE;IACvE,KAAK,CAAC,EAAE,WAAW,CAAA;IACnB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2CAA2C;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAA;IACnB,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,cAAc,EAAE,MAAM,CAAA;IACtB,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,SAAS,CAAA;CAAE,CAAA;AAElC,yEAAyE;AACzE,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C;;OAEG;gBACS,OAAO,EAAE,MAAM;CAI5B;AAED,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,GAAG,IAAI,MAAM,CAAA;IACb,WAAW,IAAI,MAAM,CAAA;CACtB;AAED,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,UAAU,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAED,yDAAyD;AACzD,eAAO,MAAM,YAAY,EAAE,YAG1B,CAAA;AAED;;;;;;;;GAQG;AACH,qBAAa,cAAc;IAyBhB,MAAM,EAAE,aAAa;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,EAAE;IA1BrB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiD;IAC3E,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,WAAW,CAAI;IACvB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,CAA4B;IAC5C,sEAAsE;IACtE,OAAO,CAAC,UAAU,CAAoB;IACtC,mEAAmE;IACnE,OAAO,CAAC,YAAY,CAA0B;IAC9C,OAAO,CAAC,WAAW,CAAyB;IAC5C,OAAO,CAAC,OAAO,CAA6B;IAC5C,8EAA8E;IAC9E,OAAO,CAAC,KAAK,CAAQ;IAErB;;;;;OAKG;gBAEM,MAAM,EAAE,aAAa,EACX,KAAK,GAAE,YAA2B,EAClC,EAAE,GAAE,cAAmB;IAK1C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,aAAa;IA4BrB,iEAAiE;IACjE,OAAO,CAAC,SAAS;IAajB;;;;;;;;;OASG;IACH,OAAO,CAAC,OAAO;IAkBf;;;;;;;;OAQG;IACH,OAAO,CAAC,iBAAiB;IAazB,wFAAwF;IACxF,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAEnC;IAED,oFAAoF;IACpF,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIlD,wEAAwE;IACxE,IAAI,YAAY,IAAI,MAAM,EAAE,CAE3B;IAED,6EAA6E;IAC7E,WAAW,CAAC,OAAO,EAAE,SAAS;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,GAAG,IAAI;IAKxE,+DAA+D;IAC/D,IAAI,aAAa,IAAI,MAAM,GAAG,SAAS,CAEtC;IAED;;;;;OAKG;IACH,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAK/C,qFAAqF;IACrF,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI;IAKvD,iFAAiF;IACjF,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI;IAKrD,iEAAiE;IACjE,UAAU,CAAC,QAAQ,EAAE,eAAe,GAAG,SAAS,GAAG,IAAI;IAKvD;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;IAuBnF;;;;;;;;;OASG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,YAAY;IA+BvD;;;;OAIG;IACH,IAAI,IAAI,IAAI;IAQZ;;;;;OAKG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAQjC;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAQtC,oEAAoE;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIpC;;;;;OAKG;IACH,KAAK,IAAI,IAAI;IAOb,2EAA2E;IAC3E,QAAQ,IAAI,eAAe;IAsB3B,+EAA+E;IAC/E,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIpC;;;;;OAKG;IACH,WAAW,IAAI,MAAM;IAIrB,yEAAyE;IACzE,OAAO,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI;IAKlE,OAAO,CAAC,WAAW;IASnB;;;OAGG;IACH,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,gBAAgB;IAUxB,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,MAAM;CAaf;AA0FD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMpD;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAK7E"}
@@ -509,6 +509,7 @@ function snapshotsEqual(a, b) {
509
509
  && a.publicUrl === b.publicUrl
510
510
  && tunnelEqual(a.tunnel, b.tunnel)
511
511
  && relayEqual(a.relay, b.relay)
512
+ && postureEqual(a.posture, b.posture)
512
513
  && a.tokenId === b.tokenId
513
514
  && a.tokenExpiresAt === b.tokenExpiresAt
514
515
  && a.deviceCount === b.deviceCount
@@ -537,6 +538,48 @@ function relayEqual(a, b) {
537
538
  return a === b || (a !== undefined && b !== undefined
538
539
  && a.state === b.state && a.url === b.url && a.error === b.error);
539
540
  }
541
+ /**
542
+ * Posture equality. A probe round can change this frame while every other field
543
+ * stays put — the common case, since the probe re-runs on a settings change and
544
+ * on a tunnel reaching running, and a quick-tunnel restart churns the probed
545
+ * host set. Leaving the frame out of the comparison suppressed those emits, so
546
+ * the loopback SSE stream and the desktop panel could sit on a stale "exposed"
547
+ * (or stale "clean") verdict for as long as the phase and roster stayed put.
548
+ */
549
+ function postureEqual(a, b) {
550
+ if (a === b)
551
+ return true;
552
+ // A frame that is not an object at all (a hand-built context can pass
553
+ // anything) counts as changed instead of throwing out of the setter.
554
+ if (!isPostureFrame(a) || !isPostureFrame(b))
555
+ return false;
556
+ return a.checkedAt === b.checkedAt && hostsEqual(a.hosts, b.hosts);
557
+ }
558
+ /** Whether a value has the shape a posture frame is compared on. */
559
+ function isPostureFrame(value) {
560
+ return typeof value === 'object' && value !== null;
561
+ }
562
+ /**
563
+ * Per-host posture equality (a round's verdicts, in probe order). Tolerates a
564
+ * frame that is not the expected shape: this runs inside the emit path, outside
565
+ * the listener error containment, so a malformed snapshot handed in by another
566
+ * host-fiber caller must degrade to "changed" instead of throwing out of the
567
+ * setter that produced it.
568
+ */
569
+ function hostsEqual(a, b) {
570
+ if (a === b)
571
+ return true;
572
+ if (!Array.isArray(a) || !Array.isArray(b))
573
+ return false;
574
+ return a.length === b.length && a.every((host, index) => {
575
+ const other = b[index];
576
+ return isPostureHost(host) && isPostureHost(other) && host.host === other.host && host.exposed === other.exposed;
577
+ });
578
+ }
579
+ /** Whether a value has the shape one probed host is compared on. */
580
+ function isPostureHost(value) {
581
+ return typeof value === 'object' && value !== null;
582
+ }
540
583
  /** Element-wise string list equality (interface order is meaningful). */
541
584
  function sameStrings(a, b) {
542
585
  return a.length === b.length && a.every((value, index) => value === b[index]);
@@ -70,7 +70,19 @@ export declare function pairedDeviceIdOf(req: IncomingMessage, service: PairingS
70
70
  */
71
71
  export declare function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[];
72
72
  /**
73
- * Map one outer upgrade URL onto the loopback path (query string included).
73
+ * Map one outer upgrade URL onto the loopback path, dropping the cookieless
74
+ * device credential on the way in.
75
+ *
76
+ * The outer handshake carries the device id as `?device=<id>` because WebSocket
77
+ * handshakes cannot carry headers from the Web API. That id is a live session
78
+ * credential — it opens the full host API through this same channel — and the
79
+ * inner leg is a request the rest of the loopback origin sees (on a WebSocket
80
+ * leg, a raw handshake request line written to a loopback socket), so forwarding
81
+ * it handed a working credential to every other observer of the local port for
82
+ * no benefit: the gate above has already authenticated the caller, and no
83
+ * upstream route reads the parameter (the gateway mux matches on path alone).
84
+ * The rest of the query rides through unchanged, so a caller's own parameters
85
+ * still reach the upstream.
74
86
  */
75
87
  export declare function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string;
76
88
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAwCnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAyCjF"}
1
+ {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAwCnE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAYzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAyCjF"}
@@ -137,7 +137,19 @@ export function makeRemoteApiRoutes(deps) {
137
137
  return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }];
138
138
  }
139
139
  /**
140
- * Map one outer upgrade URL onto the loopback path (query string included).
140
+ * Map one outer upgrade URL onto the loopback path, dropping the cookieless
141
+ * device credential on the way in.
142
+ *
143
+ * The outer handshake carries the device id as `?device=<id>` because WebSocket
144
+ * handshakes cannot carry headers from the Web API. That id is a live session
145
+ * credential — it opens the full host API through this same channel — and the
146
+ * inner leg is a request the rest of the loopback origin sees (on a WebSocket
147
+ * leg, a raw handshake request line written to a loopback socket), so forwarding
148
+ * it handed a working credential to every other observer of the local port for
149
+ * no benefit: the gate above has already authenticated the caller, and no
150
+ * upstream route reads the parameter (the gateway mux matches on path alone).
151
+ * The rest of the query rides through unchanged, so a caller's own parameters
152
+ * still reach the upstream.
141
153
  */
142
154
  export function upgradeInnerPath(reqUrl, fallbackPath) {
143
155
  if (reqUrl === undefined || reqUrl === '')
@@ -152,6 +164,7 @@ export function upgradeInnerPath(reqUrl, fallbackPath) {
152
164
  const inner = innerPathOf(url.pathname);
153
165
  if (inner === undefined)
154
166
  return fallbackPath;
167
+ url.searchParams.delete(REMOTE_DEVICE_QUERY);
155
168
  return `${inner}${url.search}`;
156
169
  }
157
170
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.3",
4
+ "version": "0.4.5",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -38,7 +38,7 @@
38
38
  "license": "Apache-2.0",
39
39
  "dsh": {
40
40
  "engines": {
41
- "dsh": ">=0.1.7-rc.2"
41
+ "dsh": ">=0.2.0-rc.2"
42
42
  },
43
43
  "bundle": {
44
44
  "patch": "./cordis.patch.yml"
@@ -61,28 +61,28 @@
61
61
  "zod": "^4.4.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@deepseek-ai/dsh": ">=0.1.7-rc.2",
64
+ "@deepseek-ai/dsh": ">=0.2.0-rc.2",
65
65
  "react": "^18.2.0",
66
66
  "react-dom": "^18.2.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@deepseek-ai/cordis": "^4.0.4",
70
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
71
- "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
72
- "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
73
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
74
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
75
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
76
- "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
77
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2",
78
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
79
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
80
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
81
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
82
- "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
83
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
84
- "@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
85
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.2",
72
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.2",
73
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
76
+ "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
78
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
79
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
80
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
81
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
82
+ "@deepseek-ai/dsh-commands": "^0.2.0-rc.2",
83
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
84
+ "@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
85
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
86
86
  "@testing-library/dom": "^10.4.1",
87
87
  "@testing-library/react": "^16.3.2",
88
88
  "@types/node": "^22.20.0",
@@ -41,6 +41,13 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
41
41
  descriptionKey: TKey
42
42
  /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
43
  descriptionNode?: ReactNode
44
+ /**
45
+ * Optional leading mark identifying the plugin. A card without one keeps the
46
+ * plain official header shape, so the chrome stays a readable sibling of the
47
+ * built-in PluginCard wherever a family plugin lands in the official bundle
48
+ * seat.
49
+ */
50
+ icon?: ReactNode
44
51
  /** The card's form state: availability, writability, and what a save would do. */
45
52
  state: CardShell
46
53
  /** Write every staged edit. */
@@ -91,6 +98,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
91
98
  const header = alwaysOpen === true
92
99
  ? (
93
100
  <div className={css.headerStatic}>
101
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
94
102
  <span className={css.headText}>
95
103
  <span className={css.name} title={title}>{title}</span>
96
104
  <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
@@ -106,6 +114,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
106
114
  aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
107
115
  onClick={() => { setOpen(!open) }}
108
116
  >
117
+ {props.icon !== undefined ? <span className={css.mark} aria-hidden="true">{props.icon}</span> : null}
109
118
  <span className={css.headText}>
110
119
  <span className={css.name} title={title}>{title}</span>
111
120
  <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
@@ -9,6 +9,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
9
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
+ import { PhoneIcon } from './PhoneIcon.tsx'
12
13
  import { CardForm, booleanField, numberField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
14
  import { LanBindStatusError, readLanBindStatus, shouldStopLanBindPoll, type LanBindFrame } from './pair-api.ts'
14
15
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -173,6 +174,7 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
173
174
  t={t}
174
175
  titleKey="settings.title"
175
176
  descriptionKey="settings.description"
177
+ icon={<PhoneIcon size={18} />}
176
178
  defaultOpen={false}
177
179
  state={state}
178
180
  onSave={props.save}
@@ -242,9 +242,9 @@ export function apply(ctx: ClientContext): void {
242
242
 
243
243
  const t = ctx.locale.bind(NS)
244
244
  // Hand the `remote` translate seat to the module-scope adaptation layer:
245
- // the whale/compact-picker labels were rendered with the English fallback
246
- // before any dictionary existed; the wiring plus the layer's own sync tick
247
- // re-render them in the active locale.
245
+ // the whale label was rendered with the English fallback before any
246
+ // dictionary existed; the wiring plus the layer's own sync tick re-renders
247
+ // it in the active locale.
248
248
  if (adapt !== undefined) adapt.translate = t
249
249
  // The settings form this card edits. The family group's binder resolves a
250
250
  // family namespace to the profile entry that owns it and binds that entry's
@@ -4,8 +4,6 @@
4
4
  export const zh = {
5
5
  'entry.label': '远程访问',
6
6
  'mobile.whale.open': '打开侧边栏',
7
- 'mobile.composer.pickModel': '选择模型',
8
- 'mobile.composer.pickEffort': '选择推理等级',
9
7
  'title': '远程访问',
10
8
  'subtitle': '通过手机或另一台电脑配对,远程使用同一份 Web 界面(官方界面 + 移动端适配)',
11
9
  'card.title': '设备配对',
@@ -15,7 +13,7 @@ export const zh = {
15
13
  'status.disconnected': '已配对设备离线',
16
14
  'status.stopped': '已停止远程访问',
17
15
  'status.lanRequired': '此功能需要局域网绑定或公网地址才能使用',
18
- 'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问设置」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
16
+ 'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
19
17
  'status.loopbackRequired': '配对面板仅限本机使用',
20
18
  'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
21
19
  'status.unreachable': '无法连接配对服务',
@@ -72,8 +70,8 @@ export const zh = {
72
70
  'relay.failed': '固定域名同步失败:{error}。正在自动重试,期间二维码可能使用临时地址。',
73
71
  'close.label': '关闭远程访问面板',
74
72
  // 插件设置卡片(家族 list 席位,或官方 plugins.bundle.config 席位)。
75
- 'settings.title': '远程访问设置',
76
- 'settings.description': '配对安全与设备限额。',
73
+ 'settings.title': '远程访问',
74
+ 'settings.description': '手机配对、公网隧道与设备限额。',
77
75
  'settings.enabled': '启用远程访问',
78
76
  'settings.enabledHint': '关闭后移除侧边栏入口并停用配对路由与局域网栅栏。',
79
77
  'settings.tokenTtlMs': '配对令牌有效期(毫秒)',
@@ -135,8 +133,6 @@ export type SettingsCardKey = RemoteKey
135
133
  export const en = {
136
134
  'entry.label': 'Remote access',
137
135
  'mobile.whale.open': 'Open sidebar',
138
- 'mobile.composer.pickModel': 'Pick model',
139
- 'mobile.composer.pickEffort': 'Pick reasoning effort',
140
136
  'title': 'Remote access',
141
137
  'subtitle': 'Pair a phone or another computer to share the same Web GUI (official UI + mobile adaptation)',
142
138
  'card.title': 'Pair a device',
@@ -203,8 +199,8 @@ export const en = {
203
199
  'relay.failed': 'Stable-hostname sync failed: {error}. Retrying automatically; the QR may use the ephemeral address meanwhile.',
204
200
  'close.label': 'Close remote access panel',
205
201
  // Plugin settings card (the family list seat, or the official bundle-config seat).
206
- 'settings.title': 'Remote access settings',
207
- 'settings.description': 'Pairing security and device limits.',
202
+ 'settings.title': 'Remote access',
203
+ 'settings.description': 'Phone pairing, public tunnel and device limits.',
208
204
  'settings.enabled': 'Enable remote access',
209
205
  'settings.enabledHint': 'When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.',
210
206
  'settings.tokenTtlMs': 'Pairing token lifetime (ms)',