diff --git a/README.md b/README.md index 5a129b5b..cc146bd6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ ## ✨ 功能一览 - **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `
` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF -- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) +- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容默认运行在沙箱 iframe,状态栏可临时解锁或持久切换全局沙箱;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原;工作区容器下自动发现子仓库并显示**仓库选择器**,支持 linked worktree 变更发现 @@ -475,7 +475,7 @@ pnpm watch # tsdown --watch - 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份 - HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址 -- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用 +- 界面实时显示沙箱状态(关闭时红色警示):可临时解锁当前页面;浏览器状态栏另提供持久化全局开关,对所有浏览器 tab、后续会话和页面重载生效;设置页也可按功能切换(默认保持沙箱开启,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用 ## ⚠️ 已知限制 diff --git a/README_EN.md b/README_EN.md index 4c66da3d..8f482548 100644 --- a/README_EN.md +++ b/README_EN.md @@ -471,7 +471,7 @@ pnpm watch # tsdown --watch - Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity - HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost -- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content +- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the browser status row also has a persistent global switch that applies to every browser tab, future sessions, and page reloads; the settings page retains the same per-feature control (sandbox stays on by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content ## ⚠️ Known Limitations diff --git a/src/client/BrowserView.tsx b/src/client/BrowserView.tsx index c44d41a3..821bc22d 100644 --- a/src/client/BrowserView.tsx +++ b/src/client/BrowserView.tsx @@ -16,7 +16,7 @@ * address-bar navigations (in-frame link clicks are cross-origin and * invisible — a documented limitation). */ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { IconChevronLeftOutline14, IconChevronRightOutline14, @@ -27,6 +27,7 @@ import { import { VscLinkExternal } from 'react-icons/vsc' import { api } from './api.ts' import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts' +import { parsePrefs } from './prefs.ts' import { patchTab } from './state.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { t } from './locales.ts' @@ -95,7 +96,22 @@ export function BrowserView(props: TabComponentProps) { /** 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 + /** The persistent browser-wide setting is reactive: changing it from any + * browser tab remounts every open browser iframe in this page. */ + const subscribePrefs = useCallback((listener: () => void) => store.subscribe(listener), [store]) + const readBrowserNoSandbox = useCallback(() => store.getSnapshot().prefs.browserNoSandbox, [store]) + const globallyUnsandboxed = useSyncExternalStore(subscribePrefs, readBrowserNoSandbox, readBrowserNoSandbox) + const noSandbox = globallyUnsandboxed || localUnlock + const [sandboxSettingPending, setSandboxSettingPending] = useState(false) + const [sandboxSettingError, setSandboxSettingError] = useState(null) + + // A global restore is authoritative over every tab's earlier temporary + // unlock. All mounted BrowserViews observe the shared pref transition and + // clear their local escape hatch together. + useEffect(() => { + if (!globallyUnsandboxed) setLocalUnlock(false) + }, [globallyUnsandboxed]) + /** 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) @@ -161,6 +177,22 @@ export function BrowserView(props: TabComponentProps) { setReloadKey(key => key + 1) } + /** Persist the browser-wide sandbox mode and publish the round-tripped + * preferences through the shared store. This affects every open browser + * tab immediately and survives session/page changes. */ + const toggleGlobalSandbox = (): void => { + if (sandboxSettingPending) return + setSandboxSettingPending(true) + setSandboxSettingError(null) + void api.settingsUpdate({ browserNoSandbox: !globallyUnsandboxed }).then((view) => { + store.setPrefs(parsePrefs(view.value)) + }).catch(() => { + setSandboxSettingError(t('settingsSaveFailed')) + }).finally(() => { + setSandboxSettingPending(false) + }) + } + return (
@@ -228,11 +260,18 @@ export function BrowserView(props: TabComponentProps) { {message !== null &&
{message}
} { setLocalUnlock(true) }} onRestore={() => { setLocalUnlock(false) }} + persistentAction={{ + label: globallyUnsandboxed ? t('sandboxRestore') : t('settingsBrowserSandboxTitle'), + title: t('settingsBrowserSandboxDesc'), + pending: sandboxSettingPending, + onClick: toggleGlobalSandbox, + }} /> + {sandboxSettingError !== null &&
{sandboxSettingError}
} {url === undefined ? (
{t('browserStart')}
) : embedBlocked !== null && !forceEmbed ? ( diff --git a/src/client/SandboxStatusBar.tsx b/src/client/SandboxStatusBar.tsx index e46f2f05..32aa487f 100644 --- a/src/client/SandboxStatusBar.tsx +++ b/src/client/SandboxStatusBar.tsx @@ -2,14 +2,15 @@ * The live sandbox status row of the two built-in web surfaces (HTML * preview and the browser tab): a green "sandbox on" state with a one-tap * TEMPORARY unlock, or a RED "sandbox off" state (global setting or the - * temporary unlock) with a restore action. + * temporary unlock) with a restore action. A surface may additionally expose + * one persistent/global action in the same row (the browser uses it to write + * `browserNoSandbox` for every browser tab and future session). * * The temporary unlock is component state only — it never writes the * global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`); * it lasts until the surface unmounts (tab switch / file switch) or the - * user restores the sandbox from the row. When the global setting already - * drops the sandbox, no unlock/restore action is offered (changing the - * global setting is the settings page's job) — the red warning stands. + * user restores the sandbox from the row. Persistent actions are optional, + * so the HTML preview keeps its existing local-only status controls. */ import clsx from 'clsx' import { t } from './locales.ts' @@ -24,21 +25,43 @@ export function SandboxStatusBar(props: { dangerCopy: string onUnlock: () => void onRestore: () => void + /** Optional persistent action (for example the browser-wide sandbox setting). */ + persistentAction?: { + label: string + title?: string + pending?: boolean + onClick: () => void + } }) { - const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props + const { sandboxed, local, dangerCopy, onUnlock, onRestore, persistentAction } = props + const persistentButton = persistentAction === undefined ? null : ( + + ) if (sandboxed) { const copy = t('sandboxStatusOn') return (
{copy} - +
+ + {persistentButton} +
) } @@ -46,14 +69,19 @@ export function SandboxStatusBar(props: {
{dangerCopy} - {local && ( - + {(local || persistentButton !== null) && ( +
+ {local && ( + + )} + {persistentButton} +
)}
) diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 5a6e0a26..179cc6e9 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -596,6 +596,22 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { const optimisticRef = useRef(prefs) useEffect(() => { optimisticRef.current = prefs }, [prefs]) + // Keep this settings surface aligned with preference writes made elsewhere + // in the page (notably the browser status row's global sandbox switch). + // SidebarStore also publishes ordinary session-state changes, so compare the + // stable prefs snapshot reference before touching local/optimistic state. + useEffect(() => { + let current = store.getSnapshot().prefs + return store.subscribe(() => { + const next = store.getSnapshot().prefs + if (next === current) return + current = next + optimisticRef.current = next + setPrefs(next) + setWidthDraft(String(next.defaultWidthPercent)) + }) + }, [store]) + // The declarative inventory: the registered tab types and file viewers. // Local state + service.subscribe (registry changes are rare — plugin // load/unload — so a plain effect is enough; no external-store ceremony). diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 9d06be8e..c8fce36b 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1546,6 +1546,13 @@ body[data-dsh-sidebar-dragging] .bottomPanel { white-space: nowrap; } +.sandboxActions { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} + .sandboxAction { flex: none; padding: 2px 8px; @@ -1557,10 +1564,15 @@ body[data-dsh-sidebar-dragging] .bottomPanel { cursor: pointer; } -.sandboxAction:hover { +.sandboxAction:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +.sandboxAction:disabled { + opacity: 0.5; + cursor: wait; +} + /* The HTML preview iframe: fills the editor body. Route-src (never srcdoc) so the frame is cross-origin by construction even if the sandbox attribute is ever dropped; sandboxed by default (see TextEditor). */ diff --git a/tests/browser-global-sandbox-toggle.spec.tsx b/tests/browser-global-sandbox-toggle.spec.tsx new file mode 100644 index 00000000..49073907 --- /dev/null +++ b/tests/browser-global-sandbox-toggle.spec.tsx @@ -0,0 +1,96 @@ +/** + * The browser status row owns a persistent/global sandbox switch in addition + * to its local temporary unlock. One write must update every mounted browser + * tab through the shared prefs store and the round-tripped value must restore + * the sandbox just as directly. + */ +// @vitest-environment jsdom +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { createElement, Fragment } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import type { Context } from '../src/context-types.ts' +import { api } from '../src/client/api.ts' +import { BrowserView } from '../src/client/BrowserView.tsx' +import { createSidebarStore } from '../src/client/state.ts' + +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'zh-CN', configurable: true }) +}) + +const CTX = {} as Context + +function tabProps(store: ReturnType, id: string) { + return { + ctx: CTX, + store, + scope: { sessionId: 's1', cwd: '/p' }, + tab: { id, type: 'browser', title: '浏览器', path: 'https://example.com/' }, + visible: true, + } +} + +describe('browser global sandbox toggle', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + + afterEach(() => { + if (root !== undefined) act(() => { root!.unmount() }) + container?.remove() + root = undefined + container = undefined + vi.restoreAllMocks() + }) + + it('persists the setting and updates every open browser iframe', async () => { + const store = createSidebarStore() + vi.spyOn(api, 'browserProbe').mockResolvedValue({ reachable: false }) + const update = vi.spyOn(api, 'settingsUpdate').mockImplementation(async (patch) => ({ + value: { ...store.getPrefs(), ...patch }, + revision: 2, + })) + + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + act(() => { + root!.render(createElement(Fragment, null, + createElement(BrowserView, tabProps(store, 'browser:1')), + createElement(BrowserView, tabProps(store, 'browser:2')), + )) + }) + + const temporaryUnlocks = [...container.querySelectorAll('button')] + .filter(button => button.textContent?.trim() === '临时解锁(不安全)') + const globalOff = [...container.querySelectorAll('button')] + .find(button => button.textContent?.includes('关闭浏览器沙箱')) + expect(temporaryUnlocks).toHaveLength(2) + expect(globalOff).toBeDefined() + expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(2) + + // Leave tab B locally unlocked before exercising the global switch. A + // later global restore must still secure both tabs. + act(() => { temporaryUnlocks[1]!.click() }) + expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(1) + + await act(async () => { globalOff!.click() }) + + expect(update).toHaveBeenLastCalledWith({ browserNoSandbox: true }) + expect(store.getPrefs().browserNoSandbox).toBe(true) + expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(0) + expect(container.textContent).toContain('沙箱已关闭') + + const globalOn = [...container.querySelectorAll('button')] + .find(button => button.textContent?.trim() === '恢复沙箱') + expect(globalOn).toBeDefined() + + await act(async () => { globalOn!.click() }) + + expect(update).toHaveBeenLastCalledWith({ browserNoSandbox: false }) + expect(store.getPrefs().browserNoSandbox).toBe(false) + expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(2) + expect(container.textContent).toContain('沙箱模式:已启用') + }) +}) diff --git a/tests/sandbox-views.spec.tsx b/tests/sandbox-views.spec.tsx index 2b881579..3a374609 100644 --- a/tests/sandbox-views.spec.tsx +++ b/tests/sandbox-views.spec.tsx @@ -5,7 +5,8 @@ * boundary of both features; these tests pin the exact attribute so a * refactor cannot silently widen it. The side card settings can drop the * sandbox per-feature (warned); those paths render the warning bar and no - * sandbox attribute. + * sandbox attribute. The browser status row also exposes that setting as a + * persistent/global switch. */ import { describe, expect, it, beforeEach } from 'vitest' import { renderToString } from 'react-dom/server' @@ -157,11 +158,12 @@ describe('browser tab iframe sandbox', () => { expect(iframeSandboxFor('https://example.com/', '', guiOrigin)).toBe(BROWSER_IFRAME_SANDBOX) }) - it('renders the live sandbox status row with the temporary unlock action', () => { + it('renders the live sandbox status row with temporary and global controls', () => { const store = createSidebarStore() const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/'))) expect(html).toContain('沙箱模式:已启用') expect(html).toContain('临时解锁(不安全)') + expect(html).toContain('关闭浏览器沙箱(不安全)') }) it('offers the open-in-browser action once a URL is loaded (disabled before navigation)', () => { @@ -176,7 +178,7 @@ describe('browser tab iframe sandbox', () => { expect(loaded).not.toContain('title="在浏览器中打开" disabled=""') }) - it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => { + it('drops the sandbox attribute with the red warning and offers a global restore', () => { const store = createSidebarStore() store.setPrefs({ ...store.getPrefs(), browserNoSandbox: true }) const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/'))) @@ -185,7 +187,7 @@ describe('browser tab iframe sandbox', () => { expect(iframe).not.toContain('sandbox=') expect(html).toContain('沙箱已关闭') expect(html).not.toContain('临时解锁(不安全)') - expect(html).not.toContain('恢复沙箱') + expect(html).toContain('恢复沙箱') }) }) diff --git a/tests/side-card-preference-sync.spec.tsx b/tests/side-card-preference-sync.spec.tsx new file mode 100644 index 00000000..de955172 --- /dev/null +++ b/tests/side-card-preference-sync.spec.tsx @@ -0,0 +1,56 @@ +/** The settings surface follows preference writes made outside its own form. */ +// @vitest-environment jsdom +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { api } from '../src/client/api.ts' +import { SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx' +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' + +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +describe('SideCardSection external preference sync', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + + afterEach(() => { + if (root !== undefined) act(() => { root!.unmount() }) + container?.remove() + root = undefined + container = undefined + vi.restoreAllMocks() + }) + + it('updates mounted controls when another surface writes the shared store', async () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + vi.spyOn(api, 'settingsGet').mockResolvedValue({ value: store.getPrefs(), revision: 1 }) + + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => { + root!.render(createElement(SideCardSection, { + store, + service, + close: () => {}, + } as unknown as SideCardSectionProps)) + }) + + const control = container.querySelector('input[aria-label="Open by default for new conversations"]') + expect(control).not.toBeNull() + expect(control!.checked).toBe(false) + + act(() => { + store.setPrefs({ ...store.getPrefs(), openByDefault: true }) + }) + + expect(control!.checked).toBe(true) + }) +})