From 5912274e4146d182da6c02b9e318bc6718dfb5a2 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 21 Sep 2026 20:35:11 +0800 Subject: [PATCH] feat: yield the terminal and browser to DSH 0.1.6's own sidebar types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features. --- cordis.patch.yml | 13 +- package.json | 4 - pnpm-lock.yaml | 32 -- pnpm-workspace.yaml | 6 +- scripts/e2e-aggregate-mount.sh | 16 +- scripts/package-registry.mjs | 2 - src/agent-pty.ts | 722 ------------------------- src/browser-probe.ts | 26 - src/bundle-route.ts | 2 +- src/client/BrowserView.tsx | 289 ---------- src/client/Sidebar.tsx | 107 +--- src/client/TabBar.tsx | 96 +--- src/client/TerminalView.tsx | 477 ---------------- src/client/TerminalWaitBanner.tsx | 43 -- src/client/api.ts | 40 -- src/client/browser.ts | 167 ------ src/client/builtins/index.ts | 5 +- src/client/builtins/tabs.tsx | 188 +------ src/client/chunk-loader.ts | 14 +- src/client/chunks/terminal.tsx | 9 - src/client/icons.tsx | 11 - src/client/index.tsx | 30 +- src/client/pinned.ts | 177 ------ src/client/prefs.ts | 26 - src/client/service.ts | 10 +- src/client/sidebar.module.css | 187 ++----- src/client/sidebar/TabContent.tsx | 11 +- src/client/sidebar/use-host-feeds.ts | 83 +-- src/client/sidebar/use-pinned-tabs.ts | 147 ----- src/client/split-pane.tsx | 14 +- src/client/state.ts | 255 +-------- src/client/tab-content-memo.ts | 10 +- src/client/terminal-font.ts | 255 --------- src/client/terminal-links.ts | 194 ------- src/config.ts | 48 -- src/index.ts | 574 +------------------- src/prefs-shared.ts | 91 +--- src/pty-deps.ts | 240 -------- src/pty-manager.ts | 548 ------------------- src/tools.ts | 500 ----------------- src/wire.ts | 5 +- tests/agent-pty.spec.ts | 566 ------------------- tests/agent-terminal-reconcile.spec.ts | 148 ----- tests/agent-wait-badge.spec.tsx | 138 ----- tests/bottom-auto-terminal.spec.tsx | 180 ------ tests/browser-probe.spec.ts | 50 -- tests/browser.spec.ts | 79 --- tests/builtins.spec.ts | 103 +--- tests/bundle-route.spec.ts | 6 +- tests/chunk-artifact.spec.ts | 4 +- tests/chunk-loader.spec.ts | 28 +- tests/consumer-types.ts | 4 +- tests/e2e/mount.e2e.ts | 29 +- tests/e2e/perf.e2e.ts | 9 +- tests/lazy-chunk.spec.tsx | 65 +-- tests/manifest-consistency.spec.ts | 14 +- tests/native-surface.spec.ts | 21 +- tests/open-when-sized.spec.ts | 138 ----- tests/orphaned-tab.spec.tsx | 4 +- tests/pinned.spec.ts | 261 --------- tests/plugin-shape.spec.ts | 27 +- tests/prefs.spec.ts | 62 +-- tests/pty-deps.spec.ts | 209 ------- tests/pty-helpers.spec.ts | 225 -------- tests/sandbox-views.spec.tsx | 95 ---- tests/service.spec.ts | 8 +- tests/side-card-section-rows.spec.tsx | 41 +- tests/side-card-section.spec.tsx | 22 +- tests/smoke.spec.ts | 403 +------------- tests/state.spec.ts | 116 +--- tests/tab-bar-context-menu.spec.tsx | 153 ------ tests/tab-content-memo.spec.ts | 1 - tests/terminal-deps-banner.spec.tsx | 83 --- tests/terminal-font.spec.ts | 248 --------- tests/terminal-links.spec.ts | 287 ---------- tests/terminal-wait-banner.spec.tsx | 60 -- tests/theme.spec.ts | 23 + tests/tools.spec.ts | 250 --------- tsdown.config.ts | 11 +- 79 files changed, 381 insertions(+), 9464 deletions(-) delete mode 100644 src/agent-pty.ts delete mode 100644 src/browser-probe.ts delete mode 100644 src/client/BrowserView.tsx delete mode 100644 src/client/TerminalView.tsx delete mode 100644 src/client/TerminalWaitBanner.tsx delete mode 100644 src/client/browser.ts delete mode 100644 src/client/chunks/terminal.tsx delete mode 100644 src/client/pinned.ts delete mode 100644 src/client/sidebar/use-pinned-tabs.ts delete mode 100644 src/client/terminal-font.ts delete mode 100644 src/client/terminal-links.ts delete mode 100644 src/pty-deps.ts delete mode 100644 src/pty-manager.ts delete mode 100644 src/tools.ts delete mode 100644 tests/agent-pty.spec.ts delete mode 100644 tests/agent-terminal-reconcile.spec.ts delete mode 100644 tests/agent-wait-badge.spec.tsx delete mode 100644 tests/bottom-auto-terminal.spec.tsx delete mode 100644 tests/browser-probe.spec.ts delete mode 100644 tests/browser.spec.ts delete mode 100644 tests/open-when-sized.spec.ts delete mode 100644 tests/pinned.spec.ts delete mode 100644 tests/pty-deps.spec.ts delete mode 100644 tests/pty-helpers.spec.ts delete mode 100644 tests/terminal-deps-banner.spec.tsx delete mode 100644 tests/terminal-font.spec.ts delete mode 100644 tests/terminal-links.spec.ts delete mode 100644 tests/terminal-wait-banner.spec.tsx delete mode 100644 tests/tools.spec.ts diff --git a/cordis.patch.yml b/cordis.patch.yml index 420076f..4090f16 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -15,20 +15,13 @@ # cordis.patch.yml, remove it before switching to the bundle channel to # avoid double-mounting (two Node halves, two sidebars). # -# To choose the terminal shell, add `config.shell` to the inserted plugin -# row (or to the profile's manual cordis.patch.yml mount line). When omitted, -# the host keeps resolving $SHELL / the login shell / powershell.exe. -# `config.shellArgs` optionally supplies explicit startup arguments; when -# non-empty they replace the automatic POSIX `-l` login flag: +# The `config.shell` / `config.shellArgs` rows are gone with the plugin's own +# terminal (DSH 0.1.6 ships its own right-Sidebar terminal, and its shell is +# chosen from the shell menu there): # # - insert: # - id: better-sidebar # name: 'dsh-better-sidebar' -# config: -# shell: /bin/zsh -# shellArgs: -# - --noprofile -# - --no-rc # # Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all) # may already mount this package under their own entry id (e.g. diff --git a/package.json b/package.json index f88058f..c9129d6 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "lib/invariant.js", "lib/client.js", "lib/client-registry.js", - "lib/client-terminal.js", "lib/client-editor.js", "lib/client-mermaid.js", "lib/types/**/*.d.ts", @@ -143,7 +142,6 @@ "clsx": "^2.1.1", "dompurify": "^3.4.14", "mermaid": "^11.16.1", - "node-pty": "^1.1.0", "react-icons": "5.7.0", "rxjs": "^7.8.2", "schemastery": "^3.18.0", @@ -182,8 +180,6 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.1", "@types/ws": "^8.5.10", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^5.5.0", "anser": "^2.3.5", "cordis": "^4.0.0-rc.8", "diff": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54fc298..c4d8c7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,9 +86,6 @@ importers: mermaid: specifier: ^11.16.1 version: 11.17.0 - node-pty: - specifier: ^1.1.0 - version: 1.1.0 react-icons: specifier: 5.7.0 version: 5.7.0(react@18.2.0) @@ -198,12 +195,6 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.18.1 - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 anser: specifier: ^2.3.5 version: 2.3.5 @@ -1480,12 +1471,6 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/xterm@5.5.0': - resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - '@yuku-codegen/binding-android-arm64@0.8.7': resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] @@ -2584,13 +2569,6 @@ packages: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} - node-addon-api@7.1.0: - resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==} - engines: {node: ^16 || ^18 || >= 20} - - node-pty@1.1.0: - resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} - node-releases@2.0.54: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} @@ -4476,10 +4454,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 - '@xterm/addon-fit@0.11.0': {} - - '@xterm/xterm@5.5.0': {} - '@yuku-codegen/binding-android-arm64@0.8.7': optional: true @@ -5676,12 +5650,6 @@ snapshots: dependencies: content-type: 2.1.0 - node-addon-api@7.1.0: {} - - node-pty@1.1.0: - dependencies: - node-addon-api: 7.1.0 - node-releases@2.0.54: {} obug@2.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4a3648..75a673f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,12 +1,8 @@ packages: - . -# node-pty's install script copies its shipped prebuilt binaries into place -# (no source compilation on supported platforms); pnpm 10+ blocks dependency -# build scripts unless approved. allowBuilds: - node-pty: true - protobufjs: true + node-pty: set this to true or false # The @deepseek-ai/* packages declare their dsh-* siblings as peerDependencies # (the DSH host bundle provides them all at mount time). This repo installs diff --git a/scripts/e2e-aggregate-mount.sh b/scripts/e2e-aggregate-mount.sh index 322e847..cb36c63 100755 --- a/scripts/e2e-aggregate-mount.sh +++ b/scripts/e2e-aggregate-mount.sh @@ -108,14 +108,16 @@ if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then die "检测到 duplicate prefix route(双挂载未退让)" fi -# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 + -# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由 -# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。 -DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)" -if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then - die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))" +# 真实方法探活:settings.get 是插件侧栏卡偏好的读取端(writeOk → HTTP 200 + +# {"ok":true,...};未挂 settings 服务的部署也照回 ok,值取缺省)。选它当哨兵 +# 的理由:/sidebar/api 是插件自己 fence 的 prefix 路由,未注册的方法统统 404, +# 所以 {"ok":true} 只可能来自插件真实注册的 handler —— generic missing-route +# 404 无法冒充(P2: Probe a real sidebar API method)。 +RESP="$(curl -s -X POST "$URL/sidebar/api/settings.get" 2>/dev/null || true)" +if ! printf '%s' "$RESP" | grep -q '"ok":true'; then + die "/sidebar/api/settings.get 未返回 ok:true(响应:$(printf '%s' "$RESP" | head -c 200))" fi -say "/sidebar/api/terminal.deps → ok:true(真实 handler 存活)" +say "/sidebar/api/settings.get → ok:true(真实 handler 存活)" STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)" say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)" diff --git a/scripts/package-registry.mjs b/scripts/package-registry.mjs index 92f45b8..93fe646 100644 --- a/scripts/package-registry.mjs +++ b/scripts/package-registry.mjs @@ -31,8 +31,6 @@ const files = [ 'lib/client-registry.js.map', // Lazy chunks: the client fetches these from the host's /sidebar/bundle // route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts). - 'lib/client-terminal.js', - 'lib/client-terminal.js.map', 'lib/client-editor.js', 'lib/client-editor.js.map', 'lib/client-mermaid.js', diff --git a/src/agent-pty.ts b/src/agent-pty.ts deleted file mode 100644 index cd67df2..0000000 --- a/src/agent-pty.ts +++ /dev/null @@ -1,722 +0,0 @@ -/** - * Agent-owned terminal registry: a uuid-keyed table of long-lived PTY - * sessions created by the model through the `terminal_create` tool. Each - * handle survives across tool calls (and across WebSocket disconnects from - * the sidebar view) until the model calls `terminal_close` or the user - * closes the corresponding sidebar tab — tmux semantics, scoped per agent - * session. - * - * This is a parallel registry to {@link PtyManager}: UI tabs are keyed by - * `${sessionId}:${tabId}` and capped per session, while agent terminals are - * keyed by uuid and uncapped (the model is trusted to close unused ones). - * Both registries share the same shell resolver and spawn-helper fix. - */ -import { randomUUID } from 'node:crypto' -import type { IPty } from 'node-pty' -import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts' -import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts' -import { SidebarError } from './wire.ts' - -/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */ -const TRANSCRIPT_LIMIT = 1 << 20 - -/** POSIX signals the registry forwards to a live pty. */ -export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const -/** Signal name accepted by `signal()`. */ -export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number] - -/** Default read page size (lines) when the caller omits `count`. */ -export const DEFAULT_READ_COUNT = 500 - -/** Smallest pty dimension the registry accepts (mirrors the tool contract). */ -export const TERMINAL_DIM_MIN = 2 -/** Largest pty dimension the registry accepts (mirrors the tool contract). */ -export const TERMINAL_DIM_MAX = 1024 - -/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */ -export function clampDims(cols: number, rows: number): { cols: number; rows: number } { - const clamp = (value: number): number => - Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value))) - return { cols: clamp(cols), rows: clamp(rows) } -} - -/** - * Per-pty Windows resize-gate state (see {@link armPtyResizeGate}). - */ -interface ResizeGateState { - /** Whether the pty has produced any output yet (ConPTY flush completed). */ - sawData: boolean - /** Latest dims requested before the first output; replayed once it arrives. */ - pending?: { cols: number; rows: number } -} - -/** - * node-pty's Windows terminal queues resize calls that arrive before the - * ConPTY control socket's first data flush (`_deferNoArgs` in - * windowsTerminal.js). If the pty exits before the queue flushes, the - * deferred resize throws inside the socket's 'data' handler — uncatchable - * by any caller and fatal to the host process. POSIX terminals have no such - * queue (resize is synchronous), so the gate is armed on Windows only: - * {@link tryResizePty} parks dims requested before the first output and - * replays them after the flush, when node-pty executes resizes - * synchronously (and the throw for an exited pty is catchable). - */ -const ptyResizeGates = new WeakMap() - -/** - * Arm the Windows pre-ready resize gate for one freshly spawned pty. - * No-op on POSIX and for injected ptys without `onData`. - */ -export function armPtyResizeGate(pty: IPty): void { - if (process.platform !== 'win32') return - if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return - const state: ResizeGateState = { sawData: false } - ptyResizeGates.set(pty, state) - pty.onData(() => { - if (state.sawData) return - state.sawData = true - const dims = state.pending - state.pending = undefined - if (dims === undefined) return - // node-pty's deferred flush shares this very 'data' dispatch; hop past - // it so the replayed resize executes synchronously (catchable) instead - // of being queued into the same deferred list we are draining. - setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) }) - }) -} - -/** - * Best-effort resize for WebSocket-driven terminal views. Layout animation - * can briefly produce unusable dimensions, and node-pty can reject a resize - * after the socket setup's outer try/catch has returned. Ignore that one - * frame so the host stays alive and a later valid measurement can retry. - * Returns whether node-pty accepted the resize (or parked it for replay on - * the first output — the Windows pre-ready window). - */ -export function tryResizePty( - pty: Pick, - cols: number, - rows: number, -): boolean { - if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false - const dims = clampDims(cols, rows) - const gate = ptyResizeGates.get(pty) - if (gate !== undefined && !gate.sawData) { - gate.pending = dims - return true - } - try { - pty.resize(dims.cols, dims.rows) - return true - } catch { - return false - } -} - -/** - * Serializable snapshot of one agent terminal — the shape the model sees - * through `terminal_list` and the sidebar sees through the push endpoint. - * Carries no pty reference and no transcript (those are reached through - * dedicated read/attach paths), and no sessionId: ownership is registry - * internals, scoped by the caller (list filters by session, the push - * endpoint scopes by its query param), never part of the serialized view. - */ -export interface AgentTerminalSnapshot { - /** Stable opaque handle the model passes back to other terminal_* tools. */ - uuid: string - /** Display title the model chose at create time. */ - title: string - /** The command the model asked to run at create time (verbatim). */ - command: string - /** Whether the top-level process has exited. */ - exited: boolean - /** Exit code if exited normally; absent until the process exits. */ - exitCode?: number | null - /** Exit signal name if the process was killed by a signal; null otherwise. */ - exitSignal?: string | null - - /** - * The model's active `terminal_wait_for` on this terminal (the sidebar - * renders the wait banner from it). Present only while a wait is - * registered; carries the LATEST wait when several overlap. - */ - waiting?: { needle: string; since: number } -} - -/** Map a POSIX signal number to its conventional name (best-effort). */ -const SIGNAL_NAMES: Record = { - 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT', - 9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM', - 17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP', -} - -/** Convert a raw signal number to a name (or null when absent/unknown). */ -function signalNameOf(signal: number | null | undefined): string | null { - if (signal === null || signal === undefined) return null - return SIGNAL_NAMES[signal] ?? `signal ${signal}` -} - -/** - * Compile a wait needle into a RegExp. The needle is treated as a JavaScript - * regular expression; a pattern that fails to compile ( e.g. an unbalanced - * group typed as a literal ) degrades to verbatim substring matching so - * legacy literal needles keep working. - */ -function compileNeedle(needle: string): RegExp | null { - try { - return new RegExp(needle) - } catch { - return null - } -} - -/** - * Locate the first occurrence of `needle` in `transcript`, returning its - * line/column plus the actual matched text — for alternation patterns - * ( e.g. `(BUILD_OK|BUILD_FAIL)` ) the match tells which alternative hit. - * `re` is the precompiled form of `needle` (from {@link compileNeedle}); - * `null` means verbatim substring matching. - */ -function locateNeedle( - transcript: string, - needle: string, - re: RegExp | null, -): { line: number; column: number; match: string } | undefined { - if (needle === '') return undefined - // Regex hit → use the match index/text; literal fallback → indexOf with - // the needle itself as the matched text. - let hit: { index: number; text: string } | undefined - if (re !== null) { - re.lastIndex = 0 - const m = re.exec(transcript) - hit = m === null ? undefined : { index: m.index, text: m[0] } - } else { - const idx = transcript.indexOf(needle) - hit = idx === -1 ? undefined : { index: idx, text: needle } - } - if (hit === undefined) return undefined - // Walk the transcript up to the match index, counting newlines to derive - // the 0-based line; the column is the offset within that line. - let line = 0 - let lineStart = 0 - for (let i = 0; i < hit.index; i += 1) { - if (transcript.charCodeAt(i) === 0x0a /* \n */) { - line += 1 - lineStart = i + 1 - } - } - return { line, column: hit.index - lineStart, match: hit.text } -} - -/** One live agent terminal. */ -export interface AgentTerminalHandle { - /** Stable opaque handle. */ - uuid: string - /** Owning conversation id. */ - sessionId: string - /** Display title. */ - title: string - /** The command written to stdin right after spawn. */ - command: string - /** The working directory the process was spawned with. */ - cwd: string - /** The live pty process. */ - pty: IPty - /** Output accumulated since spawn (bounded; head dropped when over the limit). */ - transcript: string - /** Whether the top-level process exited (transcript stays replayable). */ - exited: boolean - /** Exit code once known. */ - exitCode?: number | null - /** Exit signal number once known (POSIX only; undefined on Windows). */ - exitSignal?: number | null - /** Active wait_for registrations (skip bookkeeping; empty while idle). */ - waits: AgentTerminalActiveWait[] -} - -/** Read result shape (mirrors the official tool-pty terminal_read contract). */ -export interface AgentTerminalReadResult { - /** The slice of transcript text for the requested page. */ - text: string - /** Total lines in the retained transcript (the page may be a subset). */ - totalLines: number - /** 0-based index of the first line in `text` (inclusive). */ - lineBegin: number - /** 0-based index of the last line in `text` (exclusive). */ - lineEnd: number -} - -/** One active wait_for registration on a handle (banner + skip bookkeeping). */ -export interface AgentTerminalActiveWait { - /** The needle being awaited (shown on the sidebar wait banner). */ - needle: string - /** Epoch ms when the wait registered (age display / debugging). */ - since: number - /** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */ - skipped: boolean -} - -/** Outcome of {@link AgentPtyRegistry.waitFor}. */ -export type AgentTerminalWaitResult = - | { - /** The needle was found in the transcript. */ - kind: 'found' - /** The pattern that was awaited. */ - needle: string - /** 0-based line index (in the retained transcript) where the needle first appeared. */ - line: number - /** 0-based column index within that line where the match starts. */ - column: number - /** - * The text that actually matched — for multi-outcome patterns - * ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched. - */ - match: string - /** Elapsed wall-clock milliseconds from the wait start to the match. */ - elapsedMs: number - } - | { - /** The needle did not appear before the timeout. */ - kind: 'timeout' - /** The needle that was awaited. */ - needle: string - /** The configured timeout in milliseconds. */ - timeoutMs: number - /** Total lines retained when the timeout fired (call terminal_read to inspect). */ - totalLines: number - } - | { - /** The terminal exited before the needle appeared. */ - kind: 'exited' - /** The needle that was awaited. */ - needle: string - /** The exit code, if known. */ - exitCode?: number | null - /** The exit signal name, if the process was killed by a signal. */ - exitSignal?: string | null - } - | { - /** The user skipped the wait from the sidebar banner. */ - kind: 'skipped' - /** The needle that was awaited. */ - needle: string - } - -/** Snapshot projection of a handle (drops the pty reference and transcript). */ -export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { - const out: AgentTerminalSnapshot = { - uuid: handle.uuid, - title: handle.title, - command: handle.command, - exited: handle.exited, - } - if (handle.exited) { - out.exitCode = handle.exitCode ?? null - out.exitSignal = signalNameOf(handle.exitSignal) - } - const active = handle.waits.at(-1) - if (active !== undefined) out.waiting = { needle: active.needle, since: active.since } - return out -} - -/** - * The agent terminal registry. The constructor takes the resolved shell - * binary (the same `defaultShell()` the UI-tab registry uses) and runs the - * spawn-helper chmod fix once at construction so the first agent terminal - * does not race a lazy fixer. - */ -export class AgentPtyRegistry { - private readonly sessions = new Map() - private readonly changeListeners = new Set<() => void>() - - constructor( - private readonly shell: string, - private readonly shellArgs: string[] = [], - /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */ - private readonly nodePty: NodePtyModule = loadRequiredNodePty(), - ) { - ensureSpawnHelper() - } - - /** - * Spawn one agent terminal: start the shell in `cwd`, then write - * `command + '\n'` to stdin so the command runs in the fresh shell. The - * terminal stays alive after the command exits — the model can send more - * input through `terminal_send` until it calls `terminal_close` or the - * user closes the sidebar tab. An empty `command` spawns a bare shell. - * @returns the new handle's uuid (the model-facing opaque id). - */ - create( - sessionId: string, - title: string, - command: string, - cwd: string, - cols = 80, - rows = 24, - shell?: string, - shellArgs?: string[], - ): string { - const uuid = randomUUID() - const dims = clampDims(cols, rows) - const executable = resolveShellExecutable(shell ?? this.shell) - const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), { - name: 'xterm-256color', - cols: dims.cols, - rows: dims.rows, - cwd, - env: { ...process.env }, - }) - armPtyResizeGate(pty) - const handle: AgentTerminalHandle = { - uuid, - sessionId, - title, - command, - cwd, - pty, - transcript: '', - exited: false, - waits: [], - } - pty.onData((data) => { - handle.transcript += data - if (handle.transcript.length > TRANSCRIPT_LIMIT) { - handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT) - } - }) - pty.onExit(({ exitCode, signal }) => { - handle.exited = true - handle.exitCode = exitCode - handle.exitSignal = signal - this.notify() - }) - if (command !== '') { - // Write the command + Enter so it runs in the freshly spawned shell. - // Use \r (carriage return) — the actual character a terminal sends for - // the Enter key — not \n (line feed). PowerShell treats a bare \n as a - // soft line break (continuation prompt ">>") rather than a command - // submit; \r is the cross-shell Enter semantics on both POSIX and Windows. - try { - pty.write(`${command}\r`) - } catch { - // A spawn that failed between onData and onExit surfaces its own - // exit; the create call still returns the uuid so the model can - // read the transcript and see the failure. - } - } - this.sessions.set(uuid, handle) - this.notify() - return uuid - } - - /** All live agent terminals belonging to one conversation. */ - list(sessionId: string): AgentTerminalSnapshot[] { - const out: AgentTerminalSnapshot[] = [] - for (const handle of this.sessions.values()) { - if (handle.sessionId === sessionId) out.push(snapshotOf(handle)) - } - return out - } - - /** Resolve a live handle by uuid, or throw `not-found`. */ - private expect(uuid: string): AgentTerminalHandle { - const handle = this.sessions.get(uuid) - if (handle === undefined) { - throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404) - } - return handle - } - - /** - * Resolve a live handle that belongs to `sessionId`, or throw `not-found`. - * The model-facing tools call this before every uuid-keyed operation: a - * uuid from another session is indistinguishable from an unknown one, so a - * model can never reach (or probe) a terminal it does not own. - */ - assertOwned(uuid: string, sessionId: string): AgentTerminalHandle { - const handle = this.expect(uuid) - if (handle.sessionId !== sessionId) { - throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404) - } - return handle - } - - /** Resolve a handle's snapshot, or undefined if it does not exist. */ - snapshot(uuid: string): AgentTerminalSnapshot | undefined { - const handle = this.sessions.get(uuid) - return handle === undefined ? undefined : snapshotOf(handle) - } - - /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */ - send(uuid: string, text: string): void { - const handle = this.expect(uuid) - if (handle.exited) { - throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400) - } - handle.pty.write(text) - } - - /** - * Read one bounded page of the retained transcript. `offset` is a 0-based - * line index from the start of the retained transcript (default 0); - * `count` caps the page size (default 500). A negative `offset` reads - * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines` - * so the model can paginate. - */ - read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult { - const handle = this.expect(uuid) - const lines = handle.transcript.split('\n') - const totalLines = lines.length - const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT)) - let start: number - if (offset === undefined || offset === 0) { - start = 0 - } else if (offset < 0) { - // Negative offset: read from the end (e.g. -50 → last 50 lines). - start = Math.max(0, totalLines + offset) - } else { - start = Math.min(offset, totalLines) - } - const end = Math.min(start + pageSize, totalLines) - const slice = lines.slice(start, end).join('\n') - return { - text: slice, - totalLines, - lineBegin: start, - lineEnd: end, - } - } - - /** - * Resize a terminal's pty, clamped to the 2..1024 sane range. - * @returns the dimensions actually applied (the caller echoes these, so the - * reported value always matches the pty). - */ - resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } { - const handle = this.expect(uuid) - const dims = clampDims(cols, rows) - // Through tryResizePty: node-pty throws on a just-exited pty (the exit - // event may not have flipped `handle.exited` yet), and on Windows a - // pre-ready resize must go through the gate instead of node-pty's own - // deferred queue (which flushes uncatchably inside its socket handler). - if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows) - return dims - } - - /** - * Wait for `needle` to appear in a terminal's transcript, or for the - * terminal to exit, or for the timeout to elapse — whichever happens - * first. The wait polls the live transcript every ~50ms and short-circuits - * on `signal` abort (re-thrown as the abort reason so the tool layer - * surfaces cancellation). - * - * The match scans the FULL retained transcript on each poll, not just the - * delta since the last poll — a needle that scrolled past the most recent - * chunk but is still within the ~1 MiB bound is still a match. The - * returned line/column locate the FIRST occurrence (oldest), which is what - * a user watching the terminal would have seen first. - * - * The implementation uses polling (not pty onData subscription) because - * node-pty's onData fires before the registry's own onData listener - * updates the transcript (listener order is not guaranteed), and on - * Windows ConPTY output can arrive in bursts with batching delays that - * make event-driven wakeups unreliable. A 50ms poll is fast enough for - * interactive use and simple enough to be obviously correct. - * @param uuid - terminal to watch. - * @param needle - JavaScript regular expression to search for - * (case-sensitive); a pattern that fails to compile falls back to - * verbatim substring matching. May cover several outcomes at once - * ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure ) — the - * returned `match` reports the text that actually matched, so callers - * can tell which outcome hit. - * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms. - * @param signal - caller-owned cancellation; aborts the wait re-throwing. - * A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`. - * @returns one of `found` / `timeout` / `exited` / `skipped`. - */ - async waitFor( - uuid: string, - needle: string, - timeoutMs = 10_000, - signal?: AbortSignal, - ): Promise { - if (needle === '') { - throw new SidebarError('bad-request', 'needle must be a non-empty string', 400) - } - const handle = this.expect(uuid) - const timeout = Math.max(100, Math.floor(timeoutMs)) - const start = Date.now() - const deadline = start + timeout - // Compile the needle once (regex semantics; invalid patterns degrade to - // verbatim matching) and reuse the compiled form across every poll. - const re = compileNeedle(needle) - // Fast path: already exited, or the needle is already in the transcript - // (a `terminal_send` may have produced the expected output before this - // call even started). - if (handle.exited) { - return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } - } - const firstHit = locateNeedle(handle.transcript, needle, re) - if (firstHit !== undefined) { - return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start } - } - // Register the active wait so the sidebar can show the wait banner (the - // snapshot's `waiting` field rides the agent-terminals push). Registered - // only after the fast paths: a wait that resolves instantly never - // flashes the banner. The notify() makes every push subscriber (the - // sidebar view) converge on the new waiting state immediately. - const record: AgentTerminalActiveWait = { needle, since: start, skipped: false } - handle.waits.push(record) - this.notify() - try { - // Exit outranks a skip in the same tick: `skipWait` flips `record.skipped` - // and the next poll observes it, but a pty that died first is the - // objective fact (a skip is moot once the process is gone). The user - // sees the same three things either way — the banner clears, `skipWait` - // reports 0 for the resolved wait, and the tool result says the wait - // ended — only the reported `kind` differs (see the design doc's - // implementation-deviation record). - while (true) { - if (signal?.aborted) signal.throwIfAborted() - if (handle.exited) { - return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } - } - if (record.skipped) { - return { kind: 'skipped', needle } - } - const hit = locateNeedle(handle.transcript, needle, re) - if (hit !== undefined) { - return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start } - } - if (Date.now() >= deadline) { - return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } - } - await new Promise(resolve => { - const t = setTimeout(resolve, 50) - // Allow the Node process to exit even if the timer is pending. - if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref() - }) - } - } finally { - const index = handle.waits.indexOf(record) - if (index !== -1) handle.waits.splice(index, 1) - this.notify() - } - } - - /** - * Mark every active wait on one terminal as skipped (the sidebar banner's - * skip button). Each waiting poll loop observes its record's flag within - * one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing - * is waiting (a stale banner racing a wait that already resolved). - * @returns the number of waits that transitioned to skipped. - */ - skipWait(uuid: string): number { - const handle = this.expect(uuid) - let count = 0 - for (const record of handle.waits) { - if (!record.skipped) { - record.skipped = true - count += 1 - } - } - return count - } - - /** - * Send a POSIX signal to a terminal's foreground process. - * - * Two delivery paths, by signal kind: - * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by - * writing the corresponding control character to the pty stdin. This is - * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel - * line discipline (POSIX ISIG mode) or the ConPTY input pipeline - * (Windows), which translates it into a SIGINT/SIGTSTP for the - * foreground process group. This works on every platform — calling - * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX, - * but writing `\x03` is universally correct. - * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`, - * which maps to the platform's process-termination path (POSIX - * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with - * control characters. - */ - signal(uuid: string, signal: AgentTerminalSignal): void { - const handle = this.expect(uuid) - if (handle.exited) return - // Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty - // master is the cross-platform way to deliver the signal to the - // foreground process group through the terminal's input pipeline. - if (signal === 'SIGINT' || signal === 'SIGTSTP') { - const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a' - try { - handle.pty.write(ctrlByte) - } catch { - // A pty that rejects writes is already tearing down; the next - // onExit will mark it exited. Not an error from the caller's view. - } - return - } - // SIGKILL / SIGTERM / SIGHUP: use the process-termination path. - try { - handle.pty.kill(signal) - } catch { - // node-pty on Windows rejects named signals other than the default; - // fall back to the default kill (TerminateProcess on Windows, - // SIGKILL-equivalent on POSIX) so the signal still takes effect. - try { - handle.pty.kill() - } catch { - // Already exited or gone; nothing left to kill. - } - } - } - - /** - * Close a terminal and drop its state. Idempotent: a second close of the - * same uuid is a no-op. Returns true iff a live handle was actually - * dropped. - */ - close(uuid: string): boolean { - const handle = this.sessions.get(uuid) - if (handle === undefined) return false - this.sessions.delete(uuid) - try { - handle.pty.kill() - } catch { - // Already exited or gone; nothing left to kill. - } - this.notify() - return true - } - - /** Resolve a live handle by uuid (for the WS attach path). */ - get(uuid: string): AgentTerminalHandle | undefined { - return this.sessions.get(uuid) - } - - /** - * Subscribe to registry changes (create / close / exit). The sidebar push - * endpoint uses this to forward snapshots to the connected view. Returns - * the unsubscribe function. - */ - subscribe(listener: () => void): () => void { - this.changeListeners.add(listener) - return () => { this.changeListeners.delete(listener) } - } - - /** Close every agent terminal (plugin teardown). */ - disposeAll(): void { - for (const uuid of [...this.sessions.keys()]) this.close(uuid) - } - - /** Fire every change listener (callers wrap in try/catch if needed). */ - private notify(): void { - for (const listener of [...this.changeListeners]) { - try { - listener() - } catch { - // A listener throwing must not break the others or the registry. - } - } - } -} diff --git a/src/browser-probe.ts b/src/browser-probe.ts deleted file mode 100644 index ab356a6..0000000 --- a/src/browser-probe.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Pure helpers for the `browser.probe` route (sidebar browser): the host - * fetches the response HEADERS of a URL the user is browsing and the client - * decides whether the target site forbids being embedded (X-Frame-Options / - * CSP frame-ancestors are exactly the signals the browser enforces when it - * refuses an iframe load). Kept dependency-free so the parser is - * unit-testable. - */ - -/** - * Extract the `frame-ancestors` source list of a Content-Security-Policy - * header, or undefined when the directive is absent (or empty). The - * directive is the only one with a source list; sources are space-separated - * tokens (`'none'`, `'self'`, `*`, or origins). - */ -export function extractFrameAncestors(csp: string | null): string[] | undefined { - if (csp === null) return undefined - for (const directive of csp.split(';')) { - const parts = directive.trim().split(/\s+/) - if (parts[0] === 'frame-ancestors') { - const sources = parts.slice(1).filter(source => source !== '') - return sources.length === 0 ? undefined : sources - } - } - return undefined -} diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 4f098ec..452fa01 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url' import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts' /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */ -export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const +export const CHUNK_NAMES = ['editor', 'mermaid', 'locale'] as const export type ChunkName = (typeof CHUNK_NAMES)[number] /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */ diff --git a/src/client/BrowserView.tsx b/src/client/BrowserView.tsx deleted file mode 100644 index c44d41a..0000000 --- a/src/client/BrowserView.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/** - * The built-in browser tab: an address bar plus a sandboxed iframe. - * - * Security model (see browser.ts + the sandbox tokens below): the iframe is - * ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited - * page can never sit on the GUI's origin, read its storage, or reach - * /sidebar/api) and without `allow-top-navigation` (a page must not hijack - * the GUI). The address bar only accepts http(s) and refuses loopback / - * the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the - * sandbox attribute entirely for fully trusted sites — the visited page then - * runs with the GUI's own origin and full session access, so a persistent - * warning bar renders while it is off. - * - * The URL is persisted onto the tab (path/title via the patchTab reducer) - * so a reload restores the visited page; the back/forward stack only tracks - * address-bar navigations (in-frame link clicks are cross-origin and - * invisible — a documented limitation). - */ -import { useEffect, useState } from 'react' -import { - IconChevronLeftOutline14, - IconChevronRightOutline14, - IconLinkOutline14, - IconRefreshOutline14, - IconWarningOutline16, -} from '@deepseek-ai/dsh-client-ui-primitives' -import { VscLinkExternal } from 'react-icons/vsc' -import { api } from './api.ts' -import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts' -import { patchTab } from './state.ts' -import { SandboxStatusBar } from './SandboxStatusBar.tsx' -import { t } from './locales.ts' -import type { TabComponentProps } from './service.ts' -import css from './sidebar.module.css' - -/** - * The browser iframe sandbox tokens. NO allow-same-origin (opaque origin — - * no GUI storage/API access), NO allow-top-navigation (a browsed page must - * not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals - * keep login flows working; allow-popups-to-escape-sandbox lets OAuth - * popups open as normal tabs (they are cross-origin to the GUI either way). - */ -export const BROWSER_IFRAME_SANDBOX = - 'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox' - -/** allow-same-origin appended for explicitly allowlisted local addresses. */ -const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN = - `${BROWSER_IFRAME_SANDBOX} allow-same-origin` - -/** - * The sandbox tokens for one URL: allowlisted loopback addresses (local dev - * servers the user explicitly trusts) additionally get `allow-same-origin` - * so Vite/module/HMR pipelines that need a real origin work; every other - * site keeps the opaque-origin sandbox. `allow-same-origin` does NOT give - * the page access to the GUI — it stays cross-origin to it and to every - * other site — but it does give it its OWN origin privileges (localStorage, - * fetch without CORS), so it is only granted for the explicit allowlist. - * - * The GUI itself is the one hard exception: even when its own host is - * allowlisted (a bare-host entry covers every port, so the GUI origin - * matches), a page at the GUI's exact origin must never get - * `allow-same-origin` — that would make it same-origin with its parent and - * hand it the GUI's storage/API (and the ability to shed the sandbox). The - * GUI keeps the opaque-origin sandbox no matter what the allowlist says. - */ -export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined { - if (url === undefined) return undefined - if (selfOrigin !== undefined) { - let parsed: URL - try { - parsed = new URL(url) - } catch { - return BROWSER_IFRAME_SANDBOX - } - if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX - } - return isAllowedLoopbackUrl(url, allowedLoopback) - ? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN - : BROWSER_IFRAME_SANDBOX -} - -export function BrowserView(props: TabComponentProps) { - const { store, tab } = props - // The current address (initialized from the persisted tab.path so a - // reload restores the visited page). - const [url, setUrl] = useState(tab.path) - const [input, setInput] = useState(tab.path ?? '') - /** Blocked/invalid hint shown under the address bar (null = none). */ - const [message, setMessage] = useState(null) - /** Address-bar navigation history (in-frame clicks are not tracked). */ - const [history, setHistory] = useState(tab.path !== undefined ? [tab.path] : []) - const [cursor, setCursor] = useState(tab.path !== undefined ? 0 : -1) - /** Bumped on reload to remount the iframe (also remounts on sandbox flip). */ - const [reloadKey, setReloadKey] = useState(0) - /** TEMPORARY sandbox unlock for THIS surface only (never writes the global - * side card setting; lasts until the tab unmounts or the user restores). */ - const [localUnlock, setLocalUnlock] = useState(false) - const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock - /** A site that refuses to be embedded (X-Frame-Options / frame-ancestors): - * the probe verdict shown instead of the blank iframe. */ - const [embedBlocked, setEmbedBlocked] = useState(null) - /** The user asked to load the refused site anyway (keeps the plain iframe). */ - const [forceEmbed, setForceEmbed] = useState(false) - - // Probe every navigation (address bar, history, restored path): when the - // target forbids embedding, show the reason + open-in-browser instead of - // the browser's cryptic "refused to connect" blank frame. A failed probe - // (unreachable) keeps the plain iframe. - useEffect(() => { - if (url === undefined) return - let cancelled = false - setEmbedBlocked(null) - setForceEmbed(false) - void api.browserProbe(url).then((probe) => { - if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url) - }).catch(() => { /* unreachable: keep the plain iframe */ }) - return () => { cancelled = true } - }, [url]) - - const persist = (nextUrl: string): void => { - let host = nextUrl - try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ } - store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host })) - } - - const navigateTo = (raw: string): void => { - const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback) - if (result.kind === 'ok') { - const next = result.url - setUrl(next) - setInput(next) - setMessage(null) - // Push onto the stack, dropping any stale forward entries. - setHistory(previous => [...previous.slice(0, cursor + 1), next]) - setCursor(previous => previous + 1) - setReloadKey(key => key + 1) - persist(next) - return - } - setMessage(result.kind === 'invalid' - ? t('browserInvalid') - : result.reason === 'scheme' ? t('browserBlockedScheme') - : t('browserBlockedLoopback')) - } - - const goBack = (): void => { - if (cursor <= 0) return - const next = history[cursor - 1]! - setCursor(cursor - 1) - setUrl(next) - setInput(next) - setReloadKey(key => key + 1) - } - - const goForward = (): void => { - if (cursor >= history.length - 1) return - const next = history[cursor + 1]! - setCursor(cursor + 1) - setUrl(next) - setInput(next) - setReloadKey(key => key + 1) - } - - return ( -
-
- - - - { setInput(event.target.value) }} - onKeyDown={event => { - if (event.key === 'Enter') navigateTo(input) - }} - /> - - -
- {message !== null &&
{message}
} - { setLocalUnlock(true) }} - onRestore={() => { setLocalUnlock(false) }} - /> - {url === undefined ? ( -
{t('browserStart')}
- ) : embedBlocked !== null && !forceEmbed ? ( - { window.open(embedBlocked, '_blank', 'noopener') }} - onLoadAnyway={() => { setForceEmbed(true) }} - /> - ) : ( -