diff --git a/Cargo.toml b/Cargo.toml index 69fdf54f..b2a0cd87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ similar = "2.5" urlencoding = "2.1" # Tauri (desktop only) -tauri = { version = "2", features = [] } + tauri = { version = "2", features = ["unstable"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2.6" tauri-plugin-fs = "2" diff --git a/src/apps/desktop/capabilities/browser-webview.json b/src/apps/desktop/capabilities/browser-webview.json new file mode 100644 index 00000000..a1589b67 --- /dev/null +++ b/src/apps/desktop/capabilities/browser-webview.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "browser-webview", + "description": "Minimal permissions for embedded browser webviews to emit events back to the host", + "webviews": ["embedded-browser-*"], + "local": true, + "remote": { + "urls": ["https://*", "http://*"] + }, + "permissions": [ + "core:event:allow-emit" + ] +} diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 603475a9..f3e3ec1e 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -11,6 +11,15 @@ "core:event:allow-listen", "core:event:allow-emit", "core:window:default", + "core:webview:default", + "core:webview:allow-create-webview", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-set-webview-focus", + "core:webview:allow-reparent", + "core:webview:allow-webview-show", + "core:webview:allow-webview-hide", + "core:webview:allow-webview-close", "core:window:allow-create", "core:window:allow-set-focus", "core:window:allow-set-always-on-top", diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs new file mode 100644 index 00000000..20350972 --- /dev/null +++ b/src/apps/desktop/src/api/browser_api.rs @@ -0,0 +1,81 @@ +//! Browser API — commands for the embedded browser feature. + +use serde::Deserialize; +use tauri::Manager; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebviewEvalRequest { + pub label: String, + pub script: String, +} + +#[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 + .eval(&request.script) + .map_err(|e| format!("eval failed: {e}")) +} + +// #region agent log +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebviewLabelRequest { + pub label: String, +} + +/// Pull debug logs from the injected inspector script. +/// The script stores logs in `window.__bitfun_debug_logs`. +/// We eval a script that writes them into URL hash, then read the URL. +#[tauri::command] +pub async fn browser_pull_debug_logs( + app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result { + let webview = app + .get_webview(&request.label) + .ok_or_else(|| format!("Webview not found: {}", request.label))?; + + webview + .eval( + r#"(function(){ + var logs = window.__bitfun_debug_logs || []; + window.__bitfun_debug_logs = []; + try { + var hash = '__BFDEBUG__' + encodeURIComponent(JSON.stringify(logs)); + history.replaceState(null, '', '#' + hash); + } catch(e) { + history.replaceState(null, '', '#__BFDEBUG__ERR_' + encodeURIComponent(String(e))); + } + })()"#, + ) + .map_err(|e| format!("eval failed: {e}"))?; + + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + let url = webview.url().map_err(|e| format!("url failed: {e}"))?; + let fragment = url.fragment().unwrap_or(""); + + if fragment.starts_with("__BFDEBUG__") { + let encoded = &fragment[11..]; + let decoded = urlencoding::decode(encoded) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| format!("decode_error:{}", encoded)); + + webview + .eval("try { history.replaceState(null, '', location.pathname + location.search); } catch(e) {}") + .ok(); + + Ok(decoded) + } else { + Ok(format!("no_debug_marker_in_fragment:{}", &fragment.chars().take(100).collect::())) + } +} +// #endregion diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 9b3f416e..86d300d6 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod agentic_api; pub mod ai_memory_api; +pub mod browser_api; pub mod ai_rules_api; pub mod app_state; pub mod btw_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0fbc330f..a09a856d 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -633,6 +633,9 @@ pub async fn run() { api::miniapp_api::miniapp_dialog_message, api::miniapp_api::miniapp_import_from_path, api::miniapp_api::miniapp_sync_from_fs, + // Browser API + api::browser_api::browser_webview_eval, + api::browser_api::browser_pull_debug_logs, ]) .run(tauri::generate_context!()); if let Err(e) = run_result { diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index fb1561cc..6b1c584d 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -34,8 +34,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Utilities -chrono = { workspace = true } -dashmap = { workspace = true } -rand = { workspace = true } -base64 = { workspace = true } -sha2 = { workspace = true } +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock"] } +dashmap = "5.5" +rand = "0.8" +base64 = "0.21" +sha2 = "0.10" diff --git a/src/apps/relay-server/docker-compose.yml b/src/apps/relay-server/docker-compose.yml index 40874a5a..a6cb2e7e 100644 --- a/src/apps/relay-server/docker-compose.yml +++ b/src/apps/relay-server/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: bitfun-relay restart: unless-stopped ports: - - "9700:9700" + - "${RELAY_HOST_BIND_IP:-0.0.0.0}:9700:9700" environment: - RELAY_PORT=9700 - RELAY_STATIC_DIR=/app/static diff --git a/src/apps/relay-server/restart.sh b/src/apps/relay-server/restart.sh index 639e6ec2..3ed2b2dc 100755 --- a/src/apps/relay-server/restart.sh +++ b/src/apps/relay-server/restart.sh @@ -6,6 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_NAME="bitfun-relay" +RELAY_HOST_BIND_IP="127.0.0.1" usage() { cat <<'EOF' @@ -65,13 +66,14 @@ cd "$SCRIPT_DIR" if container_running; then echo "Relay service is running. Restarting it..." - docker compose restart + RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d --force-recreate else echo "Relay service is not running. Starting it instead..." - docker compose up -d + RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d fi echo "" echo "Relay service is ready." +echo "Relay endpoint: http://127.0.0.1:9700" echo "Check status: docker compose ps" echo "View logs: docker compose logs -f relay-server" diff --git a/src/apps/relay-server/start.sh b/src/apps/relay-server/start.sh index 34987d66..8cca1fd1 100755 --- a/src/apps/relay-server/start.sh +++ b/src/apps/relay-server/start.sh @@ -6,6 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_NAME="bitfun-relay" +RELAY_HOST_BIND_IP="127.0.0.1" usage() { cat <<'EOF' @@ -77,9 +78,10 @@ else echo "Relay service is not created yet. Creating and starting it..." fi -docker compose up -d +RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d echo "" echo "Relay service started." +echo "Relay endpoint: http://127.0.0.1:9700" echo "Check status: docker compose ps" echo "View logs: docker compose logs -f relay-server" diff --git a/src/apps/relay-server/static/index.html b/src/apps/relay-server/static/index.html index f7c493e0..1a5b656f 100644 --- a/src/apps/relay-server/static/index.html +++ b/src/apps/relay-server/static/index.html @@ -1,18 +1,253 @@ - + - - - BitFun Remote + + + + BitFun Relay Server + + + + - - -
+ + + +
+ + +
+
+ +

BitFun Relay Server

+

桌面端与移动端之间的中继服务,提供配对与消息转发能力。

+ +
+ +
+
+
+ +
+ 桌面端 + BitFun Agent +
+
WebSocket
+
+
+ +
+ 中继服务 + 配对 & 转发 +
+
HTTP
+
+
+ +
+ 移动端 + 远程控制 +
+
+ +
+
+
+
+

建立连接

+
+

承接配对请求,建立远程会话。

+
+
+
+
+

消息转发

+
+

桥接 WebSocket 与 HTTP 双向通信。

+
+
+
+
+

自托管友好

+
+

私有部署、自定义域名与反向代理。

+
+
+
+ +
BitFun Relay Server · Self-Hosted Gateway for Remote Connect
+
+ + diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 4b9c0803..22efe185 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -40,6 +40,12 @@ const AppContent: React.FC = () => { const [prevPage, setPrevPage] = useState(null); const timerRef = useRef>(); + // Track the page stack for browser history integration. + // When user triggers browser back (phone back button / edge swipe), + // we intercept popstate and perform in-app navigation instead. + const pageStackRef = useRef(['pairing']); + const isPopstateNavRef = useRef(false); + const navigateTo = useCallback((target: Page, direction: NavDirection) => { setPage(prev => { setPrevPage(prev); @@ -47,11 +53,22 @@ const AppContent: React.FC = () => { }); setNavDir(direction); clearTimeout(timerRef.current); - const duration = NAV_DURATION; timerRef.current = setTimeout(() => { setPrevPage(null); setNavDir(null); - }, duration); + }, NAV_DURATION); + + if (direction === 'push') { + pageStackRef.current = [...pageStackRef.current, target]; + if (!isPopstateNavRef.current) { + history.pushState({ page: target }, ''); + } + } else if (direction === 'pop') { + pageStackRef.current = pageStackRef.current.slice(0, -1); + if (!isPopstateNavRef.current) { + history.back(); + } + } }, []); useEffect(() => () => clearTimeout(timerRef.current), []); @@ -60,11 +77,51 @@ const AppContent: React.FC = () => { (client: RelayHttpClient, sessionMgr: RemoteSessionManager) => { clientRef.current = client; sessionMgrRef.current = sessionMgr; + pageStackRef.current = ['pairing', 'sessions']; + history.pushState({ page: 'sessions' }, ''); setPage('sessions'); }, [], ); + // Pop navigation handlers that can be called from both UI buttons and popstate + const doPopFromChat = useCallback(() => { + navigateTo('sessions', 'pop'); + setTimeout(() => setActiveSessionId(null), NAV_DURATION); + }, [navigateTo]); + + const doPopFromWorkspace = useCallback(() => { + navigateTo('sessions', 'pop'); + }, [navigateTo]); + + useEffect(() => { + const onPopState = () => { + const stack = pageStackRef.current; + const currentPage = stack[stack.length - 1]; + + if (currentPage === 'pairing' || currentPage === 'sessions') { + // At the root-level pages: re-push a history entry so the user + // can't accidentally close the app with another back gesture. + history.pushState({ page: currentPage }, ''); + return; + } + + isPopstateNavRef.current = true; + try { + if (currentPage === 'chat') { + doPopFromChat(); + } else if (currentPage === 'workspace') { + doPopFromWorkspace(); + } + } finally { + isPopstateNavRef.current = false; + } + }; + + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, [doPopFromChat, doPopFromWorkspace]); + const handleOpenWorkspace = useCallback(() => { navigateTo('workspace', 'push'); }, [navigateTo]); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index f4396607..22928cdd 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -611,8 +611,6 @@ const MainNav: React.FC = ({ [isAssistantNavMode] ); const myAgentEntryLabel = t('nav.actions.openMyAgent'); - - return ( <>
diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index cd935784..03ff0365 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,9 +1,11 @@ import React, { useState, useCallback } from 'react'; -import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Wifi } from 'lucide-react'; +import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Wifi, Globe } from 'lucide-react'; import { Tooltip, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneManager } from '../../../hooks/useSceneManager'; import { useNavSceneStore } from '../../../stores/navSceneStore'; +import { useSceneStore } from '../../../stores/sceneStore'; +import { useCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { useToolbarModeContext } from '@/flow_chat/components/toolbar-mode/ToolbarModeContext'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useNotification } from '@/shared/notification-system'; @@ -19,10 +21,17 @@ import { const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); const { openScene } = useSceneManager(); + const activeTabId = useSceneStore((s) => s.activeTabId); const showSceneNav = useNavSceneStore((s) => s.showSceneNav); const navSceneId = useNavSceneStore((s) => s.navSceneId); const openNavScene = useNavSceneStore((s) => s.openNavScene); const closeNavScene = useNavSceneStore((s) => s.closeNavScene); + + // Check if a browser panel is the active tab in the AuxPane canvas + const isBrowserPanelActiveInCanvas = useCanvasStore((s) => { + const activeTab = s.primaryGroup.tabs.find((t) => t.id === s.primaryGroup.activeTabId); + return activeTab?.content.type === 'browser'; + }); const { enableToolbarMode } = useToolbarModeContext(); const { hasWorkspace } = useCurrentWorkspace(); const { warning } = useNotification(); @@ -63,6 +72,23 @@ const PersistentFooterActions: React.FC = () => { openNavScene('shell'); }, [closeNavScene, navSceneId, openNavScene, showSceneNav]); + const handleOpenBrowser = useCallback(() => { + if (activeTabId === 'session') { + // Open browser as a panel in the AuxPane (right side of chat) + window.dispatchEvent(new CustomEvent('agent-create-tab', { + detail: { + type: 'browser', + title: t('scenes.browser'), + checkDuplicate: true, + duplicateCheckKey: 'browser-panel', + replaceExisting: false, + }, + })); + } else { + openScene('browser'); + } + }, [activeTabId, openScene, t]); + const handleShowAbout = () => { closeMenu(); setShowAbout(true); @@ -184,6 +210,18 @@ const PersistentFooterActions: React.FC = () => { + + + + setShowAbout(false)} /> setShowRemoteConnect(false)} /> diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 71d7615f..0a507292 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -16,6 +16,7 @@ export type SceneTabId = | 'agents' | 'skills' | 'miniapps' + | 'browser' | 'my-agent' | 'shell' | `miniapp:${string}`; diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index b0c6d338..270300bb 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -57,6 +57,10 @@ const TerminalTabPanel = React.lazy(() => })) ); +const BrowserPanel = React.lazy(() => + import('@/app/scenes/browser/BrowserPanel') +); + const TaskDetailPanel = React.lazy(() => import('@/flow_chat/components/TaskDetailPanel').then(module => ({ default: module.TaskDetailPanel @@ -83,6 +87,8 @@ import './FlexiblePanel.scss'; interface ExtendedFlexiblePanelProps extends FlexiblePanelProps { onDirtyStateChange?: (isDirty: boolean) => void; + /** Whether this panel is the active/visible tab in its EditorGroup */ + isActive?: boolean; } const FlexiblePanel: React.FC = memo(({ @@ -92,7 +98,8 @@ const FlexiblePanel: React.FC = memo(({ onInteraction, workspacePath, onBeforeClose, - onDirtyStateChange + onDirtyStateChange, + isActive = true, }) => { const { t } = useI18n('components'); @@ -730,6 +737,16 @@ const FlexiblePanel: React.FC = memo(({ ); + case 'browser': + return ( + {t('flexiblePanel.loading.terminal')}
}> + + + ); + default: return (
diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index 1c5ccdb3..0ce9577b 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -27,7 +27,8 @@ export type PanelContentType = | 'task-detail' | 'plan-viewer' | 'btw-session' - | 'terminal'; + | 'terminal' + | 'browser'; export interface PanelContent { type: PanelContentType; diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index e58c986c..13628225 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -15,7 +15,8 @@ import { ClipboardList, Image, Network, - MessageSquareQuote + MessageSquareQuote, + Globe, } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -212,7 +213,15 @@ export const PANEL_CONTENT_CONFIGS: Record supportsCopy: false, supportsDownload: false, showHeader: false - } + }, + 'browser': { + type: 'browser', + displayName: 'Browser', + icon: Globe, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, }; /** diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 12de0afe..0bcc5844 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -160,6 +160,7 @@ export const EditorGroup: React.FC = ({ > = ({ className = '' }) => { isMaximized={isMaximized} isEntering={transitionDir === 'entering'} isExiting={transitionDir === 'returning'} - sceneOverlay={!isWelcomeScene && !state.layout.chatCollapsed && isAgentScene ? ( - {}} /> - ) : undefined} /> diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index bc9705ce..a35b2245 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -23,6 +23,7 @@ const ProfileScene = lazy(() => import('./profile/ProfileScene')); const AgentsScene = lazy(() => import('./agents/AgentsScene')); const SkillsScene = lazy(() => import('./skills/SkillsScene')); const MiniAppGalleryScene = lazy(() => import('./miniapps/MiniAppGalleryScene')); +const BrowserScene = lazy(() => import('./browser/BrowserScene')); const MyAgentScene = lazy(() => import('./my-agent/MyAgentScene')); const ShellScene = lazy(() => import('./shell/ShellScene')); const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); @@ -88,6 +89,8 @@ function renderScene(id: SceneTabId, workspacePath?: string, isEntering?: boolea return ; case 'miniapps': return ; + case 'browser': + return ; case 'my-agent': return ; case 'shell': diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.scss b/src/web-ui/src/app/scenes/browser/BrowserPanel.scss new file mode 100644 index 00000000..b6947331 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.scss @@ -0,0 +1,131 @@ +@use '../../../component-library/styles/tokens' as *; + +.browser-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + + &__toolbar { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--element-bg-soft) 70%, transparent); + flex-shrink: 0; + } + + &__address { + flex: 1; + min-width: 0; + height: 30px; + display: flex; + align-items: center; + gap: $size-gap-2; + padding: 0 $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: var(--color-bg-primary); + color: var(--color-text-muted); + + input { + flex: 1; + min-width: 0; + border: none; + outline: none; + box-shadow: none; + background: transparent; + color: var(--color-text-primary); + font-size: $font-size-sm; + appearance: none; + -webkit-appearance: none; + border-radius: 0; + + &:focus, + &:focus-visible, + &:active { + outline: none; + box-shadow: none; + border: none; + } + } + } + + &__error { + display: flex; + align-items: center; + gap: $size-gap-2; + margin: $size-gap-2 $size-gap-3 0; + padding: $size-gap-2 $size-gap-3; + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + color: var(--color-text-secondary); + font-size: $font-size-xs; + flex-shrink: 0; + + svg { + color: var(--color-warning); + flex-shrink: 0; + } + } + + &__content { + flex: 1; + min-height: 0; + position: relative; + background: var(--color-bg-primary); + } + + &__iframe, + &__webview-host { + width: 100%; + height: 100%; + border: none; + display: block; + } + + &__webview-host { + position: relative; + } + + &__webview-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-2; + color: var(--color-text-muted); + font-size: $font-size-sm; + background: + radial-gradient(circle at top, color-mix(in srgb, var(--color-accent-500) 8%, transparent), transparent 42%), + var(--color-bg-primary); + + span { + max-width: min(60%, 400px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__spinning { + animation: browser-panel-spin 0.8s linear infinite; + } + + &__inspector-btn--active { + color: var(--color-accent-500) !important; + background: color-mix(in srgb, var(--color-accent-500) 14%, transparent) !important; + + &:hover { + background: color-mix(in srgb, var(--color-accent-500) 22%, transparent) !important; + } + } +} + +@keyframes browser-panel-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx new file mode 100644 index 00000000..a92b58ce --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx @@ -0,0 +1,524 @@ +/** + * 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. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, Globe, RefreshCw, MousePointer2 } from 'lucide-react'; +import { IconButton } from '@/component-library'; +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 } from './browserInspectorScript'; +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; + path: string; + attributes: Record; + textContent: string; + outerHTML: string; +} + +export interface BrowserPanelProps { + /** Whether this panel is the active tab in the EditorGroup */ + isActive: boolean; + /** Optional initial URL (falls back to DEFAULT_URL) */ + initialUrl?: string; +} + +const BrowserPanel: React.FC = ({ isActive, initialUrl }) => { + 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 inspectorUnlistenRef = useRef<(() => void) | 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 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; + } + setIsInspectorActive(false); + + if (!isTauri) { + setIsLoading(false); + return; + } + + try { + const handle = await recreateWebview(nextUrl); + await syncWebviewBounds(handle); + if (shouldShowWebview) { + await handle.show(); + await handle.setFocus(); + } + } 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 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 (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]); + + useEffect(() => () => { + if (holderWindowRef.current) { + void holderWindowRef.current.close().catch((e) => log.warn('Close browser panel holder window failed', e)); + } + }, []); + + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + void loadUrl(inputValue); + }, [inputValue, loadUrl]); + + const handleRefresh = useCallback(() => { + void loadUrl(currentUrl); + }, [currentUrl, loadUrl]); + + const handleInspector = useCallback(async () => { + if (!isTauri || !webviewRef.current) 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; + return; + } + + const label = webviewLabelRef.current; + if (!label) return; + + try { + const { listen } = await import('@tauri-apps/api/event'); + + const eventSelected = `browser-inspector-element-selected-${label}`; + const eventCancelled = `browser-inspector-cancelled-${label}`; + + const unlistenSelected = await listen( + eventSelected, + (event) => { + const data = event.payload; + const context: WebElementContext = { + id: `web-element-${Date.now()}`, + type: 'web-element', + timestamp: Date.now(), + tagName: data.tagName, + path: data.path, + attributes: data.attributes, + textContent: data.textContent, + outerHTML: data.outerHTML, + sourceUrl: currentUrlRef.current, + }; + + addContext(context); + window.dispatchEvent( + new CustomEvent('insert-context-tag', { detail: { context } }), + ); + }, + ); + + const unlistenCancelled = await listen( + eventCancelled, + () => { + unlistenSelected(); + unlistenCancelled(); + inspectorUnlistenRef.current = null; + setIsInspectorActive(false); + }, + ); + + inspectorUnlistenRef.current = () => { + unlistenSelected(); + unlistenCancelled(); + }; + + await evalWebview(label, createInspectorScript(label)); + setIsInspectorActive(true); + + } catch (e) { + log.error('Start inspector failed', e); + setIsInspectorActive(false); + } + }, [addContext, isInspectorActive, isTauri]); + + return ( +
+
+
+ + setInputValue(e.target.value)} + placeholder="输入网址,例如 https://example.com" + spellCheck={false} + /> +
+ + + + {isTauri && ( + void handleInspector()} + aria-label={isInspectorActive ? '退出元素选取' : '选取元素'} + className={isInspectorActive ? 'browser-panel__inspector-btn--active' : undefined} + > + + + )} +
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ {!isTauri ? ( +