Files
apes-sidebar/src/client/link-intercept.ts
T
Menghuan1918 302c20109f feat: 内置 HTML 预览器 + 沙箱浏览器 tab(v0.5.0)
- html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换
- browser tab:+ 菜单多开(browser:<n>),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser)
- 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复
- 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全)
- 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行
- 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载
- 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿
2026-08-11 19:28:48 +08:00

67 lines
2.8 KiB
TypeScript

/**
* Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar browser instead of a new browser tab. Gated by BOTH the
* `browserInterceptLinks` pref and the browser tab's enable switch; a
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
* user can still force a real browser tab.
*
* Only the GUI's OWN document is watched — links inside the browser tab's
* sandboxed iframe live in another document and never bubble here (and
* their clicks must keep working inside the sidebar).
*/
/** The pure decision: the URL to open in the sidebar, or null to let the
* click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL
try {
url = new URL(anchorHref)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar browser.
try {
if (url.origin === new URL(selfOrigin).origin) return null
} catch {
// Unparsable selfOrigin (never in practice): intercept defensively.
}
return url.href
}
/** Whether a left-click may be taken over (unmodified left click only). */
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
}
/**
* Register the document-level click capture that funnels external links
* into the sidebar browser. Returns the disposer (HMR-safe).
*/
export function registerLinkInterception(opts: {
takeoverEnabled: () => boolean
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string
}): () => void {
const onClick = (event: MouseEvent): void => {
if (!isPlainLeftClick(event)) return
if (event.defaultPrevented) return
if (!opts.takeoverEnabled()) return
const target = event.target
if (target === null || typeof (target as Element).closest !== 'function') return
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
if (anchor === null) return
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
if (url === null) return
event.preventDefault()
opts.openInSidebar(url)
}
document.addEventListener('click', onClick, true)
return () => { document.removeEventListener('click', onClick, true) }
}