feat(browser): add manual page recovery and cwd storage grouping
This commit is contained in:
1 parent
ab4bd4c630
commit
c402892c1a
18 files changed
+143
-45
No files matched your search
+2
-2
@@ -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/architecture/2026-09-20-sidebar-retained-tab-layout.md
|
||||
2026-09-20-sidebar-retained-tab-layout.md: ed5404009d6d8844c54aef224eac2dd238dc7a4c
|
||||
2026-09-20-sidebar-retained-tab-layout.zh.md: 0539dee38c9b2b9ecc1f5e9b8d1608dd9b5ea518
|
||||
2026-09-20-sidebar-retained-tab-layout.md: 7430c0f20c5e7161658f8d55e2b597ecfca91d8c
|
||||
2026-09-20-sidebar-retained-tab-layout.zh.md: a6efd26b52ec8a1b671369b5f70ddb677358adca
|
||||
@@ -50,7 +50,8 @@ The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md)
|
||||
| Collapse Sidebar | Hide docked cells; foreground floats remain visible. |
|
||||
| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. |
|
||||
| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. |
|
||||
| Application restart, window reload or guest crash | Use provider recovery or retry; page memory and native history are not durable. |
|
||||
| Application restart or window reload | Show the saved title and URL; load only after an explicit restore or address submission. |
|
||||
| Guest crash | Retry through the provider; page memory and native history are not durable. |
|
||||
|
||||
[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking.
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ Tab 提供 Body 保活持有,而不独立申请会话引用。这些持有跟
|
||||
| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 |
|
||||
| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 |
|
||||
| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 |
|
||||
| 应用重启、窗口刷新或 guest 崩溃 | 走提供方恢复或重试;页面内存和原生 history 不持久化。 |
|
||||
| 应用重启或窗口刷新 | 展示保存的标题和 URL,显式恢复或提交地址后才加载。 |
|
||||
| guest 崩溃 | 通过提供方重试;页面内存和原生 history 不持久化。 |
|
||||
|
||||
[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。
|
||||
|
||||
|
||||
@@ -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: ffea3d4bb75a67b7ed1c6f5c6b2450a80190b0c3
|
||||
2026-09-16-sidebar-browser.zh.md: da1ed607804b940f0b614411b78ab895581832ed
|
||||
2026-09-16-sidebar-browser.md: eff143cc042da7c6455152f684ec83dfa654a1ec
|
||||
2026-09-16-sidebar-browser.zh.md: a037c2fb54be745a3a85f43561c0bd587873c008
|
||||
@@ -22,13 +22,17 @@ The Web carrier is an iframe; Desktop uses the [retained webview carrier](2026-0
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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. Within a live controller, remounting a body reloads its last requested URL. The Session-scoped store persists immutable checkpoints for tab titles and cold-start recovery; it does not serialize a live page.
|
||||
|
||||
A controller created from a saved checkpoint offers its last title and URL with a Restore page button. The provider starts idle: mounting the restored tab neither creates a browsing guest nor requests the saved site. Restore, toolbar Reload or address submission starts navigation; a fresh typed-open URL remains an explicit navigation request and loads directly. Before a page is requested, Back and Forward remain disabled, while Reload restores the saved address through the same controller action as Restore. Changing an idle iframe's sandbox does not restore it. This offer belongs to `BrowserControllerState`, not the live `BrowserFrame` interface or the persisted navigation status.
|
||||
|
||||
Occurrence cancellation releases the controller. It removes the saved checkpoint only when the Sidebar's authoritative `openTabs` inventory no longer contains that tab. The inventory publishes layout removal before `TabDomain` aborts its occurrence; plugin unload also aborts occurrences but leaves saved tab membership intact. Cleanup therefore does not interpret plugin unload as a request to erase recovery data.
|
||||
|
||||
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.
|
||||
|
||||
## Web navigation states
|
||||
|
||||
The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored.
|
||||
The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. These states describe active navigation; an unrequested saved checkpoint remains a separate restore offer.
|
||||
|
||||
| State | Entry | Address and controls |
|
||||
|---|---|---|
|
||||
@@ -37,7 +41,7 @@ The Web carrier treats only the first iframe `load` for a controlled revision as
|
||||
| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. |
|
||||
| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. |
|
||||
|
||||
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.
|
||||
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. For an already requested page, 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.
|
||||
|
||||
## Electron carrier
|
||||
|
||||
@@ -45,6 +49,8 @@ The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns nativ
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Automatically reload saved pages when their tabs mount.** This resumes third-party requests and scripts merely by restoring the layout. An explicit restore action preserves the saved address without opening the site.
|
||||
|
||||
**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning.
|
||||
|
||||
**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support.
|
||||
@@ -59,6 +65,8 @@ The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns nativ
|
||||
|
||||
Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal.
|
||||
|
||||
Manual cold-start restoration and checkpoint retention across plugin unload have no runtime verification yet.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
|
||||
@@ -22,13 +22,17 @@ Web 使用 iframe 载体;Desktop 使用[保持页面实例的 webview 载体](
|
||||
|
||||
每个 tab 获得一个 `BrowserController`,负责地址校验与载体无关的命令。`BrowserFrame` 提供导航、可观察的地址/加载/history/错误状态和可选 sandbox 控制,`BrowserPresentation` 负责 DOM 挂载。`pages.ts` 把两个对象组装成 `BrowserPage`。`IframeImpl` 持有应用已知的 `BrowserNavigation`,`ElectronWebViewImpl` 则观察原生 history。Slot injection 通过 `useBrowserState` 提供按 key 索引的控制器状态和普通回调;React body 只持有草稿与内容容器,没有载体分支、控制器实例或可观察源。
|
||||
|
||||
对于 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。
|
||||
对于 iframe 载体,`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。在仍存活的控制器内,body 重挂载会重新加载最后请求的 URL。Session-scoped store 持久化不可变检查点,供 Tab 标题与冷启动恢复使用,不序列化活页面。
|
||||
|
||||
从已保存检查点创建的控制器展示上次标题、URL 和“恢复页面”按钮。提供方初始为空闲状态:挂载恢复的 Tab 既不创建浏览 guest,也不请求保存的站点。恢复、工具栏刷新或提交地址才启动导航;新打开 Tab 时显式传入的 URL 仍是主动导航请求,会直接加载。页面尚未请求时,后退和前进仍禁用,刷新则通过与“恢复页面”相同的控制器操作加载保存的地址。切换空闲 iframe 的沙箱也不会恢复页面。恢复提示属于 `BrowserControllerState`,不进入活页面的 `BrowserFrame` 接口或持久化导航状态。
|
||||
|
||||
occurrence 取消会释放控制器。只有 Sidebar 的权威 `openTabs` 清单已不再包含该 Tab 时,才删除保存的检查点。清单先发布布局中的 Tab 移除,再由 `TabDomain` abort 对应 occurrence;插件卸载也会 abort occurrence,但仍保留已保存的 Tab 成员信息。因此清理不会把插件卸载当成删除恢复数据的请求。
|
||||
|
||||
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)继续负责各自现有职责。
|
||||
|
||||
## Web 导航状态
|
||||
|
||||
对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。
|
||||
对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。这些状态描述活动导航;尚未请求的已保存检查点仍是独立的恢复提示。
|
||||
|
||||
| 状态 | 进入条件 | 地址与控件 |
|
||||
|---|---|---|
|
||||
@@ -37,7 +41,7 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour
|
||||
| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 |
|
||||
| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 |
|
||||
|
||||
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 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 变化仍不可观察。
|
||||
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 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 变化仍不可观察。
|
||||
|
||||
## Electron carrier
|
||||
|
||||
@@ -45,6 +49,8 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Tab 挂载时自动重新加载已保存页面。** 这会仅因恢复布局就恢复第三方请求与脚本执行。显式恢复操作保留已保存地址,但不会自行打开站点。
|
||||
|
||||
**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。
|
||||
|
||||
**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。
|
||||
@@ -59,6 +65,8 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour
|
||||
|
||||
单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
|
||||
|
||||
手动冷启动恢复和跨插件卸载保留检查点尚无运行时验证。
|
||||
|
||||
## Consequences
|
||||
|
||||
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 变化可能完全不可见。
|
||||
|
||||
@@ -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-20-desktop-browser-webview.md
|
||||
2026-09-20-desktop-browser-webview.md: cd809b956811af4ad8e73b4c06bd7daa44f154b8
|
||||
2026-09-20-desktop-browser-webview.zh.md: bc2b2f208d02b9dba5096b5e1c3754f75aea2ea2
|
||||
2026-09-20-desktop-browser-webview.md: 869308f2105f5145f22d3d4041b9f59fc6f36aa1
|
||||
2026-09-20-desktop-browser-webview.zh.md: 73299056d840b9e3f888f230514b38d704f33153
|
||||
@@ -20,13 +20,13 @@ The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retain
|
||||
- Controller registries are keyed by DSH Session, independently of presentation bindings. Rebinding replaces the store writer without recreating the page.
|
||||
- `pages.ts` composes a navigation provider and a presentation object. `ElectronWebViewImpl` owns guest leases and native navigation; `ElectronWebviewPresentation` creates the tag and attaches it inside the Sidebar-owned content container.
|
||||
- The Desktop Browser type declares `keepMounted`. Sidebar retains its DOM ancestors across hiding and docking; CSS owns layout and clipping. Docking gestures disable guest pointer input, and body-local drop hints use normal stacking. A physical unmount cancels pending attachment and releases the guest; a later mount recreates it from the known address.
|
||||
- Tab occurrence cancellation, plugin unload and window destruction release guests. Guest crashes leave a retryable failure; Reload creates a new guest. Application restart restores an address, not page memory or native history.
|
||||
- Tab occurrence cancellation, plugin unload and window destruction release guests. Guest crashes leave a retryable failure; Reload creates a new guest. Application restart offers the saved title and URL for explicit restoration; it creates no guest until the user restores or submits an address. Page memory and native history are not restored.
|
||||
|
||||
The package's type-only `./desktop` entry declares the lease, reservation, open-request and bridge types shared by the main process, preload and Client. Preload exposes scoped operations and callbacks, not raw IPC or Electron objects.
|
||||
|
||||
### Storage ownership
|
||||
|
||||
`DesktopBrowserGuests` assigns a random, non-persistent Electron partition to each Workspace account. Browser tabs in different DSH Sessions share that account when they belong to the same Workspace; ungrouped Sessions have separate accounts. Workspace membership is resolved after the Client receives its authoritative baseline and is fixed for a guest occurrence.
|
||||
`DesktopBrowserGuests` assigns a random, non-persistent Electron partition to each canonical CWD account. The Client uses the Host-normalized `WorkspaceView.path`, not the Workspace record UUID, so recreating a Workspace at the same directory does not change its storage account key. Browser tabs whose DSH Sessions resolve to that CWD share the account; Sessions without a resolved Workspace remain separately isolated. Workspace membership is resolved after the Client receives its authoritative baseline and is fixed for a guest occurrence. The CWD key controls sharing, not disk persistence.
|
||||
|
||||
Closing a tab releases its guest, not its account's cookies or storage. Cookies, localStorage, IndexedDB, Service Workers and cache remain partition-owned and subject to normal origin rules. DOM, native history and sessionStorage remain page-owned. Account partitions survive window recreation within the Electron process but do not persist across application exit.
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ Desktop 通过 `ElectronWebViewImpl` 使用 `<webview>`;Web 保留显式启用
|
||||
- 控制器注册表按 DSH Session 索引,不依赖呈现绑定的存活期。重新绑定只替换存储写入方,不重建页面。
|
||||
- `pages.ts` 组装导航提供方和呈现对象。`ElectronWebViewImpl` 负责 guest 租约与原生导航;`ElectronWebviewPresentation` 创建标签并挂载到 Sidebar 持有的内容容器内。
|
||||
- Desktop Browser 类型声明 `keepMounted`。Sidebar 在隐藏和停靠切换时保留其 DOM 祖先,CSS 负责布局与裁剪。停靠手势期间禁用 guest 指针输入,Body 内的放置提示使用普通层叠。物理卸载会取消未完成的挂载并释放 guest,之后重新挂载时从已知地址重建。
|
||||
- tab occurrence 取消、插件卸载与窗口销毁会释放 guest。guest 崩溃留下可重试的失败状态;刷新创建新 guest。应用重启只恢复地址,不恢复页面内存或原生 history。
|
||||
- tab occurrence 取消、插件卸载与窗口销毁会释放 guest。guest 崩溃留下可重试的失败状态;刷新创建新 guest。应用重启展示保存的标题与 URL,等待显式恢复;用户恢复或提交地址之前不创建 guest。不恢复页面内存或原生 history。
|
||||
|
||||
包内仅含类型的 `./desktop` 入口声明主进程、preload 和 Client 共用的租约、申请结果、打开请求与桥接类型。preload 只暴露限定范围的操作与回调,不暴露原始 IPC 或 Electron 对象。
|
||||
|
||||
### Storage ownership
|
||||
|
||||
`DesktopBrowserGuests` 为每个 Workspace 存储账号分配随机、非持久化的 Electron partition。不同 DSH Session 的 Browser tab 属于同一 Workspace 时共享该账号;未分组 Session 使用独立账号。Workspace 归属在 Client 收到权威基线后解析,并在一次 guest occurrence 内保持不变。
|
||||
`DesktopBrowserGuests` 为每个规范化 CWD 存储账号分配随机、非持久化的 Electron partition。Client 使用 Host 规范化后的 `WorkspaceView.path`,不使用 Workspace 记录 UUID,因此在同一目录重建 Workspace 不改变存储账号 key。DSH Session 解析到同一 CWD 的 Browser Tab 共享账号;没有可解析 Workspace 的 Session 仍单独隔离。Workspace 归属在 Client 收到权威基线后解析,并在一次 guest occurrence 内保持不变。CWD key 控制共享关系,不决定是否落盘。
|
||||
|
||||
关闭 tab 只释放它的 guest,不清空账号的 Cookie 或存储。Cookie、localStorage、IndexedDB、Service Worker 与缓存由 partition 持有,并继续遵循普通 origin 规则。DOM、原生 history 与 sessionStorage 仍由页面持有。账号 partition 在同一 Electron 进程内重建窗口后仍保留,但不跨应用退出持久化。
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ 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, type BrowserAddressFailure } from './url.ts'
|
||||
import { parseBrowserAddress, type BrowserAddressFailure, type BrowserTarget } from './url.ts'
|
||||
|
||||
/** Live tab state; navigation comes from its provider and draft validation stays local. */
|
||||
export interface BrowserControllerState {
|
||||
readonly frame: BrowserFrameState
|
||||
/** Saved address offered for explicit restoration before any page has been requested. */
|
||||
readonly restoreTarget: BrowserTarget | undefined
|
||||
readonly addressFailure: BrowserAddressFailure | undefined
|
||||
readonly addressRevision: number
|
||||
}
|
||||
@@ -56,13 +58,15 @@ export class BrowserController implements HostObservable<BrowserControllerState>
|
||||
options.openTab(result.target.url)
|
||||
},
|
||||
})
|
||||
this.store = createSnapshotStore({ frame: this.page.frame.getSnapshot(), addressFailure: undefined, addressRevision: 0 })
|
||||
this.store = createSnapshotStore({ frame: this.page.frame.getSnapshot(),
|
||||
restoreTarget: currentBrowserTarget(this.checkpoint), 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,
|
||||
this.store.set({ frame, restoreTarget: frame.target === undefined ? currentBrowserTarget(this.checkpoint) : undefined,
|
||||
addressFailure: changed ? undefined : current.addressFailure,
|
||||
addressRevision: current.addressRevision + Number(changed) })
|
||||
})
|
||||
options.signal.addEventListener('abort', this.abort, { once: true })
|
||||
@@ -79,12 +83,17 @@ export class BrowserController implements HostObservable<BrowserControllerState>
|
||||
return this.page.presentation.mount(viewportId)
|
||||
}
|
||||
|
||||
/** @param initialUrl - typed-open address used only when no checkpoint exists. */
|
||||
/** @param initialUrl - explicit typed-open address; a saved checkpoint alone never starts navigation. */
|
||||
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)
|
||||
if (initialUrl !== undefined) this.loadUrl(initialUrl)
|
||||
}
|
||||
|
||||
/** Load the saved address only after an explicit restore action. */
|
||||
restore(): void {
|
||||
const target = this.store.getSnapshot().restoreTarget
|
||||
if (target !== undefined) this.loadUrl(target.url)
|
||||
}
|
||||
|
||||
/** @param value - address-bar or typed-open input. */
|
||||
@@ -99,8 +108,11 @@ export class BrowserController implements HostObservable<BrowserControllerState>
|
||||
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() }) }
|
||||
/** Restore a saved address, or reload the already requested page. */
|
||||
reload(): void {
|
||||
if (this.store.getSnapshot().restoreTarget !== undefined) this.restore()
|
||||
else this.command(() => { this.page.frame.reload() })
|
||||
}
|
||||
|
||||
/** @param enabled - optional embedding-sandbox control; unavailable providers remain unchanged. */
|
||||
setSandbox(enabled: boolean): void {
|
||||
@@ -161,18 +173,26 @@ export interface BrowserInjected {
|
||||
rebind(actions: BoundActions<BrowserStore>): void
|
||||
/** @param tabId - owning tab. @param value - address input. */
|
||||
loadUrl(tabId: TabId, value: string): void
|
||||
/** @param tabId - tab whose saved address the user requested to restore. */
|
||||
restore(tabId: TabId): void
|
||||
/** @param tabId - owning tab. */
|
||||
goBack(tabId: TabId): void
|
||||
/** @param tabId - owning tab. */
|
||||
goForward(tabId: TabId): void
|
||||
/** @param tabId - owning tab. */
|
||||
/** Restore a saved address or reload its page. @param tabId - owning tab. */
|
||||
reload(tabId: TabId): void
|
||||
/** @param tabId - owning tab. @param enabled - provider's optional sandbox control. */
|
||||
setSandbox(tabId: TabId, enabled: boolean): void
|
||||
}
|
||||
|
||||
/** @param actions - persisted view-state writer. @param createPage - composition-selected provider. @returns tab callbacks. */
|
||||
export function createBrowserControllers(actions: BoundActions<BrowserStore>, createPage: BrowserPageFactory): BrowserInjected {
|
||||
/**
|
||||
* @param actions - persisted view-state writer.
|
||||
* @param createPage - composition-selected provider.
|
||||
* @param isTabOpen - authoritative layout membership, independent of mounted bodies and plugin lifetime.
|
||||
* @returns tab callbacks.
|
||||
*/
|
||||
export function createBrowserControllers(actions: BoundActions<BrowserStore>, createPage: BrowserPageFactory,
|
||||
isTabOpen: (tabId: TabId) => boolean): BrowserInjected {
|
||||
let currentActions = actions
|
||||
const controllers = new Map<TabId, {
|
||||
readonly signal: AbortSignal
|
||||
@@ -195,7 +215,8 @@ export function createBrowserControllers(actions: BoundActions<BrowserStore>, cr
|
||||
const forget = (): void => {
|
||||
if (controllers.get(tabId)?.controller !== created) return
|
||||
controllers.delete(tabId)
|
||||
currentActions.forget(tabId)
|
||||
// Plugin unload also aborts occurrences; only layout removal deletes saved navigation.
|
||||
if (!isTabOpen(tabId)) currentActions.forget(tabId)
|
||||
}
|
||||
held = { signal, controller: created, forget }
|
||||
controllers.set(tabId, held)
|
||||
@@ -218,6 +239,7 @@ export function createBrowserControllers(actions: BoundActions<BrowserStore>, cr
|
||||
for (const { controller } of controllers.values()) controller.rebind(actions)
|
||||
},
|
||||
loadUrl: (id, value) => { controller(id)?.loadUrl(value) },
|
||||
restore: id => { controller(id)?.restore() },
|
||||
goBack: id => { controller(id)?.goBack() },
|
||||
goForward: id => { controller(id)?.goForward() },
|
||||
reload: id => { controller(id)?.reload() },
|
||||
|
||||
@@ -16,5 +16,5 @@ export interface BrowserPage {
|
||||
readonly presentation: BrowserPresentation
|
||||
}
|
||||
|
||||
/** Construct a provider without starting navigation or attaching DOM. */
|
||||
/** Construct an idle provider without attaching DOM; saved navigation waits for an explicit frame command. */
|
||||
export type BrowserPageFactory = (options: BrowserPageOptions) => BrowserPage
|
||||
@@ -40,9 +40,8 @@ export class ElectronWebViewImpl implements BrowserFrame {
|
||||
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' })
|
||||
this.checkpoint = currentBrowserTarget(options.initial)
|
||||
this.store = createSnapshotStore(emptyBrowserFrame())
|
||||
}
|
||||
|
||||
/** @returns immutable carrier-neutral navigation state. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 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 { emptyBrowserFrame, type BrowserFrame, type BrowserFrameState, type BrowserLoadError, type BrowserSandboxControl } from './BrowserFrame.ts'
|
||||
import type { BrowserPageOptions } from './BrowserPage.ts'
|
||||
import { BrowserNavigation } from './BrowserNavigation.ts'
|
||||
import type { BrowserTarget } from './url.ts'
|
||||
@@ -19,7 +19,7 @@ export class IframeImpl implements BrowserFrame {
|
||||
/** @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())
|
||||
this.store = createSnapshotStore({ ...emptyBrowserFrame(), sandboxEnabled: this.sandboxed })
|
||||
}
|
||||
|
||||
/** @param revision - document generation whose iframe emitted load. */
|
||||
@@ -68,6 +68,10 @@ export class IframeImpl implements BrowserFrame {
|
||||
private setSandbox(enabled: boolean): void {
|
||||
if (this.disposed || enabled === this.sandboxed) return
|
||||
this.sandboxed = enabled
|
||||
if (this.store.getSnapshot().target === undefined) {
|
||||
this.store.set({ ...this.store.getSnapshot(), sandboxEnabled: enabled })
|
||||
return
|
||||
}
|
||||
const request = this.navigation.reload()
|
||||
if (request === undefined) this.publish()
|
||||
else this.load(request)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Resolve browser storage only after the authoritative Workspace list has arrived. */
|
||||
/** Resolve CWD-keyed browser storage 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.
|
||||
* Use the Workspace's canonical CWD, not its record id; ungrouped Sessions remain isolated.
|
||||
* @param source - authoritative Workspace membership.
|
||||
* @param sessionId - owning DSH Session.
|
||||
* @param signal - guest initialization lifetime.
|
||||
@@ -24,5 +24,5 @@ export async function browserWorkspace(source: WorkspaceSource, sessionId: strin
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const workspace = source.getSnapshot().items.find(item => item.sessionIds.some(id => id === sessionId))
|
||||
return workspace === undefined ? `session:${sessionId}` : `workspace:${workspace.workspaceId}`
|
||||
return workspace === undefined ? `session:${sessionId}` : `cwd:${workspace.path}`
|
||||
}
|
||||
@@ -35,13 +35,14 @@ declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
|
||||
}
|
||||
|
||||
/** Required Browser services. */
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs']
|
||||
export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']
|
||||
|
||||
/** Register the Browser type, localized guide entry, body, and title. */
|
||||
export function apply(ctx: Context): void {
|
||||
const namespace = 'sidebarBrowser'
|
||||
const t = ctx.locale.bind(namespace)
|
||||
const store = createBrowserStore()
|
||||
const openTabs = ctx.sidebarRight.openTabs
|
||||
const carrier = (globalThis as typeof globalThis & {
|
||||
dshDesktop?: { readonly protocolVersion: number; readonly browser?: DesktopBrowserBridge }
|
||||
}).dshDesktop
|
||||
@@ -63,7 +64,8 @@ export function apply(ctx: Context): void {
|
||||
existing.rebind(actions)
|
||||
return existing
|
||||
}
|
||||
const controller = createBrowserControllers(actions, factory(sessionId))
|
||||
const controller = createBrowserControllers(actions, factory(sessionId), tabId =>
|
||||
openTabs.getSnapshot().some(tab => tab.sessionId === sessionId && tab.tabId === tabId))
|
||||
controllers.set(sessionId, controller)
|
||||
return controller
|
||||
},
|
||||
|
||||
@@ -15,13 +15,15 @@ export const zh = {
|
||||
'sandbox.warning': '沙箱限制已关闭;页面可以导航顶层应用,并使用下载、模态对话框与输入锁定。',
|
||||
start: '输入 HTTP(S) 地址开始浏览',
|
||||
loading: '正在打开…',
|
||||
'restore.previous': '上次打开',
|
||||
'restore.action': '恢复页面',
|
||||
'error.empty': '请输入地址。',
|
||||
'error.invalid': '这个地址无效或过长。',
|
||||
'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。',
|
||||
'error.credentials': '地址不能包含用户名或密码。',
|
||||
'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
|
||||
'load.failed': '页面加载失败;请刷新重试或在系统浏览器中打开。',
|
||||
'load.failed.detail': '页面加载失败({code}):{description}',
|
||||
'load.failed.detail': '页面加载失败 ({code}): {description}',
|
||||
'address.unknown': '页面已跳转;当前载体无法读取新的 URL。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -45,6 +47,8 @@ export const en = {
|
||||
'sandbox.warning': 'Sandbox restrictions are disabled; the page can navigate the top-level app and use downloads, modal dialogs, and input locks.',
|
||||
start: 'Enter an HTTP(S) address to start browsing',
|
||||
loading: 'Opening…',
|
||||
'restore.previous': 'Previously opened',
|
||||
'restore.action': 'Restore page',
|
||||
'error.empty': 'Enter an address.',
|
||||
'error.invalid': 'That address is invalid or too long.',
|
||||
'error.protocol': 'Only HTTP and HTTPS addresses are supported; use Document Preview for local files.',
|
||||
|
||||
@@ -143,6 +143,45 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.restore {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restoreLabel {
|
||||
margin: auto 0 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.restoreTitle {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.restoreUrl {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.restore > :last-child {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.webview {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
Button,
|
||||
IconChevronLeftOutlineRegular,
|
||||
IconChevronRightOutlineRegular,
|
||||
IconLinkOutlineRegular,
|
||||
@@ -44,7 +45,7 @@ function useBrowserDraft(url: string | undefined, revision: number): readonly [s
|
||||
|
||||
/** Render provider-neutral navigation state and optional controls. */
|
||||
export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
const { mount, loadUrl, goBack, goForward, reload, setSandbox, useBrowserState, useStore, useTabInfo, t } = props
|
||||
const { mount, loadUrl, restore, goBack, goForward, reload, setSandbox, useBrowserState, useStore, useTabInfo, t } = props
|
||||
const { tab } = useTabInfo()
|
||||
const saved = useStore(state => state.byTab[tab.id])
|
||||
const initial = useRef(saved)
|
||||
@@ -53,7 +54,8 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
const [mountEpoch, setMountEpoch] = useState(0)
|
||||
const state = useBrowserState(tab.id)
|
||||
const frame = state?.frame ?? EMPTY_FRAME
|
||||
const target = frame.target ?? currentBrowserTarget(initial.current)
|
||||
const restoreTarget = state === undefined ? currentBrowserTarget(initial.current) : state.restoreTarget
|
||||
const target = frame.target ?? restoreTarget
|
||||
const [draft, setDraft] = useBrowserDraft(target?.url ?? initialUrl.current, state?.addressRevision ?? 0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -110,7 +112,15 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
{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}>
|
||||
{restoreTarget !== undefined && <section className={css.restore} aria-label={t('restore.previous')}>
|
||||
<p className={css.restoreLabel}>{t('restore.previous')}</p>
|
||||
<p className={css.restoreTitle}>{restoreTarget.title}</p>
|
||||
<p className={css.restoreUrl}>{restoreTarget.url}</p>
|
||||
<Button variant="primary" size="sm" disabled={mountEpoch === 0} onClick={() => { restore(tab.id) }}>
|
||||
{t('restore.action')}
|
||||
</Button>
|
||||
</section>}
|
||||
{restoreTarget === undefined && (target === undefined || frame.loading) && error === undefined && <div className={css.placeholder}>
|
||||
<div className={css.start}>{t(target === undefined ? 'start' : 'loading')}</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user