refactor(browser): separate navigation and presentation providers
This commit is contained in:
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'])
|
||||
Generated
+6
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user