Files
PROXY_GOD_MAC/proxy_chain_manager/browser_launcher.py
2026-05-23 21:58:06 -07:00

403 lines
15 KiB
Python

from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
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 = 0 if sys.platform != "win32" else (
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."""
if sys.platform == "darwin":
candidates = [
Path("/Applications/Firefox.app/Contents/MacOS/firefox"),
Path.home() / "Applications" / "Firefox.app" / "Contents" / "MacOS" / "firefox",
]
which = shutil.which("firefox")
if which:
candidates.insert(0, Path(which))
for c in candidates:
if c.is_file():
return str(c)
return ""
# 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)."""
if sys.platform == "darwin":
try:
r = subprocess.run(["pgrep", "-x", "firefox"], capture_output=True, text=True, timeout=6)
return r.returncode == 0 and bool((r.stdout or "").strip())
except Exception:
return False
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
if sys.platform == "darwin":
try:
r = subprocess.run(["ps", "-p", str(root_pid)], capture_output=True, text=True, timeout=6)
return str(root_pid) in (r.stdout or "")
except Exception:
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:
if sys.platform == "darwin":
try:
subprocess.run(["pkill", "-P", str(launched_pid)], capture_output=True, timeout=10)
except Exception:
pass
else:
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."