Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export function Example() {

The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs.

Floating dialog/sheet overlays, menu popovers, select/combobox popups and tooltips declare
`data-openbitfun-native-webview-occlusion` on their rendered floating surface.
Native hosts use its visible bounds to temporarily hide overlapping child views;
the components do not call platform APIs. Custom product popovers should declare
the same marker on the floating element, including while its exit animation runs.

## Voice calls

`VoiceCallPanel` owns the complete compact call surface: navigation, particle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ const CollectionPicker = forwardRef<HTMLDivElement, PickerProps>(function Collec
className={styles.popover}
data-openbitfun-component={multiple ? "multi-select-popup" : "combobox-popup"}
data-openbitfun-part="popover"
data-openbitfun-native-webview-occlusion
data-keyboard-open={keyboardOpen ? "true" : "false"}
data-invalid={invalid ? "true" : "false"}
data-placement={layout?.placement ?? placement}
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const OverlaySurface = forwardRef<HTMLDivElement, OverlaySurfaceProps>(function
className={styles.overlay}
data-openbitfun-component={kind}
data-openbitfun-part="overlay"
data-openbitfun-native-webview-occlusion
data-placement={placement}
data-state={exiting ? "exiting" : "open"}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos

return <>
<MenuSurface {...props} ref={node => { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1}
style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase}
style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-native-webview-occlusion data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase}
aria-hidden={!open || undefined} {...(!open ? { inert: "" } : {})} onContextMenu={event => event.preventDefault()}>
{items.map(item => item.separator ? <Separator key={item.id} /> : <Item key={item.id} data-menu-id={item.id} leading={item.icon ? <Leading>{item.icon}</Leading> : undefined} shortcut={item.shortcut ? <Shortcut>{item.shortcut}</Shortcut> : undefined} tone={item.tone} role={item.role} checked={item.checked}
disabled={item.disabled} aria-disabled={item.disabled || undefined} aria-haspopup={item.submenu?.length ? "menu" : undefined} aria-expanded={item.submenu?.length ? activeEntry?.id === item.id : undefined}
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
className={styles.popover}
data-openbitfun-component="select-popup"
data-openbitfun-part="popover"
data-openbitfun-native-webview-occlusion
data-invalid={resolvedInvalid ? "true" : "false"}
data-placement={layout?.placement ?? placement}
data-size={size}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ export function Tooltip({
ref={tooltipRef}
id={tooltipId}
role="tooltip"
data-openbitfun-native-webview-occlusion
className={classNames(styles.root, className)}
data-openbitfun-component="tooltip"
data-openbitfun-placement={layout.placement}
Expand Down
4 changes: 2 additions & 2 deletions docs/interactive-capabilities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a
- Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json`
- Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json`

说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **643** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **644** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。

Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **643** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **644** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.

## 控制边界 / Control boundary

Expand Down
22 changes: 19 additions & 3 deletions docs/interactive-capabilities/technical/tauri-command-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"schemaVersion": 2,
"generatedFrom": "src/shared/interactive-capabilities/catalog.json",
"catalogDigest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5",
"commandCount": 643,
"commandCount": 644,
"coverage": {
"commandCount": 643,
"documentedCommandCount": 607,
"commandCount": 644,
"documentedCommandCount": 608,
"implementationCommandCount": 36,
"implementationDigest": "c9bbc0f2ca3695d8c18cff285386edeba7bcf3b11120adb57509a80b09ee3297"
},
Expand Down Expand Up @@ -712,6 +712,22 @@
"signature": "fn browser_get_url( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<String, String>",
"remoteWorkspacePolicy": "LocalOnly"
},
{
"id": "browser_webview_capture_preview",
"moduleId": "browser",
"capabilityId": "feature.browser",
"capabilityIds": [
"feature.browser"
],
"documentedItemIds": [
"feature.browser:embedded-webview"
],
"visibility": "documented",
"rustPath": "api::browser_api::browser_webview_capture_preview",
"sourceFile": "src/apps/desktop/src/api/browser_api.rs",
"signature": "fn browser_webview_capture_preview( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<WebviewPreviewResponse, String>",
"remoteWorkspacePolicy": "LocalOnly"
},
{
"id": "browser_webview_create",
"moduleId": "browser",
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ For window geometry recovery, legacy state compatibility, and snapshot persisten
run `cargo test -p openbitfun-desktop --lib window_state_support::tests`.
For the matching startup wiring contract, run
`pnpm --dir src/web-ui run test:run src/app/startup/startupPerformanceContract.test.ts`.
For embedded browser preview encoding and target correlation, run
`cargo test -p openbitfun-desktop --lib api::browser_api::tests`.
After browser command registration changes, also run
`cargo test -p openbitfun-desktop --lib remote_workspace_policy`.
After changing updater command registration, also run
`cargo test -p openbitfun-desktop --lib remote_workspace_policy`.

Expand Down
77 changes: 76 additions & 1 deletion src/apps/desktop/src/api/browser_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! adapter so stream-specific initialization can run before page scripts.

use openbitfun_core::agentic::tools::browser_control::BuiltInBrowserTarget;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use tauri::Manager;
Expand Down Expand Up @@ -384,6 +384,64 @@ pub struct WebviewLabelRequest {
pub label: String,
}

#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum WebviewPreviewResponse {
Ready {
#[serde(rename = "dataUrl")]
data_url: String,
},
Unsupported {
reason: String,
},
}

// Preview frames are ephemeral UI assets, never files or full-page captures.
fn encode_browser_preview(png_base64: &str) -> Result<String, String> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(png_base64)
.map_err(|e| format!("decode browser preview failed: {e}"))?;
let image = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png)
.map_err(|e| format!("decode browser preview PNG failed: {e}"))?;
let image = if image.width() > 1600 || image.height() > 1600 {
image.thumbnail(1600, 1600)
} else {
image
};
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 80)
.encode_image(&image.to_rgb8())
.map_err(|e| format!("encode browser preview JPEG failed: {e}"))?;
Ok(format!(
"data:image/jpeg;base64,{}",
base64::engine::general_purpose::STANDARD.encode(jpeg)
))
}

/// Capture only the controller's embedded page for DOM occlusion placeholders.
#[tauri::command]
pub async fn browser_webview_capture_preview(
app: tauri::AppHandle,
request: WebviewLabelRequest,
) -> Result<WebviewPreviewResponse, String> {
validate_browser_label(&request.label)?;
let webview = find_browser_webview(&app, &request.label)?;
let png = match openbitfun_webdriver::platform::take_screenshot(webview, 1000).await {
Ok(png) => png,
Err(error) if error.error == "unsupported operation" => {
return Ok(WebviewPreviewResponse::Unsupported {
reason: error.message,
});
}
Err(error) => return Err(error.message),
};
let data_url = tokio::task::spawn_blocking(move || encode_browser_preview(&png))
.await
.map_err(|e| format!("browser preview encoding task failed: {e}"))??;
Ok(WebviewPreviewResponse::Ready { data_url })
}

#[tauri::command]
pub async fn browser_webview_reload(
app: tauri::AppHandle,
Expand Down Expand Up @@ -437,6 +495,23 @@ pub async fn browser_get_url(
mod tests {
use super::*;

#[test]
fn preview_is_a_bounded_jpeg_and_rejects_invalid_data() {
use base64::Engine as _;
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2000, 1000)
.write_to(&mut png, image::ImageFormat::Png)
.unwrap();
let encoded = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
let preview = encode_browser_preview(&encoded).unwrap();
let bytes = base64::engine::general_purpose::STANDARD
.decode(preview.strip_prefix("data:image/jpeg;base64,").unwrap())
.unwrap();
let image = image::load_from_memory(&bytes).unwrap();
assert_eq!((image.width(), image.height()), (1600, 800));
assert!(encode_browser_preview("invalid").is_err());
}

#[test]
fn open_request_correlation_requires_the_exact_active_target() {
let mut registry = BrowserTargetRegistry::default();
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,7 @@ pub async fn run() {
api::browser_api::browser_webview_navigate,
api::browser_api::browser_webview_reload,
api::browser_api::browser_webview_set_bounds,
api::browser_api::browser_webview_capture_preview,
api::browser_api::browser_webview_set_agent_target_state,
api::browser_api::browser_get_url,
api::html_preview_api::html_preview_create,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"digest": "fnv1a64:57f26e05119c5457",
"digest": "fnv1a64:6c2c8c606588ee3f",
"retiredCommandPrefixes": [
{
"prefix": "lsp_",
Expand Down Expand Up @@ -562,6 +562,18 @@
"reason": "the CLI peer host has no handler for this command"
}
},
{
"id": "browser_webview_capture_preview",
"surface": "tauri_command",
"remoteWorkspace": "LocalOnly",
"peer": {
"kind": "controller_local"
},
"cliPeer": {
"kind": "unsupported",
"reason": "the controller keeps this command; peer hosts refuse it before dispatch"
}
},
{
"id": "browser_webview_create",
"surface": "tauri_command",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[
op("browser_control_list_browsers", LocalOnly, Proxied, CLI_NO_BROWSER_OR_OS_AUTOMATION),
op("browser_control_restart_with_cdp", LocalOnly, Proxied, CLI_NO_BROWSER_OR_OS_AUTOMATION),
op("browser_get_url", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED),
op("browser_webview_capture_preview", LocalOnly, ControllerLocal, REFUSED),
op("browser_webview_create", LocalOnly, ControllerLocal, REFUSED),
op("browser_webview_eval", LocalOnly, ControllerLocal, REFUSED),
op("browser_webview_navigate", LocalOnly, ControllerLocal, REFUSED),
Expand Down
2 changes: 2 additions & 0 deletions src/shared/interactive-capabilities/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -2474,6 +2474,8 @@
},
"evidence": [
"command:browser_webview_set_bounds",
"command:browser_webview_capture_preview",
"source:src/web-ui/src/app/scenes/browser/browserPreviewCache.ts#export class BrowserPreviewCache",
"source:src/web-ui/src/app/scenes/browser/BrowserPanel.tsx#sandbox=",
"source:src/web-ui/src/app/scenes/browser/browserInspectorScript.ts#BLANK_TARGET_INTERCEPT_SCRIPT"
]
Expand Down
12 changes: 8 additions & 4 deletions src/web-ui/src/app/scenes/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useContextStore } from '@/shared/context-system';
import type { WebElementContext } from '@/shared/types/context';
import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT } from './browserInspectorScript';
import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview';
import { BrowserPreview } from './BrowserPreview';
import './BrowserPanel.scss';

const log = createLogger('BrowserPanel');
Expand Down Expand Up @@ -237,10 +238,13 @@ const BrowserPanel: React.FC<BrowserPanelProps> = ({ isActive, initialUrl, openR
className="browser-panel__webview-host"
data-webview-label={webviewLabel}
>
<div data-openbitfun-component="browser-panel" data-openbitfun-part="placeholder" className="browser-panel__webview-placeholder">
<Icon name="browser" size="lg" />
<OverflowText data-testid="browser-current-url">{currentUrl}</OverflowText>
</div>
{!browser.previewUrl && (
<div data-openbitfun-component="browser-panel" data-openbitfun-part="placeholder" className="browser-panel__webview-placeholder">
<Icon name="browser" size="lg" />
<OverflowText data-testid="browser-current-url">{currentUrl}</OverflowText>
</div>
)}
<BrowserPreview src={browser.previewUrl} bounds={browser.previewBounds} />
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance';

export const browserPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = {
id: 'browser-preview',
parts: [{ id: 'image', propertyProfile: 'layout', visualRole: 'content' }],
};
12 changes: 12 additions & 0 deletions src/web-ui/src/app/scenes/browser/BrowserPreview.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.browser-preview {
position: absolute;
display: block;
max-width: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
object-fit: fill;
pointer-events: none;
user-select: none;
}
18 changes: 18 additions & 0 deletions src/web-ui/src/app/scenes/browser/BrowserPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import './BrowserPreview.scss';
import type { BrowserViewportBounds } from './browserViewportGeometry';

/** Decorative frozen frame, below all DOM floating surfaces and native content. */
export function BrowserPreview({ src, bounds }: { src: string | null; bounds: BrowserViewportBounds | null }) {
return src && bounds ? (
<img
className="browser-preview"
data-openbitfun-component="browser-preview"
data-openbitfun-part="image"
src={src}
style={bounds}
alt=""
aria-hidden="true"
draggable={false}
/>
) : null;
}
12 changes: 8 additions & 4 deletions src/web-ui/src/app/scenes/browser/BrowserScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { createLogger } from '@/shared/utils/logger';
import { useSceneStore } from '@/app/stores/sceneStore';
import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview';
import { BrowserPreview } from './BrowserPreview';
import './BrowserScene.scss';

const log = createLogger('BrowserScene');
Expand Down Expand Up @@ -103,10 +104,13 @@ const BrowserScene: React.FC = () => {
className="browser-scene__webview-host"
data-webview-label={browser.webviewLabel}
>
<div className="browser-scene__webview-placeholder">
<Icon name="browser" size="lg" />
<OverflowText data-testid="browser-current-url">{browser.currentUrl}</OverflowText>
</div>
{!browser.previewUrl && (
<div className="browser-scene__webview-placeholder">
<Icon name="browser" size="lg" />
<OverflowText data-testid="browser-current-url">{browser.currentUrl}</OverflowText>
</div>
)}
<BrowserPreview src={browser.previewUrl} bounds={browser.previewBounds} />
</div>
)}
</div>
Expand Down
Loading
Loading