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
82 changes: 74 additions & 8 deletions src/apps/desktop/src/theme.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Theme System

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{OnceLock, RwLock};
use std::time::Instant;

Expand All @@ -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;

Expand All @@ -25,6 +26,29 @@ static STARTUP_THEME_BOOTSTRAP_MANIFEST: OnceLock<StartupThemeBootstrapManifest>

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(()))
}
Expand Down Expand Up @@ -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")]
{
Expand Down Expand Up @@ -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")));
}
}
105 changes: 105 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<!doctype html><html><body>Market Lens</body></html>',
permissions: {},
source: {},
} as unknown as MiniApp;

describe('MiniAppRunner strict bridge startup', () => {
let container: HTMLDivElement;
let root: Root | null;
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;

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(<MiniAppRunner app={app} strictRuntime />);
});

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(<MiniAppRunner app={app} strictRuntime />);
});

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: '<!doctype html><html><body>Updated</body></html>',
};
await act(async () => {
root?.render(<MiniAppRunner app={updatedApp} strictRuntime />);
});

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');
});
});
26 changes: 23 additions & 3 deletions src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,12 +16,13 @@ interface MiniAppRunnerProps {

const MiniAppRunner: React.FC<MiniAppRunnerProps> = ({ app, runScope, strictRuntime = false }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useMiniAppBridge(
const bridgeReady = useMiniAppBridge(
iframeRef,
app,
runScope ?? { kind: 'active', appId: app.id },
strictRuntime,
);
const [strictDocumentUrl, setStrictDocumentUrl] = useState<string>();

const writeCompiledHtml = useCallback(() => {
const iframe = iframeRef.current;
Expand Down Expand Up @@ -70,11 +71,30 @@ const MiniAppRunner: React.FC<MiniAppRunnerProps> = ({ 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 (
<iframe
ref={iframeRef}
srcDoc={app.compiled_html}
src={strictDocumentUrl ?? 'about:blank'}
data-app-id={app.id}
data-run-scope={runScope?.kind ?? 'active'}
data-runtime-profile="market-strict"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';

import { shouldOpenMiniAppAgentRunInMainScene } from './miniAppAgentVisibility';

describe('shouldOpenMiniAppAgentRunInMainScene', () => {
it('keeps compatibility-profile sessions on their existing surface', () => {
expect(
shouldOpenMiniAppAgentRunInMainScene(
false,
undefined,
'app#1',
'session-1',
false,
),
).toBe(false);
});

it('opens a strict session in the main scene when no runner owns the composer', () => {
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
undefined,
'app#1',
'session-1',
true,
),
).toBe(true);
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
{ token: 'app#2', sessionId: 'session-1' },
'app#1',
'session-1',
true,
),
).toBe(true);
});

it('keeps a strict run in the bubble only after that session is bound', () => {
const claim = { token: 'app#1', sessionId: 'session-1' };
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
claim,
'app#1',
'session-1',
true,
),
).toBe(false);
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
claim,
'app#1',
'session-2',
true,
),
).toBe(true);
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
{ token: 'app#1' },
'app#1',
'session-1',
true,
),
).toBe(true);
});

it('opens a background MiniApp run in the main scene', () => {
expect(
shouldOpenMiniAppAgentRunInMainScene(
true,
{ token: 'app#1', sessionId: 'session-1' },
'app#1',
'session-1',
false,
),
).toBe(true);
});
});
18 changes: 18 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/hooks/miniAppAgentVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { MiniAppComposerClaim } from '../miniAppStore';

/**
* Marketplace Agent sessions must stay visible to the user. A runner-owned
* floating composer satisfies that requirement without navigating away from
* the MiniApp; otherwise the host falls back to the main session scene.
*/
export function shouldOpenMiniAppAgentRunInMainScene(
strictRuntime: boolean,
claim: MiniAppComposerClaim | undefined,
composerToken: string,
sessionId: string,
isActiveMiniApp: boolean,
): boolean {
if (!strictRuntime) return false;
if (!isActiveMiniApp || !claim || claim.token !== composerToken) return true;
return claim.sessionId !== sessionId;
}
Loading