from __future__ import annotations import logging import shutil import subprocess import time from dataclasses import dataclass from pathlib import Path from .browser_profile import FirefoxHardening, ensure_firefox_profile from .paths import app_data_dir log = logging.getLogger(__name__) @dataclass class BrowserConfig: firefox_path: str = "" profile_dir: str = "" clear_on_close: bool = True disposable_profile: bool = False kill_on_chain_drop: bool = True auto_relaunch: bool = False lock_managed_profile: bool = True force_proxy: bool = True disable_webrtc: bool = True resist_fingerprinting: bool = True disable_telemetry: bool = True first_party_isolation: bool = True strict_tracking_protection: bool = True timezone_utc: bool = True def default_firefox_path() -> str: candidates = [ Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"), Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"), ] for c in candidates: if c.is_file(): return str(c) return "" def default_profile_dir() -> str: return str(app_data_dir() / "browser_profiles" / "firefox_hardened") class BrowserSession: def __init__(self) -> None: self._proc: subprocess.Popen[str] | None = None self._profile_path: Path | None = None def is_running(self) -> bool: return self._proc is not None and self._proc.poll() is None def pid(self) -> int | None: if self._proc is None: return None return self._proc.pid def launch( self, cfg: BrowserConfig, proxy_host: str, proxy_port: int, ) -> tuple[bool, str]: raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'") exe = Path(raw_exe) if not exe.is_file(): return False, f"Firefox executable not found: {raw_exe or '(empty path)'}" raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'") profile_dir = Path(raw_profile) hard = FirefoxHardening( force_proxy=cfg.force_proxy, disable_webrtc=cfg.disable_webrtc, resist_fingerprinting=cfg.resist_fingerprinting, disable_telemetry=cfg.disable_telemetry, first_party_isolation=cfg.first_party_isolation, strict_tracking_protection=cfg.strict_tracking_protection, clear_on_shutdown=cfg.clear_on_close, timezone_utc=cfg.timezone_utc, ) if cfg.lock_managed_profile: ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard) if self.is_running(): self.stop() if cfg.lock_managed_profile: args = [str(exe), "-no-remote", "-profile", str(profile_dir)] else: args = [str(exe)] try: self._proc = subprocess.Popen( args, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True, ) # Smoke-check: catch immediate startup crashes and report clearly. time.sleep(0.4) rc = self._proc.poll() if rc is not None: self._proc = None return False, f"Firefox exited immediately (code {rc}). Check path/profile." self._profile_path = profile_dir if cfg.lock_managed_profile else None log.info("Launched hardened Firefox pid=%s profile=%s", self._proc.pid, profile_dir) if cfg.lock_managed_profile: return True, f"Hardened Firefox started (pid {self._proc.pid})." return True, f"Firefox started (unlocked mode, pid {self._proc.pid})." except Exception as e: self._proc = None return False, f"Launch failed: {e!s}" def stop(self, dispose: bool = False) -> tuple[bool, str]: if self._proc and self._proc.poll() is None: try: self._proc.terminate() self._proc.wait(timeout=8) except Exception: try: self._proc.kill() except Exception: pass finally: self._proc = None if dispose and self._profile_path and self._profile_path.exists(): try: shutil.rmtree(self._profile_path, ignore_errors=True) except Exception: pass return True, "Browser stopped."