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
69 changes: 43 additions & 26 deletions src/apps/desktop/src/api/browser_control_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,48 +76,65 @@ pub struct BrowserControlLaunchResponse {
pub browser_kind: String,
}

/// Launch the user's default browser with CDP debug port.
#[tauri::command]
pub async fn browser_control_launch(
request: BrowserControlLaunchRequest,
) -> Result<BrowserControlLaunchResponse, String> {
let port = request.port;
let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?;

let result = BrowserLauncher::launch_with_cdp(&kind, port)
.await
.map_err(|e| e.to_string())?;

fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserControlLaunchResponse {
match result {
LaunchResult::AlreadyConnected => Ok(BrowserControlLaunchResponse {
LaunchResult::AlreadyConnected => BrowserControlLaunchResponse {
success: true,
status: "already_connected".into(),
message: None,
browser_kind: kind.to_string(),
}),
LaunchResult::Launched => Ok(BrowserControlLaunchResponse {
},
LaunchResult::Launched => BrowserControlLaunchResponse {
success: true,
status: "launched".into(),
message: None,
browser_kind: kind.to_string(),
}),
LaunchResult::LaunchedButCdpNotReady { message, .. } => Ok(BrowserControlLaunchResponse {
},
LaunchResult::LaunchedButCdpNotReady { message, .. } => BrowserControlLaunchResponse {
success: false,
status: "cdp_not_ready".into(),
message: Some(message),
browser_kind: kind.to_string(),
}),
LaunchResult::BrowserRunningWithoutCdp { instructions, .. } => {
Ok(BrowserControlLaunchResponse {
success: false,
status: "needs_restart".into(),
message: Some(instructions),
browser_kind: kind.to_string(),
})
}
},
LaunchResult::BrowserRunningWithoutCdp { instructions, .. } => BrowserControlLaunchResponse {
success: false,
status: "needs_restart".into(),
message: Some(instructions),
browser_kind: kind.to_string(),
},
}
}

/// Launch the user's default browser with CDP debug port.
#[tauri::command]
pub async fn browser_control_launch(
request: BrowserControlLaunchRequest,
) -> Result<BrowserControlLaunchResponse, String> {
let port = request.port;
let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?;

let result = BrowserLauncher::launch_with_cdp(&kind, port)
.await
.map_err(|e| e.to_string())?;

Ok(to_launch_response(&kind, result))
}

/// Restart the user's default browser with CDP debug port enabled.
#[tauri::command]
pub async fn browser_control_restart_with_cdp(
request: BrowserControlLaunchRequest,
) -> Result<BrowserControlLaunchResponse, String> {
let port = request.port;
let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?;

let result = BrowserLauncher::restart_with_cdp(&kind, port)
.await
.map_err(|e| e.to_string())?;

Ok(to_launch_response(&kind, result))
}

/// Create a macOS .app wrapper for the browser with CDP enabled.
#[tauri::command]
pub async fn browser_control_create_launcher() -> Result<String, String> {
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ pub async fn run() {
// Browser Control API (CDP-based user browser control)
api::browser_control_api::browser_control_get_status,
api::browser_control_api::browser_control_launch,
api::browser_control_api::browser_control_restart_with_cdp,
api::browser_control_api::browser_control_create_launcher,
api::self_control_api::submit_self_control_response,
// Insights API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::util::errors::{BitFunError, BitFunResult};
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::Duration;

/// Default CDP debug port.
pub const DEFAULT_CDP_PORT: u16 = 9222;
Expand Down Expand Up @@ -410,6 +411,97 @@ impl BrowserLauncher {
}
}

pub async fn restart_with_cdp(kind: &BrowserKind, port: u16) -> BitFunResult<LaunchResult> {
Self::terminate_browser(kind)?;
Self::wait_for_browser_exit(kind, Duration::from_secs(8)).await?;
Self::launch_with_cdp_opts(kind, port, None).await
}

fn terminate_browser(kind: &BrowserKind) -> BitFunResult<()> {
#[cfg(target_os = "macos")]
{
let app_name = match kind {
BrowserKind::Chrome => "Google Chrome",
BrowserKind::Edge => "Microsoft Edge",
BrowserKind::Brave => "Brave Browser",
BrowserKind::Arc => "Arc",
BrowserKind::Chromium => "Chromium",
BrowserKind::Unknown(name) => name.as_str(),
};
let script = format!("tell application \"{}\" to quit", app_name.replace('"', "\\\""));
let output = silent_command("osascript")
.args(["-e", &script])
.output()
.map_err(|e| BitFunError::tool(format!("Failed to quit {}: {}", kind, e)))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BitFunError::tool(format!("Failed to quit {}: {}", kind, stderr.trim())));
}

#[cfg(target_os = "windows")]
{
let process_names: &[&str] = match kind {
BrowserKind::Chrome => &["chrome.exe"],
BrowserKind::Edge => &["msedge.exe"],
BrowserKind::Brave => &["brave.exe"],
BrowserKind::Arc => &["arc.exe"],
BrowserKind::Chromium => &["chromium.exe", "chrome.exe"],
BrowserKind::Unknown(_) => {
return Err(BitFunError::tool(
"Unsupported browser kind for restart on Windows".to_string(),
))
}
};
for process_name in process_names {
let output = silent_command("taskkill")
.args(["/IM", process_name, "/F"])
.output()
.map_err(|e| BitFunError::tool(format!("Failed to terminate {}: {}", process_name, e)))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if output.status.success()
|| stdout.contains("no instance")
|| stdout.contains("not found")
|| stderr.contains("no instance")
|| stderr.contains("not found")
{
continue;
}
return Err(BitFunError::tool(format!(
"Failed to terminate {}: {}{}",
process_name,
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
return Ok(());
}

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = kind;
Err(BitFunError::tool(
"Browser restart with CDP is not supported on this platform".to_string(),
))
}
}

async fn wait_for_browser_exit(kind: &BrowserKind, timeout: Duration) -> BitFunResult<()> {
let started = std::time::Instant::now();
while Self::is_browser_running(kind) {
if started.elapsed() >= timeout {
return Err(BitFunError::tool(format!(
"Timed out waiting for {} to exit before restart",
kind
)));
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Ok(())
}

/// Check if a browser process is currently running.
fn is_browser_running(kind: &BrowserKind) -> bool {
// Per-platform process names.
Expand Down
81 changes: 75 additions & 6 deletions src/web-ui/src/infrastructure/config/components/SessionConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ type ComputerUseStatusPayload = {
platformNote: string | null;
};

type BrowserControlLaunchResponse = {
success: boolean;
status: string;
message: string | null;
browserKind: string;
};

const SessionConfig: React.FC = () => {
const { t } = useTranslation('settings/session-config');
const { t: tTools } = useTranslation('settings/agentic-tools');
Expand Down Expand Up @@ -72,6 +79,7 @@ const SessionConfig: React.FC = () => {
const [browserPageCount, setBrowserPageCount] = useState(0);
const [browserControlBusy, setBrowserControlBusy] = useState(false);
const [platform, setPlatform] = useState<string>('');
const [browserRestartPrompt, setBrowserRestartPrompt] = useState<BrowserControlLaunchResponse | null>(null);

// ── Debug mode config state ──────────────────────────────────────────────
const [debugConfig, setDebugConfig] = useState<DebugModeConfig>(DEFAULT_DEBUG_MODE_CONFIG);
Expand Down Expand Up @@ -278,17 +286,14 @@ const SessionConfig: React.FC = () => {
setBrowserControlBusy(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<{
success: boolean;
status: string;
message: string | null;
browserKind: string;
}>('browser_control_launch', { request: { port: 9222 } });
const result = await invoke<BrowserControlLaunchResponse>('browser_control_launch', { request: { port: 9222 } });
if (result.success) {
notificationService.success(
t('browserControl.connectSuccess', { browser: result.browserKind }),
{ duration: 3000 }
);
} else if (result.status === 'needs_restart') {
setBrowserRestartPrompt(result);
} else if (result.message) {
notificationService.info(result.message, { duration: 8000 });
}
Expand All @@ -301,6 +306,32 @@ const SessionConfig: React.FC = () => {
}
};

const handleBrowserControlRestart = async () => {
if (!browserRestartPrompt) return;
setBrowserControlBusy(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<BrowserControlLaunchResponse>('browser_control_restart_with_cdp', {
request: { port: 9222 },
});
if (result.success) {
notificationService.success(
t('browserControl.restartSuccess', { browser: result.browserKind }),
{ duration: 3000 }
);
setBrowserRestartPrompt(null);
} else if (result.message) {
notificationService.info(result.message, { duration: 8000 });
}
await refreshBrowserControlStatus();
} catch (error) {
log.error('browser_control_restart_with_cdp failed', error);
notificationService.error(t('browserControl.restartFailed'));
} finally {
setBrowserControlBusy(false);
}
};

const handleBrowserControlCreateLauncher = async () => {
setBrowserControlBusy(true);
try {
Expand Down Expand Up @@ -997,6 +1028,44 @@ const SessionConfig: React.FC = () => {
)}
</Modal>

<Modal
isOpen={browserRestartPrompt !== null}
onClose={() => {
if (!browserControlBusy) setBrowserRestartPrompt(null);
}}
title={t('browserControl.restartModal.title')}
size="small"
closeOnOverlayClick={!browserControlBusy}
>
<div className="bitfun-debug-config__modal-body">
<p>{t('browserControl.restartModal.description', { browser: browserRestartPrompt?.browserKind || browserKind })}</p>
<p>{t('browserControl.restartModal.warning')}</p>
{browserRestartPrompt?.message ? (
<p className="bitfun-func-agent-config__hint">{browserRestartPrompt.message}</p>
) : null}
</div>
<div className="bitfun-debug-config__modal-footer">
<Button
variant="secondary"
size="small"
onClick={() => setBrowserRestartPrompt(null)}
disabled={browserControlBusy}
>
{t('browserControl.restartModal.cancel')}
</Button>
<Button
variant="primary"
size="small"
onClick={() => void handleBrowserControlRestart()}
disabled={browserControlBusy}
>
{browserControlBusy
? t('browserControl.restartModal.restarting')
: t('browserControl.restartModal.confirm')}
</Button>
</div>
</Modal>

</ConfigPageContent>
</ConfigPageLayout>
);
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/locales/en-US/settings/session-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
"connect": "Connect",
"connectSuccess": "Connected to {{browser}}",
"connectFailed": "Failed to connect to browser",
"restartSuccess": "Restarted {{browser}} with debug mode enabled",
"restartFailed": "Failed to restart browser with debug mode enabled",
"restartModal": {
"title": "Enable browser debug mode",
"description": "{{browser}} is already running and the current instance was not started with the debug port enabled. BitFun needs to restart the browser in debug mode before it can control it.",
"warning": "This will close the current browser windows.",
"cancel": "Cancel",
"confirm": "Restart and enable debug",
"restarting": "Restarting..."
},
"createLauncher": "Create launcher",
"createLauncherSuccess": "Launcher created at {{path}}",
"createLauncherFailed": "Failed to create launcher",
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/session-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
"connect": "连接浏览器",
"connectSuccess": "已连接 {{browser}}",
"connectFailed": "连接浏览器失败",
"restartSuccess": "已重启 {{browser}} 并启用调试模式",
"restartFailed": "重启浏览器并启用调试失败",
"restartModal": {
"title": "启用浏览器调试模式",
"description": "检测到 {{browser}} 已在运行,当前实例未启用调试端口。要让 BitFun 控制浏览器,需要先重启浏览器并以调试模式启动。",
"warning": "此操作会关闭当前浏览器窗口。",
"cancel": "取消",
"confirm": "重启并启用调试",
"restarting": "正在重启..."
},
"createLauncher": "创建启动器",
"createLauncherSuccess": "启动器已创建:{{path}}",
"createLauncherFailed": "创建启动器失败",
Expand Down
Loading