diff --git a/src/apps/desktop/src/api/browser_control_api.rs b/src/apps/desktop/src/api/browser_control_api.rs index 209948a7f..e36bd0421 100644 --- a/src/apps/desktop/src/api/browser_control_api.rs +++ b/src/apps/desktop/src/api/browser_control_api.rs @@ -18,6 +18,52 @@ fn default_cdp_port() -> u16 { DEFAULT_CDP_PORT } +/// Reattach to a browser that is already running with remote debugging on. +/// +/// The browser remembers the remote debugging preference across its own +/// restarts, and it keeps an approved connection grant for as long as it stays +/// running — but BitFun's connection registry lives in this process, so every +/// BitFun restart otherwise leaves Settings reporting "not connected" until +/// something asks for the browser. Reattaching here restores that connection +/// without the user having to click anything. +/// +/// This never starts a browser and never opens a settings page: when there is +/// no live endpoint to reattach to, it does nothing and leaves the on-demand +/// path to handle it. +pub fn init_on_startup() { + tokio::spawn(async { + let Ok(kind) = selected_browser_kind().await else { + return; + }; + let Some(endpoint) = BrowserLauncher::user_profile_debug_endpoint(&kind) else { + return; + }; + if CdpClient::browser_connection_for_kind(DEFAULT_CDP_PORT, &kind) + .await + .is_some() + { + return; + } + // A denial or an approval timeout is an ordinary outcome here, not an + // error worth surfacing: the user never asked for this connection. + match CdpClient::connect_user_profile_browser( + DEFAULT_CDP_PORT, + endpoint.port, + &kind, + &endpoint.web_socket_url, + ) + .await + { + Ok(_) => log::info!("Reattached to the running {} profile on startup", kind), + Err(error) => log::info!( + "Could not reattach to the running {} profile on startup: {}", + kind, + error + ), + } + }); +} + async fn selected_browser_kind() -> Result { let config = get_global_config_service() .await @@ -93,6 +139,10 @@ pub struct BrowserControlStatusResponse { pub cdp_available: bool, pub default_cdp_supported: bool, pub default_cdp_enabled: bool, + /// The selected browser is running with remote debugging on, so BitFun can + /// attach whenever it needs to. Distinguishes "ready, nothing attached yet" + /// from "nothing to attach to", which both used to read as "not connected". + pub browser_ready: bool, pub browser_kind: String, pub browser_version: Option, pub port: u16, @@ -107,7 +157,14 @@ pub async fn browser_control_get_status( let port = request.port; let configured_kind = selected_browser_kind().await?; let default_cdp_supported = BrowserLauncher::supports_default_cdp(&configured_kind); - let default_cdp_enabled = BrowserLauncher::is_default_cdp_enabled(&configured_kind); + // Probe the live endpoint once and answer both questions from it: whether + // the persistent setting is on, and whether there is something to attach to + // right now. The probe is a file read plus a short local TCP connect, so it + // never prompts the browser the way attaching does. + let user_profile_endpoint = BrowserLauncher::user_profile_debug_endpoint(&configured_kind); + let default_cdp_enabled = default_cdp_supported + && (user_profile_endpoint.is_some() + || BrowserLauncher::is_default_cdp_enabled(&configured_kind)); let user_profile_connection = CdpClient::browser_connection_for_kind(port, &configured_kind).await; let legacy_version = @@ -132,6 +189,7 @@ pub async fn browser_control_get_status( } }); let available = user_profile_connection.is_some() || legacy_matches_selection; + let browser_ready = available || user_profile_endpoint.is_some(); let (version, page_count, actual_kind) = if available { let ver_info = if let Some(connection) = &user_profile_connection { @@ -167,6 +225,7 @@ pub async fn browser_control_get_status( cdp_available: available, default_cdp_supported, default_cdp_enabled, + browser_ready, browser_kind: actual_kind.to_string(), browser_version: version, port, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 1118116a4..aa24b400d 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1070,6 +1070,16 @@ pub async fn run() { step_started, ); + // Reattach to a browser that is already running with remote + // debugging on, so a BitFun restart does not drop the connection. + let step_started = Instant::now(); + api::browser_control_api::init_on_startup(); + startup_trace.record_elapsed_step( + "native_setup", + "browser_control_init_on_startup", + step_started, + ); + { let step_started = Instant::now(); let _terminal_state: tauri::State<'_, api::terminal_api::TerminalState> = diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs index a0fc78c11..f25a72903 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs @@ -61,6 +61,13 @@ impl BrowserLauncher { provider::BrowserLauncher::is_default_cdp_enabled(kind) } + /// Browser-level endpoint published by a browser that is running right now + /// with remote debugging enabled. `None` means there is nothing to attach + /// to without going through the launch flow. + pub fn user_profile_debug_endpoint(kind: &BrowserKind) -> Option { + provider::BrowserLauncher::user_profile_debug_endpoint(kind) + } + pub async fn launch_with_cdp(kind: &BrowserKind, port: u16) -> BitFunResult { Ok(provider::BrowserLauncher::launch_with_cdp_options( kind, diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index ee951e4fd..75625fa5f 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -143,6 +143,7 @@ const SessionSettingsPanels: React.FC = ({ variant } // ── Browser control state ─────────────────────────────────────────────── const [browserCdpAvailable, setBrowserCdpAvailable] = useState(false); + const [browserReady, setBrowserReady] = useState(false); const [browserDefaultCdpSupported, setBrowserDefaultCdpSupported] = useState(false); const [browserDefaultCdpEnabled, setBrowserDefaultCdpEnabled] = useState(false); const [browserKind, setBrowserKind] = useState(''); @@ -191,6 +192,7 @@ const SessionSettingsPanels: React.FC = ({ variant } cdpAvailable: boolean; defaultCdpSupported: boolean; defaultCdpEnabled: boolean; + browserReady: boolean; browserKind: string; browserVersion: string | null; port: number; @@ -201,6 +203,7 @@ const SessionSettingsPanels: React.FC = ({ variant } setBrowserCdpAvailable(s.cdpAvailable); setBrowserDefaultCdpSupported(s.defaultCdpSupported); setBrowserDefaultCdpEnabled(s.defaultCdpEnabled); + setBrowserReady(s.browserReady); setBrowserKind(s.browserKind); setBrowserVersion(s.browserVersion); setBrowserPageCount(s.pageCount); @@ -899,9 +902,15 @@ const SessionSettingsPanels: React.FC = ({ variant } const computerUseScreenLabel = computerUseStatusLoading ? t('loading.text') : computerUseScreen ? t('computerUse.granted') : t('computerUse.notGranted'); + // A ready browser is not a failure state: BitFun attaches to it the moment + // something needs it, so say that rather than the bare "not connected". const browserStatusLabel = browserCdpAvailable ? `${browserKind} · ${browserPageCount} ${t('browserControl.tabs')}` - : browserStatusLoading ? t('loading.text') : t('browserControl.notConnected'); + : browserStatusLoading + ? t('loading.text') + : browserReady + ? t('browserControl.readyNotConnected') + : t('browserControl.notConnected'); const browserSelectOptions: SelectOption[] = browserOptions.map((option) => ({ value: option.value, label: option.installed ? option.label : `${option.label} (${t('browserControl.notInstalled')})`, diff --git a/src/web-ui/src/locales/en-US/settings/session-config.json b/src/web-ui/src/locales/en-US/settings/session-config.json index 823e5bf43..6bb6f5b50 100644 --- a/src/web-ui/src/locales/en-US/settings/session-config.json +++ b/src/web-ui/src/locales/en-US/settings/session-config.json @@ -148,6 +148,7 @@ "status": "Connection", "statusDesc": "", "notConnected": "Not connected", + "readyNotConnected": "Ready, connects on use", "refreshStatus": "Refresh status", "connect": "Connect", "defaultCdp": "Default CDP", diff --git a/src/web-ui/src/locales/zh-CN/settings/session-config.json b/src/web-ui/src/locales/zh-CN/settings/session-config.json index 15331ce00..196216219 100644 --- a/src/web-ui/src/locales/zh-CN/settings/session-config.json +++ b/src/web-ui/src/locales/zh-CN/settings/session-config.json @@ -148,6 +148,7 @@ "status": "连接状态", "statusDesc": "", "notConnected": "未连接", + "readyNotConnected": "已就绪,使用时自动连接", "refreshStatus": "刷新状态", "connect": "连接浏览器", "defaultCdp": "默认 CDP", diff --git a/src/web-ui/src/locales/zh-TW/settings/session-config.json b/src/web-ui/src/locales/zh-TW/settings/session-config.json index 526b794b5..6b163bac2 100644 --- a/src/web-ui/src/locales/zh-TW/settings/session-config.json +++ b/src/web-ui/src/locales/zh-TW/settings/session-config.json @@ -145,6 +145,7 @@ "status": "連接狀態", "statusDesc": "", "notConnected": "未連接", + "readyNotConnected": "已就緒,使用時自動連接", "refreshStatus": "重新整理狀態", "connect": "連接瀏覽器", "defaultCdp": "預設 CDP",