diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index b0189e156c..dc8a520316 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -1,13 +1,68 @@ //! Browser API — commands for the embedded browser feature. //! -//! Browser webviews are created on the Rust side so that we can attach an -//! `on_page_load` handler that safely catches panics from the upstream wry -//! `url_from_webview` bug (WKWebView.URL() returning nil). -//! See: +//! Browser webviews are created as native child webviews by this desktop +//! adapter so stream-specific initialization can run before page scripts. use serde::Deserialize; use tauri::Manager; +const VIDEO_DECODER_MODE_ENV: &str = "BITFUN_BROWSER_VIDEO_DECODER_MODE"; + +fn video_decoder_compatibility_script() -> String { + let mode = + std::env::var(VIDEO_DECODER_MODE_ENV).unwrap_or_else(|_| "prefer-software".to_string()); + let mode = match mode.as_str() { + "prefer-hardware" | "prefer-software" => mode, + _ => String::new(), + }; + let mode_json = serde_json::to_string(&mode).unwrap_or_else(|_| "\"\"".to_string()); + let script = format!( + r#" +const isWebView2 = Boolean(window.chrome && window.chrome.webview); +const isBitFunDocument = location.protocol === 'tauri:' + || location.hostname === 'tauri.localhost' + || (location.hostname === 'localhost' && location.port === '1422'); +if (isWebView2 && !isBitFunDocument) {{ + const decoderMode = {mode_json}; + if (decoderMode && typeof VideoDecoder === 'function') {{ + const originalConfigure = VideoDecoder.prototype.configure; + VideoDecoder.prototype.configure = function(config) {{ + const codec = typeof config?.codec === 'string' ? config.codec : ''; + const isH264 = /^avc[13]\./i.test(codec); + if (isH264 && !config.hardwareAcceleration) {{ + return originalConfigure.call(this, {{ ...config, hardwareAcceleration: decoderMode }}); + }} + return originalConfigure.call(this, config); + }}; + + // #region agent log + if (location.hostname === '127.0.0.1' && location.port === '41953') {{ + void fetch('http://127.0.0.1:7469/log', {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ + hypothesis: 'D', + location: 'browser_api.video_decoder_init', + message: 'video decoder mode installed', + data: {{ decoderMode }}, + timestamp: new Date().toISOString() + }}) + }}).catch(() => {{}}); + }} + // #endregion + }} +}} +"# + ); + + script +} + +fn find_browser_webview(app: &tauri::AppHandle, label: &str) -> Result { + app.get_webview(label) + .ok_or_else(|| format!("Webview not found: {label}")) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebviewEvalRequest { @@ -15,26 +70,164 @@ pub struct WebviewEvalRequest { pub script: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebviewNavigateRequest { + pub label: String, + pub url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebviewBoundsRequest { + pub label: String, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebviewCreateRequest { + pub label: String, + pub url: String, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +fn validate_browser_label(label: &str) -> Result<(), String> { + if label.starts_with("embedded-browser-view-") + || label.starts_with("embedded-browser-panel-view-") + { + Ok(()) + } else { + Err("invalid browser webview label".to_string()) + } +} + +fn validate_webview_bounds(x: f64, y: f64, width: f64, height: f64) -> Result<(), String> { + if !x.is_finite() + || !y.is_finite() + || !width.is_finite() + || !height.is_finite() + || width <= 1.0 + || height <= 1.0 + { + Err("invalid webview bounds".to_string()) + } else { + Ok(()) + } +} + +#[tauri::command] +pub async fn browser_webview_create( + app: tauri::AppHandle, + request: WebviewCreateRequest, +) -> Result<(), String> { + validate_browser_label(&request.label)?; + validate_webview_bounds(request.x, request.y, request.width, request.height)?; + + let url = request + .url + .parse::() + .map_err(|e| format!("invalid url: {e}"))?; + match url.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported protocol: {scheme}")), + } + + let window = app + .get_window("main") + .ok_or_else(|| "main window not found".to_string())?; + let mut builder = + tauri::webview::WebviewBuilder::new(request.label, tauri::WebviewUrl::External(url)) + .initialization_script(video_decoder_compatibility_script()) + .transparent(false) + .background_color(tauri::window::Color(0, 0, 0, 255)); + + #[cfg(any(debug_assertions, feature = "devtools"))] + { + builder = builder.devtools(true); + } + + window + .add_child( + builder, + tauri::LogicalPosition::new(request.x, request.y), + tauri::LogicalSize::new(request.width, request.height), + ) + .map(|_| ()) + .map_err(|e| format!("failed to create browser webview: {e}")) +} + #[tauri::command] pub async fn browser_webview_eval( app: tauri::AppHandle, request: WebviewEvalRequest, ) -> Result<(), String> { - let webview = app - .get_webview(&request.label) - .ok_or_else(|| format!("Webview not found: {}", request.label))?; - - webview + find_browser_webview(&app, &request.label)? .eval(&request.script) .map_err(|e| format!("eval failed: {e}")) } +#[tauri::command] +pub async fn browser_webview_navigate( + app: tauri::AppHandle, + request: WebviewNavigateRequest, +) -> Result<(), String> { + let url = request + .url + .parse::() + .map_err(|e| format!("invalid url: {e}"))?; + + match url.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported protocol: {scheme}")), + } + + find_browser_webview(&app, &request.label)? + .navigate(url) + .map_err(|e| format!("navigate failed: {e}")) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebviewLabelRequest { pub label: String, } +#[tauri::command] +pub async fn browser_webview_reload( + app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result<(), String> { + find_browser_webview(&app, &request.label)? + .reload() + .map_err(|e| format!("reload failed: {e}")) +} + +#[tauri::command] +pub async fn browser_webview_set_bounds( + app: tauri::AppHandle, + request: WebviewBoundsRequest, +) -> Result<(), String> { + validate_webview_bounds(request.x, request.y, request.width, request.height)?; + + let webview = app + .get_webview(&request.label) + .ok_or_else(|| format!("Webview not found: {}", request.label))?; + + webview + .set_bounds(tauri::Rect { + position: tauri::Position::Logical(tauri::LogicalPosition::new(request.x, request.y)), + size: tauri::Size::Logical(tauri::LogicalSize::new(request.width, request.height)), + }) + .map_err(|e| format!("set bounds failed: {e}")) +} + /// Return the current URL of a browser webview. /// /// Uses `catch_unwind` to guard against a known wry bug where @@ -45,10 +238,7 @@ pub async fn browser_get_url( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result { - let webview = app - .get_webview(&request.label) - .ok_or_else(|| format!("Webview not found: {}", request.label))?; - + let webview = find_browser_webview(&app, &request.label)?; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webview.url())); match result { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 4936592827..e6836cd61e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -85,6 +85,7 @@ static MAIN_WINDOW_HIDDEN_ON_MACOS: AtomicBool = AtomicBool::new(false); static MAIN_WINDOW_CLOSE_PENDING_ON_MACOS: AtomicBool = AtomicBool::new(false); const MAIN_WINDOW_CLOSE_REQUESTED_EVENT: &str = "bitfun_main_window_close_requested"; +const BROWSER_WEBVIEW_PAGE_LOAD_EVENT: &str = "browser-webview-page-load"; const CRON_DESKTOP_START_FALLBACK_DELAY: Duration = Duration::from_secs(120); #[cfg(target_os = "macos")] @@ -429,6 +430,26 @@ pub async fn run() { .manage(scheduler) .manage(terminal_state) .manage(startup_trace.clone()) + .on_page_load(|webview, payload| { + let label = webview.label(); + if label.starts_with("embedded-browser-view-") + || label.starts_with("embedded-browser-panel-view-") + { + let event = match payload.event() { + tauri::webview::PageLoadEvent::Started => "started", + tauri::webview::PageLoadEvent::Finished => "finished", + }; + let _ = webview.emit_to( + "main", + BROWSER_WEBVIEW_PAGE_LOAD_EVENT, + serde_json::json!({ + "label": label, + "event": event, + "url": payload.url(), + }), + ); + } + }) .setup(move |app| { let setup_started = Instant::now(); startup_trace.record_phase("tauri_setup_start", "native_setup"); @@ -1302,6 +1323,10 @@ pub async fn run() { api::miniapp_export_api::miniapp_render_slide_page, // Browser API (embedded webview) api::browser_api::browser_webview_eval, + api::browser_api::browser_webview_create, + api::browser_api::browser_webview_navigate, + api::browser_api::browser_webview_reload, + api::browser_api::browser_webview_set_bounds, api::browser_api::browser_get_url, // Browser Control API (CDP-based user browser control) api::browser_control_api::browser_control_list_browsers, diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx index e46d1e8a8e..094a17b1bd 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx @@ -2,11 +2,11 @@ * BrowserPanel — embeds a browser into the AuxPane right panel. * * Uses a Tauri native Webview overlay positioned over the panel's DOM element. - * When the panel is not active (tab switch / scene switch / AuxPane collapse), - * the webview is reparented to a hidden holder window to preserve page state. + * The webview is kept attached to the main window and reused across navigations + * so video/WebRTC surfaces are not repeatedly torn down or reparented. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { AlertTriangle, ChevronLeft, ChevronRight, Globe, RefreshCw, MousePointer2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { IconButton } from '@/component-library'; @@ -14,86 +14,12 @@ import { createLogger } from '@/shared/utils/logger'; import { useSceneStore } from '@/app/stores/sceneStore'; import { useContextStore } from '@/shared/context-system'; import type { WebElementContext } from '@/shared/types/context'; -import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT, BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; -import { validateUrl, checkConnectivity } from './browserUrlCheck'; +import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT } from './browserInspectorScript'; +import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview'; import './BrowserPanel.scss'; const log = createLogger('BrowserPanel'); const DEFAULT_URL = 'https://openbitfun.com/'; -const PANEL_HOLDER_WINDOW_LABEL = 'embedded-browser-panel-holder'; - -function isTauriEnvironment(): boolean { - return typeof window !== 'undefined' && '__TAURI__' in window; -} - -type BrowserWebviewHandle = { - close: () => Promise; - hide: () => Promise; - label: string; - once: (event: string, handler: (event?: unknown) => void) => Promise<() => void>; - reparent: (window: string | unknown) => Promise; - setFocus: () => Promise; - setPosition: (position: unknown) => Promise; - setSize: (size: unknown) => Promise; - show: () => Promise; -}; - -async function evalWebview(label: string, script: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_eval', { request: { label, script } }); -} - -type BrowserHolderWindowHandle = { - close: () => Promise; - hide: () => Promise; - once: (event: string, handler: (event?: unknown) => void) => Promise<() => void>; -}; - -function formatUnknownError(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - if (error && typeof error === 'object') { - const record = error as Record; - const payload = 'payload' in record ? record.payload : undefined; - const message = - (typeof record.message === 'string' && record.message) || - (payload && typeof payload === 'object' && typeof (payload as Record).message === 'string' - ? String((payload as Record).message) - : null); - if (message) return message; - try { return JSON.stringify(error); } catch { return String(error); } - } - return String(error); -} - -function isWebviewNotFoundError(error: unknown): boolean { - return formatUnknownError(error).toLowerCase().includes('webview not found'); -} - -async function waitForWebviewCreated(handle: BrowserWebviewHandle): Promise { - await new Promise((resolve, reject) => { - let settled = false; - const finish = (cb: () => void) => { if (!settled) { settled = true; cb(); } }; - void handle.once('tauri://created', () => finish(resolve)); - void handle.once('tauri://error', (event) => finish(() => reject(new Error(formatUnknownError(event))))); - }); -} - -async function waitForWindowCreated(handle: BrowserHolderWindowHandle): Promise { - await new Promise((resolve, reject) => { - let settled = false; - const finish = (cb: () => void) => { if (!settled) { settled = true; cb(); } }; - void handle.once('tauri://created', () => finish(resolve)); - void handle.once('tauri://error', (event) => finish(() => reject(new Error(formatUnknownError(event))))); - }); -} - -function normalizeUrl(raw: string): string { - const value = raw.trim(); - if (!value) return DEFAULT_URL; - if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)) return value; - return `https://${value}`; -} interface InspectorElementData { tagName: string; @@ -113,340 +39,70 @@ export interface BrowserPanelProps { const BrowserPanel: React.FC = ({ isActive, initialUrl }) => { const { t } = useTranslation('common'); const activeTabId = useSceneStore((s) => s.activeTabId); - // Show webview only when this tab is active AND the session scene is visible - const isSceneActive = activeTabId === 'session'; - const shouldShowWebview = isActive && isSceneActive; - - const isTauri = useMemo(() => isTauriEnvironment(), []); - - const startUrl = initialUrl ?? DEFAULT_URL; - const viewportRef = useRef(null); - const webviewRef = useRef(null); - const holderWindowRef = useRef(null); - const webviewSequenceRef = useRef(0); - const currentUrlRef = useRef(startUrl); - const resizeFrameRef = useRef(null); - const webviewLabelRef = useRef(''); + const shouldShowWebview = isActive && activeTabId === 'session'; + const addContext = useContextStore((s) => s.addContext); const inspectorUnlistenRef = useRef<(() => void) | null>(null); - const urlPollTimerRef = useRef | null>(null); - - const [inputValue, setInputValue] = useState(startUrl); - const [currentUrl, setCurrentUrl] = useState(startUrl); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const [isInspectorActive, setIsInspectorActive] = useState(false); - const addContext = useContextStore((s) => s.addContext); - - /** - * Sync webview bounds to the panel container. - * Hides the webview if the container has no visible area (AuxPane collapsed, etc.). - */ - const syncWebviewBounds = useCallback(async (handle?: BrowserWebviewHandle | null) => { - const target = handle ?? webviewRef.current; - if (!isTauri || !target || !viewportRef.current) return; - - const rect = viewportRef.current.getBoundingClientRect(); - if (rect.width <= 1 || rect.height <= 1) { - await target.hide().catch(() => {}); - return; - } - - const { LogicalPosition, LogicalSize } = await import('@tauri-apps/api/dpi'); - await Promise.all([ - target.setPosition(new LogicalPosition(rect.left, rect.top)), - target.setSize(new LogicalSize(rect.width, rect.height)), - ]); - if (shouldShowWebview) { - await target.show().catch(() => {}); - } - }, [isTauri, shouldShowWebview]); - - const closeWebview = useCallback(async (handle?: BrowserWebviewHandle | null) => { - const target = handle ?? webviewRef.current; - if (!target) return; - try { - await target.close(); - } catch (e) { - if (!isWebviewNotFoundError(e)) log.warn('Close browser panel webview failed', e); - } finally { - if (!handle || target === webviewRef.current) webviewRef.current = null; - } - }, []); - - const ensureHolderWindow = useCallback(async (): Promise => { - if (holderWindowRef.current) return holderWindowRef.current; - - const { Window } = await import('@tauri-apps/api/window'); - const existing = (await Window.getByLabel(PANEL_HOLDER_WINDOW_LABEL)) as BrowserHolderWindowHandle | null; - if (existing) { - holderWindowRef.current = existing; - return existing; - } - - const holder = new Window(PANEL_HOLDER_WINDOW_LABEL, { - visible: false, - decorations: false, - skipTaskbar: true, - shadow: false, - width: 1, - height: 1, - x: -10000, - y: -10000, - title: 'Browser Panel Holder', - }) as BrowserHolderWindowHandle; - - await waitForWindowCreated(holder); - await holder.hide().catch(() => {}); - holderWindowRef.current = holder; - return holder; - }, []); - - const recreateWebview = useCallback(async (url: string) => { - const previous = webviewRef.current; - if (previous) await closeWebview(previous); - - const [{ Webview }, { getCurrentWindow }] = await Promise.all([ - import('@tauri-apps/api/webview'), - import('@tauri-apps/api/window'), - ]); - const label = `embedded-browser-panel-view-${webviewSequenceRef.current++}`; - webviewLabelRef.current = label; - const handle = new Webview(getCurrentWindow(), label, { - url, - x: 0, - y: 0, - width: 960, - height: 640, - }) as unknown as BrowserWebviewHandle; - - await waitForWebviewCreated(handle); - webviewRef.current = handle; - return handle; - }, [closeWebview]); + const browser = useEmbeddedBrowserWebview({ + defaultUrl: DEFAULT_URL, + initialUrl, + isVisible: shouldShowWebview, + labelPrefix: 'embedded-browser-panel-view', + log, + }); - const loadUrl = useCallback(async (rawUrl: string) => { - const nextUrl = normalizeUrl(rawUrl); - setInputValue(nextUrl); - setCurrentUrl(nextUrl); - currentUrlRef.current = nextUrl; - setError(null); - setIsLoading(true); - if (inspectorUnlistenRef.current && webviewLabelRef.current) { - void evalWebview(webviewLabelRef.current, CANCEL_INSPECTOR_SCRIPT).catch(() => {}); - inspectorUnlistenRef.current(); - inspectorUnlistenRef.current = null; + const { + currentUrl, + error, + evalInWebview, + getCurrentUrl, + getWebviewLabel, + goBack, + goForward, + hasWebview, + inputValue, + isLoading, + isTauri, + loadUrl, + reload, + setInputValue, + viewportRef, + webviewLabel, + } = browser; + + const stopInspector = useCallback(() => { + if (getWebviewLabel()) { + void evalInWebview(CANCEL_INSPECTOR_SCRIPT).catch(() => {}); } + inspectorUnlistenRef.current?.(); + inspectorUnlistenRef.current = null; setIsInspectorActive(false); + }, [evalInWebview, getWebviewLabel]); - if (!isTauri) { - setIsLoading(false); - return; - } - - try { - validateUrl(nextUrl); - await checkConnectivity(nextUrl); - - if (urlPollTimerRef.current) { - clearInterval(urlPollTimerRef.current); - urlPollTimerRef.current = null; - } - - const handle = await recreateWebview(nextUrl); - await syncWebviewBounds(handle); - if (shouldShowWebview) { - await handle.show(); - await handle.setFocus(); - } - - const label = webviewLabelRef.current; - await evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT); - - const { invoke } = await import('@tauri-apps/api/core'); - urlPollTimerRef.current = setInterval(() => { - invoke('browser_get_url', { request: { label } }) - .then((url) => { - if (url && url !== currentUrlRef.current) { - currentUrlRef.current = url; - setInputValue(url); - setCurrentUrl(url); - setError(null); - evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {}); - } - }) - .catch(() => {}); - }, 500); - } catch (loadError) { - const message = formatUnknownError(loadError); - log.error('Load browser panel url failed', loadError); - setError(message); - } finally { - setIsLoading(false); - } - }, [isTauri, recreateWebview, shouldShowWebview, syncWebviewBounds]); - - const queueSync = useCallback(() => { - if (resizeFrameRef.current !== null) window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = window.requestAnimationFrame(() => { - resizeFrameRef.current = null; - void syncWebviewBounds().catch((e) => log.warn('Sync browser panel webview bounds failed', e)); - }); - }, [syncWebviewBounds]); - - // Activate / deactivate webview based on shouldShowWebview - useEffect(() => { - if (!isTauri) return; - - if (shouldShowWebview) { - if (!webviewRef.current) { - void loadUrl(currentUrlRef.current).catch((e) => log.warn('Restore browser panel webview failed', e)); - return; - } - - void (async () => { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await webviewRef.current?.reparent(getCurrentWindow()); - await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); - await syncWebviewBounds(); - })() - .then(() => webviewRef.current?.show()) - .then(() => webviewRef.current?.setFocus()) - .catch((e) => log.warn('Activate browser panel webview failed', e)); - return; - } - - if (webviewRef.current) { - void ensureHolderWindow() - .then((holder) => webviewRef.current?.reparent(holder)) - .then(() => holderWindowRef.current?.hide()) - .catch((e) => { - log.warn('Reparent browser panel webview to holder failed', e); - return closeWebview(); - }) - .catch((e) => log.warn('Close browser panel webview on deactivate failed', e)); - } - }, [closeWebview, ensureHolderWindow, loadUrl, shouldShowWebview, syncWebviewBounds, isTauri]); - - // ResizeObserver + window resize → sync bounds - useEffect(() => { - if (!isTauri) return; - - const observer = new ResizeObserver(() => { - if (shouldShowWebview) queueSync(); - }); - - if (viewportRef.current) observer.observe(viewportRef.current); - - const handleResize = () => { if (shouldShowWebview) queueSync(); }; - window.addEventListener('resize', handleResize); - - return () => { - observer.disconnect(); - window.removeEventListener('resize', handleResize); - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = null; - } - }; - }, [isTauri, queueSync, shouldShowWebview]); - - // Cleanup on unmount - useEffect(() => () => { - if (urlPollTimerRef.current) { - clearInterval(urlPollTimerRef.current); - urlPollTimerRef.current = null; - } - if (inspectorUnlistenRef.current) { - inspectorUnlistenRef.current(); - inspectorUnlistenRef.current = null; - } - if (webviewLabelRef.current) { - void evalWebview(webviewLabelRef.current, CANCEL_INSPECTOR_SCRIPT).catch(() => {}); - } - void closeWebview(); - }, [closeWebview]); - - // Hide webview when any overlay (modal, mission-control, toolbar-mode) is present. - // Uses MutationObserver on document.body to detect overlay DOM nodes, so no - // coupling with individual overlay components is needed. - useEffect(() => { - if (!isTauri) return; - - const OVERLAY_SELECTOR = '.modal-overlay, .canvas-mission-control'; - let hiddenByOverlay = false; - - const checkOverlays = () => { - const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null; - if (hasOverlay && !hiddenByOverlay) { - hiddenByOverlay = true; - void webviewRef.current?.hide().catch(() => {}); - } else if (!hasOverlay && hiddenByOverlay) { - hiddenByOverlay = false; - if (shouldShowWebview) { - void syncWebviewBounds() - .then(() => webviewRef.current?.show()) - .catch(() => {}); - } - } - }; - - const observer = new MutationObserver(checkOverlays); - observer.observe(document.body, { childList: true, subtree: true }); - - const handleToolbarActivating = () => { - void webviewRef.current?.hide().catch(() => {}); - }; - window.addEventListener('toolbar-mode-activating', handleToolbarActivating); - - return () => { - observer.disconnect(); - window.removeEventListener('toolbar-mode-activating', handleToolbarActivating); - }; - }, [isTauri, shouldShowWebview, syncWebviewBounds]); + const loadPanelUrl = useCallback(async (rawUrl: string) => { + stopInspector(); + await loadUrl(rawUrl); + }, [loadUrl, stopInspector]); useEffect(() => () => { - if (holderWindowRef.current) { - void holderWindowRef.current.close().catch((e) => log.warn('Close browser panel holder window failed', e)); - } - }, []); + stopInspector(); + }, [stopInspector]); const handleSubmit = useCallback((event: React.FormEvent) => { event.preventDefault(); - void loadUrl(inputValue); - }, [inputValue, loadUrl]); - - const handleGoBack = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'history.back()').catch(() => {}); - }, [isTauri]); - - const handleGoForward = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'history.forward()').catch(() => {}); - }, [isTauri]); - - const handleRefresh = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'location.reload()').catch(() => {}); - }, [isTauri]); + void loadPanelUrl(inputValue); + }, [inputValue, loadPanelUrl]); const handleInspector = useCallback(async () => { - if (!isTauri || !webviewRef.current) return; + if (!isTauri || !hasWebview()) return; if (isInspectorActive) { - try { - await evalWebview(webviewLabelRef.current, CANCEL_INSPECTOR_SCRIPT); - } catch (e) { - log.warn('Cancel inspector eval failed', e); - } - setIsInspectorActive(false); - inspectorUnlistenRef.current?.(); - inspectorUnlistenRef.current = null; + stopInspector(); return; } - const label = webviewLabelRef.current; + const label = getWebviewLabel(); if (!label) return; try { @@ -468,7 +124,7 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => attributes: data.attributes, textContent: data.textContent, outerHTML: data.outerHTML, - sourceUrl: currentUrlRef.current, + sourceUrl: getCurrentUrl(), }; addContext(context); @@ -493,14 +149,13 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => unlistenCancelled(); }; - await evalWebview(label, createInspectorScript(label)); + await evalInWebview(createInspectorScript(label)); setIsInspectorActive(true); - - } catch (e) { - log.error('Start inspector failed', e); + } catch (inspectorError) { + log.error('Start inspector failed', inspectorError); setIsInspectorActive(false); } - }, [addContext, isInspectorActive, isTauri]); + }, [addContext, evalInWebview, getCurrentUrl, getWebviewLabel, hasWebview, isInspectorActive, isTauri, stopInspector]); return (
@@ -509,7 +164,7 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => type="button" variant="ghost" size="small" - onClick={handleGoBack} + onClick={goBack} aria-label={t('nav.back')} data-testid="browser-back-button" > @@ -519,7 +174,7 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => type="button" variant="ghost" size="small" - onClick={handleGoForward} + onClick={goForward} aria-label={t('nav.forward')} data-testid="browser-forward-button" > @@ -529,7 +184,7 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => type="button" variant="ghost" size="small" - onClick={handleRefresh} + onClick={reload} disabled={isLoading} aria-label={t('actions.refresh')} data-testid="browser-refresh-button" @@ -581,7 +236,11 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" /> ) : ( -
+
{currentUrl} diff --git a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx index 8b5cca255f..f9572deba4 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx @@ -1,463 +1,30 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback } from 'react'; import { AlertTriangle, ChevronLeft, ChevronRight, Globe, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { IconButton } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { useSceneStore } from '@/app/stores/sceneStore'; -import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; -import { validateUrl, checkConnectivity } from './browserUrlCheck'; +import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview'; import './BrowserScene.scss'; const log = createLogger('BrowserScene'); const DEFAULT_URL = 'https://openbitfun.com/'; -const BROWSER_HOLDER_WINDOW_LABEL = 'embedded-browser-holder-window'; - -function isTauriEnvironment(): boolean { - return typeof window !== 'undefined' && '__TAURI__' in window; -} - -type BrowserWebviewHandle = { - close: () => Promise; - hide: () => Promise; - label: string; - once: (event: string, handler: (event?: unknown) => void) => Promise<() => void>; - reparent: (window: string | unknown) => Promise; - setFocus: () => Promise; - setPosition: (position: unknown) => Promise; - setSize: (size: unknown) => Promise; - show: () => Promise; -}; - -type BrowserHolderWindowHandle = { - close: () => Promise; - hide: () => Promise; - once: (event: string, handler: (event?: unknown) => void) => Promise<() => void>; -}; - -function formatUnknownError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - if (error && typeof error === 'object') { - const record = error as Record; - const payload = 'payload' in record ? record.payload : undefined; - const message = - (typeof record.message === 'string' && record.message) || - (payload && typeof payload === 'object' && typeof (payload as Record).message === 'string' - ? String((payload as Record).message) - : null); - - if (message) { - return message; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - - return String(error); -} - -function isWebviewNotFoundError(error: unknown): boolean { - const message = formatUnknownError(error); - return message.toLowerCase().includes('webview not found'); -} - -async function evalWebview(label: string, script: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_eval', { request: { label, script } }); -} - -async function waitForWebviewCreated(handle: BrowserWebviewHandle): Promise { - await new Promise((resolve, reject) => { - let settled = false; - - const finish = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - callback(); - }; - - void handle.once('tauri://created', () => { - finish(resolve); - }); - - void handle.once('tauri://error', (event) => { - finish(() => reject(new Error(formatUnknownError(event)))); - }); - }); -} - -async function waitForWindowCreated(handle: BrowserHolderWindowHandle): Promise { - await new Promise((resolve, reject) => { - let settled = false; - - const finish = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - callback(); - }; - - void handle.once('tauri://created', () => { - finish(resolve); - }); - - void handle.once('tauri://error', (event) => { - finish(() => reject(new Error(formatUnknownError(event)))); - }); - }); -} - -function normalizeUrl(raw: string): string { - const value = raw.trim(); - if (!value) { - return DEFAULT_URL; - } - - if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)) { - return value; - } - - return `https://${value}`; -} const BrowserScene: React.FC = () => { const { t } = useTranslation('common'); const activeTabId = useSceneStore((state) => state.activeTabId); const isActive = activeTabId === 'browser'; - const isTauri = useMemo(() => isTauriEnvironment(), []); - - const viewportRef = useRef(null); - const webviewRef = useRef(null); - const holderWindowRef = useRef(null); - const webviewSequenceRef = useRef(0); - const currentUrlRef = useRef(DEFAULT_URL); - const resizeFrameRef = useRef(null); - const webviewLabelRef = useRef(''); - const urlPollTimerRef = useRef | null>(null); - - const [inputValue, setInputValue] = useState(DEFAULT_URL); - const [currentUrl, setCurrentUrl] = useState(DEFAULT_URL); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const syncWebviewBounds = useCallback(async (handle?: BrowserWebviewHandle | null) => { - const target = handle ?? webviewRef.current; - if (!isTauri || !isActive || !viewportRef.current || !target) { - return; - } - - const rect = viewportRef.current.getBoundingClientRect(); - if (rect.width <= 1 || rect.height <= 1) { - return; - } - - const [{ LogicalPosition, LogicalSize }] = await Promise.all([ - import('@tauri-apps/api/dpi'), - ]); - - await Promise.all([ - target.setPosition(new LogicalPosition(rect.left, rect.top)), - target.setSize(new LogicalSize(rect.width, rect.height)), - ]); - }, [isActive, isTauri]); - - const closeWebview = useCallback(async (handle?: BrowserWebviewHandle | null) => { - const target = handle ?? webviewRef.current; - if (!target) { - return; - } - - try { - await target.close(); - } catch (closeError) { - if (!isWebviewNotFoundError(closeError)) { - log.warn('Close browser webview failed', closeError); - } - } finally { - if (!handle || target === webviewRef.current) { - webviewRef.current = null; - } - } - }, []); - - const ensureHolderWindow = useCallback(async (): Promise => { - if (holderWindowRef.current) { - return holderWindowRef.current; - } - - const { Window } = await import('@tauri-apps/api/window'); - const existing = (await Window.getByLabel(BROWSER_HOLDER_WINDOW_LABEL)) as BrowserHolderWindowHandle | null; - if (existing) { - holderWindowRef.current = existing; - return existing; - } - - const holder = new Window(BROWSER_HOLDER_WINDOW_LABEL, { - visible: false, - decorations: false, - skipTaskbar: true, - shadow: false, - width: 1, - height: 1, - x: -10000, - y: -10000, - title: 'Browser Holder', - }) as BrowserHolderWindowHandle; - - await waitForWindowCreated(holder); - await holder.hide().catch(() => {}); - holderWindowRef.current = holder; - return holder; - }, []); - - const recreateWebview = useCallback(async (url: string) => { - const previous = webviewRef.current; - if (previous) { - await closeWebview(previous); - } - - const [{ Webview }, { getCurrentWindow }] = await Promise.all([ - import('@tauri-apps/api/webview'), - import('@tauri-apps/api/window'), - ]); - const nextLabel = `embedded-browser-view-${webviewSequenceRef.current++}`; - webviewLabelRef.current = nextLabel; - const handle = new Webview(getCurrentWindow(), nextLabel, { - url, - x: 0, - y: 0, - width: 960, - height: 640, - }) as unknown as BrowserWebviewHandle; - - await waitForWebviewCreated(handle); - webviewRef.current = handle; - return handle; - }, [closeWebview]); - - const loadUrl = useCallback(async (rawUrl: string) => { - const nextUrl = normalizeUrl(rawUrl); - setInputValue(nextUrl); - setCurrentUrl(nextUrl); - currentUrlRef.current = nextUrl; - setError(null); - setIsLoading(true); - - if (!isTauri) { - setIsLoading(false); - return; - } - - try { - validateUrl(nextUrl); - await checkConnectivity(nextUrl); - - if (urlPollTimerRef.current) { - clearInterval(urlPollTimerRef.current); - urlPollTimerRef.current = null; - } - - const handle = await recreateWebview(nextUrl); - await syncWebviewBounds(handle); - if (isActive) { - await handle.show(); - await handle.setFocus(); - } - - const label = webviewLabelRef.current; - await evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT); - - const { invoke } = await import('@tauri-apps/api/core'); - urlPollTimerRef.current = setInterval(() => { - invoke('browser_get_url', { request: { label } }) - .then((url) => { - if (url && url !== currentUrlRef.current) { - currentUrlRef.current = url; - setInputValue(url); - setCurrentUrl(url); - setError(null); - evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {}); - } - }) - .catch(() => {}); - }, 500); - } catch (loadError) { - const message = formatUnknownError(loadError); - log.error('Load browser url failed', loadError); - setError(message); - } finally { - setIsLoading(false); - } - }, [isActive, isTauri, recreateWebview, syncWebviewBounds]); - - const queueSync = useCallback(() => { - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - } - resizeFrameRef.current = window.requestAnimationFrame(() => { - resizeFrameRef.current = null; - void syncWebviewBounds().catch((syncError) => { - log.warn('Sync browser webview bounds failed', syncError); - }); - }); - }, [syncWebviewBounds]); - - useEffect(() => { - if (!isTauri) { - return; - } - - if (isActive) { - if (!webviewRef.current) { - void loadUrl(currentUrlRef.current).catch((loadError) => { - log.warn('Restore browser webview failed', loadError); - }); - return; - } - - void (async () => { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await webviewRef.current?.reparent(getCurrentWindow()); - await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); - await syncWebviewBounds(); - })() - .then(() => webviewRef.current?.show()) - .then(() => webviewRef.current?.setFocus()) - .catch((syncError) => { - log.warn('Activate browser webview failed', syncError); - }); - return; - } - - if (webviewRef.current) { - void ensureHolderWindow() - .then((holderWindow) => webviewRef.current?.reparent(holderWindow)) - .then(() => holderWindowRef.current?.hide()) - .catch((reparentError) => { - log.warn('Reparent browser webview to holder window failed', reparentError); - return closeWebview(); - }) - .catch((closeError) => { - log.warn('Close browser webview on tab switch failed', closeError); - }); - } - }, [closeWebview, ensureHolderWindow, isActive, isTauri, loadUrl, syncWebviewBounds]); - - useEffect(() => () => { - if (holderWindowRef.current) { - void holderWindowRef.current.close().catch((closeError) => { - log.warn('Close browser holder window failed', closeError); - }); - } - }, []); - - useEffect(() => { - if (!isTauri) { - return; - } - - const observer = new ResizeObserver(() => { - if (isActive) { - queueSync(); - } - }); - - if (viewportRef.current) { - observer.observe(viewportRef.current); - } - - const handleResize = () => { - if (isActive) { - queueSync(); - } - }; - - window.addEventListener('resize', handleResize); - return () => { - observer.disconnect(); - window.removeEventListener('resize', handleResize); - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = null; - } - }; - }, [isActive, isTauri, queueSync]); - - useEffect(() => () => { - if (urlPollTimerRef.current) { - clearInterval(urlPollTimerRef.current); - urlPollTimerRef.current = null; - } - void closeWebview(); - }, [closeWebview]); - - useEffect(() => { - if (!isTauri) return; - - const OVERLAY_SELECTOR = '.modal-overlay, .canvas-mission-control'; - let hiddenByOverlay = false; - - const checkOverlays = () => { - const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null; - if (hasOverlay && !hiddenByOverlay) { - hiddenByOverlay = true; - void webviewRef.current?.hide().catch(() => {}); - } else if (!hasOverlay && hiddenByOverlay) { - hiddenByOverlay = false; - if (isActive) { - void syncWebviewBounds() - .then(() => webviewRef.current?.show()) - .catch(() => {}); - } - } - }; - - const observer = new MutationObserver(checkOverlays); - observer.observe(document.body, { childList: true, subtree: true }); - - const handleToolbarActivating = () => { - void webviewRef.current?.hide().catch(() => {}); - }; - window.addEventListener('toolbar-mode-activating', handleToolbarActivating); - - return () => { - observer.disconnect(); - window.removeEventListener('toolbar-mode-activating', handleToolbarActivating); - }; - }, [isActive, isTauri, syncWebviewBounds]); + const browser = useEmbeddedBrowserWebview({ + defaultUrl: DEFAULT_URL, + isVisible: isActive, + labelPrefix: 'embedded-browser-view', + log, + }); const handleSubmit = useCallback((event: React.FormEvent) => { event.preventDefault(); - void loadUrl(inputValue); - }, [inputValue, loadUrl]); - - const handleGoBack = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'history.back()').catch(() => {}); - }, [isTauri]); - - const handleGoForward = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'history.forward()').catch(() => {}); - }, [isTauri]); - - const handleRefresh = useCallback(() => { - if (!isTauri || !webviewLabelRef.current) return; - void evalWebview(webviewLabelRef.current, 'location.reload()').catch(() => {}); - }, [isTauri]); + void browser.loadUrl(browser.inputValue); + }, [browser]); return (
@@ -466,7 +33,7 @@ const BrowserScene: React.FC = () => { type="button" variant="ghost" size="small" - onClick={handleGoBack} + onClick={browser.goBack} aria-label={t('nav.back')} data-testid="browser-back-button" > @@ -476,7 +43,7 @@ const BrowserScene: React.FC = () => { type="button" variant="ghost" size="small" - onClick={handleGoForward} + onClick={browser.goForward} aria-label={t('nav.forward')} data-testid="browser-forward-button" > @@ -486,23 +53,23 @@ const BrowserScene: React.FC = () => { type="button" variant="ghost" size="small" - onClick={handleRefresh} - disabled={isLoading} + onClick={browser.reload} + disabled={browser.isLoading} aria-label={t('actions.refresh')} data-testid="browser-refresh-button" >
setInputValue(event.target.value)} + value={browser.inputValue} + onChange={(event) => browser.setInputValue(event.target.value)} placeholder={t('browserView.addressPlaceholder', { exampleUrl: 'https://example.com' })} spellCheck={false} data-testid="browser-url-input" @@ -510,26 +77,30 @@ const BrowserScene: React.FC = () => {
- {error ? ( + {browser.error ? (
- {error} + {browser.error}
) : null}
- {!isTauri ? ( + {!browser.isTauri ? (