from __future__ import annotations import logging import os import shutil import subprocess import time import winreg from dataclasses import dataclass from pathlib import Path from .browser_profile import FirefoxHardening, ensure_firefox_profile, persona_env from .paths import app_data_dir from .signup_prep import SignupDraft, install_signup_extension log = logging.getLogger(__name__) # CREATE_NO_WINDOW suppresses the console; DETACHED_PROCESS fully separates # the child from Python's job object so Firefox lives past Python's exit. _CREATE_FLAGS = ( getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) ) @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 persona: str = "blend_windows_chrome" cookie_mode: str = "block_third_party" def _find_firefox_via_registry() -> str: """Look up Firefox install path in the Windows registry.""" hives = [ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Mozilla\Mozilla Firefox"), (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Mozilla\Mozilla Firefox"), (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Mozilla\Mozilla Firefox"), ] for hive, key_path in hives: try: with winreg.OpenKey(hive, key_path) as root: version = winreg.QueryValue(root, None) for sub in (f"{version}\\Main", "bin"): try: with winreg.OpenKey(root, sub) as k: path = winreg.QueryValueEx(k, "PathToExe")[0] if path and Path(path).is_file(): return str(path) except OSError: continue except OSError: continue # Also try the "Uninstall" key for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): for root_path in ( r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", ): try: with winreg.OpenKey(hive, root_path) as base: i = 0 while True: try: sub = winreg.EnumKey(base, i) i += 1 if "firefox" not in sub.lower(): continue with winreg.OpenKey(base, sub) as k: loc = winreg.QueryValueEx(k, "InstallLocation")[0] candidate = Path(loc) / "firefox.exe" if candidate.is_file(): return str(candidate) except OSError: break except OSError: continue return "" def default_firefox_path() -> str: """Find Firefox.exe — registry first, then well-known paths.""" # 1) Registry reg = _find_firefox_via_registry() if reg: return reg # 2) Standard install locations candidates: list[Path] = [ Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"), Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"), Path(os.path.expandvars(r"%LOCALAPPDATA%\Mozilla Firefox\firefox.exe")), Path(os.path.expandvars(r"%APPDATA%\Mozilla Firefox\firefox.exe")), ] # 3) Scoop / winget / portable in PATH which = shutil.which("firefox") or shutil.which("firefox.exe") if which: candidates.insert(0, Path(which)) 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") def _firefox_is_running_on_system() -> bool: """Check if any firefox.exe process is alive (tasklist, no admin needed).""" try: r = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq firefox.exe", "/NH", "/FO", "CSV"], capture_output=True, text=True, timeout=6, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), ) return "firefox.exe" in (r.stdout or "").lower() except Exception: return False def _has_descendants(root_pid: int) -> bool: """True iff *root_pid* or any descendant process is currently alive. Walks the process tree from root_pid using WMIC ProcessId/ParentProcessId (no admin required). Falls back to checking the root PID's existence via tasklist if WMIC is missing on the host. """ if not root_pid: return False # Fast path: is the root PID itself still alive? try: r = subprocess.run( ["tasklist", "/FI", f"PID eq {root_pid}", "/NH", "/FO", "CSV"], capture_output=True, text=True, timeout=6, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), ) if str(root_pid) in (r.stdout or ""): return True except Exception: # noqa: BLE001 pass # Walk descendants via WMIC. Builds {parent: [child, ...]} then BFS. try: w = subprocess.run( ["wmic", "process", "get", "ProcessId,ParentProcessId", "/FORMAT:CSV"], capture_output=True, text=True, timeout=10, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), ) children: dict[int, list[int]] = {} for line in (w.stdout or "").splitlines(): parts = [p.strip() for p in line.split(",")] # CSV header: Node,ParentProcessId,ProcessId if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit(): continue ppid, pid = int(parts[1]), int(parts[2]) children.setdefault(ppid, []).append(pid) stack = [root_pid] seen = {root_pid} while stack: cur = stack.pop() kids = children.get(cur, []) for k in kids: if k in seen: continue seen.add(k) # Any live descendant = our session is still alive. return True return False except Exception: # noqa: BLE001 # WMIC missing (newer Windows) — be conservative and return False # rather than the old "any firefox.exe = mine" heuristic. return False class BrowserSession: def __init__(self) -> None: self._proc: subprocess.Popen[str] | None = None self._profile_path: Path | None = None self._launched_at: float = 0.0 def is_running(self) -> bool: if self._proc is None: return False # Firefox's initial launcher process exits quickly (code 0) while # browser child processes carry on. Poll the parent process first. if self._proc.poll() is None: return True # Parent has exited — check whether any process in our spawned tree # (the original PID's children) is still alive. Using a PID-scoped # WMIC query avoids the old failure mode of treating any unrelated # firefox.exe on the machine as ours. launched_pid = self._proc.pid return _has_descendants(launched_pid) 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, start_url: str = "", signup_draft: SignupDraft | None = None, ) -> tuple[bool, str]: raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'") if not raw_exe: return ( False, "Firefox not found. Install Firefox then set the path in the Browser tab " "(or browse to it with the Browse button).", ) exe = Path(raw_exe) if not exe.is_file(): return False, ( f"Firefox executable not found: {raw_exe} " "Use the Browse button on the Browser tab to locate firefox.exe." ) 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, persona_key=cfg.persona, cookie_mode=cfg.cookie_mode, ) try: if cfg.lock_managed_profile: ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard) if signup_draft is not None: install_signup_extension(profile_dir, signup_draft) except Exception as e: return False, f"Profile setup failed: {e!s}" if self.is_running(): self.stop() if cfg.lock_managed_profile: args = [str(exe), "-no-remote", "-profile", str(profile_dir)] else: args = [str(exe)] url = (start_url or "").strip() if url: args.append(url) env = os.environ.copy() env.update(persona_env(hard)) try: self._proc = subprocess.Popen( args, # DETACHED_PROCESS ensures Firefox outlives Python and is not # killed when Python's job object exits. CREATE_NO_WINDOW # suppresses any console window. CREATE_NEW_PROCESS_GROUP # isolates Ctrl-C handling. creationflags=_CREATE_FLAGS, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True, env=env, ) except Exception as e: self._proc = None return False, f"Launch failed: {e!s}" self._launched_at = time.monotonic() # Firefox's multi-process launcher exits (code 0) within ~0.5 s while # the actual browser window loads in child processes. We therefore do # NOT treat a quick exit of the parent as an error — we just verify # that *some* firefox.exe appears in the process list within 3 s. deadline = time.monotonic() + 3.0 found = False while time.monotonic() < deadline: time.sleep(0.3) rc = self._proc.poll() # If it already exited with an error code, report it. if rc is not None and rc not in (0, None): self._proc = None return False, f"Firefox exited with error code {rc}." if _firefox_is_running_on_system(): found = True break if not found: # One last check — maybe the profile write was the only action needed # and Firefox opened fast; accept success if parent is still alive. if self._proc.poll() is None: found = True if not found: self._proc = None return False, ( "Firefox did not appear in the process list after 3 s. " "Check that the path is correct and Firefox is not already blocked by another -profile lock." ) self._profile_path = profile_dir if cfg.lock_managed_profile else None pid = self._proc.pid log.info("Launched hardened Firefox pid=%s profile=%s", pid, profile_dir) mode = "hardened profile" if cfg.lock_managed_profile else "unlocked mode" return True, f"Firefox started ({mode}, pid {pid})." def stop(self, dispose: bool = False) -> tuple[bool, str]: # Capture our PID before clearing the handle launched_pid: int | None = self._proc.pid if self._proc else None # Terminate the Popen handle if still alive 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 # Kill only the process tree we spawned (handles the detached-child # case where the launcher process already exited naturally). # /T kills the entire child tree; /PID scopes to our PID only. if launched_pid is not None: try: subprocess.run( ["taskkill", "/F", "/T", "/PID", str(launched_pid)], capture_output=True, timeout=10, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), ) except Exception: pass 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 self._launched_at = 0.0 return True, "Browser stopped."