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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml 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: 3a962a7fe14fb426a6c1beb238a51837dc345766
6
- README.zh.md: 1d3d36c040c5c35ce520299934471132234de457
5
+ README.md: 826dec8a7f2a90ecf59546d8ddc506508e70cb58
6
+ README.zh.md: 7ba22e3137725fe96ed6899946e1b41dc609236a
package/README.md CHANGED
@@ -122,7 +122,7 @@ pnpm --filter @linxin666/dsh-remote-web-ui test
122
122
  pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
123
123
  ```
124
124
 
125
- The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package used here is declared in devDependencies (0.1.2-alpha.2 cohort), and TypeScript/Vitest resolve types straight from node_modules — no DSH source checkout is required. The consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without type checking, so git installs work without any harness checkout either.
125
+ The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package used here is declared in devDependencies (0.2.0-rc.1 cohort), and TypeScript/Vitest resolve types straight from node_modules — no DSH source checkout is required. The consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without type checking, so git installs work without any harness checkout either.
126
126
 
127
127
  ## Checks
128
128
 
@@ -134,9 +134,9 @@ pnpm run build
134
134
 
135
135
  ## Harness contract dependencies
136
136
 
137
- Pinned to the 0.1.2-alpha.2 line; the seams this build relies on:
137
+ Pinned to the 0.2.0-rc.1 line; the seams this build relies on:
138
138
 
139
- - **`sidebar.footer.action` foot seat** (the 0.1.2 shell composition): the sidebar declares and renders the seat the remote entry occupies.
139
+ - **`sidebar.footer.action` foot seat** (the 0.1.7 shell composition): the sidebar declares and renders the seat the remote entry occupies.
140
140
  - **`ctx.layout.toggleSidebar()`** (packages/client/ui-layout): the whale button expands the collapsed sidebar through the official panel-action face.
141
141
  - **`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.
142
142
  - **`__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).
@@ -161,7 +161,7 @@ The public path is the same round trip through a tunnel (see "Remote access over
161
161
 
162
162
  - **Pairing is the access control for the `/remote` channel, unconditionally**: every request must carry a live paired-device cookie, enforced before any bytes are forwarded. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body. This gate does **not** follow `requirePairingForLan`: the channel re-issues every call with the process's own browser-auth credential, so admitting an unpaired caller would hand out that authority. The LAN policy governs the plain `/api` surface only and can never widen the channel (issue #1665).
163
163
  - **The channel carries the process's own inner credential.** The harness browser-auth cookie is authority-bound (minted for the exact `host:port` a browser visited) and has no loopback exemption, so a proxied re-issue to `127.0.0.1` cannot reuse a device's cookie. The plugin therefore redeems its own launch token once — the same exchange a first browser visit performs — and attaches that cookie to re-issued requests. The credential is only ever exercised behind the pairing gate above; 停止/取消配对 immediately stop exercising it.
164
- - **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.
164
+ - **Runtime reality: pairing does not gate direct `/api`.** Nothing in the official runtime emits the `api/gate` seam (verified against the 0.2.0-rc.1 packages installed by this repository), 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.
165
165
  - **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.
166
166
  - **Control endpoints stay loopback-only**: mint/stop/revoke, the device roster, and the lan-bind status answer only to loopback, and the gated channel keeps the self-update and plugin-install paths physically local. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
167
167
  - **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.
package/README.zh.md CHANGED
@@ -122,7 +122,7 @@ pnpm --filter @linxin666/dsh-remote-web-ui test
122
122
  pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
123
123
  ```
124
124
 
125
- 对端 API 来自官方 NPM SDK:用到的每个 `@deepseek-ai/*` 包都声明在 devDependencies(0.1.2-alpha.2 cohort)中,TypeScript/Vitest 直接从 node_modules 解析类型——不需要 DSH 源码 checkout。消费侧 `prepare` 构建(`tsdown.prepare.config.ts`)不做类型检查地转译,git 安装同样无需 harness checkout。
125
+ 对端 API 来自官方 NPM SDK:用到的每个 `@deepseek-ai/*` 包都声明在 devDependencies(0.2.0-rc.1 cohort)中,TypeScript/Vitest 直接从 node_modules 解析类型——不需要 DSH 源码 checkout。消费侧 `prepare` 构建(`tsdown.prepare.config.ts`)不做类型检查地转译,git 安装同样无需 harness checkout。
126
126
 
127
127
  ## 检查
128
128
 
@@ -134,9 +134,9 @@ pnpm run build
134
134
 
135
135
  ## Harness 契约依赖
136
136
 
137
- 锚定 0.1.2-alpha.2 线;本构建依赖的接缝:
137
+ 锚定 0.2.0-rc.1 线;本构建依赖的接缝:
138
138
 
139
- - **`sidebar.footer.action` 底部席位**(0.1.2 shell 组合):侧栏声明并渲染远程入口占据的席位。
139
+ - **`sidebar.footer.action` 底部席位**(0.1.7 shell 组合):侧栏声明并渲染远程入口占据的席位。
140
140
  - **`ctx.layout.toggleSidebar()`**(packages/client/ui-layout):鲸鱼按钮经官方面板动作面展开折叠侧栏。
141
141
  - **`ctx.connection.authenticatedUrl()`**(packages/client/connection):代理为内部凭据一次性兑换启动令牌的官方接缝(`src/inner-auth.ts`),使再发起的 `/api` 调用满足 harness 浏览器认证校验。
142
142
  - **`__DSH_FILE_UPLOAD__`**(file-upload 客户端钩子):上传服务在构造时读取一次的可选启动前传输。远程引导补丁会发布它,使后台上传留在被改写的主线程 fetch 上,而不是逃出通道的 Web Worker(issue #1580)。
@@ -161,7 +161,7 @@ pnpm run build
161
161
 
162
162
  - **配对是 `/remote` 通道的访问控制,且无条件生效**:每个请求必须携带有效配对设备 cookie,在任何字节转发之前强制。缺失或被撤销的会话收到 HTTP 403,JSON 拒绝携带 `error.code: "unpaired"`;浏览器 `EventSource` API 只暴露流失败,不暴露响应体。该门**不**跟随 `requirePairingForLan`:通道会用进程自身的浏览器认证凭据重新发起每一次调用,放行未配对调用者等于把该权限交出去。局域网策略只作用于普通 `/api` 面,永远不能放宽本通道(issue #1665)。
163
163
  - **通道携带进程自己的内部凭据。** harness 浏览器认证 cookie 与 authority 绑定(为浏览器访问过的确切 `host:port` 签发)且没有回环豁免,因此转发到 `127.0.0.1` 的再发起请求无法复用设备的 cookie。插件因此自行兑换一次自己的启动令牌——与浏览器首次访问执行的是同一次交换——并把所得 cookie 附到再发起请求上。该凭据只在上面的配对门之后被使用;停止/取消配对会立即停止对它的使用。
164
- - **本 cohort 的现实:配对不门控直连 `/api`。** 在锚定的 0.1.2-alpha.2 线上,没有任何组件发出 `api/gate` seam,因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
164
+ - **运行时现实:配对不门控直连 `/api`。** 官方运行时没有任何组件发出 `api/gate` seam(对照本仓库安装的 0.2.0-rc.1 包核实),因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
165
165
  - **配对设备是完全控制凭据。** host 模式下它可达完整 host API——聊天、会话、设置、凭据、Agent 预设、产出物——与 SDK 对回环桌面的信任一致。只有三个控制面(配对、自更新、插件安装/卸载)保持物理本地。只配对你控制的设备;停止或逐设备取消配对立即撤销。
166
166
  - **控制端点仅限回环**:铸造/停止/撤销、设备列表与 lan-bind 状态只应答回环,门控通道同时让自更新与插件安装路径保持物理本地。局域网源浏览器看到「配对面板仅限本机使用」横幅。
167
167
  - **后台文件上传同样走通道。** 官方上传服务优先使用 Web Worker 载体,其独立全局对象不受主线程补丁影响;因此引导补丁(以及作为兜底的浏览器补丁)会发布官方启动前钩子 `__DSH_FILE_UPLOAD__`,并把打过补丁的 `fetch` 交给它:原始 `/api/session/uploadFileBinary` POST 会被改写到 `/remote`,并像其他受门控调用一样携带设备凭据。没有该钩子时,配对浏览器的上传会绕过通道,被 harness 浏览器认证围栏拒为 401(issue #1580)。该钩子仅在非回环源、且通道安装期间发布,且永不覆盖页面已有的钩子。
package/lib/client.js CHANGED
@@ -4081,7 +4081,7 @@ window.__ModuleLoader__.load({
4081
4081
  /** The building package's version, when the bundle carries it. */
4082
4082
  function bakedVersion() {
4083
4083
  try {
4084
- return "0.4.3";
4084
+ return "0.4.4";
4085
4085
  } catch {
4086
4086
  return;
4087
4087
  }
@@ -4155,6 +4155,16 @@ window.__ModuleLoader__.load({
4155
4155
  const ACTIVE_CLASS = "dsh-remote-portrait";
4156
4156
  /** Body class while the collapsed rail is hidden behind the whale. */
4157
4157
  const RAIL_HIDDEN_CLASS = "dsh-remote-rail-hidden";
4158
+ /**
4159
+ * The official sidebar column root. Also the scope of the row sweep: every row
4160
+ * the drag suppression covers is a descendant of it, so the document-wide scan
4161
+ * it replaces only ever returned these.
4162
+ */
4163
+ const SIDEBAR_SELECTOR = "[class*=\"_sidebarCol\"]";
4164
+ /** The official draggable session/project rows (see disableRowDrag). */
4165
+ const ROW_SELECTOR = "[class*=\"_sessionRow\"], [class*=\"_projectRow\"]";
4166
+ /** The injected stylesheet, addressed through the same cached lookup. */
4167
+ const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`;
4158
4168
  /** Whale button id. */
4159
4169
  const WHALE_ID = "dshRemoteWhale";
4160
4170
  /** Compact picker: synthesized model button id. */
@@ -4322,6 +4332,8 @@ window.__ModuleLoader__.load({
4322
4332
  let whaleObserver = null;
4323
4333
  /** Header subtree observer: marks the geometry measurement dirty on re-render. */
4324
4334
  let headerObserver = null;
4335
+ /** Document child-list observer: invalidates the cached-absent selector set. */
4336
+ let domObserver = null;
4325
4337
  let observedHeader = null;
4326
4338
  /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
4327
4339
  let headerGeometryDirty = true;
@@ -4340,16 +4352,19 @@ window.__ModuleLoader__.load({
4340
4352
  * rail compaction) while the body class stays.
4341
4353
  */
4342
4354
  function ensureAdaptStyle() {
4343
- if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return;
4355
+ if (nodeOf(STYLE_SELECTOR) !== null) return;
4344
4356
  const tag = document.createElement("style");
4345
4357
  tag.dataset.plugin = "remote-web-ui";
4346
4358
  tag.dataset.pluginCss = ADAPT_CSS_ID;
4347
4359
  tag.textContent = ADAPT_CSS.join("");
4348
4360
  document.head.appendChild(tag);
4361
+ nodeCache.set(STYLE_SELECTOR, tag);
4362
+ nodeMissCache.delete(STYLE_SELECTOR);
4349
4363
  }
4350
4364
  function apply() {
4351
4365
  if (active) return;
4352
4366
  active = true;
4367
+ forgetAbsentSelectors();
4353
4368
  document.body.classList.add(ACTIVE_CLASS);
4354
4369
  try {
4355
4370
  w.__dshRemoteAdapt?.closeDetails?.();
@@ -4361,6 +4376,7 @@ window.__ModuleLoader__.load({
4361
4376
  meta.setAttribute("content", `${savedViewportContent}, viewport-fit=cover`);
4362
4377
  }
4363
4378
  ensureWhale();
4379
+ ensureDomObserver();
4364
4380
  syncWhale();
4365
4381
  setWhaleTimer(true);
4366
4382
  seatHeaderActions();
@@ -4386,6 +4402,40 @@ window.__ModuleLoader__.load({
4386
4402
  whaleObserver.disconnect();
4387
4403
  whaleObserver = null;
4388
4404
  }
4405
+ if (domObserver !== null) {
4406
+ domObserver.disconnect();
4407
+ domObserver = null;
4408
+ }
4409
+ forgetAbsentSelectors();
4410
+ }
4411
+ /**
4412
+ * Observe the document for the two kinds of change that can make a cached-absent
4413
+ * target discoverable again (see nodeOf): node insertion/removal, and the
4414
+ * class / compat-stamp attributes the cached selectors match on.
4415
+ *
4416
+ * Body class writes are excluded by target: the layer toggles three body
4417
+ * classes every tick, and counting those would invalidate the negative cache
4418
+ * on every tick and restore the very scan this cache removes. They cannot
4419
+ * create a target either — every cached selector matches an element other
4420
+ * than <body>.
4421
+ *
4422
+ * The record queue is drained on a microtask, so this stays off the layout
4423
+ * path; a chat turn's insertions are a handful per second, not per element.
4424
+ */
4425
+ function ensureDomObserver() {
4426
+ if (domObserver !== null || typeof MutationObserver === "undefined" || !document.body) return;
4427
+ domObserver = new MutationObserver((records) => {
4428
+ for (const record of records) if (record.type === "childList" || record.target !== document.body) {
4429
+ noteDomChange();
4430
+ return;
4431
+ }
4432
+ });
4433
+ domObserver.observe(document.body, {
4434
+ childList: true,
4435
+ subtree: true,
4436
+ attributes: true,
4437
+ attributeFilter: ["class", "data-dsh-frame"]
4438
+ });
4389
4439
  }
4390
4440
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
4391
4441
  function setWhaleTimer(on) {
@@ -4634,7 +4684,7 @@ window.__ModuleLoader__.load({
4634
4684
  const dragOverridden = /* @__PURE__ */ new Map();
4635
4685
  function disableRowDrag() {
4636
4686
  if (!active) return;
4637
- const rows = document.querySelectorAll("[class*=\"_sidebarCol\"] [class*=\"_sessionRow\"], [class*=\"_sidebarCol\"] [class*=\"_projectRow\"]");
4687
+ const rows = sidebarRows();
4638
4688
  for (const row of rows) if (row.getAttribute("draggable") !== "false") {
4639
4689
  if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute("draggable"));
4640
4690
  row.setAttribute("draggable", "false");
@@ -4655,14 +4705,83 @@ window.__ModuleLoader__.load({
4655
4705
  * node on a major re-render, which the isConnected guard detects.
4656
4706
  */
4657
4707
  const nodeCache = /* @__PURE__ */ new Map();
4708
+ /**
4709
+ * Selectors known to be absent as of {@link domGeneration}. A miss is the
4710
+ * common case, not the exception: the layer holds no overlay of its own, and
4711
+ * several official surfaces it looks for (tabs row, tools row, sidebar rows)
4712
+ * legitimately do not exist at once. Re-walking a conversation-sized document
4713
+ * for the same absent selector on every 600ms tick was waste that almost
4714
+ * always resolved to null: five document-wide lookups per tick on the measured
4715
+ * fixture, zero after this cache. That is a lookup count — the wall-clock
4716
+ * saving is real while the mounted content is quiet and shrinks to the
4717
+ * row-scope saving when a turn streams (see the Agent Note for the harness and
4718
+ * the before/after numbers).
4719
+ *
4720
+ * A miss is trusted only while the observed content has not changed, so a
4721
+ * surface that appears is still found on the very next tick — the same
4722
+ * discovery latency the unconditional probe had. The cache is never aged by a
4723
+ * clock: it is cleared by {@link noteDomChange} while the observer is live and
4724
+ * by {@link forgetAbsentSelectors} across the windows where it is not (apply
4725
+ * and revert). One blind spot is deliberate and load-bearing: the observer
4726
+ * watches <body>, so a head-resident target is not covered by that signal —
4727
+ * the one such target is seeded into the cache where it is created (see
4728
+ * ensureAdaptStyle).
4729
+ */
4730
+ const nodeMissCache = /* @__PURE__ */ new Set();
4731
+ /**
4732
+ * Bumped whenever a cached target may have appeared or disappeared. A surface
4733
+ * qualifies on a class token or on the aggregate compat stamp
4734
+ * (`data-dsh-frame`), so both insertions/removals AND those two attributes
4735
+ * can change the answer. Nothing the layer writes per tick lands here: its own
4736
+ * class writes are all on <body>, and its row/transform/label writes touch
4737
+ * other attributes entirely (see ensureDomObserver).
4738
+ */
4739
+ let domGeneration = 0;
4740
+ /** The generation the negative cache was recorded against. */
4741
+ let missGeneration = -1;
4742
+ function noteDomChange() {
4743
+ domGeneration += 1;
4744
+ }
4745
+ /**
4746
+ * Drop every cached "absent" verdict. Called on apply and revert: while the
4747
+ * observer is disconnected the layer is blind to insertions, so a verdict
4748
+ * recorded before the gap could otherwise outlive the change that falsified
4749
+ * it and be served without a probe. The positive cache needs no equivalent —
4750
+ * its `isConnected` guard already re-resolves a replaced node.
4751
+ */
4752
+ function forgetAbsentSelectors() {
4753
+ nodeMissCache.clear();
4754
+ domGeneration += 1;
4755
+ }
4658
4756
  function nodeOf(selector) {
4659
4757
  const cached = nodeCache.get(selector);
4660
- if (cached !== void 0 && cached.isConnected) return cached;
4758
+ if (cached !== void 0) {
4759
+ if (cached.isConnected) return cached;
4760
+ nodeCache.delete(selector);
4761
+ }
4762
+ if (missGeneration !== domGeneration) {
4763
+ nodeMissCache.clear();
4764
+ missGeneration = domGeneration;
4765
+ }
4766
+ if (nodeMissCache.has(selector)) return null;
4661
4767
  const found = document.querySelector(selector);
4662
- if (found === null) nodeCache.delete(selector);
4663
- else nodeCache.set(selector, found);
4768
+ if (found === null) {
4769
+ nodeMissCache.add(selector);
4770
+ return null;
4771
+ }
4772
+ nodeCache.set(selector, found);
4664
4773
  return found;
4665
4774
  }
4775
+ /**
4776
+ * The session/project rows the drag suppression covers, scoped to the cached
4777
+ * sidebar root. Each row carries the sidebar column class as an ancestor, so
4778
+ * the document-wide scan this replaces only ever returned rows from here.
4779
+ */
4780
+ function sidebarRows() {
4781
+ const sidebar = nodeOf(SIDEBAR_SELECTOR);
4782
+ if (sidebar === null) return [];
4783
+ return Array.from(sidebar.querySelectorAll(ROW_SELECTOR));
4784
+ }
4666
4785
  /** The official application frame, through the same cached lookup. */
4667
4786
  function frameEl() {
4668
4787
  const frame = nodeOf(APP_FRAME_SELECTOR);