@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
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 8e93c9e36c4c1cb042df29d494419347181142b0
6
- README.zh.md: fb6955a0be096fe3d04cff2600484409c8b62df9
5
+ README.md: 0b2dd290c6a1f829ffc74c5fb465b4afe860f34a
6
+ README.zh.md: 174d0ccc8b1c5d7ff5d98f9301b6b9a016180577
package/README.md CHANGED
@@ -66,6 +66,8 @@ Restart the profile (`dsh web`), then open the phone icon in the sidebar foot. T
66
66
 
67
67
  ## Remote access over the internet (tunnels)
68
68
 
69
+ A tunnel process restart — most often a mobile network dropping and reconnecting — used to drop the public host from the pairing fence the moment the tunnel left `running`, so the address still printed in the QR code answered 403, including on the deliberately public `/api/pair/status` (issue #1547). A named tunnel's fixed hostname and a registered relay origin never change, so both now stay trusted throughout the reconnect; a quick tunnel's previous host stays trusted for a 60 s grace window, because the edge may still be delivering a connection the phone already opened, and is dropped only if the tunnel has not come back by then.
70
+
69
71
  ### One-click public tunnel (recommended)
70
72
 
71
73
  Set **自动公网隧道** (autoTunnel) in the settings card. The plugin runs its own Cloudflare quick tunnel (`cloudflared` ships with the package), feeds the minted public URL into the QR base and the pairing fence dynamically, and keeps the posture probe informed — a phone anywhere can pair at any time. The manual public address and the named-tunnel token below are ignored while this is on. The minted hostname is ephemeral and changes on every `dsh web` restart — the stable-hostname relay below keeps the phone's origin fixed across those restarts.
@@ -134,6 +136,7 @@ Pinned to the 0.1.2-alpha.2 line; the seams this build relies on:
134
136
  - **`sidebar.footer.action` foot seat** (the 0.1.2 shell composition): the sidebar declares and renders the seat the remote entry occupies.
135
137
  - **`ctx.layout.toggleSidebar()`** (packages/client/ui-layout): the whale button expands the collapsed sidebar through the official panel-action face.
136
138
  - **`ctx.connection.authenticatedUrl()`** (packages/client/connection): the sanctioned launch-token seam the proxy redeems once for its inner credential (`src/inner-auth.ts`), so re-issued `/api` calls satisfy the harness browser-auth check.
139
+ - **`__DSH_FILE_UPLOAD__`** (file-upload client hooks): the optional pre-Cordis transport the upload service reads once at construction. The remote boot patch publishes it so background uploads stay on the rewritten main-thread fetch instead of a Web Worker that escapes the channel (issue #1580).
137
140
  - **`__DSH_TRANSPORT__.ownsHost`** (client-connection transport hooks): the host-mode flip for paired remote desktops. There is no host-side per-method privilege pin on this line — the configuration plane branches on `connection.isLoopback` in the client — and no `api/gate` waterfall either (the gate listener stays mounted for deployments that gain the seam; pairing enforcement lives on this plugin's own `/remote` channel).
138
141
  - **User-patch bind semantics**: same-id patch rows replace the row config wholesale, and the user patch layer cannot evaluate `webStartup`-dependent `!!js` expressions reliably — the LAN bind block therefore materializes static values and the plugin re-asserts it every boot.
139
142
 
@@ -158,6 +161,7 @@ The public path is the same round trip through a tunnel (see "Remote access over
158
161
  - **Cohort reality: pairing does not gate direct `/api`.** On the pinned 0.1.2-alpha.2 line nothing emits the `api/gate` seam, so a direct `/api` call from a LAN origin is governed solely by the harness fence (which auto-trusts LAN literals under a `0.0.0.0` bind) plus the harness browser-auth cookie. A browser credential a device has already redeemed therefore survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the `/remote` channel and the pairing cookie, not that credential. The plugin probes the `/api` posture and warns loudly; treat a LAN-exposed bind as a deliberate decision, and prefer loopback plus tunnels when the machine is shared.
159
162
  - **A paired device is a full-control credential.** With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. Only the three control planes (pairing, self-update, plugin install/remove) stay physically local. Only pair devices you control; 停止 or per-device 取消配对 revokes immediately.
160
163
  - **Control endpoints stay loopback-only**: mint/stop/revoke, the device roster, the lan-bind status, and the update endpoints answer only to loopback. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
164
+ - **Background file uploads ride the channel too.** The official upload service prefers a Web Worker carrier whose own globals no main-thread patch reaches, so the boot patch (and the browser patch as a fallback) publishes the official pre-Cordis hook `__DSH_FILE_UPLOAD__` and hands it the patched `fetch`: the raw `/api/session/uploadFileBinary` POST is rewritten onto `/remote` and carries the device credential exactly like every other gated call. Without the hook a paired browser's uploads bypass the channel and the harness browser-auth fence answers 401 (issue #1580). The hook is only published on non-loopback origins, only while the channel is installed, and never overwrites a hook the page already owns.
161
165
  - **The app landing is cookieless-optional.** After pairing, the QR lands the device on `/pair-app`, served by this plugin: the official shell is delivered without passing the harness index gate, and the device credential rides a `x-dsh-remote-device` header (fetch) / `device` query (WebSocket upgrades) that the boot patch attaches from sessionStorage. The flow therefore works when the device browser blocks all cookies; the pairing cookie remains the primary credential where the browser stores it, and the harness browser-auth cookie is no longer required on the phone path at all.
162
166
  - **Reopens are owned by a service worker (https origins).** A paired phone returning from history, a bookmark, or tab restore navigates to bare `/` — a path the plugin does not own, where the harness fallback seat answers with its browser-auth 401 (the cookieless flow never holds that credential). The app shell therefore registers `/pair-app.sw.js` (fenced like `/pair-app`; the script is inert logic with no secrets): it intercepts navigations to `/` only, re-serves the shell network-first through `/pair-app` — which re-validates the device cookie and refreshes its presence, so every reopen also keeps the session alive — falls back to the cached shell offline, and passes the navigation through when the plugin no longer answers (a revoked device then sees the harness response or the bilingual re-scan page). Plain-HTTP LAN origins are not secure contexts and never register the worker; there a reopen means scanning a fresh QR.
163
167
  - **Revocation is per-request**: a paired device whose request is already in flight when 停止 lands completes that request; the next one 403s.
package/README.zh.md CHANGED
@@ -66,6 +66,8 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
66
66
 
67
67
  ## 通过互联网远程访问(隧道)
68
68
 
69
+ 隧道进程重启(最常见的原因是移动网络闪断重连)过去会让插件在隧道离开 `running` 的瞬间把公网主机移出配对栅栏,于是二维码上仍显示着的地址会返回 403——连有意公开的 `/api/pair/status` 也不例外(issue #1547)。命名隧道的固定主机名与已注册的中继源本来就不会变,现在在整个重连期间保持受信任;快速隧道的上一个地址保留 60 秒宽限期(边缘可能仍在投递手机已经建立的连接),只有到期仍未恢复才彻底失效。
70
+
69
71
  ### 一键公网隧道(推荐)
70
72
 
71
73
  在设置卡片打开**自动公网隧道**(autoTunnel)。插件运行自己的 Cloudflare 快速隧道(`cloudflared` 随包分发),把铸造的公网 URL 动态喂给二维码与配对栅栏,并同步姿态探测——手机随时随地都能配对。开启期间忽略下方手动公网地址与命名隧道令牌。铸造的域名是临时的,每次 `dsh web` 重启都会变化——下方的固定域名中继会把手机的访问源钉在固定地址上,重启不再需要重新扫码。
@@ -134,6 +136,7 @@ pnpm run build
134
136
  - **`sidebar.footer.action` 底部席位**(0.1.2 shell 组合):侧栏声明并渲染远程入口占据的席位。
135
137
  - **`ctx.layout.toggleSidebar()`**(packages/client/ui-layout):鲸鱼按钮经官方面板动作面展开折叠侧栏。
136
138
  - **`ctx.connection.authenticatedUrl()`**(packages/client/connection):代理为内部凭据一次性兑换启动令牌的官方接缝(`src/inner-auth.ts`),使再发起的 `/api` 调用满足 harness 浏览器认证校验。
139
+ - **`__DSH_FILE_UPLOAD__`**(file-upload 客户端钩子):上传服务在构造时读取一次的可选启动前传输。远程引导补丁会发布它,使后台上传留在被改写的主线程 fetch 上,而不是逃出通道的 Web Worker(issue #1580)。
137
140
  - **`__DSH_TRANSPORT__.ownsHost`**(client-connection 传输钩子):配对远程桌面的 host 模式翻转。本线没有 host 侧按方法特权锁定——配置面在客户端按 `connection.isLoopback` 分支——也没有 `api/gate` 瀑布(gate 监听器保持挂载,待未来部署获得该接缝;配对强制在插件自己的 `/remote` 通道上)。
138
141
  - **用户补丁绑定语义**:同 id 补丁行整行替换 config,且用户补丁层无法可靠求值依赖 `webStartup` 的 `!!js` 表达式——局域网绑定块因此落静态值,插件每次启动重断言。
139
142
 
@@ -158,6 +161,7 @@ pnpm run build
158
161
  - **本 cohort 的现实:配对不门控直连 `/api`。** 在锚定的 0.1.2-alpha.2 线上,没有任何组件发出 `api/gate` seam,因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
159
162
  - **配对设备是完全控制凭据。** host 模式下它可达完整 host API——聊天、会话、设置、凭据、Agent 预设、产出物——与 SDK 对回环桌面的信任一致。只有三个控制面(配对、自更新、插件安装/卸载)保持物理本地。只配对你控制的设备;停止或逐设备取消配对立即撤销。
160
163
  - **控制端点仅限回环**:铸造/停止/撤销、设备列表、lan-bind 状态与更新端点只应答回环。局域网源浏览器看到「配对面板仅限本机使用」横幅。
164
+ - **后台文件上传同样走通道。** 官方上传服务优先使用 Web Worker 载体,其独立全局对象不受主线程补丁影响;因此引导补丁(以及作为兜底的浏览器补丁)会发布官方启动前钩子 `__DSH_FILE_UPLOAD__`,并把打过补丁的 `fetch` 交给它:原始 `/api/session/uploadFileBinary` POST 会被改写到 `/remote`,并像其他受门控调用一样携带设备凭据。没有该钩子时,配对浏览器的上传会绕过通道,被 harness 浏览器认证围栏拒为 401(issue #1580)。该钩子仅在非回环源、且通道安装期间发布,且永不覆盖页面已有的钩子。
161
165
  - **应用落地页不依赖 cookie。** 配对后二维码把设备带到 `/pair-app`——由本插件直接交付官方应用壳,不经过 harness 索引认证门;设备凭据经 `x-dsh-remote-device` 请求头(fetch)与 `device` 查询参数(WebSocket 升级)由引导补丁从 sessionStorage 挂载。因此手机浏览器完全禁用 cookie 时链路依然成立;有 cookie 时配对 cookie 仍是主凭据,手机路径不再需要 harness 浏览器认证 cookie。
162
166
  - **重开由 service worker 接管(仅 https 源)。** 配对过的手机从历史、书签或标签恢复回来时导航到裸 `/`——插件不拥有的路径,harness 兜底座会用浏览器认证 401 应答(不依赖 cookie 的流程永远拿不到那份凭据)。应用壳因此注册 `/pair-app.sw.js`(与 `/pair-app` 同一栅栏;脚本是不含任何秘密的惰性逻辑):只拦截对 `/` 的导航,经 `/pair-app` 网络优先地重发应用壳——同时校验设备 cookie 并刷新其活跃时间,每次重开也在为会话续期——离线时回退缓存的壳,插件不再应答时把导航原样放行(被撤销的设备随后看到 harness 应答或双语重扫页)。纯 HTTP 的局域网源不是安全上下文,永远不会注册该 worker;那里的重开意味着重新扫码。
163
167
  - **撤销按请求生效**:停止落地时已在途的请求会完成;下一个请求 403。
package/lib/client.js CHANGED
@@ -4070,7 +4070,9 @@ window.__ModuleLoader__.load({
4070
4070
  ],
4071
4071
  deviceHeader: REMOTE_DEVICE_HEADER,
4072
4072
  deviceKey: "dsh-remote-device",
4073
- deviceQuery: REMOTE_DEVICE_QUERY
4073
+ deviceQuery: REMOTE_DEVICE_QUERY,
4074
+ uploadPath: "/api/session/uploadFileBinary",
4075
+ uploadHookGlobal: "__DSH_FILE_UPLOAD__"
4074
4076
  };
4075
4077
  /** The window global the boot patch publishes its seat under. */
4076
4078
  const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
@@ -4218,6 +4220,7 @@ window.__ModuleLoader__.load({
4218
4220
  const originalFetch = window.fetch;
4219
4221
  const OriginalWebSocket = window.WebSocket;
4220
4222
  const OriginalEventSource = window.EventSource;
4223
+ const restoreUploadHook = installFileUploadHook(window);
4221
4224
  const sameOrigin = (url) => url.origin === window.location.origin;
4222
4225
  const rewrite = (raw) => rewriteRawUrl(raw, window.location.href, window.location.origin);
4223
4226
  const device = (() => {
@@ -4303,6 +4306,46 @@ window.__ModuleLoader__.load({
4303
4306
  window.WebSocket = OriginalWebSocket;
4304
4307
  if (OriginalEventSource !== void 0) window.EventSource = OriginalEventSource;
4305
4308
  for (const restore of restoreSrc) restore();
4309
+ restoreUploadHook();
4310
+ };
4311
+ }
4312
+ /**
4313
+ * Publish the official pre-Cordis upload hook so background uploads keep
4314
+ * riding the patched main-thread fetch (issue #1580).
4315
+ *
4316
+ * `@deepseek-ai/dsh-client-file-upload` reads `globalThis.__DSH_FILE_UPLOAD__`
4317
+ * once when its runtime is constructed; without it the carrier is a Web
4318
+ * Worker, whose own globals no main-thread patch reaches. That worker's XHR
4319
+ * goes straight to `<origin>/api/session/uploadFileBinary` with neither the
4320
+ * `/remote` rewrite nor the cookieless device credential, so the harness
4321
+ * browser-auth fence answers 401 and every upload from a paired browser
4322
+ * fails. Rewriting the worker URL cannot fix it: a worker context carries
4323
+ * neither the pairing cookie nor the device header.
4324
+ *
4325
+ * The hook hands the runtime the same transport the rest of the page uses -
4326
+ * the boot script publishes an identical one (remote-channel-boot.ts), and
4327
+ * this is the fallback for pages served without it.
4328
+ *
4329
+ * @param window - the browser window (or a test double), BEFORE the channel patch.
4330
+ * @returns a function retiring the hook (a pre-existing one is left alone).
4331
+ */
4332
+ function installFileUploadHook(window) {
4333
+ if (window.__DSH_FILE_UPLOAD__ !== void 0) return () => {};
4334
+ const originalFetch = window.fetch;
4335
+ const hook = { fetch: (input, init) => {
4336
+ const raw = typeof input === "string" ? input : input.href;
4337
+ let url;
4338
+ try {
4339
+ url = new URL(raw, window.location.href);
4340
+ } catch {
4341
+ return originalFetch.call(window, input, init);
4342
+ }
4343
+ if (url.origin === window.location.origin && url.pathname === RULES.uploadPath) return window.fetch.call(window, raw, init);
4344
+ return originalFetch.call(window, input, init);
4345
+ } };
4346
+ window.__DSH_FILE_UPLOAD__ = hook;
4347
+ return () => {
4348
+ if (window.__DSH_FILE_UPLOAD__ === hook) delete window.__DSH_FILE_UPLOAD__;
4306
4349
  };
4307
4350
  }
4308
4351
  /**
@@ -4453,7 +4496,7 @@ window.__ModuleLoader__.load({
4453
4496
  /** The building package's version, when the bundle carries it. */
4454
4497
  function bakedVersion() {
4455
4498
  try {
4456
- return "0.3.22";
4499
+ return "0.3.24";
4457
4500
  } catch {
4458
4501
  return;
4459
4502
  }
@@ -4537,6 +4580,12 @@ window.__ModuleLoader__.load({
4537
4580
  const COMPACT_CLASS = "dsh-remote-compact-picker";
4538
4581
  /** Body class while the header actions are seated in the tabs row. */
4539
4582
  const HEADER_SEATED_CLASS = "dsh-remote-header-seated";
4583
+ /**
4584
+ * Width the seated header actions paint over the tabs row, as a CSS variable
4585
+ * on <html>. The row caps its own width with it (see the v80 rules) instead of
4586
+ * reserving the space with padding.
4587
+ */
4588
+ const HEADER_RESERVE_VAR = "--dsh-remote-header-actions-reserve";
4540
4589
  /** Locale-dependent fast path for the official picker cells (zh/en). */
4541
4590
  const PICKER_CELL_PATTERN = {
4542
4591
  model: /模型|Model/,
@@ -4635,6 +4684,9 @@ window.__ModuleLoader__.load({
4635
4684
  "[class$=\"_composerSeat\"] [class$=\"_frame\"] [class$=\"_card\"]{max-width:none;width:100%}",
4636
4685
  `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_titleCluster"] [class$="_headerActions"]{position:absolute;left:0;top:0;margin:0;display:flex;align-items:center;gap:6px;flex:none;z-index:2}`,
4637
4686
  "[class$=\"_header\"] [class$=\"_tabs\"] [class*=\"_tab\"]{font-size:12px;white-space:nowrap}",
4687
+ `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}`,
4688
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]::-webkit-scrollbar{display:none}`,
4689
+ `body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"] > [class*="_tab"]{flex:0 0 auto}`,
4638
4690
  `body.${ACTIVE_CLASS} [class$=\"_detailsCol\"]{display:none !important}`,
4639
4691
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"ssh\"],`,
4640
4692
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"skill-explorer\"],`,
@@ -5053,7 +5105,9 @@ window.__ModuleLoader__.load({
5053
5105
  let dx = translate !== null ? parseFloat(translate[1] ?? "0") : 0;
5054
5106
  let dy = translate !== null ? parseFloat(translate[2] ?? "0") : 0;
5055
5107
  const actionsRect = actions.getBoundingClientRect();
5056
- const rightDiff = tabs.getBoundingClientRect().right - actionsRect.right;
5108
+ const headerRect = header.getBoundingClientRect();
5109
+ const headerStyle = getComputedStyle(header);
5110
+ const rightDiff = headerRect.right - (parseFloat(headerStyle.paddingRight) || 0) - (parseFloat(headerStyle.borderRightWidth) || 0) - actionsRect.right;
5057
5111
  if (Math.abs(rightDiff) >= .5) dx = Math.round((dx + rightDiff) * 10) / 10;
5058
5112
  const tabBottom = textBottom(tabBtn);
5059
5113
  if (tabBottom !== null) {
@@ -5070,10 +5124,12 @@ window.__ModuleLoader__.load({
5070
5124
  const next = `translate(${dx}px, ${dy}px)`;
5071
5125
  if (actions.style.transform !== next) actions.style.transform = next;
5072
5126
  const reserve = `${Math.ceil(actionsRect.width) + 8}px`;
5073
- if (tabs.style.paddingRight !== reserve) tabs.style.paddingRight = reserve;
5127
+ const rootStyle = document.documentElement.style;
5128
+ if (rootStyle.getPropertyValue(HEADER_RESERVE_VAR) !== reserve) rootStyle.setProperty(HEADER_RESERVE_VAR, reserve);
5074
5129
  }
5075
5130
  function unseatHeaderActions() {
5076
5131
  document.body.classList.remove(HEADER_SEATED_CLASS);
5132
+ document.documentElement.style.removeProperty(HEADER_RESERVE_VAR);
5077
5133
  const header = document.querySelector("[class$=\"_header\"]");
5078
5134
  const tabs = header !== null ? header.querySelector("[class$=\"_tabs\"]") : null;
5079
5135
  const actions = header !== null ? header.querySelector("[class$=\"_titleCluster\"] [class$=\"_headerActions\"]") : null;
@@ -5382,6 +5438,129 @@ window.__ModuleLoader__.load({
5382
5438
  };
5383
5439
  }
5384
5440
  //#endregion
5441
+ //#region src/client/plugin-card-seat.ts
5442
+ /**
5443
+ * Family plugin-card seat.
5444
+ *
5445
+ * A family plugin contributes its settings card to whichever plugin-card seat
5446
+ * the running host actually renders:
5447
+ *
5448
+ * - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
5449
+ * group section (this family's own first-level "Web UI plugins" section);
5450
+ * - `settings.plugin.item` — the official keyed seat of the harness's
5451
+ * `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
5452
+ *
5453
+ * SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
5454
+ * row belongs to the harness bundle and its `configurable` tab always declares
5455
+ * `settings.plugin.item` before any external plugin's `apply()` runs, so
5456
+ * "is the official seat declared?" answers yes even in the deployment whose
5457
+ * whole point is the family group. Choosing on that probe sends every family
5458
+ * card to the official Plugins tab and leaves the group's own section
5459
+ * permanently empty — the family of reports where the section renders its
5460
+ * heading and zero cards.
5461
+ *
5462
+ * The signal that actually distinguishes the two deployments is whether
5463
+ * dsh-web-settings is loaded: it is the package that owns the group section and
5464
+ * it publishes the `webUiSettings` service during `apply()`, which every
5465
+ * family plugin already reads for its settings scope. Group loaded -> the family
5466
+ * seat; group absent -> the official seat.
5467
+ *
5468
+ * The decision is re-evaluated on every `slots/changed` because the group may
5469
+ * apply after this plugin (the family aggregate orders it first, a profile that
5470
+ * installs the group separately need not): the initial contribution goes to the
5471
+ * official seat, then moves to the family seat the moment the group's section
5472
+ * registers. The entry is disposed before the replacement is registered, so a
5473
+ * card is never in two seats at once.
5474
+ *
5475
+ * The shared tree has no client-SDK dependency, so this module reads its
5476
+ * context through the structural shape below; callers pass the plugin's own
5477
+ * `ctx`.
5478
+ */
5479
+ /** The family list seat key. */
5480
+ const FAMILY_PLUGIN_CARD_SEAT = "web-ui.plugin.item";
5481
+ /** The official keyed plugin-card seat key. */
5482
+ const OFFICIAL_PLUGIN_CARD_SEAT = "settings.plugin.item";
5483
+ /** The service dsh-web-settings publishes while it is loaded. */
5484
+ const FAMILY_GROUP_SERVICE = "webUiSettings";
5485
+ /**
5486
+ * Whether the family group (dsh-web-settings) is loaded in this page. The
5487
+ * service is the group package's own contract, so the probe cannot be fooled
5488
+ * by a harness release that starts declaring the official seat differently.
5489
+ */
5490
+ function familyGroupLoaded(ctx) {
5491
+ const get = ctx.get;
5492
+ if (typeof get !== "function") return false;
5493
+ try {
5494
+ return get.call(ctx, FAMILY_GROUP_SERVICE) !== void 0;
5495
+ } catch {
5496
+ return false;
5497
+ }
5498
+ }
5499
+ /** Report a refused registration instead of leaving the user with no card. */
5500
+ function warnRefusedSeat(seat, error) {
5501
+ try {
5502
+ console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error);
5503
+ } catch {}
5504
+ }
5505
+ /**
5506
+ * Contribute one family plugin card to the seat this host renders, following
5507
+ * the group if it loads later. The entry is disposed and re-registered on a
5508
+ * seat change, never duplicated.
5509
+ * @param ctx - client context (its slot registry decides the seat).
5510
+ * @param seat - the card contribution.
5511
+ */
5512
+ function installPluginCard(ctx, seat) {
5513
+ const slots = ctx.slots;
5514
+ const component = seat.component;
5515
+ const inject = seat.inject;
5516
+ let dispose;
5517
+ let current;
5518
+ /**
5519
+ * Re-entrancy latch. The registry emits a change event synchronously from
5520
+ * inside both `register` and the previous entry's disposer, so an unguarded
5521
+ * reconcile would re-enter itself mid-move and register the card twice into
5522
+ * the seat it is leaving ("already has an entry for key ...").
5523
+ */
5524
+ let reconciling = false;
5525
+ /** Reconcile the contribution with the currently live seat (no-op when unchanged). */
5526
+ const reconcile = () => {
5527
+ if (reconciling) return;
5528
+ const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT;
5529
+ if (current === target) return;
5530
+ reconciling = true;
5531
+ const previous = dispose;
5532
+ dispose = void 0;
5533
+ current = void 0;
5534
+ previous?.();
5535
+ try {
5536
+ dispose = slots.register(target === "web-ui.plugin.item" ? {
5537
+ name: FAMILY_PLUGIN_CARD_SEAT,
5538
+ id: seat.id,
5539
+ ...seat.order === void 0 ? {} : { order: seat.order },
5540
+ ...seat.label === void 0 ? {} : { label: seat.label },
5541
+ locale: seat.locale,
5542
+ ...seat.inject === void 0 ? {} : { inject }
5543
+ } : {
5544
+ name: OFFICIAL_PLUGIN_CARD_SEAT,
5545
+ key: seat.namespace,
5546
+ locale: seat.locale,
5547
+ ...seat.inject === void 0 ? {} : { inject }
5548
+ }, component);
5549
+ current = target;
5550
+ } catch (error) {
5551
+ warnRefusedSeat(target, error);
5552
+ } finally {
5553
+ reconciling = false;
5554
+ }
5555
+ };
5556
+ if (typeof ctx.on === "function") try {
5557
+ ctx.on("slots/changed", () => {
5558
+ reconcile();
5559
+ });
5560
+ } catch {}
5561
+ reconcile();
5562
+ }
5563
+ //#endregion
5385
5564
  //#region src/client/index.ts
5386
5565
  /**
5387
5566
  * Remote control — browser half. Registers the `remote` dictionaries, the
@@ -5482,23 +5661,17 @@ window.__ModuleLoader__.load({
5482
5661
  };
5483
5662
  });
5484
5663
  const remoteSettings = new RemoteSettingsCardController(settingsScope);
5485
- ctx.slots.inject("web-ui.plugin.item", () => {
5486
- try {
5487
- const unregister = ctx.slots.register({
5488
- name: "web-ui.plugin.item",
5489
- id: "remote-web-ui",
5490
- order: 90,
5491
- locale: NS,
5492
- inject: () => remoteSettings.inject()
5493
- }, RemoteSettingsCard);
5494
- return () => {
5495
- remoteSettings.dispose();
5496
- unregister();
5497
- };
5498
- } catch {
5499
- return () => {};
5500
- }
5664
+ installPluginCard(ctx, {
5665
+ namespace: REMOTE_WEB_UI_NS,
5666
+ id: "remote-web-ui",
5667
+ order: 90,
5668
+ locale: NS,
5669
+ inject: () => remoteSettings.inject(),
5670
+ component: RemoteSettingsCard
5501
5671
  });
5672
+ ctx.effect(() => () => {
5673
+ remoteSettings.dispose();
5674
+ }, "remote-web-ui: settings card");
5502
5675
  let disposeRuntime;
5503
5676
  const syncRuntime = () => {
5504
5677
  if (enabled() && disposeRuntime === void 0) disposeRuntime = ctx.effect(() => {