okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f479db5
main
1"""Browser ownership, target routing and explicit event scopes.""" 2 3from __future__import annotations 4 5from typing import SupportsIndex 6 7import asyncio 8import os 9from pathlib import Path 10from typing import Any ,TYPE_CHECKING 11from urllib .parse import urlsplit ,urlunsplit 12 13import json 14 15from .errors import BrowserError ,ConnectionLost ,UnsupportedOperation 16from .events import EventScope 17from .page import Page 18from .transport import CDPSession ,Connection ,JSON ,positive 19 20 21if TYPE_CHECKING : 22from .launch import OwnedChrome 23 24 25class Browser : 26def __init__ ( 27self , 28connection :Connection , 29endpoint :str , 30* , 31headers :dict [str ,str ]| None = None , 32 )-> None : 33self ._connection = connection 34self .endpoint = endpoint 35self ._headers = dict (headers )if headers else None 36self ._lifecycle_lock = asyncio .Lock () 37self .version :JSON = {} 38self .cdp = CDPSession (connection ) 39self ._pages :dict [str ,Page ]= {} 40self ._routes :dict [str ,Page ]= {} 41self ._contexts :set [str ]= set () 42self ._owner :OwnedChrome | None = None 43self ._close_task :asyncio .Task [None ]| None = None 44connection .on_event = self ._on_event 45 46def _on_event (self ,method :str ,params :JSON ,session_id :str | None )-> None : 47page = self ._routes .get (session_id ) 48if page is not None and session_id is not None : 49page ._on_event (method ,params ,session_id ) 50if method == "Target.detachedFromTarget" : 51sid = params .get ("sessionId" ) 52self ._connection .detach_session (sid ) 53owner = self ._routes .get (sid ) 54if owner is not None and isinstance (sid ,str ): 55owner ._detached (sid ) 56self ._routes .pop (sid ,None ) 57if method == "Target.targetDestroyed" : 58owner = self ._pages .get (params .get ("targetId" )) 59if owner is not None : 60owner ._invalidate (destroyed = True ) 61 62async def pages (self )-> list [JSON ]: 63result = await self .cdp .send ("Target.getTargets" ) 64return [dict (t )for t in result ["targetInfos" ]if t ["type" ]== "page" ] 65 66async def attach (self ,target_id :str ,* ,owned :bool = False )-> Page : 67page = self ._pages .get (target_id ) 68if page is not None and not page ._destroyed : 69return page if page .attached else await page .reattach () 70async with self ._lifecycle_lock : 71# Another attach may have completed while waiting for the lock. 72page = self ._pages .get (target_id ) 73if page is not None and not page ._destroyed : 74if page .attached : 75return page 76async with page ._lock : 77async with page ._settings_lock : 78return await page ._reattach_locked ( 79restore_settings = True ,max_message_bytes = None 80 ) 81return await self ._attach_new (target_id ,owned = owned ) 82 83async def _attach_new (self ,target_id :str ,* ,owned :bool = False )-> Page : 84result = await self .cdp .send ( 85"Target.attachToTarget" , {"targetId" :target_id ,"flatten" :True } 86 ) 87sid = result ["sessionId" ] 88page = Page (self ,target_id ,sid ,owned = owned ) 89self ._pages [target_id ]= page 90self ._routes [sid ]= page 91try : 92await page ._initialize () 93except BaseException : 94self ._pages .pop (target_id ,None ) 95page ._invalidate () 96await page ._stop_tasks () 97try : 98await self .cdp .send ( 99"Target.detachFromTarget" , {"sessionId" :sid },timeout = 2 100 ) 101except Exception : 102pass 103raise 104return page 105 106async def new_page ( 107self ,url :str = "about:blank" ,* ,context_id :str | None = None 108 )-> Page : 109params :JSON = {"url" :"about:blank" } 110if context_id is not None : 111params ["browserContextId" ]= context_id 112result = await self .cdp .send ("Target.createTarget" ,params ) 113target_id = result ["targetId" ] 114page = await self .attach (target_id ,owned = True ) 115if url != "about:blank" : 116await page .goto (url ) 117return page 118 119async def new_context (self )-> str : 120"""Create isolated session storage. Dispose explicitly; disconnect leaves it alive.""" 121context = (await self .cdp .send ("Target.createBrowserContext" ))[ 122"browserContextId" 123 ] 124self ._contexts .add (context ) 125return context 126 127async def dispose_context (self ,context_id :str )-> None : 128if context_id not in self ._contexts : 129raise UnsupportedOperation ( 130"Will not dispose a context this connection did not create." 131 ) 132await self .cdp .send ( 133"Target.disposeBrowserContext" , {"browserContextId" :context_id } 134 ) 135self ._contexts .discard (context_id ) 136 137def events (self ,method :str ,** options :Any )-> EventScope : 138return EventScope (self .cdp ,method ,** options ) 139 140async def disconnect (self )-> None : 141"""Disconnect only. Pages retain identity/settings for explicit reattach.""" 142async with self ._lifecycle_lock : 143await self ._disconnect () 144 145async def _disconnect (self )-> None : 146for page in list (self ._pages .values ()): 147page ._invalidate () 148await page ._stop_tasks () 149await self ._connection .close () 150self ._routes .clear () 151 152async def _ensure_connection (self ,max_message_bytes :int | None = None )-> bool : 153"""Called only with the lifecycle lock held. Never launches another process.""" 154old = self ._connection 155if not old .closed : 156return False 157for page in list (self ._pages .values ()): 158page ._invalidate () 159await page ._stop_tasks () 160self ._routes .clear () 161await old .close () 162replacement = await connect ( 163self .endpoint , 164timeout = old .timeout , 165headers = self ._headers , 166max_message_bytes = ( 167old .max_message_bytes 168if max_message_bytes is None 169else max_message_bytes 170 ), 171 ) 172# Transfer only transport ownership; the original Browser retains its 173# process, profiles, contexts and page handles. There is no await below. 174self ._connection = replacement ._connection 175self .cdp = CDPSession (self ._connection ) 176self .version = replacement .version 177self ._connection .on_event = self ._on_event 178return True 179 180@property 181def owns_process (self )-> bool : 182"""Whether this handle was returned by spawn(), not connect().""" 183return self ._owner is not None 184 185@property 186def pid (self )-> int | None : 187"""Launched PID (retained for diagnostics after close), or None for connect().""" 188return self ._owner .process .pid if self ._owner and self ._owner .process else None 189 190@property 191def profile (self )-> Path | None : 192"""Owned profile path, or None; only temporary profiles are deleted on close.""" 193return self ._owner .profile if self ._owner else None 194 195async def _close (self )-> None : 196async with self ._lifecycle_lock : 197await self ._close_locked () 198 199async def _close_locked (self )-> None : 200owner = self ._owner 201try : 202if owner is not None and not self ._connection .closed : 203try : 204await self .cdp .send ("Browser.close" ,timeout = 2 ) 205except BrowserError : 206pass # Closing the browser can close the socket before its reply. 207await owner .wait (2 ) 208finally : 209try : 210await self ._disconnect () 211finally : 212if owner is not None : 213await owner .stop () 214 215async def close (self )-> None : 216"""Disconnect; also stop Chrome and clean temporary data when spawned here. 217 218Attached browsers are never terminated. Idempotent, including concurrent 219close calls. Cancellation propagates after bounded cleanup completes. 220""" 221from .launch import _finish_cleanup 222 223if self ._close_task is None or ( 224self ._close_task .done () 225and ( 226self ._close_task .cancelled ()or self ._close_task .exception ()is not None 227 ) 228 ): 229self ._close_task = asyncio .create_task (self ._close (),name = "browser-close" ) 230await _finish_cleanup (self ._close_task ) 231 232async def __aenter__ (self )-> Browser : 233return self 234 235async def __aexit__ (self ,* exc :Any )-> None : 236await self .close () 237 238def __reduce_ex__ (self ,protocol :SupportsIndex ,/ )-> Any : 239raise TypeError ( 240"Live browsers cannot be saved; save endpoint and page.descriptor()." 241 ) 242 243 244async def connect ( 245endpoint :str | None = None , 246* , 247timeout :float = 15 , 248headers :dict [str ,str ]| None = None , 249max_message_bytes :int = 16 * 1024 * 1024 , 250)-> Browser : 251"""Attach to a browser-level CDP endpoint; do not launch or own its process. 252 253Accepts an HTTP base URL (discovers /json/version) or a browser WebSocket URL. 254Headers are sent to discovery and the WebSocket handshake, but never saved in 255reconnect descriptors. Environment proxies are deliberately disabled. 256""" 257positive (timeout ,"timeout" ) 258endpoint = endpoint or os .environ .get ( 259"ALBEDO_BROWSER_CDP_URL" ,"http://127.0.0.1:9222" 260 ) 261parsed = urlsplit (endpoint ) 262if parsed .scheme not in {"http" ,"https" ,"ws" ,"wss" }or not parsed .hostname : 263raise ValueError ("Expected an http(s) or ws(s) CDP endpoint" ) 264ws_url = endpoint 265if parsed .scheme in {"http" ,"https" }: 266path = parsed .path .rstrip ("/" ) 267if not path .endswith ("/json/version" ): 268path += "/json/version" 269discovery = urlunsplit ((parsed .scheme ,parsed .netloc ,path ,parsed .query ,"" )) 270 271def _get_discovery ()-> str : 272import urllib .request # one GET at connect time; not worth every kernel's boot 273 274req = urllib .request .Request (discovery ,headers = headers or {}) 275opener = urllib .request .build_opener (urllib .request .ProxyHandler ({})) 276with opener .open (req ,timeout = timeout )as resp : 277data = json .loads (resp .read ().decode ("utf-8" )) 278return str (data ["webSocketDebuggerUrl" ]) 279 280try : 281ws_url = await asyncio .to_thread (_get_discovery ) 282except Exception as exc : 283raise ConnectionLost ( 284"CDP discovery failed. Start Chrome with a dedicated debugging profile, " 285"or supply a browser WebSocket URL." , 286cause = type (exc ).__name__ , 287 )from exc 288# Never forward caller-supplied credentials to an arbitrary discovery hostname. 289discovered = urlsplit (ws_url ) 290if headers and ( 291discovered .hostname != parsed .hostname 292or (parsed .scheme == "https" and discovered .scheme != "wss" ) 293 ): 294raise ConnectionLost ( 295"Discovery returned a different host; use its explicit WebSocket URL with appropriate credentials." 296 ) 297if "/devtools/page/" in ws_url : 298raise ValueError ( 299"Use the browser endpoint from /json/version, not a page WebSocket URL" 300 ) 301connection = await Connection .open ( 302ws_url ,timeout = timeout ,headers = headers ,max_message_bytes = max_message_bytes 303 ) 304browser = Browser (connection ,endpoint ,headers = headers ) 305try : 306browser .version = await browser .cdp .send ("Browser.getVersion" ) 307await browser .cdp .send ("Target.setDiscoverTargets" , {"discover" :True }) 308return browser 309except BaseException : 310await browser .disconnect () 311raise