@linxin666/dsh-remote-web-ui 0.3.22 → 0.3.24

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 (43) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/lib/client.js +193 -20
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +181 -34
  7. package/lib/types/client/index.d.ts.map +1 -1
  8. package/lib/types/client/index.js +13 -18
  9. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  10. package/lib/types/client/mobile-adapt.js +40 -6
  11. package/lib/types/client/plugin-card-seat.d.ts +97 -0
  12. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  13. package/lib/types/client/plugin-card-seat.js +141 -0
  14. package/lib/types/client/remote-channel.d.ts +35 -0
  15. package/lib/types/client/remote-channel.d.ts.map +1 -1
  16. package/lib/types/client/remote-channel.js +55 -0
  17. package/lib/types/console-output.d.ts +41 -0
  18. package/lib/types/console-output.d.ts.map +1 -0
  19. package/lib/types/console-output.js +89 -0
  20. package/lib/types/index.d.ts.map +1 -1
  21. package/lib/types/index.js +26 -22
  22. package/lib/types/public-base.d.ts +67 -0
  23. package/lib/types/public-base.d.ts.map +1 -0
  24. package/lib/types/public-base.js +110 -0
  25. package/lib/types/remote-channel-boot.d.ts +5 -2
  26. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  27. package/lib/types/remote-channel-boot.js +28 -2
  28. package/lib/types/remote-channel-rules.d.ts +4 -0
  29. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  30. package/lib/types/remote-channel-rules.js +2 -0
  31. package/lib/types/update.d.ts.map +1 -1
  32. package/lib/types/update.js +5 -12
  33. package/package.json +1 -1
  34. package/src/client/index.ts +13 -17
  35. package/src/client/mobile-adapt.ts +39 -5
  36. package/src/client/plugin-card-seat.ts +185 -0
  37. package/src/client/remote-channel.ts +64 -0
  38. package/src/console-output.ts +97 -0
  39. package/src/index.ts +26 -22
  40. package/src/public-base.ts +122 -0
  41. package/src/remote-channel-boot.ts +28 -2
  42. package/src/remote-channel-rules.ts +6 -0
  43. package/src/update.ts +5 -10
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The public base the pairing fence trusts while a tunnel is not running
3
+ * (issue #1547).
4
+ *
5
+ * The fence reads exactly one public host: `service.publicBaseUrl`. Dropping
6
+ * it the instant the tunnel leaves `running` makes every public request from
7
+ * the address still printed in the QR code answer 403 — including
8
+ * `/api/pair/status`, which exists to be callable before pairing. Two of the
9
+ * three tunnel modes never lose their host: a named tunnel keeps its fixed
10
+ * dashboard hostname, and a registered relay keeps its stable subdomain. A
11
+ * quick tunnel does mint a new hostname on restart, so its previous host is
12
+ * kept only for a bounded grace window: Cloudflare's edge can still deliver a
13
+ * connection the phone already opened, and a mobile network reconnects
14
+ * constantly, so an instant drop turns the phone's retry into a silent 403.
15
+ * @module @linxin666/dsh-remote-web-ui/public-base
16
+ */
17
+ /** How long a quick-tunnel host stays trusted after the tunnel stops running. */
18
+ export const PUBLIC_BASE_GRACE_MS = 60_000;
19
+ /**
20
+ * Owns the trusted public base across tunnel and relay transitions.
21
+ *
22
+ * The keeper never decides on its own to widen trust: it only postpones
23
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
24
+ * already published, and it forgets it once the tunnel comes back with the
25
+ * address that actually serves the QR.
26
+ */
27
+ export class PublicBaseKeeper {
28
+ onChange;
29
+ graceMs;
30
+ mode = 'off';
31
+ tunnelUrl;
32
+ relayUrl;
33
+ graceTimer;
34
+ /**
35
+ * @param onChange - receives the base the fence should trust from now on.
36
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
37
+ */
38
+ constructor(onChange, graceMs = PUBLIC_BASE_GRACE_MS) {
39
+ this.onChange = onChange;
40
+ this.graceMs = graceMs;
41
+ }
42
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
43
+ current() {
44
+ return this.relayUrl ?? this.tunnelUrl;
45
+ }
46
+ /** The mode that currently owns the public base. */
47
+ setMode(mode) {
48
+ this.mode = mode;
49
+ }
50
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
51
+ quickUrl() {
52
+ return this.tunnelUrl;
53
+ }
54
+ /** A relay registration result; undefined withdraws the stable origin. */
55
+ setRelay(url) {
56
+ this.relayUrl = url;
57
+ this.emit();
58
+ }
59
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
60
+ markRunning(url) {
61
+ this.cancelGrace();
62
+ this.tunnelUrl = url;
63
+ this.emit();
64
+ }
65
+ /**
66
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
67
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
68
+ * trusted for the grace window, then is dropped if nothing replaced it.
69
+ */
70
+ markReconnecting() {
71
+ if (this.mode === 'named')
72
+ return;
73
+ this.startGrace();
74
+ }
75
+ /** Re-publish the current base after a branch change that did not move it. */
76
+ refresh() {
77
+ this.emit();
78
+ }
79
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
80
+ reset() {
81
+ this.cancelGrace();
82
+ this.tunnelUrl = undefined;
83
+ this.relayUrl = undefined;
84
+ this.emit();
85
+ }
86
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
87
+ dispose() {
88
+ this.cancelGrace();
89
+ }
90
+ startGrace() {
91
+ if (this.tunnelUrl === undefined)
92
+ return;
93
+ if (this.graceTimer !== undefined)
94
+ return;
95
+ this.graceTimer = setTimeout(() => {
96
+ this.graceTimer = undefined;
97
+ this.tunnelUrl = undefined;
98
+ this.emit();
99
+ }, this.graceMs);
100
+ }
101
+ cancelGrace() {
102
+ if (this.graceTimer === undefined)
103
+ return;
104
+ clearTimeout(this.graceTimer);
105
+ this.graceTimer = undefined;
106
+ }
107
+ emit() {
108
+ this.onChange(this.current());
109
+ }
110
+ }
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
  import { type RemoteChannelRules } from './remote-channel-rules.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CA8HrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAgB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
1
+ {"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CAqJrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAgB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
  import { REMOTE_CHANNEL_BOOT_GLOBAL, REMOTE_CHANNEL_RULES } from "./remote-channel-rules.js";
@@ -140,6 +143,29 @@ export function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
140
143
  'Object.defineProperty(C.prototype,"src",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});' +
141
144
  'restores.push(function(){Object.defineProperty(C.prototype,"src",d)})}' +
142
145
  'patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);' +
146
+ // Background uploads must stay on the patched main-thread fetch
147
+ // (issue #1580). Without this hook @deepseek-ai/dsh-client-file-upload
148
+ // runs its carrier in a Web Worker, whose own globals no main-thread
149
+ // patch reaches: the worker's XHR goes straight to <origin>/api/...
150
+ // without the /remote rewrite and without the device credential, so the
151
+ // harness browser-auth fence answers 401 and every upload from a paired
152
+ // browser fails. The hook is the official pre-Cordis seam (the runtime
153
+ // reads it once in its constructor) and rewriting the worker URL is
154
+ // provably insufficient - a worker context carries neither the pairing
155
+ // cookie nor the device header. Hand the runtime the patched fetch
156
+ // instead: it takes the absolute route URL and a RequestInit, and hands
157
+ // both to the patched w.fetch, which owns the /remote rewrite AND the
158
+ // cookieless device header. Delegating the whole decision is deliberate:
159
+ // rewriting the path here first would make w.fetch see an already-gated
160
+ // /remote/api path, skip its own rewrite branch, and drop the device
161
+ // credential that the fence requires.
162
+ 'function uf(u,init){' +
163
+ 'var raw=typeof u==="string"?u:u.href;' +
164
+ 'if(typeof raw!=="string")return of.call(w,u,init||{});' +
165
+ 'var q=new URL(raw,loc.href);' +
166
+ 'if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});' +
167
+ 'return of.call(w,u,init||{})}' +
168
+ 'try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}' +
143
169
  'seat.restore=function(){' +
144
170
  'w.fetch=of;' +
145
171
  'w.WebSocket=OW;' +
@@ -28,6 +28,10 @@ export interface RemoteChannelRules {
28
28
  readonly deviceKey: string;
29
29
  /** Query parameter carrying it on WebSocket upgrades. */
30
30
  readonly deviceQuery: string;
31
+ /** Raw upload route the boot hook keeps on the gated channel. */
32
+ readonly uploadPath: string;
33
+ /** Page global the pre-Cordis upload hook is published under. */
34
+ readonly uploadHookGlobal: string;
31
35
  }
32
36
  /** The live rule set. */
33
37
  export declare const REMOTE_CHANNEL_RULES: RemoteChannelRules;
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBAqBlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
1
+ {"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;CAClC;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBAuBlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
@@ -33,6 +33,8 @@ export const REMOTE_CHANNEL_RULES = {
33
33
  deviceHeader: REMOTE_DEVICE_HEADER,
34
34
  deviceKey: 'dsh-remote-device',
35
35
  deviceQuery: REMOTE_DEVICE_QUERY,
36
+ uploadPath: '/api/session/uploadFileBinary',
37
+ uploadHookGlobal: '__DSH_FILE_UPLOAD__',
36
38
  };
37
39
  /** The window global the boot patch publishes its seat under. */
38
40
  export const REMOTE_CHANNEL_BOOT_GLOBAL = '__DSH_REMOTE_CHANNEL_BOOT__';
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@
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 { decodeConsoleBytes } from "./console-output.js";
14
15
  /** npm registry base used for version probes. */
15
16
  export const NPM_REGISTRY = 'https://registry.npmjs.org';
16
17
  /** The family scope every dsh-web package is published under. */
@@ -434,20 +435,12 @@ const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|
434
435
  /**
435
436
  * Decode process output with tolerant decoding:
436
437
  * 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.
438
+ * Windows consoles using code page 936/GBK), falls back to the code page that
439
+ * loses the least text. Bytes are decoded once per whole chunk by the caller's
440
+ * accumulator — decoding per \`data\` event would split multi-byte characters.
438
441
  */
439
442
  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
- }
443
+ return decodeConsoleBytes(chunk);
451
444
  }
452
445
  /**
453
446
  * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
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.3.22",
4
+ "version": "0.3.24",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -39,6 +39,7 @@ import {
39
39
  import { FenceNotice } from './FenceNotice.tsx'
40
40
  import { reportDailyHeartbeat } from './telemetry.ts'
41
41
  import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
42
+ import { installPluginCard } from './plugin-card-seat.ts'
42
43
 
43
44
  // Portrait-touch adaptation of the official UI: installed under the plugin
44
45
  // lifecycle (apply) so disabling the plugin in cordis patch (disabled: true)
@@ -232,25 +233,20 @@ export function apply(ctx: ClientContext): void {
232
233
  })
233
234
 
234
235
  // Plugin configuration card: one staged form over the `remote-web-ui`
235
- // settings namespace, contributed to the Web UI plugin group.
236
+ // settings namespace, contributed to whichever plugin-card seat the running
237
+ // host declares (the family group's list seat, or the official keyed seat of
238
+ // the plugin-configuration tab when dsh-web-settings is not installed —
239
+ // issue #1589).
236
240
  const remoteSettings = new RemoteSettingsCardController(settingsScope)
237
- ctx.slots.inject('web-ui.plugin.item', () => {
238
- try {
239
- const unregister = ctx.slots.register({
240
- name: 'web-ui.plugin.item',
241
- id: 'remote-web-ui',
242
- order: 90,
243
- locale: NS,
244
- inject: () => remoteSettings.inject(),
245
- }, RemoteSettingsCard)
246
- return () => {
247
- remoteSettings.dispose()
248
- unregister()
249
- }
250
- } catch {
251
- return () => {}
252
- }
241
+ installPluginCard(ctx, {
242
+ namespace: REMOTE_WEB_UI_NS,
243
+ id: 'remote-web-ui',
244
+ order: 90,
245
+ locale: NS,
246
+ inject: () => remoteSettings.inject(),
247
+ component: RemoteSettingsCard,
253
248
  })
249
+ ctx.effect(() => () => { remoteSettings.dispose() }, 'remote-web-ui: settings card')
254
250
 
255
251
  // Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
256
252
  // heartbeat; the server ignores unpaired heartbeats anyway. Both run only
@@ -64,6 +64,12 @@ const EFFORT_BTN_ID = 'dshRemoteEffortPick'
64
64
  const COMPACT_CLASS = 'dsh-remote-compact-picker'
65
65
  /** Body class while the header actions are seated in the tabs row. */
66
66
  const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
67
+ /**
68
+ * Width the seated header actions paint over the tabs row, as a CSS variable
69
+ * on <html>. The row caps its own width with it (see the v80 rules) instead of
70
+ * reserving the space with padding.
71
+ */
72
+ const HEADER_RESERVE_VAR = '--dsh-remote-header-actions-reserve'
67
73
  /** The two composer picker entries the compact buttons drill into. */
68
74
  type PickerKind = 'model' | 'effort'
69
75
  /** Locale-dependent fast path for the official picker cells (zh/en). */
@@ -254,6 +260,20 @@ const ADAPT_CSS: readonly string[] = [
254
260
  // [class$="_tab"] misses the active tab (its class ends in "_tabActive")
255
261
  // — containment so BOTH tabs match.
256
262
  '[class$="_header"] [class$="_tabs"] [class*="_tab"]{font-size:12px;white-space:nowrap}',
263
+ // v80: the tabs row must give the painted actions their width up on its own.
264
+ // The v67 padding reservation could not do it: the row is a nowrap flex
265
+ // container, and once the tab labels plus their gaps overflow the reduced
266
+ // content box the flex items spill over the padding box, so the trailing
267
+ // tabs render underneath the actions (reported on a phone: a long agent
268
+ // preset label covered the last tabs). Cap the row against the reserve and
269
+ // let the labels scroll instead. The value rides a CSS variable on <html>
270
+ // because the update lands before React re-creates the row node, and an
271
+ // inline reservation on that node is dropped until the next sync tick.
272
+ // The 2px bottom padding keeps the active-tab underline inside the clipping
273
+ // box; the matching negative margin keeps the header height official.
274
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]{max-width:max(0px,calc(100% - var(${HEADER_RESERVE_VAR},0px)));overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-webkit-overflow-scrolling:touch;padding-bottom:2px;margin-bottom:-2px}`,
275
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]::-webkit-scrollbar{display:none}`,
276
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"] > [class*="_tab"]{flex:0 0 auto}`,
257
277
  // Mobile scope: hide the plugin surfaces that do not fit a phone — the
258
278
  // right-hand details column and every desktop-oriented tool surface. The
259
279
  // list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
@@ -764,9 +784,14 @@ export function startMobileAdapt(): void {
764
784
  let dx = translate !== null ? parseFloat(translate[1] ?? '0') : 0
765
785
  let dy = translate !== null ? parseFloat(translate[2] ?? '0') : 0
766
786
  const actionsRect = actions.getBoundingClientRect()
767
- const tabsRect = tabs.getBoundingClientRect()
768
- // Horizontal: the actions' right edge lands on the tabs row's right edge.
769
- const rightDiff = tabsRect.right - actionsRect.right
787
+ // Horizontal: the actions' right edge lands on the header's content edge,
788
+ // NOT on the tabs row's right edge. The row caps its own width against the
789
+ // reserve below (v80), so anchoring to the row would let the pair walk
790
+ // leftwards on every measurement.
791
+ const headerRect = header.getBoundingClientRect()
792
+ const headerStyle = getComputedStyle(header)
793
+ const contentRight = headerRect.right - (parseFloat(headerStyle.paddingRight) || 0) - (parseFloat(headerStyle.borderRightWidth) || 0)
794
+ const rightDiff = contentRight - actionsRect.right
770
795
  if (Math.abs(rightDiff) >= 0.5) dx = Math.round((dx + rightDiff) * 10) / 10
771
796
  // Vertical: the actions' text bottom lands on the tab text bottom.
772
797
  const tabBottom = textBottom(tabBtn)
@@ -783,17 +808,26 @@ export function startMobileAdapt(): void {
783
808
  }
784
809
  const next = `translate(${dx}px, ${dy}px)`
785
810
  if (actions.style.transform !== next) actions.style.transform = next
786
- // Reserve the painted width so the tab labels never slide under it.
811
+ // Reserve the painted width on the row itself, so the tab labels never
812
+ // slide under it. The value rides a CSS variable on <html>: React
813
+ // recreates the row node on re-render, which would drop an inline
814
+ // reservation until the next sync tick, and until then the labels render
815
+ // underneath the painted actions.
787
816
  const reserve = `${Math.ceil(actionsRect.width) + 8}px`
788
- if (tabs.style.paddingRight !== reserve) tabs.style.paddingRight = reserve
817
+ const rootStyle = document.documentElement.style
818
+ if (rootStyle.getPropertyValue(HEADER_RESERVE_VAR) !== reserve) rootStyle.setProperty(HEADER_RESERVE_VAR, reserve)
789
819
  }
790
820
 
791
821
  function unseatHeaderActions(): void {
792
822
  document.body.classList.remove(HEADER_SEATED_CLASS)
823
+ document.documentElement.style.removeProperty(HEADER_RESERVE_VAR)
793
824
  const header = document.querySelector('[class$="_header"]')
794
825
  const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
795
826
  const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
796
827
  if (actions instanceof HTMLElement) actions.style.transform = ''
828
+ // The v67 padding reservation is gone with the v80 cap; clearing it keeps a
829
+ // profile that hot-updated from an older build from keeping a stale inline
830
+ // width on the row.
797
831
  if (tabs instanceof HTMLElement) tabs.style.paddingRight = ''
798
832
  if (headerObserver !== null) {
799
833
  headerObserver.disconnect()
@@ -0,0 +1,185 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/plugin-card-seat.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Family plugin-card seat.
4
+ *
5
+ * A family plugin contributes its settings card to whichever plugin-card seat
6
+ * the running host actually renders:
7
+ *
8
+ * - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
9
+ * group section (this family's own first-level "Web UI plugins" section);
10
+ * - `settings.plugin.item` — the official keyed seat of the harness's
11
+ * `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
12
+ *
13
+ * SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
14
+ * row belongs to the harness bundle and its `configurable` tab always declares
15
+ * `settings.plugin.item` before any external plugin's `apply()` runs, so
16
+ * "is the official seat declared?" answers yes even in the deployment whose
17
+ * whole point is the family group. Choosing on that probe sends every family
18
+ * card to the official Plugins tab and leaves the group's own section
19
+ * permanently empty — the family of reports where the section renders its
20
+ * heading and zero cards.
21
+ *
22
+ * The signal that actually distinguishes the two deployments is whether
23
+ * dsh-web-settings is loaded: it is the package that owns the group section and
24
+ * it publishes the `webUiSettings` service during `apply()`, which every
25
+ * family plugin already reads for its settings scope. Group loaded -> the family
26
+ * seat; group absent -> the official seat.
27
+ *
28
+ * The decision is re-evaluated on every `slots/changed` because the group may
29
+ * apply after this plugin (the family aggregate orders it first, a profile that
30
+ * installs the group separately need not): the initial contribution goes to the
31
+ * official seat, then moves to the family seat the moment the group's section
32
+ * registers. The entry is disposed before the replacement is registered, so a
33
+ * card is never in two seats at once.
34
+ *
35
+ * The shared tree has no client-SDK dependency, so this module reads its
36
+ * context through the structural shape below; callers pass the plugin's own
37
+ * `ctx`.
38
+ */
39
+
40
+ /** The family list seat key. */
41
+ export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
42
+
43
+ /** The official keyed plugin-card seat key. */
44
+ export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item'
45
+
46
+ /** The service dsh-web-settings publishes while it is loaded. */
47
+ export const FAMILY_GROUP_SERVICE = 'webUiSettings'
48
+
49
+ /** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
50
+ export interface PluginCardSlots {
51
+ /** Contribution of one card entry. */
52
+ register(options: never, component: never): unknown
53
+ }
54
+
55
+ /** The slice of the client context a card contribution needs. */
56
+ export interface PluginCardContext {
57
+ slots: PluginCardSlots
58
+ /** Service lookup; absent on a context double that only models the slots. */
59
+ get?(name: string): unknown
60
+ /** Event subscription seat; absent on an event-less test double. */
61
+ on?(event: string, listener: (...args: never[]) => void): unknown
62
+ }
63
+
64
+ /** Owner share of a plugin card (both seats supply nothing). */
65
+ export interface SettingsPluginItemOwnerProps {
66
+ /** Marker field: card owner props are intentionally empty. */
67
+ children?: never
68
+ }
69
+
70
+ /** One family plugin's card contribution. */
71
+ export interface PluginCardSeat {
72
+ /** Settings namespace the card edits (the official seat's dispatch key). */
73
+ namespace: string
74
+ /** Family list-seat entry id. */
75
+ id: string
76
+ /** Family list-seat sort order. */
77
+ order?: number
78
+ /** Family list-seat display label; the official keyed seat carries none. */
79
+ label?: () => string
80
+ /** Locale namespace the card renders with. */
81
+ locale: string
82
+ /** Business-face factory of the registration. */
83
+ inject?: () => object
84
+ /**
85
+ * The card component. Its props type is the seat's composed shape, which is
86
+ * chosen at runtime, so the helper takes it erased; the registrant keeps its
87
+ * own precise typing at the call site.
88
+ */
89
+ component: unknown
90
+ }
91
+
92
+ /**
93
+ * Whether the family group (dsh-web-settings) is loaded in this page. The
94
+ * service is the group package's own contract, so the probe cannot be fooled
95
+ * by a harness release that starts declaring the official seat differently.
96
+ */
97
+ export function familyGroupLoaded(ctx: PluginCardContext): boolean {
98
+ const get = ctx.get
99
+ if (typeof get !== 'function') return false
100
+ try {
101
+ return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
102
+ } catch {
103
+ // A context that refuses the lookup: treat the group as absent, which
104
+ // falls back to the official seat the harness declares.
105
+ return false
106
+ }
107
+ }
108
+
109
+ /** Report a refused registration instead of leaving the user with no card. */
110
+ function warnRefusedSeat(seat: string, error: unknown): void {
111
+ try {
112
+ console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
113
+ } catch {
114
+ // Best-effort console write; a failed warning must not break the plugin.
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Contribute one family plugin card to the seat this host renders, following
120
+ * the group if it loads later. The entry is disposed and re-registered on a
121
+ * seat change, never duplicated.
122
+ * @param ctx - client context (its slot registry decides the seat).
123
+ * @param seat - the card contribution.
124
+ */
125
+ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
126
+ const slots = ctx.slots
127
+ const component = seat.component as never
128
+ const inject = seat.inject as never
129
+
130
+ let dispose: (() => void) | undefined
131
+ let current: string | undefined
132
+ /**
133
+ * Re-entrancy latch. The registry emits a change event synchronously from
134
+ * inside both `register` and the previous entry's disposer, so an unguarded
135
+ * reconcile would re-enter itself mid-move and register the card twice into
136
+ * the seat it is leaving ("already has an entry for key ...").
137
+ */
138
+ let reconciling = false
139
+
140
+ /** Reconcile the contribution with the currently live seat (no-op when unchanged). */
141
+ const reconcile = (): void => {
142
+ if (reconciling) return
143
+ const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
144
+ if (current === target) return
145
+ reconciling = true
146
+ const previous = dispose
147
+ dispose = undefined
148
+ current = undefined
149
+ previous?.()
150
+ try {
151
+ dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
152
+ ? {
153
+ name: FAMILY_PLUGIN_CARD_SEAT,
154
+ id: seat.id,
155
+ ...(seat.order === undefined ? {} : { order: seat.order }),
156
+ ...(seat.label === undefined ? {} : { label: seat.label }),
157
+ locale: seat.locale,
158
+ ...(seat.inject === undefined ? {} : { inject }),
159
+ }
160
+ : {
161
+ name: OFFICIAL_PLUGIN_CARD_SEAT,
162
+ key: seat.namespace,
163
+ locale: seat.locale,
164
+ ...(seat.inject === undefined ? {} : { inject }),
165
+ }) as never, component) as () => void
166
+ current = target
167
+ } catch (error) {
168
+ warnRefusedSeat(target, error)
169
+ } finally {
170
+ reconciling = false
171
+ }
172
+ }
173
+
174
+ if (typeof ctx.on === 'function') {
175
+ try {
176
+ ctx.on('slots/changed', () => { reconcile() })
177
+ } catch {
178
+ // An event seat that refuses the subscription leaves the initial
179
+ // decision in place; the card still renders in the seat chosen below.
180
+ }
181
+ }
182
+ // The initial decision: the group, when it is already loaded (the aggregate
183
+ // order), otherwise the official seat the harness declares.
184
+ reconcile()
185
+ }