- BrowserView: 即使 host 在白名单,GUI 自身 origin 永不给 allow-same-origin(裸 host 条目覆盖所有端口时可提权) - intercept: revealInExplorer 相对路径经 resolveSidebarPath 解析后再 revealPaths(交付物路径是相对的) - intercept: ctx.betterSidebar 改为 ctx.get(#357 纤维链读取约定) - FileTree/EditorHost/TreePanel/tabs: 把 revealed 贯通到树行渲染 + 高亮类 + scrollIntoView(此前高亮永不渲染) - state.revealPaths: 重建祖先目录时保留前导分隔符(POSIX/UNC 此前目录永远折叠) - state.schedulePersist: 仅活动会话写入全局宽度(targeted open 不再用非活动会话的过期宽度覆盖拖拽值) - browser.probe: GET 回退显式取消 body(否则流式/大响应挂住 socket) - terminal-font: leading generic 前只放 symbols-only 字体;fully-patched 放 generic 后(避免 hijack 度量字体) - sidebar.module.css: 恢复拖拽反馈(#130 的 silent resize 与 PR 描述冲突,取 HEAD) - Sidebar: 剩余 4 处 ctx.betterSidebar 改 ctx.get - tests: 补 revealPaths/iframeSandboxFor/terminal-font 回归测试 + EditorHost/FileTree 测试补 revealed prop
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/**
|
|
* 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<string | undefined>(tab.path)
|
|
const [input, setInput] = useState<string>(tab.path ?? '')
|
|
/** Blocked/invalid hint shown under the address bar (null = none). */
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
/** Address-bar navigation history (in-frame clicks are not tracked). */
|
|
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
|
|
const [cursor, setCursor] = useState<number>(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<string | null>(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 (
|
|
<div className={css.browser}>
|
|
<div className={css.browserBar}>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('browserBack')}
|
|
title={t('browserBack')}
|
|
disabled={cursor <= 0}
|
|
onClick={goBack}
|
|
>
|
|
<IconChevronLeftOutline14 />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('browserForward')}
|
|
title={t('browserForward')}
|
|
disabled={cursor >= history.length - 1}
|
|
onClick={goForward}
|
|
>
|
|
<IconChevronRightOutline14 />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('refresh')}
|
|
title={t('refresh')}
|
|
onClick={() => { setReloadKey(key => key + 1) }}
|
|
>
|
|
<IconRefreshOutline14 />
|
|
</button>
|
|
<input
|
|
className={css.browserInput}
|
|
value={input}
|
|
placeholder={t('browserPlaceholder')}
|
|
spellCheck={false}
|
|
onChange={event => { setInput(event.target.value) }}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter') navigateTo(input)
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('browserGo')}
|
|
title={t('browserGo')}
|
|
onClick={() => { navigateTo(input) }}
|
|
>
|
|
<IconLinkOutline14 />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('browserOpenExternal')}
|
|
title={t('browserOpenExternal')}
|
|
disabled={url === undefined}
|
|
onClick={() => {
|
|
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
|
}}
|
|
>
|
|
<VscLinkExternal size={15} />
|
|
</button>
|
|
</div>
|
|
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
|
<SandboxStatusBar
|
|
sandboxed={!noSandbox}
|
|
local={localUnlock}
|
|
dangerCopy={t('browserNoSandboxWarning')}
|
|
onUnlock={() => { setLocalUnlock(true) }}
|
|
onRestore={() => { setLocalUnlock(false) }}
|
|
/>
|
|
{url === undefined ? (
|
|
<div className={css.browserStart}>{t('browserStart')}</div>
|
|
) : embedBlocked !== null && !forceEmbed ? (
|
|
<BrowserEmbedBlocked
|
|
url={embedBlocked}
|
|
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
|
|
onLoadAnyway={() => { setForceEmbed(true) }}
|
|
/>
|
|
) : (
|
|
<iframe
|
|
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
|
className={css.browserFrame}
|
|
src={url}
|
|
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback, window.location.origin)}
|
|
referrerPolicy="no-referrer"
|
|
allow=""
|
|
title={url}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The embed-refusal panel: shown when the probed site forbids being
|
|
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
|
|
* iframe would only show the browser's "refused to connect" blank. Explains
|
|
* the reason and offers the real-browser open plus a load-anyway escape.
|
|
* Exported so the copy and the actions are testable without a DOM.
|
|
*/
|
|
export function BrowserEmbedBlocked(props: {
|
|
url: string
|
|
onOpenInBrowser: () => void
|
|
onLoadAnyway: () => void
|
|
}) {
|
|
const { url, onOpenInBrowser, onLoadAnyway } = props
|
|
let host = url
|
|
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
|
return (
|
|
<div className={css.browserBlocked}>
|
|
<IconWarningOutline16 size={16} />
|
|
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
|
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
|
<div className={css.browserBlockedActions}>
|
|
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
|
|
{t('browserOpenExternal')}
|
|
</button>
|
|
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
|
|
{t('browserEmbedAnyway')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|