diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 099219af9..c1a3eed1f 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,5 +1,6 @@ //! Theme System +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{OnceLock, RwLock}; use std::time::Instant; @@ -8,7 +9,7 @@ use bitfun_core::service::config::types::GlobalConfig; use dark_light::Mode; use log::{debug, error, warn}; use tauri::webview::PageLoadEvent; -use tauri::{Manager, WebviewUrl}; +use tauri::{Manager, Url, WebviewUrl}; use crate::startup_trace::DesktopStartupTrace; @@ -25,6 +26,29 @@ static STARTUP_THEME_BOOTSTRAP_MANIFEST: OnceLock const STARTUP_THEME_BOOTSTRAP_JSON: &str = include_str!("generated/startup_theme_bootstrap.json"); +struct MainWebviewNavigationPolicy { + first_page_navigation: AtomicBool, +} + +impl MainWebviewNavigationPolicy { + fn new() -> Self { + Self { + first_page_navigation: AtomicBool::new(true), + } + } + + fn should_allow(&self, url: &Url) -> bool { + // Wry invokes the same callback for top-level and iframe navigations on + // macOS, but it does not expose the target frame here. MiniApps use + // parent-created Blob documents, while srcdoc/empty iframe documents + // use the two local about: targets below. Allowing only these local + // document URLs keeps network and app reloads behind the one-shot gate. + let is_embedded_document = url.scheme() == "blob" + || (url.scheme() == "about" && matches!(url.path(), "blank" | "srcdoc")); + is_embedded_document || self.first_page_navigation.swap(false, Ordering::SeqCst) + } +} + fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> { AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(())) } @@ -507,13 +531,10 @@ pub fn create_main_window( // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); - // Block webview reloads: allow only the first navigation (initial load), - // cancel all subsequent navigations (F5 / Ctrl+R / location.reload()). - // The app uses state-driven routing, not browser navigation, so there are - // no legitimate full-page navigations after the initial load. - let first_navigation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - builder = builder - .on_navigation(move |_| first_navigation.swap(false, std::sync::atomic::Ordering::SeqCst)); + // Block top-level webview reloads after the initial page while allowing the + // local iframe documents used by sandboxed MiniApps. + let navigation_policy = MainWebviewNavigationPolicy::new(); + builder = builder.on_navigation(move |url| navigation_policy.should_allow(url)); #[cfg(target_os = "macos")] { @@ -927,3 +948,48 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::MainWebviewNavigationPolicy; + use tauri::Url; + + fn url(value: &str) -> Url { + value.parse().expect("test URL should be valid") + } + + #[test] + fn main_webview_navigation_allows_only_the_first_page_navigation() { + let policy = MainWebviewNavigationPolicy::new(); + + assert!(policy.should_allow(&url("http://localhost:1422/"))); + assert!(!policy.should_allow(&url("http://localhost:1422/"))); + assert!(!policy.should_allow(&url("https://example.com/"))); + assert!(!policy.should_allow(&url("data:text/html,blocked"))); + } + + #[test] + fn main_webview_navigation_allows_local_iframe_documents_without_consuming_initial_page() { + let policy = MainWebviewNavigationPolicy::new(); + + assert!(policy.should_allow(&url( + "blob:http://localhost:1422/65b60dd8-a501-47c2-b7fd-aa99af720dc6" + ))); + assert!(policy.should_allow(&url("about:blank"))); + assert!(policy.should_allow(&url("about:srcdoc"))); + assert!(policy.should_allow(&url("tauri://localhost/index.html"))); + assert!(!policy.should_allow(&url("tauri://localhost/index.html"))); + } + + #[test] + fn main_webview_navigation_keeps_iframe_documents_available_after_initial_page() { + let policy = MainWebviewNavigationPolicy::new(); + + assert!(policy.should_allow(&url("tauri://localhost/index.html"))); + assert!(policy.should_allow(&url( + "blob:tauri://localhost/f5445ef0-5b0b-42b0-9540-276a0012ae56" + ))); + assert!(policy.should_allow(&url("about:blank"))); + assert!(!policy.should_allow(&url("tauri://localhost/index.html"))); + } +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx new file mode 100644 index 000000000..1321f3c19 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + */ + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import MiniAppRunner from './MiniAppRunner'; + +const bridgeMock = vi.hoisted(() => ({ + ready: false, + useMiniAppBridge: vi.fn(), +})); + +vi.mock('../hooks/useMiniAppBridge', () => ({ + useMiniAppBridge: (...args: unknown[]) => { + bridgeMock.useMiniAppBridge(...args); + return bridgeMock.ready; + }, +})); + +const app = { + id: 'market-lens', + name: 'Market Lens', + compiled_html: 'Market Lens', + permissions: {}, + source: {}, +} as unknown as MiniApp; + +describe('MiniAppRunner strict bridge startup', () => { + let container: HTMLDivElement; + let root: Root | null; + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + beforeEach(() => { + bridgeMock.ready = false; + createObjectURL = vi.fn() + .mockReturnValueOnce('blob:market-lens-1') + .mockReturnValueOnce('blob:market-lens-2'); + revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { + ...URL, + createObjectURL, + revokeObjectURL, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('loads strict HTML from a sandboxed blob only after the bridge is listening', async () => { + await act(async () => { + root?.render(); + }); + + const iframe = container.querySelector('iframe') as HTMLIFrameElement; + expect(iframe).toBeTruthy(); + expect(iframe.getAttribute('src')).toBe('about:blank'); + expect(iframe.getAttribute('srcdoc')).toBeNull(); + expect(createObjectURL).not.toHaveBeenCalled(); + + bridgeMock.ready = true; + await act(async () => { + root?.render(); + }); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(createObjectURL.mock.calls[0][0]).toBeInstanceOf(Blob); + expect(iframe.getAttribute('src')).toBe('blob:market-lens-1'); + expect(iframe.getAttribute('srcdoc')).toBeNull(); + expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + + const updatedApp = { + ...app, + compiled_html: 'Updated', + }; + await act(async () => { + root?.render(); + }); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:market-lens-1'); + expect(iframe.getAttribute('src')).toBe('blob:market-lens-2'); + + await act(async () => { + root?.unmount(); + }); + root = null; + expect(revokeObjectURL).toHaveBeenCalledWith('blob:market-lens-2'); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx index b2949e95a..7dd855d20 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx @@ -3,7 +3,7 @@ * Injects the bridge script (already in compiledHtml from Rust compiler) * and handles all postMessage RPC via useMiniAppBridge. */ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; import { useMiniAppBridge } from '../hooks/useMiniAppBridge'; import type { MiniAppRunScope } from '../customization/miniAppCustomizationTypes'; @@ -16,12 +16,13 @@ interface MiniAppRunnerProps { const MiniAppRunner: React.FC = ({ app, runScope, strictRuntime = false }) => { const iframeRef = useRef(null); - useMiniAppBridge( + const bridgeReady = useMiniAppBridge( iframeRef, app, runScope ?? { kind: 'active', appId: app.id }, strictRuntime, ); + const [strictDocumentUrl, setStrictDocumentUrl] = useState(); const writeCompiledHtml = useCallback(() => { const iframe = iframeRef.current; @@ -70,11 +71,30 @@ const MiniAppRunner: React.FC = ({ app, runScope, strictRunt }; }, [app.id, app.compiled_html, strictRuntime, writeCompiledHtml]); + useLayoutEffect(() => { + const html = app.compiled_html?.trim(); + if (!strictRuntime || !bridgeReady || !html) { + setStrictDocumentUrl(undefined); + return undefined; + } + + // Tauri's WKWebView has rendered srcdoc as a blank document in production. + // A sandboxed blob navigation is reliable while retaining the strict + // runtime's unique opaque origin because allow-same-origin is absent. + const documentUrl = URL.createObjectURL( + new Blob([html], { type: 'text/html;charset=utf-8' }), + ); + setStrictDocumentUrl(documentUrl); + return () => { + URL.revokeObjectURL(documentUrl); + }; + }, [app.id, app.compiled_html, bridgeReady, strictRuntime]); + if (strictRuntime) { return (