#!/usr/bin/env python3
"""
Camoufox browser launcher for manual browsing.
Reads config from JSON arg, launches Camoufox with profile settings.
"""

import sys
import json
import os
import plistlib
import re
import shutil
import time
import zipfile
from typing import Dict, List, Optional, Tuple


def _hide_from_dock_macos() -> None:
    """Prevent this launcher from appearing as a separate Dock icon on macOS."""
    if sys.platform != "darwin":
        return
    try:
        from ctypes import Structure, c_uint32, c_int, byref, cdll

        class ProcessSerialNumber(Structure):
            _fields_ = [
                ("highLongOfPSN", c_uint32),
                ("lowLongOfPSN", c_uint32),
            ]

        carbon = cdll.LoadLibrary("/System/Library/Frameworks/Carbon.framework/Carbon")
        psn = ProcessSerialNumber()
        carbon.GetCurrentProcess(byref(psn))
        # kProcessTransformToUIElementApplication — LSUIElement, no Dock tile
        carbon.TransformProcessType(byref(psn), c_int(4))
    except Exception:
        pass


# Must run before Playwright/Camoufox connects to the window server.
_hide_from_dock_macos()


def _mozlz4_read(path: str) -> Optional[bytes]:
    """Decompress Firefox mozLz40 (sessionstore.jsonlz4, recovery.jsonlz4)."""
    try:
        import lz4.block
    except ImportError:
        return None
    try:
        with open(path, "rb") as f:
            raw = f.read()
        if len(raw) < 13 or raw[:8] != b"mozLz40\x00":
            return None
        unc = int.from_bytes(raw[8:12], "little")
        return lz4.block.decompress(raw[12:], uncompressed_size=unc)
    except Exception:
        return None


def _sessionstore_http_urls(profile_dir: str) -> List[str]:
    """Read last session's tab URLs from disk (works when Playwright still sees about:blank)."""
    paths = [
        os.path.join(profile_dir, "sessionstore.jsonlz4"),
        os.path.join(profile_dir, "sessionstore-backups", "recovery.jsonlz4"),
        os.path.join(profile_dir, "sessionstore-backups", "previous.jsonlz4"),
    ]
    for path in paths:
        if not os.path.isfile(path):
            continue
        jb = _mozlz4_read(path)
        if not jb:
            continue
        try:
            data = json.loads(jb.decode("utf-8"))
        except Exception:
            continue
        urls: List[str] = []
        for win in data.get("windows") or []:
            for tab in win.get("tabs") or []:
                entries = tab.get("entries") or []
                idx = (tab.get("index") or 1) - 1
                if 0 <= idx < len(entries):
                    u = (entries[idx].get("url") or "").strip()
                    if u.startswith(("http://", "https://")):
                        urls.append(_normalize_launch_url(u))
        if urls:
            return urls
    return []


def _settle_initial_pages(context, timeout: float = 4.0) -> List:
    """
    Wait until Firefox exposes at least one page and the tab count stabilizes.
    Startup can briefly report 0 pages or add a default tab after the first read.
    """
    deadline = time.time() + timeout
    last_count = -1
    stable_since = time.time()
    pages: List = []

    while time.time() < deadline:
        try:
            pages = list(context.pages)
        except Exception:
            pages = []
        count = len(pages)
        if count > 0 and count == last_count:
            if time.time() - stable_since >= 0.4:
                return pages
        else:
            last_count = count
            stable_since = time.time()
        time.sleep(0.15)

    return pages


def _normalize_launch_url(url: str) -> str:
    """
    Rewrite URLs known to crash in Firefox/Camoufox (Wise marketing homepage SPA bug).
  Country landing pages (/ma/, /us/, …) and /ma/login also crash; use /login?country=XX.
    """
    try:
        from urllib.parse import urlparse, urlunparse
        parsed = urlparse(url.strip())
        host = (parsed.hostname or "").lower()
        if host not in ("wise.com", "www.wise.com"):
            return url
        scheme = parsed.scheme or "https"
        path = (parsed.path or "/").rstrip("/") or "/"
        # Root marketing homepage
        if path == "/":
            return urlunparse((scheme, host, "/login", "", parsed.query, ""))
        # Country marketing homepages: /ma, /ma/, /us, …
        parts = [p for p in path.split("/") if p]
        if len(parts) == 1 and len(parts[0]) == 2 and parts[0].isalpha():
            cc = parts[0].upper()
            return urlunparse((scheme, host, "/login", f"country={cc}", "", ""))
        # Country login paths that also crash: /ma/login, /us/login, …
        if len(parts) == 2 and len(parts[0]) == 2 and parts[0].isalpha() and parts[1].lower() == "login":
            cc = parts[0].upper()
            return urlunparse((scheme, host, "/login", f"country={cc}", "", ""))
    except Exception:
        pass
    return url


def _goto_commit(page, url: str) -> None:
    url = _normalize_launch_url(url)
    try:
        page.goto(url, wait_until="commit", timeout=30_000)
    except Exception:
        pass  # commit timeout is fine; page can keep loading


def _close_pages(pages: List) -> None:
    for page in pages:
        try:
            page.close()
        except Exception:
            pass


def _normalize_tabs(context, store_urls: List[str]) -> None:
    """
    Ensure exactly the intended tabs are open:
    - saved http(s) URLs only, reusing Firefox's initial tab when possible
    - otherwise a single about:blank tab with no extra New Tab pages
    """
    existing = _settle_initial_pages(context)
    kept: List = []

    if store_urls:
        for i, url in enumerate(store_urls):
            page = existing[i] if i < len(existing) else context.new_page()
            _goto_commit(page, url)
            kept.append(page)
        _close_pages([p for p in list(context.pages) if p not in kept])
        return

    if existing:
        keeper = existing[0]
    else:
        keeper = context.new_page()
    _goto_commit(keeper, "about:blank")
    _close_pages([p for p in list(context.pages) if p != keeper])


RUNNING_MARKER = ".cpm-running"

# Camoufox ships camoufox.cfg with locked defaultPref values. Playwright's Juggler applies
# firefox_user_prefs via setBoolPref/setIntPref at Browser.enable — any locked or unknown
# pref crashes launch (NS_ERROR_UNEXPECTED). ALL runtime prefs must go in user.js only.
_PROFILE_USER_JS_PREFS = {
    # Password manager
    "signon.rememberSignons": True,
    "signon.autofillForms": True,
    "signon.formlessCapture.enabled": True,
    "signon.management.enabled": True,
    "signon.management.page.enabled": True,
    # History + form fill
    "places.history.enabled": True,
    "keyword.enabled": True,
    "browser.formfill.enable": True,
    # URL bar suggestions (camoufox.cfg disables these by default)
    "browser.urlbar.maxRichResults": 12,
    "browser.urlbar.suggest.history": True,
    "browser.urlbar.suggest.searches": True,
    "browser.urlbar.suggest.bookmark": True,
    "browser.urlbar.suggest.openpage": True,
    "browser.urlbar.suggest.topsites": True,
    "browser.urlbar.autoFill": True,
    "browser.urlbar.autoFill.adaptiveHistory.enabled": True,
    # New tab / startup / session (formerly firefox_user_prefs — Juggler crashes on these)
    "browser.newtab.url": "about:blank",
    "browser.startup.homepage": "about:blank",
    "browser.startup.page": 0,
    "browser.sessionstore.resume_from_crash": False,
    "browser.sessionstore.interval": 5000,
    "browser.tabs.closeWindowWithLastTab": False,
    "browser.newtabpage.activity-stream.enabled": False,
    "browser.newtabpage.enabled": False,
    "privatebrowsing.autostart": False,
    # --- Payment CDN compatibility (Stripe / PayPal / DigitalOcean billing) ---
    # Firefox ships Opaque Response Blocking (ORB) since ~118. When a proxy or
    # middlebox returns the wrong Content-Type for js.stripe.com (HTML block page
    # instead of JS), ORB blocks the body → "Failed to load Stripe.js" /
    # "A resource is blocked by OpaqueResponseBlocking". Unlike Chromium, Firefox
    # still exposes about:config kill switches (Mozilla Support / Bug 1851143).
    "browser.opaqueResponseBlocking": False,
    "browser.opaqueResponseBlocking.javascriptValidator": False,
    # Enhanced Tracking Protection / content blocking often treats Stripe,
    # hCaptcha, and PayPal as trackers and strips their scripts/iframes
    # (support.mozilla.org Stripe threads; Firefox Focus WooCommerce reports).
    # Camoufox already defaults privacy.trackingprotection.enabled=false; reinforce
    # related prefs so stale profile prefs.js cannot re-enable them.
    "privacy.trackingprotection.enabled": False,
    "privacy.trackingprotection.pbmode.enabled": False,
    "privacy.trackingprotection.socialtracking.enabled": False,
    "privacy.trackingprotection.emailtracking.enabled": False,
    "privacy.trackingprotection.cryptomining.enabled": False,
    "privacy.trackingprotection.fingerprinting.enabled": False,
    "browser.contentblocking.category": "custom",
    # cookieBehavior 4 = reject trackers; Stripe/m.stripe.com are often on tracker
    # lists, which breaks Elements iframes. Accept all cookies for payment UIs.
    # Fingerprinting stays C++-level via Camoufox — this only affects cookies.
    "network.cookie.cookieBehavior": 0,
    # XOriginPolicy=2 strips cross-origin Referer and breaks Stripe/hCaptcha
    # (document.referrer empty → script falls back to unsupported APIs).
    "network.http.referer.XOriginPolicy": 0,
    # Allow cross-origin iframe interaction (Turnstile / Stripe Elements / PayPal).
    # Same pref Camoufox's disable_coop=True would set via firefox_user_prefs —
    # we write it to user.js instead to avoid Juggler NS_ERROR_UNEXPECTED.
    "browser.tabs.remote.useCrossOriginOpenerPolicy": False,
}

# Stripe CDN hosts only. Passed to Playwright proxy["bypass"] (Juggler) AND
# mirrored into network.proxy.no_proxies_on. Residential proxies often return
# 403 SITE_PERMANENTLY_BLOCKED on CONNECT to js.stripe.com — ORB then surfaces
# as "Failed to load Stripe.js". PayPal and other payment UIs stay on the proxy.
# Playwright docs: ".com, chromium.org, .domain.com".
_PAYMENT_CDN_NO_PROXIES = (
    ".stripe.com",
    "js.stripe.com",
    "m.stripe.com",
    "m.stripe.network",
    "r.stripe.com",
    "api.stripe.com",
    "hooks.stripe.com",
    "checkout.stripe.com",
    "q.stripe.com",
)

_USER_JS_START = "// camoufox-profile-manager prefs (auto-managed)"
_USER_JS_END = "// camoufox-profile-manager prefs end"


# ---------------------------------------------------------------------------
# Timezone sanitization (Camoufox shutdown-crash fix)
#
# Camoufox/Firefox 135 deterministically SIGSEGVs on shutdown (EXC_BAD_ACCESS,
# null write on the main thread, "Shutdown Hang Terminator" active -> the native
# "Camoufox quit unexpectedly" dialog) whenever the spoofed timezone is a
# 3-segment IANA zone name such as "America/Indiana/Indianapolis" or
# "America/Argentina/Buenos_Aires". Two-segment zones ("America/New_York",
# "America/Chicago", ...) shut down cleanly.
#
# With geoip=True Camoufox resolves the proxy's exit-IP timezone automatically;
# a US-Indiana / Argentina / North-Dakota exit IP yields one of these 3-segment
# zones and crashes the browser on every close. We map every multi-segment IANA
# zone to its canonical 2-segment equivalent (same UTC offset and DST rules), so
# geolocation/locale/WebRTC spoofing is preserved while the crash is eliminated.
#
# There are only 26 multi-segment zones in the IANA database (all in the
# Americas); the table below covers them exactly, and a UTC-offset fallback
# protects against any future additions.
# ---------------------------------------------------------------------------
_MULTISEGMENT_TIMEZONE_MAP = {
    # US Eastern (Indiana/Kentucky counties observing Eastern time)
    "America/Indiana/Indianapolis": "America/New_York",
    "America/Indiana/Marengo": "America/New_York",
    "America/Indiana/Petersburg": "America/New_York",
    "America/Indiana/Vevay": "America/New_York",
    "America/Indiana/Vincennes": "America/New_York",
    "America/Indiana/Winamac": "America/New_York",
    "America/Kentucky/Louisville": "America/New_York",
    "America/Kentucky/Monticello": "America/New_York",
    # US Central
    "America/Indiana/Knox": "America/Chicago",
    "America/Indiana/Tell_City": "America/Chicago",
    "America/North_Dakota/Beulah": "America/Chicago",
    "America/North_Dakota/Center": "America/Chicago",
    "America/North_Dakota/New_Salem": "America/Chicago",
    # Argentina (UTC-03, no DST) -> legacy 2-segment alias (same rules)
    "America/Argentina/Buenos_Aires": "America/Buenos_Aires",
    "America/Argentina/Catamarca": "America/Buenos_Aires",
    "America/Argentina/ComodRivadavia": "America/Buenos_Aires",
    "America/Argentina/Cordoba": "America/Buenos_Aires",
    "America/Argentina/Jujuy": "America/Buenos_Aires",
    "America/Argentina/La_Rioja": "America/Buenos_Aires",
    "America/Argentina/Mendoza": "America/Buenos_Aires",
    "America/Argentina/Rio_Gallegos": "America/Buenos_Aires",
    "America/Argentina/Salta": "America/Buenos_Aires",
    "America/Argentina/San_Juan": "America/Buenos_Aires",
    "America/Argentina/San_Luis": "America/Buenos_Aires",
    "America/Argentina/Tucuman": "America/Buenos_Aires",
    "America/Argentina/Ushuaia": "America/Buenos_Aires",
}

# Safe 2-segment zones used by the generic UTC-offset fallback, ordered so the
# most "common" representative for an offset is chosen first.
_OFFSET_FALLBACK_ZONES = (
    "Pacific/Honolulu",
    "America/Anchorage",
    "America/Los_Angeles",
    "America/Denver",
    "America/Chicago",
    "America/New_York",
    "America/Halifax",
    "America/Sao_Paulo",
    "Atlantic/Azores",
    "UTC",
    "Europe/London",
    "Europe/Paris",
    "Europe/Athens",
    "Europe/Moscow",
    "Asia/Dubai",
    "Asia/Karachi",
    "Asia/Kolkata",
    "Asia/Dhaka",
    "Asia/Bangkok",
    "Asia/Shanghai",
    "Asia/Tokyo",
    "Australia/Sydney",
    "Pacific/Auckland",
)


def _zone_by_current_offset(tz: str) -> Optional[str]:
    """Pick a safe 2-segment zone whose current UTC offset matches ``tz``."""
    try:
        from datetime import datetime
        from zoneinfo import ZoneInfo

        now = datetime.now(ZoneInfo(tz))
        target = now.utcoffset()
        for candidate in _OFFSET_FALLBACK_ZONES:
            try:
                if datetime.now(ZoneInfo(candidate)).utcoffset() == target:
                    return candidate
            except Exception:
                continue
    except Exception:
        pass
    return None


def _safe_timezone(tz: Optional[str]) -> Optional[str]:
    """
    Return a Camoufox-safe IANA timezone.

    Multi-segment zone names (e.g. "America/Indiana/Indianapolis") crash the
    browser on shutdown; collapse them to a canonical 2-segment equivalent.
    Simple ("UTC") and 2-segment zones are returned unchanged.
    """
    if not tz or tz.count("/") < 2:
        return tz
    mapped = _MULTISEGMENT_TIMEZONE_MAP.get(tz)
    if mapped:
        return mapped
    return _zone_by_current_offset(tz) or "America/New_York"


def _install_geoip_timezone_guard() -> None:
    """
    Patch Camoufox's geoip resolver so any resolved timezone is sanitized.

    geoip=True lets Camoufox derive timezone/locale/geolocation from the proxy
    exit IP. We wrap get_geolocation so the (frozen) Geolocation it returns can
    never carry a crash-inducing multi-segment timezone, while leaving latitude,
    longitude, accuracy, locale and WebRTC-IP spoofing fully intact.
    """
    try:
        import dataclasses

        import camoufox.utils as cf_utils

        original = cf_utils.get_geolocation

        if getattr(original, "_cpm_tz_guarded", False):
            return

        def guarded(ip):
            geo = original(ip)
            safe = _safe_timezone(getattr(geo, "timezone", None))
            if safe and safe != getattr(geo, "timezone", None):
                try:
                    return dataclasses.replace(geo, timezone=safe)
                except Exception:
                    return geo
            return geo

        guarded._cpm_tz_guarded = True
        cf_utils.get_geolocation = guarded
    except Exception:
        pass


def _patch_camoufox_policies() -> None:
    """
    Re-enable the password manager in Camoufox's enterprise policy file.

    Camoufox ships distribution/policies.json with::

        "PasswordManagerEnabled": false   # blocks about:logins ("organization
                                          #   has blocked this page") + disables
                                          #   the whole password manager
        "OfferToSaveLogins": false        # suppresses the save-password prompt

    Firefox policies are enforced by the enterprise policy engine and CANNOT be
    overridden by user.js or prefs.js — so writing signon.* prefs is not enough.
    We must flip the policy itself. The file lives inside the Camoufox app bundle
    (shared by every profile), so this is patched globally and idempotently on
    every launch (survives `camoufox fetch` re-installs because it re-runs).
    """
    try:
        from camoufox.pkgman import get_path

        policies_path = get_path(os.path.join("distribution", "policies.json"))
    except Exception:
        return

    try:
        if not os.path.isfile(policies_path):
            return
        with open(policies_path, encoding="utf-8") as f:
            data = json.load(f)

        policies = data.get("policies")
        if not isinstance(policies, dict):
            return

        changed = False

        if policies.get("PasswordManagerEnabled") is not True:
            policies["PasswordManagerEnabled"] = True
            changed = True
        if policies.get("OfferToSaveLogins") is not True:
            policies["OfferToSaveLogins"] = True
            changed = True

        # formautofill provides the built-in save/fill UI for logins/addresses;
        # Camoufox uninstalls it, which also weakens password capture.
        exts = policies.get("Extensions")
        if isinstance(exts, dict):
            uninstall = exts.get("Uninstall")
            if isinstance(uninstall, list) and "formautofill@mozilla.org" in uninstall:
                exts["Uninstall"] = [
                    e for e in uninstall if e != "formautofill@mozilla.org"
                ]
                changed = True

        if changed:
            with open(policies_path, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
    except Exception:
        pass


# Antidetect Browser navigator rebrand (Camoufox app bundle is shared across profiles).
_BRAND_NAME = "Antidetect Browser"
_BRAND_SHORT = "Antidetect Browser"
_BRAND_VENDOR = "Antidetect Profiles"
_BRAND_SYNC = "Antidetect Browser Sync"
_BRAND_ABOUT_TEXT = "Secure antidetect browser by Antidetect Profiles."
_BRAND_WEBSITE = "https://antidetectprofiles.com"
_BRAND_BUNDLE_ID = "com.antidetectprofiles.browser"
_LEGACY_BRAND = "Camoufox"
_LEGACY_BUNDLE_ID = "org.mozilla.camoufox"
_PREVIOUS_BRAND_NAMES = ("Antidetect Profiles", "Camoufox")
_LEGACY_APP_CONSTANTS = (
    ('MOZ_APP_DISPLAYNAME_DO_NOT_USE: "Camoufox"', f'MOZ_APP_DISPLAYNAME_DO_NOT_USE: "{_BRAND_NAME}"'),
    ('MOZ_APP_DISPLAYNAME_DO_NOT_USE: "Antidetect Profiles"', f'MOZ_APP_DISPLAYNAME_DO_NOT_USE: "{_BRAND_NAME}"'),
    ('MOZ_APP_DISPLAYNAME_DO_NOT_USE: "Antidetect Browser"', f'MOZ_APP_DISPLAYNAME_DO_NOT_USE: "{_BRAND_NAME}"'),
    ('MOZ_MACBUNDLE_NAME: "Camoufox.app"', f'MOZ_MACBUNDLE_NAME: "{_BRAND_NAME}.app"'),
    ('MOZ_MACBUNDLE_NAME: "Antidetect Profiles.app"', f'MOZ_MACBUNDLE_NAME: "{_BRAND_NAME}.app"'),
    (f'MOZ_MACBUNDLE_ID: "{_LEGACY_BUNDLE_ID}"', f'MOZ_MACBUNDLE_ID: "{_BRAND_BUNDLE_ID}"'),
)

_OMNI_BRANDING_PATHS = (
    "chrome/en-US/locale/branding/brand.properties",
    "localization/en-US/branding/brand.ftl",
    "chrome/en-US/locale/branding/brand.dtd",
    "chrome/en-US/locale/browser/appstrings.properties",
    "chrome/browser/content/browser/aboutDialog.xhtml",
    "modules/AppConstants.sys.mjs",
)

_OMNI_BRANDING_SVG_PATHS = (
    "chrome/browser/content/branding/about-logo.svg",
    "chrome/browser/content/branding/about-wordmark.svg",
    "chrome/browser/content/branding/firefox-wordmark.svg",
)

_OMNI_BRANDING_ICON_PATHS = {
    "chrome/browser/content/branding/icon16.png": 16,
    "chrome/browser/content/branding/icon32.png": 32,
    "chrome/browser/content/branding/icon48.png": 48,
    "chrome/browser/content/branding/icon64.png": 64,
    "chrome/browser/content/branding/icon128.png": 128,
    "chrome/browser/content/branding/about-logo.png": 64,
    "chrome/browser/content/branding/about-logo@2x.png": 128,
    "chrome/browser/content/branding/about.png": 128,
}

_OMNI_TEXT_SUFFIXES = (
    ".properties",
    ".ftl",
    ".dtd",
    ".xhtml",
    ".html",
    ".xml",
    ".json",
    ".css",
    ".svg",
    ".txt",
    ".js",
    ".mjs",
    ".ini",
)

_CAMOUFOX_CACHE_APP = os.path.expanduser("~/Library/Caches/camoufox/Camoufox.app")
_BRAND_CACHE_APP = os.path.expanduser(
    f"~/Library/Caches/camoufox/{_BRAND_NAME}.app"
)


def _ensure_branded_bundle_name() -> None:
    """
    Rename the on-disk bundle folder to "<Brand>.app" and keep a Camoufox.app symlink.

    macOS derives the Dock tooltip and notification-prompt name from the bundle's
    FOLDER NAME when the process is launched directly (Playwright launches the
    binary, so the app is never registered with LaunchServices). Patching
    Info.plist/InfoPlist.strings fixes the menu bar but not the Dock/notification
    name. Renaming the folder fixes those, and the Camoufox.app symlink keeps the
    camoufox library's hardcoded path working. Idempotent and re-applied each launch
    so a `camoufox fetch` re-extract is repaired.
    """
    if sys.platform != "darwin":
        return
    try:
        cache_dir = os.path.expanduser("~/Library/Caches/camoufox")
        legacy = _CAMOUFOX_CACHE_APP
        branded = _BRAND_CACHE_APP
        branded_base = os.path.basename(branded)

        # Already done: Camoufox.app symlink points at the current branded bundle.
        if os.path.islink(legacy):
            link_target = os.path.basename(os.path.realpath(legacy))
            if link_target == branded_base and os.path.isdir(branded):
                return

        source_bundle: Optional[str] = None
        if os.path.isdir(legacy) and not os.path.islink(legacy):
            source_bundle = legacy
        else:
            for old_name in _PREVIOUS_BRAND_NAMES:
                candidate = os.path.join(cache_dir, f"{old_name}.app")
                if os.path.isdir(candidate) and not os.path.islink(candidate):
                    source_bundle = candidate
                    break

        if source_bundle and source_bundle != branded:
            if os.path.exists(branded):
                if os.path.islink(branded):
                    os.unlink(branded)
                else:
                    shutil.rmtree(branded, ignore_errors=True)
            os.rename(source_bundle, branded)

        for old_name in _PREVIOUS_BRAND_NAMES:
            stale = os.path.join(cache_dir, f"{old_name}.app")
            if stale != branded and os.path.exists(stale):
                if os.path.islink(stale):
                    os.unlink(stale)
                elif os.path.isdir(stale):
                    shutil.rmtree(stale, ignore_errors=True)

        if os.path.isdir(branded):
            if os.path.islink(legacy):
                os.unlink(legacy)
            elif os.path.exists(legacy):
                shutil.rmtree(legacy, ignore_errors=True)
            if not os.path.exists(legacy):
                os.symlink(branded_base, legacy)
    except Exception:
        pass


def _branded_executable_path() -> Optional[str]:
    """
    Resolve the real branded browser executable so it launches from
    "Antidetect Browser.app", not the Camoufox.app symlink.

    The camoufox library defaults to launching via Camoufox.app/Contents/MacOS/camoufox.
    macOS derives the Dock tooltip from the bundle FOLDER NAME in the launch path, so
    launching through the symlink shows "Camoufox". Pointing executable_path at the
    real bundle makes the Dock read the branded name.
    """
    if sys.platform != "darwin":
        return None
    exe = os.path.join(_BRAND_CACHE_APP, "Contents", "MacOS", "camoufox")
    if os.path.isfile(exe):
        return exe
    return None


def _ensure_properties_beside_exe() -> None:
    """
    Make properties.json reachable next to the branded executable.

    camoufox's _load_properties() reads ``<executable_path>.parent / properties.json``
    when an explicit executable_path is given. That file actually lives in Resources/,
    so we link it into Contents/MacOS/ to avoid a "No such file" crash at launch.
    """
    if sys.platform != "darwin":
        return
    try:
        macos_dir = os.path.join(_BRAND_CACHE_APP, "Contents", "MacOS")
        resources_props = os.path.join(
            _BRAND_CACHE_APP, "Contents", "Resources", "properties.json"
        )
        target = os.path.join(macos_dir, "properties.json")
        if not os.path.isfile(resources_props):
            return
        if os.path.lexists(target):
            # Refresh a stale/broken link; leave a valid real file alone.
            if os.path.islink(target) and not os.path.exists(target):
                os.unlink(target)
            else:
                return
        os.symlink(os.path.join("..", "Resources", "properties.json"), target)
    except Exception:
        pass


def _branding_assets_dir() -> Optional[str]:
    """Resolve navigator branding assets (dev tree or Electron extraResources)."""
    launcher_dir = os.path.dirname(os.path.abspath(__file__))
    for candidate in (
        os.path.join(launcher_dir, "..", "..", "assets", "branding"),
        os.path.join(launcher_dir, "..", "..", "..", "assets", "branding"),
        os.path.join(os.path.dirname(launcher_dir), "assets", "branding"),
    ):
        base = os.path.abspath(candidate)
        navigator = os.path.join(base, "navigator")
        if os.path.isfile(os.path.join(navigator, "firefox.icns")) or os.path.isfile(
            os.path.join(navigator, "browser-icon-1024.png")
        ):
            return navigator
        if os.path.isfile(os.path.join(base, "firefox.icns")) or os.path.isfile(
            os.path.join(base, "browser-icon-1024.png")
        ):
            return base
    return None


def _branding_png_for_size(assets_dir: str, size: int) -> Optional[str]:
    for name in (f"icon-{size}.png", "browser-icon-1024.png", "icon-512.png"):
        path = os.path.join(assets_dir, name)
        if os.path.isfile(path):
            return path
    return None


def _camoufox_app_paths() -> Tuple[Optional[str], Optional[str], Optional[str]]:
    """Return (resources_dir, info_plist_path, app_bundle_path) for Camoufox."""
    resources_candidates: List[str] = []
    if sys.platform == "darwin" and os.path.isdir(_CAMOUFOX_CACHE_APP):
        resources_candidates.append(
            os.path.join(_CAMOUFOX_CACHE_APP, "Contents", "Resources")
        )
    try:
        from camoufox.pkgman import get_path

        pkg_resources = get_path(".")
        if pkg_resources:
            pkg_resources = os.path.abspath(pkg_resources)
            if pkg_resources not in resources_candidates:
                resources_candidates.append(pkg_resources)
    except Exception:
        pass

    for resources in resources_candidates:
        if not os.path.isdir(resources):
            continue
        app_bundle = os.path.abspath(os.path.join(resources, "..", ".."))
        info_plist = os.path.join(app_bundle, "Contents", "Info.plist")
        if not os.path.isfile(info_plist):
            info_plist = os.path.abspath(os.path.join(resources, "..", "Info.plist"))
        if os.path.isfile(info_plist):
            return resources, info_plist, app_bundle if os.path.isdir(app_bundle) else None
    return None, None, None


def _replace_brand_text(text: str) -> str:
    updated = text.replace(_LEGACY_BRAND, _BRAND_NAME)
    for old_name in _PREVIOUS_BRAND_NAMES:
        if old_name != _BRAND_NAME:
            updated = updated.replace(old_name, _BRAND_NAME)
    updated = updated.replace(
        "Camoufox is an independent fork of Firefox for webscraping.",
        _BRAND_ABOUT_TEXT,
    )
    updated = updated.replace(
        "Antidetect Profiles is an independent fork of Firefox for webscraping.",
        _BRAND_ABOUT_TEXT,
    )
    updated = updated.replace(
        "Antidetect Browser is an independent fork of Firefox for webscraping.",
        _BRAND_ABOUT_TEXT,
    )
    updated = updated.replace(
        "https://github.com/daijro/camoufox",
        _BRAND_WEBSITE,
    )
    for old, new in _LEGACY_APP_CONSTANTS:
        updated = updated.replace(old, new)
    if "syncBrandShortName=" in updated:
        updated = re.sub(
            r"syncBrandShortName=.*",
            f"syncBrandShortName={_BRAND_SYNC}",
            updated,
        )
    return updated


def _branding_svg_text(assets_dir: Optional[str]) -> Optional[str]:
    if not assets_dir:
        return None
    svg_path = os.path.join(assets_dir, "browser-icon.svg")
    if not os.path.isfile(svg_path):
        return None
    try:
        with open(svg_path, encoding="utf-8") as f:
            return f.read().strip()
    except Exception:
        return None


def _load_resized_png(assets_dir: str, size: int) -> Optional[bytes]:
    src = _branding_png_for_size(assets_dir, size)
    if not src:
        return None
    try:
        from PIL import Image
        import io

        with Image.open(src) as img:
            if img.size != (size, size):
                img = img.convert("RGBA").resize((size, size), Image.Resampling.LANCZOS)
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            return buf.getvalue()
    except Exception:
        try:
            with open(src, "rb") as f:
                return f.read()
        except Exception:
            return None


def _patch_omni_ja(omni_path: str, assets_dir: Optional[str] = None) -> bool:
    """Patch branding strings and icons inside omni.ja archives."""
    if not os.path.isfile(omni_path):
        return False

    try:
        with zipfile.ZipFile(omni_path, "r") as zin:
            names = {info.filename for info in zin.infolist()}
            pending: Dict[str, bytes] = {}

            targets = set(_OMNI_BRANDING_PATHS)
            for name in names:
                if name.endswith(_OMNI_TEXT_SUFFIXES) and _LEGACY_BRAND.encode() in zin.read(name):
                    targets.add(name)

            for rel_path in targets:
                if rel_path not in names:
                    continue
                raw = zin.read(rel_path)
                if not rel_path.endswith(_OMNI_TEXT_SUFFIXES):
                    continue
                try:
                    text = raw.decode("utf-8")
                except UnicodeDecodeError:
                    continue
                new_text = _replace_brand_text(text)
                if new_text != text:
                    pending[rel_path] = new_text.encode("utf-8")

            if assets_dir:
                for rel_path, px in _OMNI_BRANDING_ICON_PATHS.items():
                    if rel_path not in names:
                        continue
                    png = _load_resized_png(assets_dir, px)
                    if png and png != zin.read(rel_path):
                        pending[rel_path] = png
                svg_text = _branding_svg_text(assets_dir)
                if svg_text:
                    for rel_path in _OMNI_BRANDING_SVG_PATHS:
                        if rel_path not in names:
                            continue
                        current = zin.read(rel_path)
                        encoded = svg_text.encode("utf-8")
                        if current != encoded:
                            pending[rel_path] = encoded

            if not pending:
                return False

            tmp_path = f"{omni_path}.cpm-branding.tmp"
            with zipfile.ZipFile(tmp_path, "w") as zout:
                for info in zin.infolist():
                    data = pending.get(info.filename, zin.read(info.filename))
                    zout.writestr(info, data, compress_type=info.compress_type)
            os.replace(tmp_path, omni_path)
            return True
    except Exception:
        try:
            os.remove(f"{omni_path}.cpm-branding.tmp")
        except Exception:
            pass
        return False


def _patch_application_ini(app_ini_path: str) -> bool:
    if not os.path.isfile(app_ini_path):
        return False
    try:
        with open(app_ini_path, encoding="utf-8") as f:
            lines = f.readlines()
        changed = False
        out: List[str] = []
        for line in lines:
            stripped = line.strip()
            if stripped.startswith("Name=") and not stripped.startswith(f"Name={_BRAND_SHORT}"):
                out.append(f"Name={_BRAND_SHORT}\n")
                changed = True
            elif stripped.startswith("CodeName=") and not stripped.startswith(
                f"CodeName={_BRAND_NAME}"
            ):
                out.append(f"CodeName={_BRAND_NAME}\n")
                changed = True
            elif stripped.startswith("Vendor="):
                out.append(f"Vendor={_BRAND_VENDOR}\n")
                changed = True
            else:
                out.append(line)
        if changed:
            with open(app_ini_path, "w", encoding="utf-8") as f:
                f.writelines(out)
        return changed
    except Exception:
        return False


def _patch_info_plist(info_plist_path: str) -> bool:
    if sys.platform != "darwin" or not os.path.isfile(info_plist_path):
        return False
    try:
        with open(info_plist_path, "rb") as f:
            data = plistlib.load(f)
        if data.get("CFBundleName") == _BRAND_NAME and data.get("CFBundleDisplayName") == _BRAND_NAME:
            # Still refresh usage strings / icon key if Camoufox fetch reset them.
            pass

        changed = False
        for key in ("CFBundleName", "CFBundleDisplayName"):
            if data.get(key) != _BRAND_NAME:
                data[key] = _BRAND_NAME
                changed = True

        if data.get("CFBundleIconFile") != "firefox":
            data["CFBundleIconFile"] = "firefox"
            changed = True

        if data.get("CFBundleIdentifier") != _BRAND_BUNDLE_ID:
            data["CFBundleIdentifier"] = _BRAND_BUNDLE_ID
            changed = True

        for usage_key in ("NSCameraUsageDescription", "NSMicrophoneUsageDescription"):
            val = data.get(usage_key)
            if isinstance(val, str) and _LEGACY_BRAND in val:
                data[usage_key] = val.replace(_LEGACY_BRAND, _BRAND_NAME)
                changed = True

        get_info = data.get("CFBundleGetInfoString")
        if isinstance(get_info, str) and _LEGACY_BRAND in get_info:
            data["CFBundleGetInfoString"] = get_info.replace(_LEGACY_BRAND, _BRAND_NAME)
            changed = True

        if changed:
            with open(info_plist_path, "wb") as f:
                plistlib.dump(data, f)
        return changed
    except Exception:
        return False


def _copy_if_changed(src: str, dst: str) -> bool:
    if not os.path.isfile(src):
        return False
    try:
        if os.path.isfile(dst) and os.path.getsize(dst) == os.path.getsize(src):
            if shutil.cmp(src, dst, shallow=False):
                return False
        shutil.copy2(src, dst)
        return True
    except Exception:
        return False


def _install_bundle_icons(resources_dir: str, assets_dir: str) -> bool:
    changed = False
    icns_src = os.path.join(assets_dir, "firefox.icns")
    if not os.path.isfile(icns_src):
        return False
    for name in ("firefox.icns", "document.icns", "fileBookmark.icns"):
        dst = os.path.join(resources_dir, name)
        if _copy_if_changed(icns_src, dst):
            changed = True
    return changed


def _patch_platform_ini(platform_ini_path: str) -> bool:
    if not os.path.isfile(platform_ini_path):
        return False
    try:
        with open(platform_ini_path, encoding="utf-8") as f:
            text = f.read()
        new_text = _replace_brand_text(text)
        if new_text == text:
            return False
        with open(platform_ini_path, "w", encoding="utf-8") as f:
            f.write(new_text)
        return True
    except Exception:
        return False


def _patch_legacy_strings_file(path: str) -> bool:
    """Patch legacy UTF-16/UTF-8 .strings files (nested helper bundles)."""
    if not os.path.isfile(path):
        return False
    try:
        with open(path, "rb") as f:
            raw = f.read()
        if raw.startswith(b"\xff\xfe"):
            text = raw.decode("utf-16")
            encoding = "utf-16"
        else:
            text = raw.decode("utf-8")
            encoding = "utf-8"
        markers = (_LEGACY_BRAND, "CamoufoxCP", "Camoufox CP")
        if not any(marker in text for marker in markers):
            return False
        new_text = text.replace("CamoufoxCP", f"{_BRAND_SHORT} CP")
        new_text = new_text.replace(_LEGACY_BRAND, _BRAND_NAME)
        if new_text == text:
            return False
        with open(path, "wb") as f:
            f.write(new_text.encode(encoding))
        return True
    except Exception:
        return False


def _patch_nested_app_bundles(app_bundle: Optional[str]) -> bool:
    """Patch Info.plist/strings inside nested .app bundles (plugin-container, etc.)."""
    if not app_bundle or not os.path.isdir(app_bundle):
        return False
    changed = False
    for root, _dirs, files in os.walk(app_bundle):
        for fname in files:
            path = os.path.join(root, fname)
            if fname == "InfoPlist.strings":
                if _patch_legacy_strings_file(path):
                    changed = True
            elif fname == "Info.plist" and "Contents/MacOS/camoufox" not in path:
                try:
                    with open(path, "rb") as f:
                        data = plistlib.load(f)
                    local_changed = False
                    # Some helper bundles (e.g. media-plugin-helper.app) ship no
                    # CFBundleName/CFBundleDisplayName at all, yet still carry the
                    # legacy brand in their executable/identifier. Detect that so
                    # we can stamp a branded display name and prevent any stale
                    # "Camoufox" name from surfacing in macOS UI.
                    legacy_present = any(
                        isinstance(data.get(k), str)
                        and _LEGACY_BRAND.lower() in data.get(k).lower()
                        for k in (
                            "CFBundleName",
                            "CFBundleDisplayName",
                            "CFBundleExecutable",
                            "CFBundleIdentifier",
                            "CFBundleGetInfoString",
                        )
                    )
                    for key in ("CFBundleName", "CFBundleDisplayName"):
                        val = data.get(key)
                        if isinstance(val, str) and _LEGACY_BRAND in val:
                            data[key] = val.replace(_LEGACY_BRAND, _BRAND_NAME)
                            local_changed = True
                        elif val is None and legacy_present:
                            data[key] = _BRAND_NAME
                            local_changed = True
                    get_info = data.get("CFBundleGetInfoString")
                    if isinstance(get_info, str) and _LEGACY_BRAND in get_info:
                        data["CFBundleGetInfoString"] = get_info.replace(
                            _LEGACY_BRAND, _BRAND_NAME
                        )
                        local_changed = True
                    if data.get("CFBundleIdentifier") == _LEGACY_BUNDLE_ID:
                        data["CFBundleIdentifier"] = _BRAND_BUNDLE_ID
                        local_changed = True
                    if local_changed:
                        with open(path, "wb") as f:
                            plistlib.dump(data, f)
                        changed = True
                except Exception:
                    pass
    return changed


def _patch_info_plist_strings(resources_dir: str) -> bool:
    """Patch localized InfoPlist.strings — macOS menu bar reads this over Info.plist."""
    changed = False
    for root, _dirs, files in os.walk(resources_dir):
        for fname in files:
            if fname != "InfoPlist.strings":
                continue
            path = os.path.join(root, fname)
            try:
                if sys.platform == "darwin":
                    import subprocess

                    proc = subprocess.run(
                        ["plutil", "-convert", "xml1", "-o", "-", path],
                        capture_output=True,
                        text=True,
                        check=False,
                    )
                    if proc.returncode == 0 and proc.stdout.strip():
                        data = plistlib.loads(proc.stdout.encode("utf-8"))
                    else:
                        with open(path, "rb") as f:
                            data = plistlib.load(f)
                else:
                    with open(path, "rb") as f:
                        data = plistlib.load(f)

                local_changed = False
                for key in ("CFBundleName", "CFBundleDisplayName"):
                    val = data.get(key)
                    if isinstance(val, str) and val != _BRAND_NAME:
                        data[key] = _BRAND_NAME
                        local_changed = True
                    elif val is None:
                        data[key] = _BRAND_NAME
                        local_changed = True

                get_info = data.get("CFBundleGetInfoString")
                if isinstance(get_info, str) and _LEGACY_BRAND in get_info:
                    data["CFBundleGetInfoString"] = get_info.replace(
                        _LEGACY_BRAND, _BRAND_NAME
                    )
                    local_changed = True

                if local_changed:
                    with open(path, "wb") as f:
                        plistlib.dump(data, f)
                    changed = True
            except Exception:
                try:
                    with open(path, encoding="utf-8", errors="replace") as f:
                        text = f.read()
                    new_text = text.replace(
                        f'CFBundleName = "{_LEGACY_BRAND}";',
                        f'CFBundleName = "{_BRAND_NAME}";',
                    )
                    new_text = new_text.replace(_LEGACY_BRAND, _BRAND_NAME)
                    if new_text != text:
                        with open(path, "w", encoding="utf-8") as f:
                            f.write(new_text)
                        changed = True
                except Exception:
                    pass
    return changed


def _refresh_macos_icon_cache(app_bundle: Optional[str], info_plist: Optional[str]) -> None:
    if sys.platform != "darwin":
        return
    try:
        touch_target = info_plist or (
            os.path.join(app_bundle, "Contents", "Info.plist") if app_bundle else None
        )
        if touch_target and os.path.isfile(touch_target):
            os.utime(touch_target, None)
        if app_bundle and os.path.isdir(app_bundle):
            import subprocess

            lsregister = (
                "/System/Library/Frameworks/CoreServices.framework/Frameworks/"
                "LaunchServices.framework/Support/lsregister"
            )
            if os.path.isfile(lsregister):
                subprocess.run(
                    [lsregister, "-f", app_bundle],
                    capture_output=True,
                    check=False,
                )
                subprocess.run(
                    [
                        lsregister,
                        "-kill",
                        "-r",
                        "-domain",
                        "local",
                        "-domain",
                        "user",
                    ],
                    capture_output=True,
                    check=False,
                )
                subprocess.run(
                    [lsregister, "-f", app_bundle],
                    capture_output=True,
                    check=False,
                )
    except Exception:
        pass


def _write_branding_patch_log(
    app_bundle: Optional[str],
    resources_dir: Optional[str],
    changed: bool,
) -> None:
    try:
        log_path = os.path.expanduser("~/.camoufox-branding-patch.log")
        with open(log_path, "w", encoding="utf-8") as f:
            f.write(f"app_bundle={app_bundle}\n")
            f.write(f"resources_dir={resources_dir}\n")
            f.write(f"changed={changed}\n")
            f.write(f"timestamp={time.time()}\n")
    except Exception:
        pass


def _patch_camoufox_branding() -> None:
    """
    Rebrand the Camoufox browser bundle to Antidetect Profiles.

    Patches the shared app bundle (menu bar name, dock icon, window/about branding)
    idempotently on every launch so ``camoufox fetch`` re-installs are repaired.
    """
    resources_dir, info_plist, app_bundle = _camoufox_app_paths()
    if not resources_dir:
        return

    assets = _branding_assets_dir()
    icon_or_text_changed = False

    try:
        if info_plist and _patch_info_plist(info_plist):
            icon_or_text_changed = True
        if _patch_info_plist_strings(resources_dir):
            icon_or_text_changed = True
        if _patch_nested_app_bundles(app_bundle):
            icon_or_text_changed = True
        if _patch_application_ini(os.path.join(resources_dir, "application.ini")):
            icon_or_text_changed = True
        if _patch_platform_ini(os.path.join(resources_dir, "platform.ini")):
            icon_or_text_changed = True
        if _patch_omni_ja(os.path.join(resources_dir, "browser", "omni.ja"), assets):
            icon_or_text_changed = True
        if _patch_omni_ja(os.path.join(resources_dir, "omni.ja"), assets):
            icon_or_text_changed = True
        if assets and sys.platform == "darwin":
            if _install_bundle_icons(resources_dir, assets):
                icon_or_text_changed = True
        if icon_or_text_changed:
            _refresh_macos_icon_cache(app_bundle, info_plist)
        _write_branding_patch_log(app_bundle, resources_dir, icon_or_text_changed)
    except Exception:
        pass


def _format_user_pref(key: str, value) -> str:
    return f"user_pref({json.dumps(key)}, {json.dumps(value)});"


def _payment_cdn_bypass_list() -> str:
    """Comma-separated domains for Playwright proxy.bypass / Firefox no_proxies_on."""
    return ",".join(_PAYMENT_CDN_NO_PROXIES)


def _payment_cdn_proxy_prefs(*, has_proxy: bool) -> dict:
    """Extra user.js prefs when a profile proxy is active (belt-and-suspenders)."""
    if not has_proxy:
        return {}
    # Comma-separated host suffixes; keep localhost so CDP/devtools stay local.
    # Note: when Camoufox/Playwright sets the proxy, Juggler uses proxy["bypass"]
    # — user.js alone is not enough. See _attach_payment_cdn_proxy_bypass().
    return {"network.proxy.no_proxies_on": _payment_cdn_bypass_list()}


def _attach_payment_cdn_proxy_bypass(proxy_dict: Optional[dict], geoip):
    """
    Attach Playwright proxy.bypass so Stripe CDNs skip a blocking residential proxy.

    Camoufox's Proxy dataclass (used when geoip=True) only accepts
    server/username/password — passing bypass there raises TypeError. Resolve
    the exit IP ourselves first, then add bypass for the browser session.
    Returns (proxy_dict, geoip).
    """
    if not proxy_dict:
        return proxy_dict, geoip
    resolved_geoip = geoip
    if geoip is True:
        try:
            from camoufox.ip import Proxy as CamoufoxProxy
            from camoufox.ip import public_ip

            resolved_geoip = public_ip(
                CamoufoxProxy(
                    server=proxy_dict["server"],
                    username=proxy_dict.get("username"),
                    password=proxy_dict.get("password"),
                ).as_string()
            )
        except Exception:
            # Avoid Proxy(**proxy) with bypass; timezone can fall back from config.
            resolved_geoip = None
    out = dict(proxy_dict)
    out["bypass"] = _payment_cdn_bypass_list()
    return out, resolved_geoip


def _ensure_profile_user_js(
    profile_dir: str,
    extra_prefs: Optional[dict] = None,
) -> None:
    """
    Write profile prefs into user.js before the browser starts.

    user.js is loaded at Firefox startup and overrides defaultPref values from
    camoufox.cfg. Re-applied on every launch so stale profiles cannot win.
    """
    try:
        os.makedirs(profile_dir, exist_ok=True)
        user_js_path = os.path.join(profile_dir, "user.js")
        prefs = dict(_PROFILE_USER_JS_PREFS)
        if extra_prefs:
            prefs.update(extra_prefs)
        managed_keys = set(prefs.keys()) | {
            # Always strip prior proxy-bypass lines when regenerating without proxy.
            "network.proxy.no_proxies_on",
        }
        legacy_markers = (
            _USER_JS_START,
            _USER_JS_END,
            "// camoufox-profile-manager signon prefs (auto-managed)",
            "// camoufox-profile-manager signon prefs end",
        )

        preserved: list[str] = []
        if os.path.isfile(user_js_path):
            in_managed_block = False
            with open(user_js_path, encoding="utf-8") as f:
                for line in f:
                    stripped = line.strip()
                    if stripped in legacy_markers[:2] or stripped in legacy_markers[2:]:
                        in_managed_block = stripped.endswith("(auto-managed)")
                        continue
                    if in_managed_block:
                        continue
                    if stripped.startswith("user_pref("):
                        skip = False
                        for key in managed_keys:
                            if json.dumps(key) in line:
                                skip = True
                                break
                        if skip:
                            continue
                    preserved.append(line.rstrip("\n"))

        pref_lines = [
            _USER_JS_START,
            *(_format_user_pref(k, v) for k, v in prefs.items()),
            _USER_JS_END,
        ]
        body = preserved
        if body and body[-1]:
            body.append("")
        body.extend(pref_lines)
        with open(user_js_path, "w", encoding="utf-8") as f:
            f.write("\n".join(body) + "\n")
    except Exception:
        pass


def _repair_profile_prefs(
    profile_dir: str,
    extra_prefs: Optional[dict] = None,
) -> None:
    """
    Reset prefs.js so Firefox regenerates from user.js on next launch.

    Preserves cookies.sqlite, key4.db, logins, storage/, places.sqlite.
    """
    try:
        prefs_path = os.path.join(profile_dir, "prefs.js")
        if os.path.isfile(prefs_path):
            backup_path = prefs_path + ".cpm-backup"
            shutil.copy2(prefs_path, backup_path)
            os.remove(prefs_path)
        _ensure_profile_user_js(profile_dir, extra_prefs=extra_prefs)
    except Exception:
        pass


# Bump this whenever the bundle branding (menu names, icons, omni.ja strings)
# changes so each profile clears its stale Firefox startupCache exactly once.
_BRANDING_VERSION = "antidetect-browser-2"


def _clear_profile_startup_cache(profile_dir: str) -> None:
    """
    Delete the profile's Firefox startupCache when branding has changed.

    Editing omni.ja in place does not bump the build id, so Firefox keeps
    serving the old compiled menu/brand strings ("About Camoufox", etc.) from
    <profile>/startupCache. We invalidate it once per branding version using a
    stamp file, then let Firefox rebuild from the patched omni.ja.
    """
    try:
        stamp_path = os.path.join(profile_dir, ".cpm-branding")
        current = ""
        if os.path.isfile(stamp_path):
            try:
                with open(stamp_path, encoding="utf-8") as f:
                    current = f.read().strip()
            except Exception:
                current = ""
        if current == _BRANDING_VERSION:
            return
        cache_dir = os.path.join(profile_dir, "startupCache")
        if os.path.isdir(cache_dir):
            shutil.rmtree(cache_dir, ignore_errors=True)
        os.makedirs(profile_dir, exist_ok=True)
        with open(stamp_path, "w", encoding="utf-8") as f:
            f.write(_BRANDING_VERSION)
    except Exception:
        pass


def _is_juggler_pref_error(err: Exception) -> bool:
    msg = str(err).lower()
    return any(
        token in msg
        for token in (
            "setboolpref",
            "setintpref",
            "setstringpref",
            "ns_error_unexpected",
            "browser.enable",
            "firefox_user_prefs",
        )
    )


def _marker_path(profile_dir: str) -> str:
    return os.path.join(profile_dir, RUNNING_MARKER)


def _touch_marker(profile_dir: str) -> None:
    """Refresh the running marker mtime so Electron can detect a live session."""
    try:
        path = _marker_path(profile_dir)
        with open(path, "w") as f:
            f.write(str(os.getpid()))
        os.utime(path, None)
    except Exception:
        pass


def _remove_marker(profile_dir: str) -> None:
    try:
        os.remove(_marker_path(profile_dir))
    except Exception:
        pass


def _force_exit_after(seconds: float, code: int = 0) -> None:
    """
    Guarantee the launcher process actually dies after the browser closes.

    Playwright's persistent-context __exit__ can hang trying to talk to an
    already-dead browser; if it does, this watchdog hard-exits so Electron's
    child 'exit' handler fires and the profile flips to 'stopped'.
    """
    import threading

    def _kill() -> None:
        time.sleep(seconds)
        os._exit(code)

    t = threading.Thread(target=_kill, daemon=True)
    t.start()


def _cookies_roundtrip(context, timeout: float = 3.0) -> str:
    """
    Perform a real Playwright round-trip.
    Returns 'ok', 'dead' (browser gone), or 'timeout' (likely hung after crash).

    Must run on the main thread — Playwright sync API uses greenlets and breaks
    when context.cookies() is invoked from a worker thread.
    """
    del timeout  # kept for call-site compatibility
    try:
        context.cookies()
        return "ok"
    except Exception:
        return "dead"


def _wait_until_browser_closed(context, profile_dir: str) -> str:
    """
    Block until the user closes Camoufox/Firefox, then return.

    Returns 'closed' for a clean shutdown and 'crashed' when the browser dies
    unexpectedly (protocol error, hang, or connection loss).
    """
    import threading

    done = threading.Event()

    def _on_close(*_args) -> None:
        done.set()

    try:
        context.on("close", _on_close)
    except Exception:
        pass

    while not done.is_set():
        result = _cookies_roundtrip(context)
        if result == "dead":
            return "crashed"
        if result == "timeout":
            return "crashed"

        try:
            if not list(context.pages):
                return "closed"
        except Exception:
            return "crashed"

        _touch_marker(profile_dir)
        time.sleep(0.5)

    return "closed"


def _safe_window_size(screen_w: int, screen_h: int) -> tuple[int, int]:
    """
    Clamp requested window size to the current monitor to avoid broken/offset UI
    when profile size is larger than physical display.
    """
    w = max(900, int(screen_w))
    h = max(650, int(screen_h))
    try:
        from screeninfo import get_monitors
        mons = get_monitors()
        if mons:
            mon = max(mons, key=lambda m: m.width * m.height)
            # Keep some margin for macOS menu bar/window controls.
            w = min(w, int(mon.width) - 40)
            h = min(h, int(mon.height) - 90)
    except Exception:
        pass
    return w, h


def _normalize_samesite(raw, secure: bool) -> str | None:
    if raw is None or str(raw).lower() in ("unspecified", "null", ""):
        return "None" if secure else "Lax"
    s = str(raw).lower()
    if s in ("no_restriction", "none"):
        return "None"
    if s == "strict":
        return "Strict"
    if s == "lax":
        return "Lax"
    if raw in ("Strict", "Lax", "None"):
        return raw
    return "None" if secure else "Lax"


def _normalize_expires(c: dict) -> int | None:
    raw = c.get("expires", c.get("expirationDate"))
    if not isinstance(raw, (int, float)) or raw <= 0:
        return None
    return int(raw)


def _parse_json_cookie(c: dict) -> dict | None:
    if "name" not in c or "value" not in c:
        return None
    domain = str(c.get("domain", "")).strip()
    if not domain:
        return None
    secure = bool(c.get("secure"))
    cookie = {
        "name": str(c["name"]),
        "value": str(c["value"]),
        "domain": domain,
        "path": str(c.get("path", "/")),
    }
    expires = _normalize_expires(c)
    if expires is not None:
        cookie["expires"] = expires
    if "httpOnly" in c:
        cookie["httpOnly"] = bool(c["httpOnly"])
    cookie["secure"] = secure
    same_site = _normalize_samesite(c.get("sameSite"), secure)
    if same_site:
        cookie["sameSite"] = same_site
    return cookie


def _parse_cookies(text: str) -> list:
    """Parse cookies from Netscape cookies.txt or JSON array. Returns Playwright-compatible list."""
    text = text.strip()
    if not text:
        return []

    # JSON array (Playwright, GenLogin, Cookie-Editor, etc.)
    if text.startswith("["):
        try:
            data = json.loads(text)
            if not isinstance(data, list):
                return []
            out = []
            for c in data:
                if isinstance(c, dict):
                    parsed = _parse_json_cookie(c)
                    if parsed:
                        out.append(parsed)
            return out
        except json.JSONDecodeError:
            return []

    # Netscape cookies.txt: domain \t flag \t path \t secure \t expires \t name \t value
    out = []
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split("\t")
        if len(parts) < 7:
            continue
        domain, _flag, path, secure_str, expires_str, name, value = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], "\t".join(parts[6:])
        try:
            expires_num = int(expires_str)
        except ValueError:
            expires_num = 0
        cookie = {
            "name": name,
            "value": value,
            "domain": domain or "",
            "path": path or "/",
        }
        if expires_num > 0:
            cookie["expires"] = expires_num
        cookie["secure"] = secure_str == "TRUE"
        cookie["sameSite"] = "None" if cookie["secure"] else "Lax"
        if cookie.get("domain"):  # Playwright requires non-empty domain
            out.append(cookie)
    return out


def main():
    if len(sys.argv) < 2:
        print(json.dumps({"success": False, "error": "Missing config JSON"}))
        sys.exit(1)

    try:
        config = json.loads(sys.argv[1])
    except json.JSONDecodeError as e:
        print(json.dumps({"success": False, "error": f"Invalid JSON: {e}"}))
        sys.exit(1)

    # Rename the bundle folder to "<Brand>.app" (Dock/notification name comes from
    # the folder name for directly-launched apps), keeping a Camoufox.app symlink.
    _ensure_branded_bundle_name()
    # Rebrand before Camoufox import/launch (menu bar uses localized InfoPlist.strings).
    _patch_camoufox_branding()

    try:
        from camoufox.sync_api import Camoufox
    except ImportError:
        print(json.dumps({
            "success": False,
            "error": "Camoufox not installed. Run: pip install -U camoufox[geoip] && camoufox fetch"
        }))
        sys.exit(1)

    # Prevent geoip from spoofing a multi-segment IANA timezone, which crashes
    # Camoufox on shutdown ("Camoufox quit unexpectedly").
    _install_geoip_timezone_guard()

    profile_data_dir = config.get("profileDataDir", "")
    if not profile_data_dir or not os.path.isabs(profile_data_dir):
        print(json.dumps({"success": False, "error": "Invalid profileDataDir"}))
        sys.exit(1)

    proxy_config = config.get("proxy")
    proxy_dict = None
    if proxy_config:
        ptype = proxy_config.get("type", "http")
        host = proxy_config.get("host", "")
        port = proxy_config.get("port", 8080)
        user = proxy_config.get("username", "")
        pwd = proxy_config.get("password", "")
        if host and port:
            proxy_dict = {"server": f"{ptype}://{host}:{port}"}
            if user or pwd:
                proxy_dict["username"] = str(user or "")
                proxy_dict["password"] = str(pwd or "")

    stored_proxy_ip = str(config.get("proxyIp") or "").strip()
    use_local_ip = bool(config.get("useLocalIp"))
    if not use_local_ip and not proxy_dict:
        # No proxy configured — match timezone/locale/geolocation to the real local IP.
        use_local_ip = True
    geoip = None
    if use_local_ip:
        geoip = True
    elif proxy_dict:
        geoip = stored_proxy_ip if stored_proxy_ip else True

    locale = config.get("locale", "en-US")
    languages = config.get("languages", [locale])
    if isinstance(languages, str):
        languages = [s.strip() for s in languages.split(",") if s.strip()]

    webrtc_mode = config.get("webrtcMode", "proxy")
    block_webrtc = webrtc_mode == "disabled"

    geo_config = None
    if not use_local_ip and config.get("geolocationMode") == "custom":
        lat = config.get("geoLatitude")
        lon = config.get("geoLongitude")
        acc = config.get("geoAccuracy", 100)
        if lat is not None and lon is not None:
            geo_config = {"latitude": lat, "longitude": lon, "accuracy": acc}

    os_family = config.get("osFamily", "macos")
    screen_w = config.get("screenWidth", 1920)
    screen_h = config.get("screenHeight", 1080)

    try:
        from browserforge.fingerprints import Screen
        screen_constraint = Screen(max_width=screen_w, max_height=screen_h)
    except ImportError:
        screen_constraint = None

    if use_local_ip:
        proxy_dict = None

    # Playwright proxy.bypass is what actually skips SITE_PERMANENTLY_BLOCKED
    # payment CDNs; user.js no_proxies_on alone is ignored under Juggler proxy.
    if proxy_dict:
        proxy_dict, geoip = _attach_payment_cdn_proxy_bypass(proxy_dict, geoip)

    # uBlock Origin (Camoufox default) breaks many banking/SPA sites (Wise, etc.)
    # and is itself a strong bot signal. Exclude it for normal browsing profiles.
    exclude_addons = None
    try:
        from camoufox.addons import DefaultAddons
        exclude_addons = [DefaultAddons.UBO]
    except ImportError:
        pass

    launch_args = {
        "headless": False,
        "persistent_context": True,
        "user_data_dir": profile_data_dir,
        "os": os_family,
        "proxy": proxy_dict,
        "geoip": geoip,
        "block_webrtc": block_webrtc,
        "humanize": True,
        "enable_cache": True,
        "exclude_addons": exclude_addons,
    }
    # Always pass locale — Wise/Next.js crashes when Intl locale is undefined in Firefox.
    if languages:
        launch_args["locale"] = ",".join(languages)
    elif locale:
        launch_args["locale"] = locale
    # Launch from the real "Antidetect Browser.app" bundle (not the Camoufox.app
    # symlink) so the macOS Dock tooltip shows the branded name. Ensure
    # properties.json is reachable beside the executable first.
    branded_exe = _branded_executable_path()
    if branded_exe:
        _ensure_properties_beside_exe()
        launch_args["executable_path"] = branded_exe
    if not use_local_ip and config.get("timezone") and not geoip:
        launch_args["config"] = {"timezone": config.get("timezone")}

    if screen_constraint:
        launch_args["screen"] = screen_constraint

    # Match outer window size to profile, but clamp to the physical monitor.
    # Forcing a larger-than-screen window can cause clipped/odd browser chrome.
    launch_args["window"] = _safe_window_size(screen_w, screen_h)

    if geo_config:
        launch_args["geolocation"] = geo_config

    # We restore tabs ourselves from sessionstore.jsonlz4 (Firefox's lazy session-
    # restore creates tabs that Playwright can't reliably wake up; the title shows
    # but the page is blank and Reload stays disabled). Disable Firefox restore and
    # only keep sessionstore writes so we can read last-session URLs.
    # Do NOT pass firefox_user_prefs — Juggler setBoolPref at Browser.enable crashes
    # on Camoufox locked prefs. All prefs are written to user.js before launch.
    payment_extra_prefs = _payment_cdn_proxy_prefs(has_proxy=bool(proxy_dict))
    _ensure_profile_user_js(profile_data_dir, extra_prefs=payment_extra_prefs)
    # Drop the stale Firefox startupCache so the rebranded menu/about strings and
    # icon from the patched omni.ja actually take effect (build id is unchanged).
    _clear_profile_startup_cache(profile_data_dir)
    # The password manager is disabled by the app-bundle enterprise policy, which
    # user.js cannot override; re-enable it before the browser starts.
    _patch_camoufox_policies()

    def _geoip_lookup_failed(err: Exception) -> bool:
        msg = str(err).lower()
        return any(
            token in msg
            for token in (
                "failed to get ip address",
                "invalid ip",
                "failed to connect to proxy",
                "geoip",
                "connection refused",
                "connection reset",
                "timed out",
                "timeout",
                "proxy",
                "name or service not known",
                "nodename nor servname",
            )
        )

    def _run_browser(args: dict) -> None:
        with Camoufox(**{k: v for k, v in args.items() if v is not None}) as context:
            cookies_raw = config.get("cookies", "").strip()
            if cookies_raw:
                cookies_list = _parse_cookies(cookies_raw)
                if cookies_list:
                    try:
                        context.add_cookies(cookies_list)
                    except Exception as cookie_err:
                        print(json.dumps({"success": False, "error": f"Invalid cookies: {cookie_err}"}))
                        sys.exit(1)

            store_urls = _sessionstore_http_urls(profile_data_dir)
            _normalize_tabs(context, store_urls)
            # Browser is up and tabs are settled: publish the running marker so
            # Electron has an authoritative "session is live" signal.
            _touch_marker(profile_data_dir)
            exit_code = 0
            try:
                reason = _wait_until_browser_closed(context, profile_data_dir)
                exit_code = 0 if reason == "closed" else 1
            finally:
                # Remove the marker the instant we detect a close, BEFORE the
                # (possibly hanging) context teardown, and start a watchdog so
                # this process can't linger as a zombie "Running" session.
                _remove_marker(profile_data_dir)
                if exit_code != 0:
                    print(json.dumps({"success": False, "error": "Browser crashed unexpectedly"}))
                _force_exit_after(1.0, exit_code)

    def _launch_with_recovery(args: dict) -> None:
        try:
            _run_browser(args)
        except KeyboardInterrupt:
            raise
        except Exception as launch_err:
            if not _is_juggler_pref_error(launch_err):
                raise
            _repair_profile_prefs(profile_data_dir, extra_prefs=payment_extra_prefs)
            _run_browser(args)

    try:
        _launch_with_recovery(launch_args)
    except KeyboardInterrupt:
        pass
    except Exception as e:
        if proxy_dict and launch_args.get("geoip") and _geoip_lookup_failed(e):
            fallback_args = dict(launch_args)
            fallback_args["geoip"] = None
            tz = config.get("timezone")
            if tz:
                fallback_args["config"] = {"timezone": tz}
            try:
                _launch_with_recovery(fallback_args)
            except KeyboardInterrupt:
                pass
            except Exception as retry_err:
                print(json.dumps({"success": False, "error": str(retry_err)}))
                sys.exit(1)
        else:
            print(json.dumps({"success": False, "error": str(e)}))
            sys.exit(1)


if __name__ == "__main__":
    main()
