diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 8d257bfe..b2cf35d1 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -49,7 +49,7 @@ def _is_cdp_port_alive(port: int, timeout: float = 1.0) -> bool: try: with socket.create_connection(("localhost", port), timeout=timeout): return True - except (ConnectionRefusedError, OSError, socket.timeout): + except (TimeoutError, ConnectionRefusedError, OSError): return False @@ -234,7 +234,11 @@ def browser_agent(options: Chat): browser_log_to_file=True, stealth=True, session_id=toolkit_session_id, - **({"default_start_url": "about:blank"} if not use_pool_browser else {}), + **( + {"default_start_url": "about:blank"} + if not use_pool_browser + else {} + ), cdp_url=f"http://localhost:{selected_port}", cdp_keep_current_page=use_pool_browser, enabled_tools=[ diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index 83d01d57..11678bd8 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -78,15 +78,11 @@ def _find_chromium_executable() -> str | None: system = pf.system() if system == "Darwin": - cache_dir = os.path.join( - home, "Library", "Caches", "ms-playwright" - ) + cache_dir = os.path.join(home, "Library", "Caches", "ms-playwright") elif system == "Linux": cache_dir = os.path.join(home, ".cache", "ms-playwright") elif system == "Windows": - cache_dir = os.path.join( - home, "AppData", "Local", "ms-playwright" - ) + cache_dir = os.path.join(home, "AppData", "Local", "ms-playwright") else: return None @@ -138,9 +134,7 @@ def _find_chromium_executable() -> str | None: ), ] elif system == "Linux": - candidates = [ - os.path.join(base, "chrome-linux", "chrome") - ] + candidates = [os.path.join(base, "chrome-linux", "chrome")] else: # Windows candidates = [ os.path.join(base, "chrome-win64", "chrome.exe"), @@ -160,8 +154,7 @@ def _find_system_chrome() -> str | None: if system == "Darwin": chrome_path = ( - "/Applications/Google Chrome.app" - "/Contents/MacOS/Google Chrome" + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ) if os.path.exists(chrome_path): return chrome_path @@ -1007,9 +1000,7 @@ async def launch_cdp_browser(): ), ) - logger.info( - f"[BROWSER LAUNCH] Found available port: {port}" - ) + logger.info(f"[BROWSER LAUNCH] Found available port: {port}") # 2. Find Chromium executable chrome_executable = _find_chromium_executable() @@ -1022,9 +1013,7 @@ async def launch_cdp_browser(): ), ) - logger.info( - f"[BROWSER LAUNCH] Using Chromium: {chrome_executable}" - ) + logger.info(f"[BROWSER LAUNCH] Using Chromium: {chrome_executable}") # 3. Create user data directory user_data_dir = os.path.join( @@ -1044,10 +1033,7 @@ async def launch_cdp_browser(): "about:blank", ] - logger.info( - "[BROWSER LAUNCH] Spawning Chromium" - f" on port {port}" - ) + logger.info(f"[BROWSER LAUNCH] Spawning Chromium on port {port}") process = subprocess.Popen( args, @@ -1056,10 +1042,7 @@ async def launch_cdp_browser(): ) _launched_browser_processes[port] = process - logger.info( - "[BROWSER LAUNCH] Chromium spawned," - f" PID: {process.pid}" - ) + logger.info(f"[BROWSER LAUNCH] Chromium spawned, PID: {process.pid}") # 5. Poll for browser readiness (max 5 seconds) max_wait = 5.0 @@ -1104,18 +1087,13 @@ async def launch_cdp_browser(): "success": True, "port": port, "data": browser_info, - "message": ( - "Browser launched successfully" - f" on port {port}" - ), + "message": (f"Browser launched successfully on port {port}"), } except HTTPException: raise except Exception as e: - logger.error( - f"[BROWSER LAUNCH] Failed: {e}", exc_info=True - ) + logger.error(f"[BROWSER LAUNCH] Failed: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to launch browser: {e!s}", @@ -1171,10 +1149,7 @@ async def launch_chrome_with_profile( if not os.path.isdir(profile_path): raise HTTPException( status_code=404, - detail=( - f"Profile '{request.profile_directory}'" - " not found" - ), + detail=(f"Profile '{request.profile_directory}' not found"), ) # 4. Find available port @@ -1203,9 +1178,7 @@ async def launch_chrome_with_profile( os.makedirs(wrapper_dir, exist_ok=True) # Symlink requested profile into wrapper - link_dst = os.path.join( - wrapper_dir, request.profile_directory - ) + link_dst = os.path.join(wrapper_dir, request.profile_directory) real_profile = os.path.join( chrome_user_data_dir, request.profile_directory ) @@ -1216,12 +1189,8 @@ async def launch_chrome_with_profile( os.symlink(real_profile, link_dst) # Copy Local State (small JSON, needed by Chrome) - local_state_src = os.path.join( - chrome_user_data_dir, "Local State" - ) - local_state_dst = os.path.join( - wrapper_dir, "Local State" - ) + local_state_src = os.path.join(chrome_user_data_dir, "Local State") + local_state_dst = os.path.join(wrapper_dir, "Local State") if os.path.isfile(local_state_src): shutil.copy2(local_state_src, local_state_dst) @@ -1241,10 +1210,7 @@ async def launch_chrome_with_profile( ) _launched_browser_processes[port] = process - logger.info( - "[CHROME LAUNCH] Chrome spawned," - f" PID: {process.pid}" - ) + logger.info(f"[CHROME LAUNCH] Chrome spawned, PID: {process.pid}") # 6. Poll for browser readiness (max 8 seconds, # real Chrome may be slower than Chromium) @@ -1273,16 +1239,12 @@ async def launch_chrome_with_profile( elapsed = time.time() - start_time raise HTTPException( status_code=409, - detail=( - "Chrome failed to start CDP" - f" (waited {elapsed:.1f}s)" - ), + detail=(f"Chrome failed to start CDP (waited {elapsed:.1f}s)"), ) elapsed = time.time() - start_time logger.info( - "[CHROME LAUNCH] Chrome ready on" - f" port {port} after {elapsed:.1f}s" + f"[CHROME LAUNCH] Chrome ready on port {port} after {elapsed:.1f}s" ) return { @@ -1300,9 +1262,7 @@ async def launch_chrome_with_profile( except HTTPException: raise except Exception as e: - logger.error( - f"[CHROME LAUNCH] Failed: {e}", exc_info=True - ) + logger.error(f"[CHROME LAUNCH] Failed: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to launch Chrome: {e!s}", diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index ed4e319f..b5a00920 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -58,14 +58,14 @@ const Layout = () => { if (['running', 'pause'].includes(currentStatus)) { setNoticeOpen(true); } else { - window.electronAPI.closeWindow(true); + window.electronAPI?.closeWindow(true); } }; - window.ipcRenderer.on('before-close', handleBeforeClose); + window.ipcRenderer?.on('before-close', handleBeforeClose); return () => { - window.ipcRenderer.removeAllListeners('before-close'); + window.ipcRenderer?.removeAllListeners('before-close'); }; }, [chatStore.tasks, chatStore.activeTaskId]); diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 6c956ab7..317eab71 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -66,8 +66,8 @@ function HeaderWin() { isInstalling || installationState === 'waiting-backend'; useEffect(() => { - const p = window.electronAPI.getPlatform(); - setPlatform(p); + const p = window.electronAPI?.getPlatform(); + if (p) setPlatform(p); }, []); const logoSrc = appearance === 'dark' ? folderIconWhite : folderIconBlack; diff --git a/src/components/WindowControls/index.tsx b/src/components/WindowControls/index.tsx index f5725998..55bd6bbf 100644 --- a/src/components/WindowControls/index.tsx +++ b/src/components/WindowControls/index.tsx @@ -21,8 +21,8 @@ export default function WindowControls() { const [platform, setPlatform] = useState(''); useEffect(() => { - const p = window.electronAPI.getPlatform(); - setPlatform(p); + const p = window.electronAPI?.getPlatform(); + if (p) setPlatform(p); // Hide custom controls on macOS (uses native traffic lights) // and on Windows (now uses native frame with native controls) diff --git a/src/components/WorkSpaceMenu/index.tsx b/src/components/WorkSpaceMenu/index.tsx index 83a75a56..40c5462a 100644 --- a/src/components/WorkSpaceMenu/index.tsx +++ b/src/components/WorkSpaceMenu/index.tsx @@ -96,7 +96,7 @@ export function WorkSpaceMenu() { useEffect(() => { if (!chatStore) return; - const cleanup = window.electronAPI.onWebviewNavigated( + const cleanup = window.electronAPI?.onWebviewNavigated( (id: string, url: string) => { if (!chatStore.activeTaskId) return; let webViewUrls = [ diff --git a/src/components/update/index.tsx b/src/components/update/index.tsx index 60d17987..835660b0 100644 --- a/src/components/update/index.tsx +++ b/src/components/update/index.tsx @@ -24,7 +24,7 @@ const Update = () => { const { t } = useTranslation(); const checkUpdate = () => { - window.ipcRenderer.invoke('check-update'); + window.ipcRenderer?.invoke('check-update'); }; const onUpdateCanAvailable = useCallback( diff --git a/src/hooks/useInstallationSetup.ts b/src/hooks/useInstallationSetup.ts index 10caf706..cb9cd3cc 100644 --- a/src/hooks/useInstallationSetup.ts +++ b/src/hooks/useInstallationSetup.ts @@ -55,7 +55,7 @@ export const useInstallationSetup = () => { // Immediately check backend status once const checkBackendStatus = async () => { try { - const backendPort = await window.electronAPI.getBackendPort(); + const backendPort = await window.electronAPI?.getBackendPort(); if (backendPort && backendPort > 0) { console.log( '[useInstallationSetup] Backend immediately detected on port:', @@ -101,7 +101,7 @@ export const useInstallationSetup = () => { // This is a fallback in case the backend-ready event is missed const pollInterval = setInterval(async () => { try { - const backendPort = await window.electronAPI.getBackendPort(); + const backendPort = await window.electronAPI?.getBackendPort(); if (backendPort && backendPort > 0) { console.log( '[useInstallationSetup] Backend poll detected ready on port:', @@ -178,7 +178,7 @@ export const useInstallationSetup = () => { const checkToolInstalled = async () => { try { - const result = await window.ipcRenderer.invoke('check-tool-installed'); + const result = await window.ipcRenderer?.invoke('check-tool-installed'); if (result.success) { if (result.isInstalled) { @@ -217,7 +217,7 @@ export const useInstallationSetup = () => { const checkBackendStatus = async (_toolResult?: any) => { try { const installationStatus = - await window.electronAPI.getInstallationStatus(); + await window.electronAPI?.getInstallationStatus(); if (installationStatus.success && installationStatus.isInstalling) { startInstallation(); @@ -325,16 +325,16 @@ export const useInstallationSetup = () => { } }; - window.electronAPI.onInstallDependenciesStart(handleInstallStart); - window.electronAPI.onInstallDependenciesLog(handleInstallLog); - window.electronAPI.onInstallDependenciesComplete(handleInstallComplete); - window.electronAPI.onBackendReady(handleBackendReady); + window.electronAPI?.onInstallDependenciesStart(handleInstallStart); + window.electronAPI?.onInstallDependenciesLog(handleInstallLog); + window.electronAPI?.onInstallDependenciesComplete(handleInstallComplete); + window.electronAPI?.onBackendReady(handleBackendReady); return () => { - window.electronAPI.removeAllListeners('install-dependencies-start'); - window.electronAPI.removeAllListeners('install-dependencies-log'); - window.electronAPI.removeAllListeners('install-dependencies-complete'); - window.electronAPI.removeAllListeners('backend-ready'); + window.electronAPI?.removeAllListeners('install-dependencies-start'); + window.electronAPI?.removeAllListeners('install-dependencies-log'); + window.electronAPI?.removeAllListeners('install-dependencies-complete'); + window.electronAPI?.removeAllListeners('backend-ready'); }; }, [ startInstallation, diff --git a/src/pages/Dashboard/Browser.tsx b/src/pages/Dashboard/Browser.tsx index 522bcb65..288d40ea 100644 --- a/src/pages/Dashboard/Browser.tsx +++ b/src/pages/Dashboard/Browser.tsx @@ -20,6 +20,7 @@ import { Chrome, Cookie, Globe, + Link2, Loader2, Plus, RefreshCw, @@ -81,6 +82,12 @@ export default function Browser() { null ); + // Connect Existing Browser dialog + const [showConnectDialog, setShowConnectDialog] = useState(false); + const [connectPort, setConnectPort] = useState(''); + const [connectChecking, setConnectChecking] = useState(false); + const [connectError, setConnectError] = useState(''); + // Extract main domain (e.g., "aa.bb.cc" -> "bb.cc", "www.google.com" -> "google.com") const getMainDomain = (domain: string): string => { // Remove leading dot if present @@ -281,6 +288,59 @@ export default function Browser() { }); } }; + const handleConnectExistingBrowser = () => { + setConnectPort(''); + setConnectError(''); + setShowConnectDialog(true); + }; + + const handleCheckAndConnect = async () => { + const portNum = parseInt(connectPort, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + setConnectError(t('layout.invalid-port')); + return; + } + + // Check if port is already in the pool + if (cdpBrowsers.some((b) => b.port === portNum)) { + setConnectError(t('layout.port-already-in-use')); + return; + } + + setConnectChecking(true); + setConnectError(''); + + try { + // Probe the port to check if a CDP browser is listening + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`http://localhost:${portNum}/json/version`, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + setConnectError(t('layout.no-browser-on-port', { port: portNum })); + return; + } + + // Port is alive — add to CDP pool + if (window.electronAPI?.addCdpBrowser) { + await window.electronAPI.addCdpBrowser( + portNum, + true, + `External Browser (${portNum})` + ); + } + + toast.success(t('layout.connected-browser', { port: portNum })); + setShowConnectDialog(false); + } catch { + setConnectError(t('layout.no-browser-on-port', { port: portNum })); + } finally { + setConnectChecking(false); + } + }; const handleBrowserLogin = async () => { setLoginLoading(true); @@ -499,6 +559,59 @@ export default function Browser() { )} + {/* Connect Existing Browser Dialog */} + {showConnectDialog && ( +
+
+
+ {t('layout.connect-existing-browser')} +
+

+ {t('layout.connect-existing-browser-description')} +

+ { + setConnectPort(e.target.value); + setConnectError(''); + }} + placeholder={t('layout.enter-port-number')} + className="w-full rounded-lg border border-border-disabled bg-surface-secondary px-4 py-2 text-body-sm text-text-body outline-none focus:border-border-focus" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCheckAndConnect(); + }} + /> + {connectError && ( +

+ {connectError} +

+ )} +
+ + +
+
+
+ )}
{/* Left Sidebar */} @@ -562,6 +675,14 @@ export default function Browser() { ? t('layout.launching-chrome') : t('layout.open-my-chrome')} +
{/* CDP Browser Pool */} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 7f070829..0d562de0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -170,7 +170,7 @@ export default function Home() { const webviewContainer = document.getElementById('webview-container'); if (webviewContainer) { const rect = webviewContainer.getBoundingClientRect(); - window.electronAPI.setSize({ + window.electronAPI?.setSize({ x: rect.left, y: rect.top, width: rect.width,