refactor(browser): separate navigation and presentation providers

This commit is contained in:
imccyu committed 2026-09-21 00:58:23 +08:00
1 parent 96b2aca346
commit cbd866fa72
27 files changed
+1088 -387

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c
2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e
2026-09-16-sidebar-browser.md: ffea3d4bb75a67b7ed1c6f5c6b2450a80190b0c3
2026-09-16-sidebar-browser.zh.md: da1ed607804b940f0b614411b78ab895581832ed
@@ -18,11 +18,11 @@ A parent page cannot inspect or drive a cross-origin iframe's internal history.
The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface.
The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
The Web carrier is an iframe; Desktop uses the [retained webview carrier](2026-09-20-desktop-browser-webview.md). Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component.
Each tab receives one `BrowserController` for address validation and carrier-independent commands. `BrowserFrame` exposes navigation, observable address/loading/history/error state, and an optional sandbox control; `BrowserPresentation` owns DOM attachment. `pages.ts` composes these two objects into a `BrowserPage`. `IframeImpl` owns application-known `BrowserNavigation`, while `ElectronWebViewImpl` observes native history. Slot injection exposes keyed controller state through `useBrowserState` and plain callbacks; the React body owns its draft and content container, with no carrier branch, controller instance or observable source.
`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
For the iframe carrier, `BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities.
@@ -39,17 +39,9 @@ The Web carrier treats only the first iframe `load` for a controlled revision as
Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
## Deferred Electron carrier
## Electron carrier
Electron `<webview>` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`.
The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins.
The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied.
The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `<webview>` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization.
Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target.
The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns native page lifetime, Workspace storage sharing, guest policy, and runtime verification gaps. The iframe behavior in this note remains independently useful; its cross-origin limitations do not describe the Desktop carrier.
## Alternatives considered
@@ -69,6 +61,6 @@ Unit tests cover protocol parsing, delegated Markdown links, controller commands
## Consequences
The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior.
The iframe carrier exposes no Electron or Node APIs. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible.
Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
Web site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
@@ -18,11 +18,11 @@ Status: implemented
地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。
当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
Web 使用 iframe 载体;Desktop 使用[保持页面实例的 webview 载体](2026-09-20-desktop-browser-webview.zh.md)。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。
每个 tab 获得一个 `BrowserController`,负责地址校验与载体无关的命令。`BrowserFrame` 提供导航、可观察的地址/加载/history/错误状态和可选 sandbox 控制,`BrowserPresentation` 负责 DOM 挂载。`pages.ts` 把两个对象组装成 `BrowserPage`。`IframeImpl` 持有应用已知的 `BrowserNavigation`,`ElectronWebViewImpl` 则观察原生 history。Slot injection 通过 `useBrowserState` 提供按 key 索引的控制器状态和普通回调;React body 只持有草稿与内容容器,没有载体分支、控制器实例或可观察源。
`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
对于 iframe 载体,`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。
@@ -39,17 +39,9 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
## Deferred Electron carrier
## Electron carrier
Electron `<webview>` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。
Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。
主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。
view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `<webview>` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。
每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。
[Desktop webview 决策](2026-09-20-desktop-browser-webview.zh.md)负责原生页面生命周期、Workspace 存储共享、guest 策略与运行时验证缺口。本文的 iframe 行为仍具有独立价值;其跨域限制不描述 Desktop 载体。
## Alternatives considered
@@ -69,6 +61,6 @@ view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中
## Consequences
Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
iframe 载体不暴露 Electron 或 Node API。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。
站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
Web 站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
@@ -22,6 +22,10 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./desktop": {
"types": "./lib/types/desktop.d.ts",
"default": "./lib/desktop.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -44,6 +48,8 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
@@ -61,6 +67,7 @@
"files": [
"lib/index.js",
"lib/client.js",
"lib/desktop.js",
"lib/types/**/*.d.ts"
]
}
@@ -1,166 +1,226 @@
/** Per-tab Browser controller. */
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
/** Carrier-independent tab commands and renderer-facing state. */
import { createSnapshotStore, type BoundActions, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import { IframeImpl } from './BrowserFrame.ts'
import type { BrowserFrame, BrowserFrameState } from './BrowserFrame.ts'
import { BrowserNavigation } from './BrowserNavigation.ts'
import type { BrowserTabState } from './BrowserNavigation.ts'
import type { BrowserFrameState } from './BrowserFrame.ts'
import type { BrowserPage, BrowserPageFactory } from './BrowserPage.ts'
import { currentBrowserTarget, type BrowserTabState } from './BrowserPersistence.ts'
import type { BrowserStore } from './store.ts'
import { parseBrowserAddress } from './url.ts'
import { parseBrowserAddress, type BrowserAddressFailure } from './url.ts'
/** Construction dependencies for one tab-scoped Browser controller. */
/** Live tab state; navigation comes from its provider and draft validation stays local. */
export interface BrowserControllerState {
readonly frame: BrowserFrameState
readonly addressFailure: BrowserAddressFailure | undefined
readonly addressRevision: number
}
/** Construction inputs for one tab occurrence. */
export interface BrowserControllerOptions {
readonly tabId: TabId
readonly signal: AbortSignal
readonly applicationOrigin: string
readonly initial?: BrowserTabState
readonly initial: BrowserTabState | undefined
readonly actions: BoundActions<BrowserStore>
readonly createPage: BrowserPageFactory
readonly openTab: (url: string) => void
}
/** Owns one Browser tab's URL state, loading lifecycle, and four navigation commands. */
export class BrowserController {
/** Renderer-facing iframe and sandbox state. */
readonly frame: BrowserFrame
private readonly navigation: BrowserNavigation
/** Owns input validation and page lifetime without inspecting the carrier type. */
export class BrowserController implements HostObservable<BrowserControllerState> {
private readonly page: BrowserPage
private readonly store: SnapshotStore<BrowserControllerState>
private readonly unsubscribe: () => void
private actions: BoundActions<BrowserStore>
private checkpoint: BrowserTabState | undefined
private started = false
private disposed = false
private disposal: Promise<void> | undefined
private readonly abort = (): void => { void this.dispose() }
/**
* @param options - tab identity, persistence writer, URL origin, and lifetime.
*/
/** @param options - identity, persistence, page factory and source-tab navigation. */
constructor(private readonly options: BrowserControllerOptions) {
this.navigation = new BrowserNavigation(options.initial)
this.frame = new IframeImpl(
() => { this.reload() },
(revision) => { this.frameLoaded(revision) },
)
options.signal.addEventListener('abort', () => { this.dispose() }, { once: true })
this.actions = options.actions
this.checkpoint = options.initial
this.page = options.createPage({
initial: options.initial,
persist: state => {
if (this.disposed) return
this.checkpoint = state
this.actions.replace(options.tabId, state)
},
openRequested: value => {
if (this.disposed) return
const result = parseBrowserAddress(value, options.applicationOrigin)
if (!result.ok) { this.addressFailed(result.reason); return }
options.openTab(result.target.url)
},
})
this.store = createSnapshotStore({ frame: this.page.frame.getSnapshot(), addressFailure: undefined, addressRevision: 0 })
this.unsubscribe = this.page.frame.subscribe(() => {
if (this.disposed) return
const current = this.store.getSnapshot()
const frame = this.page.frame.getSnapshot()
const changed = frame.target?.url !== current.frame.target?.url
this.store.set({ frame, addressFailure: changed ? undefined : current.addressFailure,
addressRevision: current.addressRevision + Number(changed) })
})
options.signal.addEventListener('abort', this.abort, { once: true })
}
/**
* Validate and load one address, replacing a same-address load with Reload.
* @param value - address-bar or typed-tab value.
*/
/** @returns immutable state for the common toolbar. */
getSnapshot = (): BrowserControllerState => this.store.getSnapshot()
/** @param listener - state invalidation. @returns unsubscribe callback. */
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener)
/** @param viewportId - mounted content container. @returns physical attachment cleanup only. */
mount(viewportId: string): () => void {
this.publishSaved()
return this.page.presentation.mount(viewportId)
}
/** @param initialUrl - typed-open address used only when no checkpoint exists. */
start(initialUrl: string | undefined): void {
if (this.started || this.disposed) return
this.started = true
const value = currentBrowserTarget(this.checkpoint)?.url ?? initialUrl
if (value !== undefined) this.loadUrl(value)
}
/** @param value - address-bar or typed-open input. */
loadUrl(value: string): void {
if (this.disposed) return
const parsed = parseBrowserAddress(value, this.options.applicationOrigin)
if (!parsed.ok) {
this.navigation.addressFailed(parsed.reason)
this.publish()
return
}
const current = BrowserNavigation.current(this.navigation.snapshot)
if (current?.url === parsed.target.url) {
this.reload()
return
}
this.start(this.navigation.navigate(parsed.target))
if (!parsed.ok) { this.addressFailed(parsed.reason); return }
this.command(() => { this.page.frame.loadUrl(parsed.target) })
}
/** Move to the preceding application-known address when Web history remains usable. */
goBack(): void {
if (this.disposed) return
const request = this.navigation.back()
if (request !== undefined) this.start(request)
/** Delegate Back to the page's navigation provider. */
goBack(): void { this.command(() => { this.page.frame.goBack() }) }
/** Delegate Forward to the page's navigation provider. */
goForward(): void { this.command(() => { this.page.frame.goForward() }) }
/** Delegate Reload to the page's navigation provider. */
reload(): void { this.command(() => { this.page.frame.reload() }) }
/** @param enabled - optional embedding-sandbox control; unavailable providers remain unchanged. */
setSandbox(enabled: boolean): void {
const sandbox = this.page.frame.sandbox
if (sandbox !== undefined) this.command(() => { sandbox.setEnabled(enabled) })
}
/** Move to the following application-known address when Web history remains usable. */
goForward(): void {
if (this.disposed) return
const request = this.navigation.forward()
if (request !== undefined) this.start(request)
}
/** @param actions - writer from a replacement Session presentation binding. */
rebind(actions: BoundActions<BrowserStore>): void { this.actions = actions }
/** Reload the last application-known address without adding history. */
reload(): void {
if (this.disposed) return
const request = this.navigation.reload()
if (request !== undefined) this.start(request)
}
private start(request: NonNullable<BrowserTabState['request']>): void {
this.publish()
this.frame.clearDocument()
this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision })
}
private frameLoaded(revision: number): void {
if (this.disposed) return
const previous = this.navigation.snapshot
this.navigation.frameLoaded(revision)
if (this.navigation.snapshot !== previous) this.publish()
}
private publish(): void {
this.options.actions.replace(this.options.tabId, this.navigation.snapshot)
}
private dispose(): void {
/** @returns after page teardown; repeated callers join the same disposal. */
dispose(): Promise<void> {
if (this.disposal !== undefined) return this.disposal
this.disposed = true
this.frame.clearDocument()
this.options.signal.removeEventListener('abort', this.abort)
this.unsubscribe()
this.disposal = this.page.frame.dispose()
return this.disposal
}
private publishSaved(): void {
if (this.checkpoint !== undefined) this.actions.replace(this.options.tabId, this.checkpoint)
}
private addressFailed(reason: BrowserAddressFailure): void {
this.store.set({ ...this.store.getSnapshot(), addressFailure: reason })
}
private command(run: () => void): void {
if (this.disposed) return
const current = this.store.getSnapshot()
this.store.set({ ...current, addressFailure: undefined, addressRevision: current.addressRevision + 1 })
run()
}
}
/** Browser commands and keyed frame state injected into the tab body. */
/** Values available once a Sidebar body has committed its content container. */
export interface BrowserMountRequest {
readonly tabId: TabId
readonly signal: AbortSignal
readonly viewportId: string
readonly applicationOrigin: string
readonly initial: BrowserTabState | undefined
readonly initialUrl: string | undefined
readonly openTab: (url: string) => void
}
/** Plain Slot callbacks and a framework-bound state source, not a desktop protocol. */
export interface BrowserInjected {
readonly keyedHooks: {
readonly browserFrame: (key: string) => HostObservable<BrowserFrameState> | undefined
readonly browserState: (key: string) => HostObservable<BrowserControllerState> | undefined
}
/**
* @param tabId - tab occurrence.
* @param signal - occurrence lifetime.
* @param applicationOrigin - current application origin.
* @param initial - persisted tab state.
*/
mount(tabId: TabId, signal: AbortSignal, applicationOrigin: string, initial?: BrowserTabState): void
/** @param tabId - tab occurrence. @param value - address-bar or typed-open value. */
/** @param request - committed tab and container. @returns ends physical attachment without closing the tab. */
mount(request: BrowserMountRequest): () => void
/** @returns after every page has been disposed. */
dispose(): Promise<void>
/** @param actions - writer from a recreated Session binding. */
rebind(actions: BoundActions<BrowserStore>): void
/** @param tabId - owning tab. @param value - address input. */
loadUrl(tabId: TabId, value: string): void
/** @param tabId - tab occurrence. */
/** @param tabId - owning tab. */
goBack(tabId: TabId): void
/** @param tabId - tab occurrence. */
/** @param tabId - owning tab. */
goForward(tabId: TabId): void
/** @param tabId - tab occurrence. */
/** @param tabId - owning tab. */
reload(tabId: TabId): void
/** @param tabId - tab occurrence. */
toggleSandbox(tabId: TabId): void
/** @param tabId - tab occurrence. @param revision - rendered document revision. */
reportLoaded(tabId: TabId, revision: number): void
/** @param tabId - tab occurrence. @param revision - rendered document revision that emitted `error`. */
reportLoadFailed(tabId: TabId, revision: number): void
/** @param tabId - owning tab. @param enabled - provider's optional sandbox control. */
setSandbox(tabId: TabId, enabled: boolean): void
}
/**
* Bind Browser controllers to one Session and its persistence writer.
* @param actions - Browser store mutation face.
* @returns a per-tab controller registry exposed as plain Slot callbacks.
*/
export function createBrowserControllers(
actions: BoundActions<BrowserStore>,
): BrowserInjected {
const controllers = new Map<TabId, { readonly signal: AbortSignal; readonly controller: BrowserController }>()
const controller = (tabId: TabId): BrowserController | undefined => controllers.get(tabId)?.controller
/** @param actions - persisted view-state writer. @param createPage - composition-selected provider. @returns tab callbacks. */
export function createBrowserControllers(actions: BoundActions<BrowserStore>, createPage: BrowserPageFactory): BrowserInjected {
let currentActions = actions
const controllers = new Map<TabId, {
readonly signal: AbortSignal
readonly controller: BrowserController
readonly forget: () => void
}>()
const controller = (id: TabId): BrowserController | undefined => controllers.get(id)?.controller
return {
keyedHooks: { browserFrame: key => controller(key as TabId)?.frame },
mount(tabId, signal, applicationOrigin, initial) {
const held = controllers.get(tabId)
if (held?.signal === signal) return
const created = new BrowserController({
tabId, signal, applicationOrigin, actions,
...(initial === undefined ? {} : { initial }),
})
controllers.set(tabId, { signal, controller: created })
signal.addEventListener('abort', () => {
if (controllers.get(tabId)?.controller !== created) return
controllers.delete(tabId)
actions.forget(tabId)
}, { once: true })
keyedHooks: { browserState: key => controller(key as TabId) },
mount(request) {
const { tabId, signal } = request
if (signal.aborted) return () => {}
let held = controllers.get(tabId)
if (held?.signal !== signal) {
if (held !== undefined) {
held.signal.removeEventListener('abort', held.forget)
void held.controller.dispose()
}
const created = new BrowserController({ ...request, actions: currentActions, createPage })
const forget = (): void => {
if (controllers.get(tabId)?.controller !== created) return
controllers.delete(tabId)
currentActions.forget(tabId)
}
held = { signal, controller: created, forget }
controllers.set(tabId, held)
signal.addEventListener('abort', forget, { once: true })
}
const hide = held.controller.mount(request.viewportId)
held.controller.start(request.initialUrl)
return hide
},
loadUrl: (tabId, value) => { controller(tabId)?.loadUrl(value) },
goBack: (tabId) => { controller(tabId)?.goBack() },
goForward: (tabId) => { controller(tabId)?.goForward() },
reload: (tabId) => { controller(tabId)?.reload() },
toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() },
reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) },
reportLoadFailed: (tabId, revision) => { controller(tabId)?.frame.reportLoadFailed(revision) },
dispose: async () => {
const pending = [...controllers.values()].map(({ signal, controller, forget }) => {
signal.removeEventListener('abort', forget)
return controller.dispose()
})
controllers.clear()
await Promise.all(pending)
},
rebind: actions => {
currentActions = actions
for (const { controller } of controllers.values()) controller.rebind(actions)
},
loadUrl: (id, value) => { controller(id)?.loadUrl(value) },
goBack: id => { controller(id)?.goBack() },
goForward: id => { controller(id)?.goForward() },
reload: id => { controller(id)?.reload() },
setSandbox: (id, enabled) => { controller(id)?.setSandbox(enabled) },
}
}
@@ -1,98 +1,48 @@
/** BrowserFrame interface and the current iframe implementation. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
/** Carrier-neutral page navigation and observable state. */
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { BrowserTarget } from './url.ts'
/** One prepared document rendered by a BrowserFrame implementation. */
export interface BrowserDocument {
readonly target: BrowserTarget
readonly src: string
readonly revision: number
/** A loading failure, optionally carrying the underlying browser's diagnostic. */
export interface BrowserLoadError {
readonly code: number | undefined
readonly description: string | undefined
}
/** Current renderer-facing state for one Browser tab. */
/** State consumed by common browser chrome, without DOM or carrier identifiers. */
export interface BrowserFrameState {
readonly document: BrowserDocument | undefined
readonly sandboxed: boolean
readonly loadFailed: boolean
readonly target: BrowserTarget | undefined
readonly address: 'empty' | 'requested' | 'observed' | 'unknown'
readonly loading: boolean
readonly canGoBack: boolean
readonly canGoForward: boolean
readonly error: BrowserLoadError | undefined
/** Undefined when this provider does not expose a sandbox control. */
readonly sandboxEnabled: boolean | undefined
}
/** Browser rendering operations shared by Web and future Electron implementations. */
/** Optional iframe policy control, not an Electron process-sandbox switch. */
export interface BrowserSandboxControl {
/** @param enabled - whether the provider's embedding sandbox is enforced. */
setEnabled(enabled: boolean): void
}
/** Navigation owns page lifetime; mounting and hiding belong to BrowserPresentation. */
export interface BrowserFrame extends HostObservable<BrowserFrameState> {
/** Toggle sandbox enforcement for this tab occurrence. */
toggleSandbox(): void
/** @param document - current prepared document. */
setDocument(document: BrowserDocument): void
/** Remove the current prepared document. */
clearDocument(): void
/** @param revision - rendered document revision reported by the carrier. */
reportLoaded(revision: number): void
/** @param revision - rendered document revision whose carrier reported an error. */
reportLoadFailed(revision: number): void
readonly sandbox?: BrowserSandboxControl
/** @param target - validated HTTP(S) address; loading failures are published in state. */
loadUrl(target: BrowserTarget): void
/** Move backward when the provider reports an available entry. */
goBack(): void
/** Move forward when the provider reports an available entry. */
goForward(): void
/** Reload the current address without creating a new history entry. */
reload(): void
/** @returns after the page, listeners and pending initialization have been released; repeated calls join disposal. */
dispose(): Promise<void>
}
/** Owns transient iframe and sandbox-toggle state independently from URL navigation. */
export class IframeImpl implements BrowserFrame {
private readonly store: SnapshotStore<BrowserFrameState> = createSnapshotStore({
document: undefined,
sandboxed: true,
loadFailed: false,
})
/**
* @param sandboxChanged - applies the new policy to the current controlled target.
* @param documentLoaded - reports an iframe load to the navigation state machine.
*/
constructor(
private readonly sandboxChanged: (sandboxed: boolean) => void,
private readonly documentLoaded: (revision: number) => void,
) {}
/** @returns the immutable renderer snapshot. */
getSnapshot = (): BrowserFrameState => this.store.getSnapshot()
/**
* Subscribe to iframe or sandbox-mode changes.
* @param listener - invalidation callback.
* @returns the unsubscribe function.
*/
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener)
/** Toggle sandbox enforcement for this tab occurrence. */
toggleSandbox(): void {
const current = this.store.getSnapshot()
const sandboxed = !current.sandboxed
this.store.set({ ...current, sandboxed })
this.sandboxChanged(sandboxed)
}
/**
* Publish a prepared frame from the owning controller.
* @param document - current prepared document.
*/
setDocument(document: BrowserDocument): void {
this.store.set({ ...this.store.getSnapshot(), document, loadFailed: false })
}
/** Remove the current prepared frame and transient load failure. */
clearDocument(): void {
const current = this.store.getSnapshot()
const { document } = current
if (document !== undefined) {
this.store.set({ ...current, document: undefined, loadFailed: false })
}
}
/** @param revision - rendered document revision reported by the iframe. */
reportLoaded(revision: number): void {
this.documentLoaded(revision)
}
/** @param revision - rendered document revision whose iframe emitted `error`. */
reportLoadFailed(revision: number): void {
const current = this.store.getSnapshot()
if (current.document?.revision !== revision || current.loadFailed) return
this.store.set({ ...current, loadFailed: true })
}
/** @returns state before any page has been requested. */
export function emptyBrowserFrame(): BrowserFrameState {
return { target: undefined, address: 'empty', loading: false, canGoBack: false, canGoForward: false,
error: undefined, sandboxEnabled: undefined }
}
@@ -1,33 +1,10 @@
/** URL, history, and observability state for one Browser tab. */
import type { BrowserAddressFailure, BrowserTarget } from './url.ts'
/** Bounded, application-known history used only by the iframe provider. */
import type { BrowserTarget } from './url.ts'
import type { BrowserHistoryEntry, BrowserTabState } from './BrowserPersistence.ts'
/** Maximum retained application-known navigation entries per tab. */
export const MAX_BROWSER_HISTORY = 100
/** One canonical address in the application-managed Web history. */
export type BrowserHistoryEntry = BrowserTarget
/** Whether the current carrier document still corresponds to an application-known URL. */
export type BrowserNavigationStatus =
| { readonly status: 'empty' }
| { readonly status: 'loading'; readonly revision: number }
| { readonly status: 'known'; readonly revision: number }
| { readonly status: 'unknown'; readonly revision: number }
/** Address-policy or loading failure shown below the toolbar. */
export type BrowserFailure =
{ readonly kind: 'address'; readonly reason: BrowserAddressFailure }
/** One Browser tab's serializable URL state. */
export interface BrowserTabState {
readonly entries: readonly BrowserHistoryEntry[]
readonly index: number
/** Last application-directed load; carrier observations do not rewrite it. */
readonly request: { readonly revision: number; readonly target: BrowserTarget } | undefined
readonly navigation: BrowserNavigationStatus
readonly failure: BrowserFailure | undefined
}
/**
* Owns the application-known URL history and the iframe observation state machine.
* The first load for a request keeps its URL authoritative; another load marks it unknown.
@@ -136,14 +113,6 @@ export class BrowserNavigation {
return target === undefined ? undefined : this.request(target, this.value)
}
/**
* Record an invalid address without changing the active document state.
* @param reason - parser refusal.
*/
addressFailed(reason: BrowserAddressFailure): void {
this.value = { ...this.value, failure: { kind: 'address', reason } }
}
/**
* Record a frame load for its captured revision.
* @param revision - revision bound to the rendered frame.
@@ -0,0 +1,20 @@
/** Composition of one navigation provider and its presentation. */
import type { BrowserPresentation } from '../view/BrowserPresentation.ts'
import type { BrowserFrame } from './BrowserFrame.ts'
import type { BrowserTabState } from './BrowserPersistence.ts'
/** Provider construction inputs; persistence does not enter the live navigation interface. */
export interface BrowserPageOptions {
readonly initial: BrowserTabState | undefined
readonly persist: (state: BrowserTabState) => void
readonly openRequested: (url: string) => void
}
/** The owning controller disposes frame; UI mounts only presentation. */
export interface BrowserPage {
readonly frame: BrowserFrame
readonly presentation: BrowserPresentation
}
/** Construct a provider without starting navigation or attaching DOM. */
export type BrowserPageFactory = (options: BrowserPageOptions) => BrowserPage
@@ -0,0 +1,35 @@
/** Existing browser-local navigation records, independent from live frame state. */
import type { BrowserAddressFailure, BrowserTarget } from './url.ts'
/** One retained address. */
export type BrowserHistoryEntry = BrowserTarget
/** Observability recorded for a controlled navigation. */
export type BrowserNavigationStatus =
| { readonly status: 'empty' }
| { readonly status: 'loading'; readonly revision: number }
| { readonly status: 'known'; readonly revision: number }
| { readonly status: 'unknown'; readonly revision: number }
/** Address refusal carried by existing browser-local records. */
export type BrowserFailure = { readonly kind: 'address'; readonly reason: BrowserAddressFailure }
/** Serializable record; providers retain the history they can restore. */
export interface BrowserTabState {
readonly entries: readonly BrowserHistoryEntry[]
readonly index: number
readonly request: { readonly revision: number; readonly target: BrowserTarget } | undefined
readonly navigation: BrowserNavigationStatus
readonly failure: BrowserFailure | undefined
}
/** @param state - saved navigation. @returns its last selected address, if any. */
export function currentBrowserTarget(state: BrowserTabState | undefined): BrowserTarget | undefined {
return state === undefined || state.index < 0 ? undefined : state.entries[state.index]
}
/** @param target - current address. @param revision - navigation generation. @returns an address-only checkpoint. */
export function browserAddressCheckpoint(target: BrowserTarget, revision: number): BrowserTabState {
return { entries: [target], index: 0, request: { target, revision },
navigation: { status: 'known', revision }, failure: undefined }
}
@@ -0,0 +1,266 @@
/** Electron navigation and guest lifetime, independent from DOM placement. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '../../desktop.ts'
import type { ElectronWebviewPresentation, WebviewElement } from '../view/ElectronWebviewPresentation.ts'
import { emptyBrowserFrame, type BrowserFrame, type BrowserFrameState, type BrowserLoadError } from './BrowserFrame.ts'
import type { BrowserPageOptions } from './BrowserPage.ts'
import { browserAddressCheckpoint, currentBrowserTarget } from './BrowserPersistence.ts'
import { parseBrowserAddress, type BrowserTarget } from './url.ts'
interface NavigationEvent extends Event { readonly isMainFrame: boolean }
interface LoadFailureEvent extends NavigationEvent {
readonly errorCode: number
readonly errorDescription: string
}
/** Owns native history and translates Electron observations into common frame state. */
export class ElectronWebViewImpl implements BrowserFrame {
private readonly store: SnapshotStore<BrowserFrameState>
private readonly lifetime = new AbortController()
private guestLifetime: AbortController | undefined
private element: WebviewElement | undefined
private lease: DesktopBrowserLeaseId | undefined
private workspaceKey: string | undefined
private initializing: Promise<void> | undefined
private ready = false
private pending: BrowserTarget | undefined
private revision = 0
private firstDocument = true
private checkpoint: BrowserTarget | undefined
private disposal: Promise<void> | undefined
private attachment: AbortController | undefined
private readonly releases = new Set<Promise<void>>()
/**
* @param options - saved address, persistence and source-tab opening callback.
* @param bridge - main-process guest operations.
* @param workspace - resolves the storage account once for this frame lifetime.
* @param presentation - tag and Sidebar placement adapter.
*/
constructor(private readonly options: BrowserPageOptions, private readonly bridge: DesktopBrowserBridge,
private readonly workspace: (signal: AbortSignal) => Promise<string>,
private readonly presentation: ElectronWebviewPresentation) {
const target = currentBrowserTarget(options.initial)
this.checkpoint = target
this.store = createSnapshotStore({ ...emptyBrowserFrame(), target, address: target === undefined ? 'empty' : 'requested' })
}
/** @returns immutable carrier-neutral navigation state. */
getSnapshot = (): BrowserFrameState => this.store.getSnapshot()
/** @param listener - state invalidation. @returns unsubscribe callback. */
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener)
/** A physical mount may recreate a lost guest; ordinary Sidebar hiding never calls this. */
attach(): void {
if (this.lifetime.signal.aborted) return
this.attachment = new AbortController()
this.pending ??= this.store.getSnapshot().target
if (this.pending !== undefined) {
this.store.set({ ...this.store.getSnapshot(), address: 'requested', loading: true,
canGoBack: false, canGoForward: false, error: undefined })
this.initialize()
}
}
/** Invalidate pending attachment before the containing DOM is removed. */
detach(): void {
this.attachment?.abort()
this.attachment = undefined
this.pending = this.store.getSnapshot().target
void this.dropGuest()
}
/** @param target - validated address, loaded without replacing the guest. */
loadUrl(target: BrowserTarget): void {
if (this.lifetime.signal.aborted) return
const current = this.store.getSnapshot()
if (this.ready && current.address === 'observed' && current.target?.url === target.url) {
this.reload()
return
}
this.revision++
this.pending = target
this.store.set({ ...current, target, address: 'requested', loading: true, error: undefined })
this.persist(target)
if (this.ready) this.loadPending()
else this.initialize()
}
/** Move backward through Chromium history. */
goBack(): void {
if (this.store.getSnapshot().canGoBack) this.navigate('goBack')
}
/** Move forward through Chromium history. */
goForward(): void {
if (this.store.getSnapshot().canGoForward) this.navigate('goForward')
}
/** Reload the actual current page, or retry failed guest creation. */
reload(): void {
const current = this.store.getSnapshot()
if (this.lifetime.signal.aborted || current.target === undefined) return
if (!this.ready || current.error !== undefined) {
this.revision++
this.pending = current.target
this.store.set({ ...current, loading: true, error: undefined })
if (this.ready) this.loadPending()
else this.initialize()
} else this.navigate('reload')
}
/** @returns after pending initialization and the owned guest have been released. */
dispose(): Promise<void> {
if (this.disposal !== undefined) return this.disposal
this.lifetime.abort()
this.pending = undefined
this.disposal = Promise.all([this.dropGuest(), this.initializing])
.then(() => Promise.all(this.releases)).then(() => {})
this.presentation.dispose()
return this.disposal
}
private navigate(command: 'goBack' | 'goForward' | 'reload'): void {
if (this.lifetime.signal.aborted || !this.ready || this.element === undefined) return
this.revision++
this.pending = undefined
this.store.set({ ...this.store.getSnapshot(), loading: true, error: undefined })
try { this.element[command]() }
catch (error) { this.commandFailed(error) }
}
private initialize(): void {
const attachment = this.attachment
if (attachment === undefined || this.initializing !== undefined || this.element !== undefined || this.lifetime.signal.aborted) return
const signal = AbortSignal.any([this.lifetime.signal, attachment.signal])
this.initializing = this.createGuest(signal).catch(async (error: unknown) => {
await this.dropGuest()
if (!signal.aborted) this.commandFailed(error)
}).finally(() => {
this.initializing = undefined
if (this.attachment !== attachment && this.pending !== undefined) this.initialize()
})
}
private async createGuest(attachmentSignal: AbortSignal): Promise<void> {
this.workspaceKey ??= await this.workspace(attachmentSignal)
if (attachmentSignal.aborted) return
const reservation = await this.bridge.acquire(this.workspaceKey)
if (attachmentSignal.aborted) { await this.release(reservation.lease); return }
this.lease = reservation.lease
this.guestLifetime = new AbortController()
const signal = AbortSignal.any([attachmentSignal, this.guestLifetime.signal])
const element = this.presentation.createElement(reservation)
this.element = element
const unsubscribeOpen = this.bridge.onOpenRequested(reservation.lease, url => {
if (this.element === element && !signal.aborted) this.options.openRequested(url)
})
signal.addEventListener('abort', unsubscribeOpen, { once: true })
element.addEventListener('dom-ready', () => {
this.ready = true
this.observe(this.store.getSnapshot().address !== 'requested')
this.loadPending()
}, { signal })
element.addEventListener('did-navigate', () => { this.observe(true) }, { signal })
element.addEventListener('did-navigate-in-page', event => {
if ((event as NavigationEvent).isMainFrame) this.observe(true)
}, { signal })
for (const name of ['did-start-loading', 'did-stop-loading', 'page-title-updated']) {
element.addEventListener(name, () => {
this.observe(name === 'page-title-updated' && this.store.getSnapshot().address === 'observed')
}, { signal })
}
element.addEventListener('did-fail-load', event => {
const failure = event as LoadFailureEvent
if (failure.isMainFrame && failure.errorCode !== -3) {
this.failed({ code: failure.errorCode, description: failure.errorDescription })
}
}, { signal })
for (const name of ['render-process-gone', 'destroyed']) {
element.addEventListener(name, () => { void this.dropGuest(); this.failed() }, { signal })
}
this.presentation.present(element)
}
private loadPending(): void {
const target = this.pending
const element = this.element
if (!this.ready || target === undefined || element === undefined) return
const revision = this.revision
this.pending = undefined
void element.loadURL(target.url).catch((error: unknown) => {
if (this.element !== element || this.lifetime.signal.aborted || this.revision !== revision) return
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ERR_ABORTED') return
if (this.store.getSnapshot().error === undefined) this.commandFailed(error)
})
}
private observe(committed: boolean): void {
if (!this.ready || this.element === undefined || this.lifetime.signal.aborted) return
try { this.observeReady(this.element, committed) }
catch (error) { this.commandFailed(error) }
}
private observeReady(element: WebviewElement, committed: boolean): void {
const current = this.store.getSnapshot()
const parsed = committed ? parseBrowserAddress(element.getURL()) : undefined
if (parsed?.ok && this.firstDocument) {
// The lease-bearing bootstrap document is not a user history entry.
element.clearHistory()
this.firstDocument = false
}
let target = current.target
let address = current.address
if (parsed?.ok) {
target = { ...parsed.target, title: element.getTitle() || parsed.target.title }
address = 'observed'
this.presentation.show(target.title)
}
const loading = current.error === undefined && element.isLoading()
const canGoBack = element.canGoBack()
const canGoForward = element.canGoForward()
if (target?.url !== current.target?.url || target?.title !== current.target?.title || address !== current.address
|| loading !== current.loading || canGoBack !== current.canGoBack || canGoForward !== current.canGoForward) {
this.store.set({ ...current, target, address, loading, canGoBack, canGoForward })
}
if (parsed?.ok && target !== undefined) this.persist(target)
}
private persist(target: BrowserTarget): void {
if (target.url === this.checkpoint?.url && target.title === this.checkpoint.title) return
this.checkpoint = target
this.options.persist(browserAddressCheckpoint(target, this.revision))
}
private failed(error: BrowserLoadError = { code: undefined, description: undefined }): void {
if (this.lifetime.signal.aborted) return
const current = this.store.getSnapshot()
this.store.set({ ...current, loading: false, error,
canGoBack: this.ready && current.canGoBack, canGoForward: this.ready && current.canGoForward })
}
private commandFailed(error: unknown): void {
console.error('Desktop browser operation failed', error)
this.failed()
}
private dropGuest(): Promise<void> {
this.guestLifetime?.abort()
this.guestLifetime = undefined
this.presentation.clear()
this.element = undefined
this.ready = false
this.firstDocument = true
const lease = this.lease
this.lease = undefined
return lease === undefined ? Promise.resolve() : this.release(lease)
}
private release(lease: DesktopBrowserLeaseId): Promise<void> {
const released = this.bridge.release(lease)
.catch((error: unknown) => { console.error('Desktop browser guest release failed', error) })
.finally(() => { this.releases.delete(released) })
this.releases.add(released)
return released
}
}
@@ -0,0 +1,96 @@
/** Iframe navigation provider with bounded application-known history. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { IframePresentation } from '../view/IframePresentation.ts'
import type { BrowserFrame, BrowserFrameState, BrowserLoadError, BrowserSandboxControl } from './BrowserFrame.ts'
import type { BrowserPageOptions } from './BrowserPage.ts'
import { BrowserNavigation } from './BrowserNavigation.ts'
import type { BrowserTarget } from './url.ts'
/** Owns iframe navigation; the view reports loads without reading cross-origin content. */
export class IframeImpl implements BrowserFrame {
readonly sandbox: BrowserSandboxControl = { setEnabled: enabled => { this.setSandbox(enabled) } }
private readonly navigation: BrowserNavigation
private readonly store: SnapshotStore<BrowserFrameState>
private sandboxed = true
private error: BrowserLoadError | undefined
private disposed = false
private disposal: Promise<void> | undefined
/** @param options - initial checkpoint and persistence writer. @param presentation - iframe DOM adapter. */
constructor(private readonly options: BrowserPageOptions, private readonly presentation: IframePresentation) {
this.navigation = new BrowserNavigation(options.initial)
this.store = createSnapshotStore(this.snapshot())
}
/** @param revision - document generation whose iframe emitted load. */
handleLoaded(revision: number): void {
if (this.disposed) return
this.navigation.frameLoaded(revision)
this.publish()
}
/** @param revision - document generation whose iframe reported failure. */
handleLoadFailed(revision: number): void {
if (this.disposed || this.navigation.snapshot.request?.revision !== revision) return
this.error = { code: undefined, description: undefined }
this.publish()
}
/** @returns immutable navigation state. */
getSnapshot = (): BrowserFrameState => this.store.getSnapshot()
/** @param listener - state invalidation. @returns unsubscribe callback. */
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener)
/** @param target - validated address; submitting the current address reloads it. */
loadUrl(target: BrowserTarget): void {
if (this.disposed) return
const request = BrowserNavigation.current(this.navigation.snapshot)?.url === target.url
? this.navigation.reload() : this.navigation.navigate(target)
this.load(request)
}
/** Move through application-known history while the iframe address remains known. */
goBack(): void { if (!this.disposed) this.load(this.navigation.back()) }
/** Move through application-known history while the iframe address remains known. */
goForward(): void { if (!this.disposed) this.load(this.navigation.forward()) }
/** Reload the last application-known address. */
reload(): void { if (!this.disposed) this.load(this.navigation.reload()) }
/** @returns after the presentation has been removed; repeated calls join disposal. */
dispose(): Promise<void> {
if (this.disposal !== undefined) return this.disposal
this.disposed = true
this.presentation.dispose()
this.disposal = Promise.resolve()
return this.disposal
}
private setSandbox(enabled: boolean): void {
if (this.disposed || enabled === this.sandboxed) return
this.sandboxed = enabled
const request = this.navigation.reload()
if (request === undefined) this.publish()
else this.load(request)
}
private load(request: ReturnType<BrowserNavigation['navigate']> | undefined): void {
if (request === undefined) return
this.error = undefined
this.publish()
this.presentation.show({ target: request.target, revision: request.revision, sandboxed: this.sandboxed })
}
private snapshot(): BrowserFrameState {
const state = this.navigation.snapshot
const target = BrowserNavigation.current(state)
return { target, address: target === undefined ? 'empty' : state.navigation.status === 'unknown' ? 'unknown' : 'requested',
loading: state.navigation.status === 'loading' && this.error === undefined,
canGoBack: this.navigation.canGoBack, canGoForward: this.navigation.canGoForward,
error: this.error, sandboxEnabled: this.sandboxed }
}
private publish(): void {
this.store.set(this.snapshot())
this.options.persist(this.navigation.snapshot)
}
}
@@ -1,7 +1,7 @@
/** Persisted Browser tab snapshots shared by the body and title slots. */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { BrowserTabState } from './BrowserNavigation.ts'
import type { BrowserTabState } from './BrowserPersistence.ts'
/** All Browser tabs in one Session-scoped store. */
export interface BrowserState {
@@ -0,0 +1,28 @@
/** Resolve browser storage only after the authoritative Workspace list has arrived. */
import type { WorkspaceSource } from '@deepseek-ai/dsh-api-workspace-controller/client'
/**
* Resolve the workspace account; ungrouped Sessions get separate accounts.
* @param source - authoritative Workspace membership.
* @param sessionId - owning DSH Session.
* @param signal - guest initialization lifetime.
* @returns the storage account for this occurrence.
*/
export async function browserWorkspace(source: WorkspaceSource, sessionId: string, signal: AbortSignal): Promise<string> {
signal.throwIfAborted()
if (source.getSnapshot().phase !== 'ready') {
await new Promise<void>((resolve, reject) => {
const abort = (): void => { stop(); reject(signal.reason) }
const stop = source.subscribe(() => {
if (source.getSnapshot().phase !== 'ready') return
stop()
signal.removeEventListener('abort', abort)
resolve()
})
signal.addEventListener('abort', abort, { once: true })
})
}
signal.throwIfAborted()
const workspace = source.getSnapshot().items.find(item => item.sessionIds.some(id => id === sessionId))
return workspace === undefined ? `session:${sessionId}` : `workspace:${workspace.workspaceId}`
}
@@ -4,17 +4,25 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import { BrowserBody } from './view/BrowserBody.tsx'
import type {} from '@deepseek-ai/dsh-api-workspace-controller/client'
import { BrowserBody, type BrowserBodyProps } from './view/BrowserBody.tsx'
import { BrowserTitle } from './view/BrowserTitle.tsx'
import { createBrowserControllers } from './browser/BrowserController.ts'
import type { BrowserInjected } from './browser/BrowserController.ts'
import { createElectronPage, createIframePage } from './pages.ts'
import type { DesktopBrowserBridge } from '../desktop.ts'
import { browserWorkspace } from './browser/workspace.ts'
import type { BrowserPageFactory } from './browser/BrowserPage.ts'
import { BROWSER_ID, browserDefinition } from './definition.tsx'
import { en, zh } from './locales.ts'
import { createBrowserStore } from './browser/store.ts'
export type { BrowserBodyProps } from './view/BrowserBody.tsx'
export type { BrowserInjected } from './browser/BrowserController.ts'
export type { BrowserDocument, BrowserFrame, BrowserFrameState } from './browser/BrowserFrame.ts'
export type { BrowserFailure, BrowserHistoryEntry, BrowserNavigationStatus, BrowserTabState } from './browser/BrowserNavigation.ts'
export type { BrowserControllerState, BrowserInjected, BrowserMountRequest } from './browser/BrowserController.ts'
export type { BrowserFrame, BrowserFrameState, BrowserLoadError, BrowserSandboxControl } from './browser/BrowserFrame.ts'
export type { BrowserPage, BrowserPageFactory, BrowserPageOptions } from './browser/BrowserPage.ts'
export type { BrowserPresentation } from './view/BrowserPresentation.ts'
export type { BrowserFailure, BrowserHistoryEntry, BrowserNavigationStatus, BrowserTabState } from './browser/BrowserPersistence.ts'
export type { SidebarBrowserKey } from './locales.ts'
export type { BrowserState } from './browser/store.ts'
export type { BrowserAddressFailure, BrowserAddressResult, BrowserTarget } from './browser/url.ts'
@@ -34,12 +42,38 @@ export function apply(ctx: Context): void {
const namespace = 'sidebarBrowser'
const t = ctx.locale.bind(namespace)
const store = createBrowserStore()
const carrier = (globalThis as typeof globalThis & {
dshDesktop?: { readonly protocolVersion: number; readonly browser?: DesktopBrowserBridge }
}).dshDesktop
const desktop = carrier?.protocolVersion === 1 ? carrier.browser : undefined
ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-browser.copy')
ctx.effect(() => ctx.sidebarRightTabs.register(browserDefinition(t)), 'ui-sidebar-browser.type')
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab', key: BROWSER_ID, locale: namespace, store,
inject: (_sessionId, actions) => createBrowserControllers(actions),
}, BrowserBody)), 'ui-sidebar-browser.body')
ctx.effect(() => ctx.sidebarRightTabs.register({ ...browserDefinition(t), keepMounted: desktop !== undefined }), 'ui-sidebar-browser.type')
const installFrames = (scope: Context, factory: (sessionId: BrowserBodyProps['sessionId']) => BrowserPageFactory): void => {
const controllers = new Map<BrowserBodyProps['sessionId'], BrowserInjected>()
scope.effect(() => async () => {
const pending = [...controllers.values()].map(controller => controller.dispose())
controllers.clear()
await Promise.all(pending)
}, 'ui-sidebar-browser.frames')
scope.effect(() => scope.slots.inject('sidebar.right.pane.tab', () => scope.slots.register({
name: 'sidebar.right.pane.tab', key: BROWSER_ID, locale: namespace, store,
inject: (sessionId, actions) => {
const existing = controllers.get(sessionId)
if (existing !== undefined) {
existing.rebind(actions)
return existing
}
const controller = createBrowserControllers(actions, factory(sessionId))
controllers.set(sessionId, controller)
return controller
},
}, BrowserBody)), 'ui-sidebar-browser.body')
}
if (desktop === undefined) installFrames(ctx, () => createIframePage)
else ctx.inject(['workspaces'], scope => {
installFrames(scope, sessionId => options => createElectronPage(options, desktop,
signal => browserWorkspace(scope.workspaces.list, sessionId, signal)))
})
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title', key: BROWSER_ID, store,
}, BrowserTitle)), 'ui-sidebar-browser.title')
@@ -20,8 +20,9 @@ export const zh = {
'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。',
'error.credentials': '地址不能包含用户名或密码。',
'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
'web.loadFailed': '页面报告加载失败或可能禁止嵌入;可尝试在系统浏览器中打开。',
'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。',
'load.failed': '页面加载失败;请刷新重试或在系统浏览器中打开。',
'load.failed.detail': '页面加载失败({code}):{description}',
'address.unknown': '页面已跳转;当前载体无法读取新的 URL。',
} satisfies Record<string, string>
/** Browser dictionary key union. */
@@ -49,8 +50,9 @@ export const en = {
'error.protocol': 'Only HTTP and HTTPS addresses are supported; use Document Preview for local files.',
'error.credentials': 'Addresses cannot contain a username or password.',
'error.application-origin': 'The embedded browser cannot open the DSH application itself.',
'web.loadFailed': 'The page reported a load failure or may block embedding; try opening it in the system browser.',
'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.',
'load.failed': 'The page could not load; reload or open it in the system browser.',
'load.failed.detail': 'Page load failed ({code}): {description}',
'address.unknown': 'The page navigated; this carrier cannot read its new URL.',
} satisfies Record<SidebarBrowserKey, string>
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -0,0 +1,35 @@
/** Assemble navigation providers and presentation adapters without platform branches in consumers. */
import type { DesktopBrowserBridge } from '../desktop.ts'
import type { BrowserPage, BrowserPageOptions } from './browser/BrowserPage.ts'
import { IframeImpl } from './browser/IframeImpl.ts'
import { ElectronWebViewImpl } from './browser/ElectronWebViewImpl.ts'
import { IframePresentation } from './view/IframePresentation.ts'
import { ElectronWebviewPresentation } from './view/ElectronWebviewPresentation.ts'
/** @param options - saved navigation and callbacks. @returns the Web page's two independent faces. */
export function createIframePage(options: BrowserPageOptions): BrowserPage {
const presentation = new IframePresentation({
loaded: revision => { frame.handleLoaded(revision) },
failed: revision => { frame.handleLoadFailed(revision) },
remounted: () => { frame.reload() },
})
const frame = new IframeImpl(options, presentation)
return { frame, presentation }
}
/**
* @param options - checkpoint and source-tab callbacks.
* @param bridge - desktop-only transport.
* @param workspace - storage account resolver.
* @returns separate navigation and presentation faces.
*/
export function createElectronPage(options: BrowserPageOptions, bridge: DesktopBrowserBridge,
workspace: (signal: AbortSignal) => Promise<string>): BrowserPage {
let frame: ElectronWebViewImpl
const presentation = new ElectronWebviewPresentation({
mounted: () => { frame.attach() },
unmounted: () => { frame.detach() },
})
frame = new ElectronWebViewImpl(options, bridge, workspace, presentation)
return { frame, presentation }
}
@@ -121,6 +121,44 @@
background: var(--dsw-alias-bg-base);
}
.content {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
}
.webview {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
border: 0;
-webkit-app-region: no-drag;
}
/* Native guest input must not consume a docking gesture's window events. */
:global(html:has([data-dockkit-pointer])) .webview {
pointer-events: none;
}
.start {
display: flex;
flex: 1 1 auto;
@@ -1,5 +1,5 @@
/** Browser toolbar and Web iframe renderer. */
import { useEffect, useRef, useState } from 'react'
/** Common browser chrome; a presentation adapter attaches the page inside its content container. */
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { FormEvent, ReactNode } from 'react'
import {
IconChevronLeftOutlineRegular,
@@ -12,16 +12,12 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { BrowserInjected } from '../browser/BrowserController.ts'
import type { BrowserFrameState } from '../browser/BrowserFrame.ts'
import { BrowserNavigation } from '../browser/BrowserNavigation.ts'
import type { BrowserAddressFailure } from '../browser/url.ts'
import { emptyBrowserFrame } from '../browser/BrowserFrame.ts'
import { currentBrowserTarget } from '../browser/BrowserPersistence.ts'
import type { BrowserStore } from '../browser/store.ts'
import css from './Browser.module.css'
/** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */
export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true, loadFailed: false }
const EMPTY_FRAME = emptyBrowserFrame()
function SandboxPolicyIcon({ sandboxed }: { readonly sandboxed: boolean }): ReactNode {
return (
@@ -40,116 +36,85 @@ export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
& PropsLocale<'sidebarBrowser'>
& InjectFace<BrowserInjected>
/** Translate one parser refusal without matching display strings in logic. */
function failureText(reason: BrowserAddressFailure, t: BrowserBodyProps['t']): string {
return t(`error.${reason}`)
function useBrowserDraft(url: string | undefined, revision: number): readonly [string, (value: string) => void] {
const [edit, setEdit] = useState<{ readonly revision: number; readonly value: string }>()
return [edit?.revision === revision ? edit.value : url ?? '',
value => { setEdit({ revision, value }) }]
}
function useBrowserDraft(
controlledUrl: string | undefined,
requestId: number | undefined,
): readonly [string, (value: string) => void] {
const [edit, setEdit] = useState<{ readonly requestId: number | undefined; readonly value: string }>()
const value = edit !== undefined && edit.requestId === requestId ? edit.value : controlledUrl ?? ''
return [value, (draft) => { setEdit({ requestId, value: draft }) }]
}
/** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */
/** Render provider-neutral navigation state and optional controls. */
export function BrowserBody(props: BrowserBodyProps): ReactNode {
const {
goBack, goForward, loadUrl, mount, reload, reportLoaded, reportLoadFailed, toggleSandbox,
useBrowserFrame, useStore, useTabInfo, t,
} = props
const { mount, loadUrl, goBack, goForward, reload, setSandbox, useBrowserState, useStore, useTabInfo, t } = props
const { tab } = useTabInfo()
const state = useStore(snapshot => snapshot.byTab[tab.id]) ?? BrowserNavigation.empty()
const initialState = useRef(state)
const saved = useStore(state => state.byTab[tab.id])
const initial = useRef(saved)
const initialUrl = useRef(tab.navigation.params?.url)
const current = BrowserNavigation.current(state)
const [draft, setDraft] = useBrowserDraft(current?.url ?? initialUrl.current, state.request?.revision)
const [mountCount, setMountCount] = useState(0)
const viewportId = useId()
const [mountEpoch, setMountEpoch] = useState(0)
const state = useBrowserState(tab.id)
const frame = state?.frame ?? EMPTY_FRAME
const target = frame.target ?? currentBrowserTarget(initial.current)
const [draft, setDraft] = useBrowserDraft(target?.url ?? initialUrl.current, state?.addressRevision ?? 0)
useEffect(() => {
mount(tab.id, tab.signal, window.location.origin, initialState.current)
setMountCount(count => count + 1)
}, [mount, tab.id, tab.signal])
useLayoutEffect(() => {
const hide = mount({
tabId: tab.id, signal: tab.signal, viewportId, applicationOrigin: window.location.origin,
initial: initial.current, initialUrl: initialUrl.current,
openTab: url => { tab.actions.openTab('browser', { params: { url }, revealIfOpened: false }) },
})
setMountEpoch(value => value + 1)
return hide
}, [mount, tab.id, tab.signal, tab.actions, viewportId, props.actions])
useEffect(() => {
if (mountCount === 0) return
const resumed = BrowserNavigation.current(initialState.current)
if (resumed !== undefined) {
reload(tab.id)
return
}
const url = initialUrl.current
if (url !== undefined) loadUrl(tab.id, url)
}, [loadUrl, mountCount, reload, tab.id])
const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME
const { document, sandboxed, loadFailed } = frameState
const navigationUnknown = state.navigation.status === 'unknown'
const externalUrl = navigationUnknown ? undefined : current?.url
const unknown = frame.address === 'unknown'
const externalUrl = unknown ? undefined : target?.url
const sandboxed = frame.sandboxEnabled
const failure = state?.addressFailure
const error = frame.error
const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t)
const placeholder = current === undefined ? t('start') : t('loading')
return (
<div className={css.root}>
<form className={css.toolbar} onSubmit={submit}>
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!BrowserNavigation.canGoBack(state)} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!BrowserNavigation.canGoForward(state)} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('reload')} title={t('reload')} disabled={current === undefined} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('reload')} title={t('reload')} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
<div className={css.addressBox}>
<input
className={`${css.address} ${navigationUnknown ? css.addressUnknown : ''}`}
className={[css.address, unknown ? css.addressUnknown : ''].join(' ')}
value={draft}
aria-label={t('address.placeholder')}
placeholder={t('address.placeholder')}
spellCheck={false}
onChange={(event) => { setDraft(event.currentTarget.value) }}
onChange={event => { setDraft(event.currentTarget.value) }}
/>
{navigationUnknown && <span className={css.addressChanged}>{t('address.changed')}</span>}
<button type="submit" className={`${css.tool} ${css.addressGo}`} aria-label={t('go')} title={t('go')}><IconLinkOutlineRegular /></button>
{unknown && <span className={css.addressChanged}>{t('address.changed')}</span>}
<button type="submit" className={[css.tool, css.addressGo].join(' ')} aria-label={t('go')} title={t('go')}><IconLinkOutlineRegular /></button>
</div>
<button
type="button"
className={css.tool}
aria-label={t('external')}
title={t('external')}
disabled={externalUrl === undefined}
onClick={() => {
/* v8 ignore next -- React does not dispatch clicks from this disabled button. */
if (externalUrl !== undefined) window.open(externalUrl, '_blank', 'noopener,noreferrer')
}}
<button type="button" className={css.tool} aria-label={t('external')} title={t('external')} disabled={externalUrl === undefined}
onClick={() => { if (externalUrl !== undefined) window.open(externalUrl, '_blank', 'noopener,noreferrer') }}
><IconRightUpOutlineRegular size={14} /></button>
<button
{sandboxed !== undefined && <button
type="button"
className={`${css.tool} ${sandboxed ? '' : css.sandboxOff}`}
className={[css.tool, sandboxed ? '' : css.sandboxOff].join(' ')}
aria-label={t(sandboxed ? 'sandbox.disable' : 'sandbox.enable')}
title={t(sandboxed ? 'sandbox.disable' : 'sandbox.enable')}
aria-pressed={!sandboxed}
disabled={mountCount === 0}
onClick={() => { toggleSandbox(tab.id) }}
><SandboxPolicyIcon sandboxed={sandboxed} /></button>
onClick={() => { setSandbox(tab.id, !sandboxed) }}
><SandboxPolicyIcon sandboxed={sandboxed} /></button>}
</form>
{!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
{loadFailed && <div className={css.failure} role="status">{t('web.loadFailed')}</div>}
{failure !== undefined && <div className={css.failure} role="alert">{failure}</div>}
{document === undefined
? <div className={css.start}>{placeholder}</div>
: <iframe
key={`${document.target.url}:${String(document.revision)}`}
className={css.frame}
src={document.src}
sandbox={sandboxed ? WEB_BROWSER_SANDBOX : undefined}
referrerPolicy="no-referrer"
title={document.target.title}
onLoad={() => { reportLoaded(tab.id, document.revision) }}
/* v8 ignore next -- jsdom does not dispatch React iframe error events; BrowserFrame owns the tested behavior. */
onError={() => { reportLoadFailed(tab.id, document.revision) }}
data-sidebar-browser-frame
/>}
{navigationUnknown && <p className={css.limit}>{t('web.unknown')}</p>}
{sandboxed === false && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
{error !== undefined && <div className={css.failure} role="status">{error.code !== undefined && error.description !== undefined
? t('load.failed.detail', { code: String(error.code), description: error.description })
: t('load.failed')}</div>}
{failure !== undefined && <div className={css.failure} role="alert">{t(`error.${failure}`)}</div>}
<div className={css.content} aria-busy={frame.loading}>
<div id={viewportId} className={css.viewport} aria-label={t('type.label')} />
{(target === undefined || frame.loading) && error === undefined && <div className={css.placeholder}>
<div className={css.start}>{t(target === undefined ? 'start' : 'loading')}</div>
</div>}
</div>
{unknown && <p className={css.limit}>{t('address.unknown')}</p>}
</div>
)
}
@@ -0,0 +1,9 @@
/** The presentation face is separate from page navigation and terminal disposal. */
export interface BrowserPresentation {
/**
* Attach the page inside a committed content container.
* @param viewportId - content element id; its DOM ancestors must remain connected for page retention.
* @returns ends physical attachment, without disposing the navigation model; a later mount may reload the page.
*/
mount(viewportId: string): () => void
}
@@ -2,7 +2,7 @@
import type { ReactNode } from 'react'
import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { BrowserNavigation } from '../browser/BrowserNavigation.ts'
import { currentBrowserTarget } from '../browser/BrowserPersistence.ts'
import type { BrowserStore } from '../browser/store.ts'
import css from './Browser.module.css'
@@ -12,6 +12,6 @@ export type BrowserTitleProps = PropsRuntime<'sidebar.right.pane.tab.title'> & P
/** Browser icon and current host name. */
export function BrowserTitle({ useTabInfo, useStore }: BrowserTitleProps): ReactNode {
const { tab } = useTabInfo()
const entry = useStore(state => BrowserNavigation.current(state.byTab[tab.id]))
const entry = useStore(state => currentBrowserTarget(state.byTab[tab.id]))
return <><IconGlobeOutlineRegular className={css.titleIcon} />{entry?.title ?? tab.title}</>
}
@@ -0,0 +1,82 @@
/** Electron tag DOM inside a Sidebar-owned, stable content container. */
import type { DesktopBrowserReservation } from '../../desktop.ts'
import type { BrowserPresentation } from './BrowserPresentation.ts'
import css from './Browser.module.css'
/** The Electron tag API used by its navigation provider. */
export interface WebviewElement extends HTMLElement {
loadURL(url: string): Promise<void>
getURL(): string
getTitle(): string
canGoBack(): boolean
canGoForward(): boolean
clearHistory(): void
goBack(): void
goForward(): void
reload(): void
isLoading(): boolean
}
/** Physical attachment notifications; hiding a retained Sidebar body emits neither. */
export interface ElectronPresentationEvents {
readonly mounted: () => void
readonly unmounted: () => void
}
/** Owns tag creation and attachment, without measuring or following another element. */
export class ElectronWebviewPresentation implements BrowserPresentation {
private element: WebviewElement | undefined
private host: HTMLElement | undefined
constructor(private readonly events: ElectronPresentationEvents) {}
/** @param viewportId - committed content container. @returns ends attachment and releases its guest. */
mount(viewportId: string): () => void {
const host = document.getElementById(viewportId)
if (host === null) throw new Error('Electron presentation: content container is not mounted')
if (this.host !== undefined) this.events.unmounted()
this.host = host
this.events.mounted()
return () => {
if (this.host !== host) return
this.events.unmounted()
this.host = undefined
}
}
/** @param reservation - main-approved partition and bootstrap lease. @returns a detached, configured webview. */
createElement(reservation: DesktopBrowserReservation): WebviewElement {
const element = document.createElement('webview') as WebviewElement
element.className = css.webview as string
element.dataset.sidebarBrowserFrame = 'webview'
element.setAttribute('partition', reservation.partition)
element.setAttribute('allowpopups', '')
element.setAttribute('src', 'about:blank#' + reservation.lease)
return element
}
/** @param element - configured guest with its navigation listeners already installed. */
present(element: WebviewElement): void {
if (this.host === undefined) throw new Error('Electron presentation: cannot attach without a content container')
this.clear()
this.element = element
this.host.append(element)
}
/** @param title - observed document title, also used as the accessible label. */
show(title: string): void {
this.element?.setAttribute('aria-label', title)
}
/** Destroy only the guest DOM; a replacement may use the same mounted container. */
clear(): void {
this.element?.remove()
this.element = undefined
}
/** Destroy the guest DOM and release its content container. */
dispose(): void {
this.clear()
this.host = undefined
}
}
@@ -0,0 +1,85 @@
/** Iframe DOM and revision-tagged load observations. */
import type { BrowserTarget } from '../browser/url.ts'
import type { BrowserPresentation } from './BrowserPresentation.ts'
import css from './Browser.module.css'
/** Fixed iframe policy; top navigation and downloads are not granted directly. */
export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
interface IframeDocument {
readonly target: BrowserTarget
readonly revision: number
readonly sandboxed: boolean
}
/** Reports only what an iframe can observe without inspecting cross-origin content. */
export interface IframePresentationEvents {
readonly loaded: (revision: number) => void
readonly failed: (revision: number) => void
readonly remounted: () => void
}
/** Owns the iframe element; navigation and history remain in IframeImpl. */
export class IframePresentation implements BrowserPresentation {
private host: HTMLElement | undefined
private element: HTMLIFrameElement | undefined
private document: IframeDocument | undefined
private rendered = false
/** @param events - provider-owned load and remount callbacks. */
constructor(private readonly events: IframePresentationEvents) {}
/** @param viewportId - mounted placeholder. @returns removes only the iframe presentation. */
mount(viewportId: string): () => void {
const host = document.getElementById(viewportId)
if (host === null) throw new Error('iframe presentation: viewport is not mounted')
this.element?.remove()
this.element = undefined
this.host = host
if (this.rendered) this.events.remounted()
else this.render()
return () => {
if (this.host !== host) return
this.element?.remove()
this.element = undefined
this.host = undefined
}
}
/** @param value - prepared document and event generation. */
show(value: IframeDocument): void {
this.document = value
this.render()
}
/** Remove the owned DOM and retained presentation data. */
dispose(): void {
this.element?.remove()
this.element = undefined
this.host = undefined
this.document = undefined
}
private render(): void {
const current = this.document
const host = this.host
if (current === undefined || host === undefined) return
this.element?.remove()
const element = document.createElement('iframe')
element.className = css.frame as string
element.src = current.target.url
element.title = current.target.title
element.referrerPolicy = 'no-referrer'
element.dataset.sidebarBrowserFrame = 'iframe'
if (current.sandboxed) element.setAttribute('sandbox', WEB_BROWSER_SANDBOX)
element.addEventListener('load', () => {
if (this.element === element) this.events.loaded(current.revision)
})
element.addEventListener('error', () => {
if (this.element === element) this.events.failed(current.revision)
})
this.element = element
this.rendered = true
host.append(element)
}
}
@@ -0,0 +1,27 @@
/** Electron bridge declarations shared by the desktop shell and browser provider. */
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Main-issued identity of one guest reservation. */
export type DesktopBrowserLeaseId = Branded<'DesktopBrowserLeaseId'>
/** A guest's approved, process-local storage partition. */
export interface DesktopBrowserReservation {
readonly lease: DesktopBrowserLeaseId
readonly partition: string
}
/** Main-approved request to open an HTTP(S) page from an existing guest. */
export interface DesktopBrowserOpenRequest {
readonly lease: DesktopBrowserLeaseId
readonly url: string
}
/** Origin-scoped operations; no Electron objects or arbitrary IPC cross this interface. */
export interface DesktopBrowserBridge {
/** @param workspace - resolved storage account. @returns one approved guest reservation. */
acquire(workspace: string): Promise<DesktopBrowserReservation>
/** @param lease - the caller's reservation. @returns after its guest has been destroyed. */
release(lease: DesktopBrowserLeaseId): Promise<void>
/** @param lease - originating guest. @param listener - approved URL consumer. @returns unsubscribe callback. */
onOpenRequested(lease: DesktopBrowserLeaseId, listener: (url: string) => void): () => void
}
@@ -9,6 +9,8 @@
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../api/workspace-controller/tsconfig.client.json" },
{ "path": "../../util/brand" },
{ "path": "../locale" },
{ "path": "../store" },
{ "path": "../ui-dockkit" },
@@ -1,3 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-browser', ['lib/types/index.js'])
export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-browser', ['lib/types/index.js', 'lib/types/desktop.js'])
+6
View File
@@ -4363,6 +4363,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-workspace-controller':
specifier: workspace:^
version: link:../../api/workspace-controller
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
+1
View File
@@ -193,6 +193,7 @@
"@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client": ["./packages/client/ui-sidebar-documentpreview/src/client"],
"@deepseek-ai/dsh-client-ui-sidebar-browser": ["./packages/client/ui-sidebar-browser/src"],
"@deepseek-ai/dsh-client-ui-sidebar-browser/client": ["./packages/client/ui-sidebar-browser/src/client"],
"@deepseek-ai/dsh-client-ui-sidebar-browser/desktop": ["./packages/client/ui-sidebar-browser/src/desktop.ts"],
"@deepseek-ai/dsh-api-workspace-files": ["./packages/api/workspace-files/src"],
"@deepseek-ai/dsh-api-workspace-files/types": ["./packages/api/workspace-files/src/types.ts"],
"@deepseek-ai/dsh-api-workspace-files/client": ["./packages/api/workspace-files/src/client/index.ts"],