master
John Lauer v2.0.737 (insiders): WebRTC streaming apps work in pup again (pup-bridge#137): pup no longer launches its browser with… 01b92b0 14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
//! pup-browser: which browser to drive, how to get Chrome for Testing onto the box, and how to
//! launch it. Ports src/chrome.js (detect/install/readiness/candidates) and the launch half of
//! src/server.js getOrLaunchBrowser. The first-run contract (docs/PLAN-rust-port.md 6b) lives
//! here: download to a temp file, unpack in a supervised child into a `.partial` folder, verify the
//! executable, rename into place atomically, prune every folder without an executable at start.

use pup_host::Host;
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};

pub mod ext_assets;
pub mod chrome_stable;

/// The machine's installed Google Chrome, if any (never Edge, never Chrome for Testing).
pub fn detect_chrome(host: &dyn Host) -> Option<Candidate> { list_system_browsers(host).into_iter().find(|c| c.kind == "chrome") }

/// Chrome extension ids (deterministic from each manifest's `key`, so staging path does not matter).
pub const EXT_IDS: &[(&str, &str)] = &[("pup-extension", "liomcaladmooagjiebkfgegciadapnpi"), ("pup-annotate-extension", "ghadnmahlldiigemdbekmhcbfcpkmclg"), ("pup-identity-extension", "mngfopllhdfgomjphhejmcdimcohcgnf"), ("pup-identity-anon-extension", "mhoenaodccbppcinfbaifamkgfhbakbe"), ("pup-nbe-extension", "nncjgceobmlenfliamnibncjojpedbcc")];

/// The Chrome for Testing build pup drives. Same build id the Node build pinned through
/// puppeteer 24.43, so both builds share one cache folder. Bump deliberately, with a ship note.
pub const CFT_BUILD_ID: &str = "152.0.7977.82";
pub const CFT_MIN_FREE_MB: u64 = 1200;
const UNPACK_STALL: Duration = Duration::from_secs(90);
const UNPACK_MAX: Duration = Duration::from_secs(15 * 60);

#[derive(Debug, Clone, PartialEq)]
pub struct Candidate { pub kind: String, pub source: String, pub executable_path: PathBuf, pub build_id: Option<String> }

impl Candidate {
    pub fn to_json(&self) -> Value { json!({ "kind": self.kind, "source": self.source, "executablePath": self.executable_path.to_string_lossy().replace('\\', "/"), "buildId": self.build_id }) }
}

// ── detection (Node: listSystemBrowsers / detectChrome / launchCandidates) ─────────────

pub fn list_system_browsers(host: &dyn Host) -> Vec<Candidate> {
    // The per-OS install layout is the host's (Program Files on Windows, /Applications bundles on macOS).
    host.system_browsers().into_iter().map(|(kind, p)| Candidate { kind, source: "system".into(), executable_path: p, build_id: None }).collect()
}

pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_cache_platform(), build_id)) }
pub fn cft_exe_path(host: &dyn Host, build_id: &str) -> PathBuf { cft_versioned_dir(host, build_id).join(host.cft_exe_relative()) }

pub fn detect_cft(host: &dyn Host) -> Option<Candidate> {
    // 2.0.734 (John, 2026-10-25: "New Chrome available" in a pup window): the NEWEST complete build in the cache
    // wins, not the pinned one, so a build the auto-updater (cft_auto_update) unpacked takes over at the next launch.
    // CFT_BUILD_ID is now only the first-install build and the floor.
    let root = host.cft_cache_dir().join("chrome");
    let mut builds: Vec<(String, PathBuf)> = fs::read_dir(&root).ok()?.flatten()
        .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_cache_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) })
        // 2.0.679: a build id is a plain version. Folders like "<build>.partial" (an unpack in progress) or a renamed
        // copy matched the prefix and could be launched half-written (found testing ab's prewarm on CRR, 2026-09-22).
        .filter(|(b, p)| !b.is_empty() && b.chars().all(|c| c.is_ascii_digit() || c == '.') && p.is_file()).collect();
    builds.sort_by(|a, b| version_key(&b.0).cmp(&version_key(&a.0)));
    builds.into_iter().next().map(|(b, p)| Candidate { kind: "chrome-for-testing".into(), source: "cache".into(), executable_path: p, build_id: Some(b) })
}

fn version_key(v: &str) -> Vec<u64> { v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect() }

/// The persisted default (~/.cache/puppeteer/pup-browser-default.json, same file as Node).
pub fn read_default(host: &dyn Host) -> Option<Value> {
    let p = host.cft_cache_dir().join("pup-browser-default.json");
    let v: Value = serde_json::from_str(&fs::read_to_string(p).ok()?).ok()?;
    if v.get("temporary").and_then(|t| t.as_bool()) == Some(true) {
        // Node: expireTemporaryDefault — a temporary pin lasts one bridge lifetime
        if let Some(at) = v.get("verifiedAt").and_then(|t| t.as_u64()) { if pup_core::now_ms().saturating_sub(at) > 6 * 3600 * 1000 { return None; } }
    }
    Some(v)
}

pub fn write_default(host: &dyn Host, cand: &Candidate, forced: bool) {
    let p = host.cft_cache_dir().join("pup-browser-default.json");
    // pup-bridge#109: a normal launch must NEVER clobber the user's pin. get_or_launch calls this with
    // forced:false after every launch, which downgraded a `pup_use {browser:"chrome"}` pin to forced:false, so
    // the pin survived exactly one launch and then silently reverted to Chrome for Testing (John measured the
    // pin "not taking" in #109).
    if !forced {
        if let Some(d) = read_default(host) { if d.get("forced").and_then(|f| f.as_bool()) == Some(true) { return; } }
    }
    let _ = fs::create_dir_all(p.parent().unwrap());
    let v = json!({ "kind": cand.kind, "executablePath": cand.executable_path.to_string_lossy().replace('\\', "/"), "source": cand.source, "buildId": cand.build_id, "forced": forced, "verifiedAt": pup_core::now_ms() });
    let _ = fs::write(p, serde_json::to_string_pretty(&v).unwrap_or_default());
}

/// Ordered fallback list the launcher spawn-verifies: pinned default first, then (CfT-first
/// policy when the toolbar is on) cached CfT, Chrome, Edge.
pub fn launch_candidates(host: &dyn Host, prefer_cft: bool) -> Vec<Candidate> {
    let mut all = list_system_browsers(host);
    if let Some(c) = detect_cft(host) { if prefer_cft { all.insert(0, c); } else { all.push(c); } }
    if let Some(d) = read_default(host) {
        if d.get("forced").and_then(|f| f.as_bool()) == Some(true) {
            let kind = d.get("kind").and_then(|k| k.as_str()).unwrap_or("");
            if let Some(i) = all.iter().position(|c| c.kind == kind) { let c = all.remove(i); all.insert(0, c); }
        }
    }
    all
}

// ── install state + readiness (Node: chrome.js state / readiness()) ────────────────────

#[derive(Debug, Clone, Default)]
pub struct InstallState {
    pub phase: String,            // idle | installing | unpacking | ready | failed
    pub progress_pct: u32,
    pub bytes_downloaded: u64,
    pub bytes_total: u64,
    pub started_at: Option<Instant>,
    pub finished_at: Option<Instant>,
    pub unpack_bytes: u64,
    pub unpack_started_at: Option<Instant>,
    pub unpack_method: Option<String>,
    pub error: Option<String>,
    pub error_code: Option<String>,
    pub build_id: Option<String>,
    pub executable_path: Option<PathBuf>,
}

pub struct Installer { pub state: Arc<Mutex<InstallState>>, inflight: Arc<AtomicBool> }

impl Default for Installer { fn default() -> Self { Installer { state: Arc::new(Mutex::new(InstallState { phase: "idle".into(), ..Default::default() })), inflight: Arc::new(AtomicBool::new(false)) } } }

impl Installer {
    pub fn new() -> Installer { Installer::default() }
    pub fn installing(&self) -> bool { self.inflight.load(Ordering::Relaxed) }

    /// Kick the download off in the background (idempotent: one in flight at a time).
    pub fn ensure_background(&self, host: Arc<dyn Host>) {
        // 2.0.549: gate on the PINNED build, not "any cached CfT". detect_cft falls back to any cached
        // build so an offline box still drives, but that fallback also masked a version bump: 148 stayed
        // cached, detect_cft returned it, and 152 was never fetched. Now: if the pinned build is present,
        // ready; if not, download it in the background (a fallback build drives meanwhile) so the box
        // upgrades on its own.
        let pinned = cft_exe_path(&*host, CFT_BUILD_ID);
        if pinned.is_file() { let mut s = self.state.lock().unwrap(); s.phase = "ready".into(); s.executable_path = Some(pinned); return; }
        // 2.0.734: a NEWER build the auto-updater installed counts as ready too; otherwise pruning the old pinned build
        // after an upgrade would make every start download it again.
        if let Some(c) = detect_cft(&*host) { if c.build_id.as_deref().map(|b| !build_newer(CFT_BUILD_ID, b)).unwrap_or(false) { let mut s = self.state.lock().unwrap(); s.phase = "ready".into(); s.executable_path = Some(c.executable_path); return; } }
        if self.inflight.swap(true, Ordering::SeqCst) { return; }
        let state = self.state.clone(); let inflight = self.inflight.clone();
        std::thread::Builder::new().name("cft-install".into()).spawn(move || {
            let r = install_cft(&*host, &state);
            if let Err((code, msg)) = r { let mut s = state.lock().unwrap(); s.phase = "failed".into(); s.error_code = Some(code); s.error = Some(msg.clone()); s.finished_at = Some(Instant::now()); pup_core::log(format!("[chrome] install failed ({}): {}", s.error_code.clone().unwrap_or_default(), msg)); }
            inflight.store(false, Ordering::SeqCst);
        }).ok();
    }

    /// Block until installed (pup_prewarm {wait:true}).
    pub fn ensure_blocking(&self, host: Arc<dyn Host>, max: Duration) -> bool {
        self.ensure_background(host.clone());
        let t0 = Instant::now();
        while t0.elapsed() < max { if detect_cft(&*host).is_some() { return true; } if !self.installing() { return detect_cft(&*host).is_some(); } std::thread::sleep(Duration::from_millis(500)); }
        false
    }

    /// The readiness fields the Node build's chrome.readiness() returned (pup_readiness + chips).
    pub fn readiness(&self, host: &dyn Host, prefer_cft: bool) -> Value {
        let s = self.state.lock().unwrap().clone();
        let installing = self.installing() || s.phase == "installing" || s.phase == "unpacking";
        let det = detect_cft(host);
        let cands = launch_candidates(host, prefer_cft);
        let best = cands.first().cloned();
        let free = disk_free_mb(&host.cft_cache_dir());
        let elapsed = if installing { s.started_at.map(|t| t.elapsed().as_secs()) } else { None };
        let eta = if installing && s.bytes_total > 0 && s.bytes_downloaded > 0 && elapsed.unwrap_or(0) >= 2 { let rate = s.bytes_downloaded as f64 / elapsed.unwrap_or(1) as f64; if rate > 0.0 { Some(((s.bytes_total - s.bytes_downloaded) as f64 / rate).round() as u64) } else { None } } else { None };
        json!({
            "ready": best.is_some(),
            "browserKind": best.as_ref().map(|c| c.kind.clone()),
            "browserSource": best.as_ref().map(|c| c.source.clone()),
            "browserExecutablePath": best.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")),
            "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(),
            "defaultBrowser": read_default(host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": d.get("pendingInstall").and_then(|f| f.as_bool()).unwrap_or(false) })),
            "nodeInstalled": true, "nodeVersion": format!("rust/{}", pup_core::version()),
            "chromeForTestingInstalled": det.is_some(),
            "chromeExecutablePath": det.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")).or_else(|| s.executable_path.as_ref().map(|p| p.to_string_lossy().replace('\\', "/"))),
            "chromeBuildId": det.as_ref().and_then(|c| c.build_id.clone()).or(s.build_id.clone()).or(Some(CFT_BUILD_ID.into())),
            "installing": installing, "installingChrome": installing,
            "installPhase": if det.is_some() && !installing { "ready" } else { s.phase.as_str() },
            "installProgressPct": if det.is_some() && !installing { 100 } else { s.progress_pct },
            "bytesDownloaded": s.bytes_downloaded, "bytesTotal": s.bytes_total,
            "downloadElapsedSec": elapsed, "downloadEtaSec": eta,
            "unpackBytes": s.unpack_bytes, "unpackElapsedSec": if s.phase == "unpacking" { s.unpack_started_at.map(|t| t.elapsed().as_secs()) } else { None }, "unpackMethod": s.unpack_method,
            "lastError": s.error, "lastErrorCode": s.error_code,
            "cacheDir": host.cft_cache_dir().to_string_lossy().replace('\\', "/"),
            "diskFreeMb": free, "lowDisk": free.map(|f| f < CFT_MIN_FREE_MB).unwrap_or(false),
            "puppeteerLoadError": Value::Null,
        })
    }
}

pub fn disk_free_mb(path: &Path) -> Option<u64> {
    // Portable enough: statvfs on unix, GetDiskFreeSpaceExW on Windows (through std::fs is not
    // available); use a tiny platform split without pulling a crate.
    #[cfg(windows)]
    {
        use std::os::windows::ffi::OsStrExt;
        extern "system" { fn GetDiskFreeSpaceExW(lpDirectoryName: *const u16, lpFreeBytesAvailableToCaller: *mut u64, lpTotalNumberOfBytes: *mut u64, lpTotalNumberOfFreeBytes: *mut u64) -> i32; }
        let mut p = path.to_path_buf(); while !p.exists() { if !p.pop() { break; } }
        let w: Vec<u16> = p.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
        let (mut a, mut b, mut c) = (0u64, 0u64, 0u64);
        let ok = unsafe { GetDiskFreeSpaceExW(w.as_ptr(), &mut a, &mut b, &mut c) } != 0;
        return if ok { Some(a / (1024 * 1024)) } else { None };
    }
    #[cfg(not(windows))]
    { let _ = path; None }
}

// ── the supervised install (Node: installChrome + unpackSupervised, 2.0.398) ───────────

fn set(state: &Arc<Mutex<InstallState>>, f: impl FnOnce(&mut InstallState)) { if let Ok(mut s) = state.lock() { f(&mut s); } }

pub fn install_cft(host: &dyn Host, state: &Arc<Mutex<InstallState>>) -> Result<PathBuf, (String, String)> { install_cft_build(host, state, CFT_BUILD_ID) }

/// The current Stable Chrome for Testing version from Google's feed, e.g. "155.0.7811.92".
pub fn latest_stable_build() -> Option<String> {
    let r = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(15)).timeout(Duration::from_secs(30)).build()
        .get("https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json").call().ok()?;
    let v: Value = serde_json::from_str(&r.into_string().ok()?).ok()?;
    let b = v["channels"]["Stable"]["version"].as_str()?.to_string();
    if b.chars().all(|c| c.is_ascii_digit() || c == '.') && b.contains('.') { Some(b) } else { None }
}
/// Is version `a` newer than `b`?
pub fn build_newer(a: &str, b: &str) -> bool { version_key(a) > version_key(b) }

pub fn install_cft_build(host: &dyn Host, state: &Arc<Mutex<InstallState>>, build: &str) -> Result<PathBuf, (String, String)> {
    set(state, |s| { *s = InstallState { phase: "installing".into(), started_at: Some(Instant::now()), build_id: Some(build.into()), ..Default::default() }; });
    let cache = host.cft_cache_dir();
    let root = cache.join("chrome");
    fs::create_dir_all(&root).map_err(|e| ("chrome_install_permission".to_string(), format!("cannot create {}: {e}", root.display())))?;
    if let Some(free) = disk_free_mb(&cache) { if free < CFT_MIN_FREE_MB { return Err(("chrome_install_no_disk".into(), format!("Not enough free disk to install Chrome for Testing: {free} MB free on the drive holding {}, need ~{CFT_MIN_FREE_MB} MB (150 MB download unpacks to ~600 MB).", cache.display()))); } }
    prune_broken_cache_dirs(host, Some(build));
    let platform = host.cft_platform();
    let archive = root.join(format!("{build}-chrome-{platform}.zip"));
    if !archive.is_file() {
        let url = format!("https://storage.googleapis.com/chrome-for-testing-public/{build}/{platform}/chrome-{platform}.zip");
        pup_core::log(format!("[chrome] installing Chrome for Testing {build} ({platform}) into {} ...", cache.display()));
        download(&url, &archive, state)?;
    } else {
        let size = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
        set(state, |s| { s.bytes_total = size; s.bytes_downloaded = size; s.progress_pct = 100; });
    }
    let versioned = cft_versioned_dir(host, build);
    let partial = PathBuf::from(format!("{}.partial", versioned.display()));
    set(state, |s| { s.phase = "unpacking".into(); s.unpack_started_at = Some(Instant::now()); s.unpack_bytes = 0; });
    let size = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
    pup_core::log(format!("[chrome] archive {} ({size} bytes) on disk — unpacking into {}", archive.display(), partial.display()));
    let method = unpack_supervised(&archive, &partial, state)?;
    let exe_in_partial = partial.join(host.cft_exe_relative());
    if !exe_in_partial.is_file() {
        let _ = fs::remove_dir_all(&partial);
        return Err(("chrome_unpack_incomplete".into(), format!("unpack via {method} finished but {} is missing", exe_in_partial.display())));
    }
    if versioned.exists() { let _ = fs::remove_dir_all(&versioned); }
    fs::rename(&partial, &versioned).map_err(|e| ("chrome_unpack_failed".to_string(), format!("rename {} -> {}: {e}", partial.display(), versioned.display())))?;
    let _ = fs::remove_file(&archive);
    let exe = cft_exe_path(host, build);
    let secs = state.lock().ok().and_then(|s| s.unpack_started_at.map(|t| t.elapsed().as_secs())).unwrap_or(0);
    let ub = state.lock().map(|s| s.unpack_bytes).unwrap_or(0);
    pup_core::log(format!("[chrome] unpacked via {method} in {secs}s ({ub} bytes) -> {}", versioned.display()));
    pup_core::log(format!("[chrome] Chrome for Testing {build} ready at {}", exe.display()));
    set(state, |s| { s.phase = "ready".into(); s.executable_path = Some(exe.clone()); s.progress_pct = 100; s.finished_at = Some(Instant::now()); s.error = None; s.error_code = None; });
    write_default(host, &Candidate { kind: "chrome-for-testing".into(), source: "cache".into(), executable_path: exe.clone(), build_id: Some(build.into()) }, false);
    Ok(exe)
}

fn download(url: &str, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), (String, String)> {
    let part = PathBuf::from(format!("{}.part", dest.display()));
    let resp = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(20)).build().get(url).call().map_err(|e| (classify_net(&e.to_string()), format!("download {url}: {e}")))?;
    let total: u64 = resp.header("Content-Length").and_then(|h| h.parse().ok()).unwrap_or(0);
    set(state, |s| { s.bytes_total = total; });
    let mut reader = resp.into_reader();
    let mut out = fs::File::create(&part).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;
    let mut buf = vec![0u8; 256 * 1024];
    let mut got: u64 = 0;
    let mut last = Instant::now();
    loop {
        let n = std::io::Read::read(&mut reader, &mut buf).map_err(|e| (classify_net(&e.to_string()), format!("download read: {e}")))?;
        if n == 0 { break; }
        std::io::Write::write_all(&mut out, &buf[..n]).map_err(|e| ("chrome_install_no_disk".to_string(), e.to_string()))?;
        got += n as u64;
        if last.elapsed() > Duration::from_millis(250) { last = Instant::now(); set(state, |s| { s.bytes_downloaded = got; s.progress_pct = if total > 0 { ((got * 100) / total) as u32 } else { 0 }; }); }
    }
    drop(out);
    if total > 0 && got != total { let _ = fs::remove_file(&part); return Err(("chrome_download_failed".into(), format!("download ended at {got} of {total} bytes"))); }
    fs::rename(&part, dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;
    set(state, |s| { s.bytes_downloaded = got; s.bytes_total = if total > 0 { total } else { got }; s.progress_pct = 100; });
    Ok(())
}

fn classify_net(msg: &str) -> String {
    let m = msg.to_ascii_lowercase();
    if m.contains("dns") || m.contains("resolve") || m.contains("timed out") || m.contains("connection") || m.contains("proxy") || m.contains("network") { "chrome_download_network".into() } else { "chrome_download_failed".into() }
}

pub fn dir_bytes(dir: &Path, depth: u32) -> u64 {
    let mut total = 0;
    if let Ok(rd) = fs::read_dir(dir) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { if depth < 6 { total += dir_bytes(&p, depth + 1); } } else if let Ok(m) = p.metadata() { total += m.len(); } } }
    total
}

fn has_cft_executable(dir: &Path) -> bool {
    if let Ok(rd) = fs::read_dir(dir) {
        for e in rd.flatten() {
            let n = e.file_name().to_string_lossy().to_string();
            if !n.starts_with("chrome-") { continue; }
            let b = e.path();
            if b.join("chrome.exe").is_file() || b.join("chrome").is_file() || b.join("Google Chrome for Testing.app").exists() { return true; }
        }
    }
    false
}

/// Every folder under <cache>/chrome/ without an executable is junk (interrupted download or
/// unzip) and would make the next install a deterministic failure. Never touches a young
/// `.partial` (an unpack in progress elsewhere).
pub fn prune_broken_cache_dirs(host: &dyn Host, keep_build: Option<&str>) -> Vec<String> {
    let root = host.cft_cache_dir().join("chrome");
    let mut removed = Vec::new();
    let Ok(rd) = fs::read_dir(&root) else { return removed };
    for e in rd.flatten() {
        let p = e.path(); if !p.is_dir() { continue; }
        let name = e.file_name().to_string_lossy().to_string();
        if let Some(k) = keep_build { if name.contains(k) && !name.ends_with(".partial") { continue; } }
        if name.ends_with(".partial") { if let Ok(m) = p.metadata() { if let Ok(t) = m.modified() { if SystemTime::now().duration_since(t).unwrap_or_default() < Duration::from_secs(30 * 60) { continue; } } } }
        if has_cft_executable(&p) { continue; }
        match fs::remove_dir_all(&p) {
            Ok(_) => { pup_core::log(format!("[chrome] pruned a Chrome-for-Testing cache folder with no executable (interrupted download or unzip): {}", p.display())); removed.push(name); }
            Err(err) => pup_core::log(format!("[chrome] could not prune broken cache folder {}: {err}", p.display())),
        }
    }
    removed
}

/// Child-process unpack with a stall watchdog: OS tar first (bsdtar reads zip natively on
/// Windows 10+ and macOS), then `unzip` on unix. Each failed method is cleaned up before the next.
pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<String, (String, String)> {
    let mut methods: Vec<(&str, PathBuf, Vec<String>)> = vec![("tar", PathBuf::from("tar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()])];
    if !cfg!(windows) { methods.push(("unzip", PathBuf::from("unzip"), vec!["-q".into(), "-o".into(), archive.to_string_lossy().into(), "-d".into(), dest.to_string_lossy().into()])); }
    let mut last = String::new();
    for (name, cmd, args) in methods {
        let _ = fs::remove_dir_all(dest);
        fs::create_dir_all(dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;
        set(state, |s| { s.unpack_method = Some(name.into()); });
        match run_unpack(&cmd, &args, dest, state) {
            Ok(()) => return Ok(name.into()),
            Err(e) => { last = format!("unpack via {name} {e}"); pup_core::log(format!("[chrome] {last} — trying the next unpack method")); }
        }
    }
    let _ = fs::remove_dir_all(dest);
    Err(("chrome_unpack_failed".into(), last))
}

fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), String> {
    let mut c = std::process::Command::new(cmd);
    c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());
    #[cfg(windows)]
    { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); }   // CREATE_NO_WINDOW
    let mut child = c.spawn().map_err(|e| format!("could not start: {e}"))?;
    let started = Instant::now();
    let (mut last_bytes, mut last_growth) = (u64::MAX, Instant::now());
    loop {
        if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
            let b = dir_bytes(dest, 0); set(state, |s| { s.unpack_bytes = b; });
            if status.success() { return Ok(()); }
            let mut err = String::new(); if let Some(mut se) = child.stderr.take() { let _ = std::io::Read::read_to_string(&mut se, &mut err); }
            return Err(format!("exited {status}: {}", err.trim().chars().rev().take(300).collect::<String>().chars().rev().collect::<String>()));
        }
        std::thread::sleep(Duration::from_millis(1000));
        let b = dir_bytes(dest, 0); set(state, |s| { s.unpack_bytes = b; });
        if b != last_bytes { last_bytes = b; last_growth = Instant::now(); }
        if last_growth.elapsed() > UNPACK_STALL { let _ = child.kill(); return Err(format!("STALLED: no bytes written for {}s ({b} bytes so far); killed it", UNPACK_STALL.as_secs())); }
        if started.elapsed() > UNPACK_MAX { let _ = child.kill(); return Err(format!("exceeded {} min; killed it", UNPACK_MAX.as_secs() / 60)); }
    }
}

// ── profile + launch (Node: prepareCleanProfile + getOrLaunchBrowser) ──────────────────

/// Stage the embedded MV3 extensions under ~/.adom/<staged name> (Node: _stageExtDir) and write
/// the toolbar's port.json so the popup finds the live bridge. Returns the dirs to --load-extension,
/// in the order the toolbar shows them: annotate pen, toolbar, identity (adom-you) or anonymous tile.
pub fn stage_extensions(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool) -> Vec<PathBuf> {
    stage_extensions_lane(host, port, profile, toolbar_on, None, None)
}

/// 2.0.502: `lane` stamps a token into port.json. The extension only serves the extension-DRIVE control
/// channel when it has one, so a NORMAL pup window (which loads the same extension) never answers another
/// thread's ext commands. Without this the channel had no browser identity: any pup browser on the box
/// answered /extcmd, and pup ended up driving a DIFFERENT thread's window (measured 2026-09-13).
pub fn stage_extensions_lane(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool, lane: Option<&str>, identity_shim: Option<String>) -> Vec<PathBuf> {
    if !toolbar_on { return Vec::new(); }
    // 2.0.659: one staging folder per lane profile, so each lane browser reads its OWN port.json (lane token)
    let root = if lane.is_some() { host.state_dir().join(if profile == "adom-you" { "extlane".to_string() } else { format!("extlane-{profile}") }) } else { host.state_dir() };
    let _ = fs::create_dir_all(&root);
    // Remove any staged extension folder that is no longer one of ours (a retired placeholder like
    // pup-lane-extension left a second browser-window icon in the toolbar until it was deleted by hand,
    // John 2026-09-13). Only prune names matching our own prefix so a user's own files are never touched.
    let keep: std::collections::HashSet<&str> = ext_assets::EXTENSIONS.iter().map(|(_, dst, _)| *dst).collect();
    if let Ok(rd) = fs::read_dir(&root) {
        for e in rd.flatten() {
            let n = e.file_name().to_string_lossy().to_string();
            if n.starts_with("pup-") && n.ends_with("-extension") && !keep.contains(n.as_str()) {
                let _ = fs::remove_dir_all(e.path());
                pup_core::log(format!("[extensions] pruned retired staged folder {n}"));
            }
        }
    }
    let mut staged: std::collections::HashMap<&str, PathBuf> = std::collections::HashMap::new();
    for (src, dst, files) in ext_assets::EXTENSIONS {
        let dir = root.join(dst);
        if fs::create_dir_all(&dir).is_err() { continue; }
        for (name, bytes) in *files {
            let p = dir.join(name);
            // a staged file may sit in a subfolder (nbe ships src/ and icons/); without this the write
            // fails silently and Chrome rejects the extension for a missing background script
            if let Some(parent) = p.parent() { let _ = fs::create_dir_all(parent); }
            let same = fs::metadata(&p).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
            if !same { let _ = fs::write(&p, bytes); }
        }
        if *src == "extension" {
            // 2.0.543: the consumer-identity shim runs in the page's MAIN world at document_start, where
            // chrome.storage is unreachable, so its values are baked into the file at staging (pup passes
            // them in; empty = leave the file inert).
            if let Some(js) = identity_shim.as_deref() { let _ = fs::write(dir.join("identity-shim.js"), js); }
            let lane_field = lane.map(|l| format!(",\"lane\":\"{l}\"")).unwrap_or_default();
            // 2.0.670: the profile rides along so the worker can say WHICH jar it runs in; pup adopts an unknown
            // lane browser only into that profile's lane (AdomLapper, 2026-09-21: the "services" browser was
            // adopted as adom-you and two adom-you windows ran in the wrong cookie jar).
            let _ = fs::write(dir.join("port.json"), format!("{{\"port\":{port},\"version\":\"{}\",\"shell\":\"rust\",\"profile\":{}{lane_field}}}", pup_core::version(), serde_json::to_string(profile).unwrap_or_else(|_| "\"\"".into())));
        }
        // 2.0.498: stamp the manifest version with pup's own version. Chrome CACHES an unpacked extension's
        // service worker in the profile and only reloads it when the manifest version changes — so every
        // worker.js fix pup shipped kept running as the OLD script on every existing profile (measured on
        // ConfRoomROG with pup_ext_diag: the worker fetched port.json fine and still had no packagedPort).
        // A cold Chrome on the same profile is NOT enough; the version bump is what invalidates it.
        if *dst == "pup-nbe-extension" { staged.insert(dst, dir); continue; }
        let mp = dir.join("manifest.json");
        if let Ok(txt) = fs::read_to_string(&mp) {
            if let Ok(mut m) = serde_json::from_str::<Value>(&txt) {
                let want = pup_core::version().to_string();
                let mut changed = false;
                if m.get("version").and_then(|v| v.as_str()) != Some(want.as_str()) { m["version"] = json!(want); changed = true; }
                // 2.0.499: the version bump alone did NOT make Chrome re-register the cached service worker
                // (ConfRoomROG, measured with pup_ext_diag after a cold browser: still the old script). A new
                // script URL does: stage the worker as worker.<version>.js and point the manifest at it, so
                // every pup release registers a fresh worker. Old copies are removed so the folder stays clean.
                if let Some(sw) = m.get("background").and_then(|b| b.get("service_worker")).and_then(|v| v.as_str()).map(|s| s.to_string()) {
                    let base = sw.split('.').next().unwrap_or("worker").to_string();
                    let versioned = format!("{base}.{}.js", want.replace('.', "-"));
                    if sw != versioned {
                        if let Ok(src) = fs::read(dir.join(&base).with_extension("js")).or_else(|_| fs::read(dir.join(&sw))) {
                            if let Ok(rd) = fs::read_dir(&dir) { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); if n.starts_with(&format!("{base}.")) && n.ends_with(".js") && n != format!("{base}.js") { let _ = fs::remove_file(e.path()); } } }
                            let _ = fs::write(dir.join(&versioned), src);
                            m["background"]["service_worker"] = json!(versioned);
                            changed = true;
                        }
                    }
                }
                if changed { let _ = fs::write(&mp, serde_json::to_string_pretty(&m).unwrap_or(txt)); }
            }
        }
        staged.insert(dst, dir);
    }
    // The anonymous tile means "isolated throwaway jar, logins here are discarded". The extension lane's
    // profile is adom-you-ext: pup-owned and DURABLE, not a throwaway, so the exact-equality check was
    // calling it something it is not (John, 2026-09-13: "why are you doing anonymous? wouldn't that even
    // hurt our ability to load sites cuz they might detect anonymous and think this is a bot"). Any
    // adom-you* jar wears the signed-in identity. NOTE this only corrects the LABEL; the lane still has
    // its own cookie jar, which is the real problem and is tracked separately.
    let identity = if profile.starts_with("adom-you") { "pup-identity-extension" } else { "pup-identity-anon-extension" };
    // A staged folder whose manifest points at a file that is not on disk makes Chrome throw a MODAL
    // "Error Loading Extension" dialog in the user's face and load NOTHING (John hit exactly that when
    // the asset generator dropped nbe's src/ folder, 2026-09-13). Never hand Chrome a folder we have
    // not verified: drop it from the load list and say so in the log instead.
    let sane = |dir: &PathBuf| -> bool {
        let Ok(txt) = fs::read_to_string(dir.join("manifest.json")) else { return false };
        let Ok(m) = serde_json::from_str::<Value>(&txt) else { return false };
        let mut needed: Vec<String> = Vec::new();
        if let Some(sw) = m["background"]["service_worker"].as_str() { needed.push(sw.to_string()); }
        if let Some(p) = m["action"]["default_popup"].as_str() { needed.push(p.to_string()); }
        for (_k, v) in m["icons"].as_object().into_iter().flatten() { if let Some(p) = v.as_str() { needed.push(p.to_string()); } }
        for f in &needed {
            if !dir.join(f).exists() {
                pup_core::log(format!("[extensions] NOT loading {}: manifest needs \"{f}\" and it is not staged", dir.display()));
                return false;
            }
        }
        true
    };
    staged.retain(|_k, dir| sane(dir));
    let mut order = vec!["pup-annotate-extension", "pup-extension", identity];
    // 2.0.506 (John): the extension-driven lane gets a FOURTH teal toolbar button, so a glance at the
    // toolbar says which lane a pup window is on. Three buttons = the CDP lane; four = this one. It is
    // staged ONLY when `lane` is set, which is what makes it a signal rather than decoration.
    // 2.0.515: nbe ALWAYS rides the extension lane. 2.0.509 hid it behind a flag because the lane looked
    // dead with it loaded; that was wrong (the lane was equally dead without it, and the real cause was
    // pup wiping its own lane token in launch_extdrive, fixed in 2.0.513). The flag was never registered
    // as a settings key either, so pup_configure silently ignored it. John needs nbe's icon in the
    // toolbar as the at-a-glance signal that a window is the EXTENSION lane and not the CDP lane:
    // three teal buttons = CDP, four = this lane.
    if lane.is_some() { order.push("pup-nbe-extension"); }
    order.iter().filter_map(|k| staged.get(k).cloned()).collect()
}

/// Profiles: `<ADOM_BRIDGE_DATA_DIR>/profiles` when ab passes the data dir (pup-bridge#98), else the
/// legacy `~/.adom/pup-profiles`; the legacy dir is moved over once so logins carry (resolved once).
pub fn profiles_dir(host: &dyn Host) -> PathBuf {
    static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
    DIR.get_or_init(|| pup_core::data_subdir("profiles", host.state_dir().join("pup-profiles"))).clone()
}
/// Session files, same rule (`<data>/sessions`, legacy `~/.adom/pup-sessions`).
pub fn sessions_dir(host: &dyn Host) -> PathBuf {
    static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
    DIR.get_or_init(|| pup_core::data_subdir("sessions", host.state_dir().join("pup-sessions"))).clone()
}

/// pup-bridge#109: when on, launch with a near-STOCK flag set. Measured 2026-09-12: nb (the user's real Chrome,
/// extension-driven) loaded 14/14 vendor sites on AdomLapper while pup got 5/14 on the same box in the same
/// minute — and pup driving the real Chrome BINARY failed identically, so the binary is not the difference. The
/// remaining suspects are the profile, the ~25 launch flags (notably --no-sandbox and --test-type, which stock
/// Chrome never carries), and the CDP channel. This isolates the flags.
static STOCK_FLAGS: AtomicBool = AtomicBool::new(false);
pub fn set_stock_flags(on: bool) { STOCK_FLAGS.store(on, Ordering::SeqCst); }

/// 2.0.720: switches a platform needs so Chrome never raises an OS prompt of its own. macOS: Chromium's
/// OSCrypt otherwise creates/reads a "Chromium Safe Storage" item in the user's LOGIN KEYCHAIN to encrypt
/// cookies, and the keychain ACL is per BINARY, so EVERY new Chrome-for-Testing build pops "Google Chrome
/// for Testing wants to use your confidential information…" (Kyle, 2026-10-20, via H). pup's profiles are
/// isolated throwaways, so an in-memory key is right; the mock keychain's key is constant across launches,
/// so cookies written under it still persist. Cookies encrypted under a real keychain key before this flag
/// are unreadable once (one re-login). Windows/Linux: nothing (DPAPI / basic store prompt nothing).
pub fn platform_switches() -> Vec<String> {
    if cfg!(target_os = "macos") { vec!["--use-mock-keychain".into()] } else { vec![] }
}

/// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features).
pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Vec<String> {
    if STOCK_FLAGS.load(Ordering::SeqCst) {
        // Only what pup CANNOT work without, plus the sign-in suppression that protects the user's profile.
        let mut v: Vec<String> = vec![
            format!("--user-data-dir={}", profile_dir.display()),
            "--remote-debugging-port=0".into(),
            "--no-first-run".into(), "--no-default-browser-check".into(),
            "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),
            "--disable-features=SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker".into(),
        ];
        if insecure_dev { v.push("--disable-web-security".into()); }
        if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new
 // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure
 // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.
 v.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }
        v.push("--no-startup-window".into());
        v.extend(platform_switches());
        return v;
    }
    let mut v: Vec<String> = vec![
        format!("--user-data-dir={}", profile_dir.display()),
        "--remote-debugging-port=0".into(),
        "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-default-apps".into(),
        "--disable-sync".into(), "--disable-background-networking".into(), "--disable-infobars".into(),
        // Suppress Chrome's browser sign-in / "set up a work profile" / "turn on sync" prompts so a Google web
        // login NEVER pulls the user off the adom-you CfT profile (v1.8.95; dropped in the Rust port, John saw
        // the intercept bubble return 2026-09-12). The wiki/site cookies we DO want are unaffected.
        "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),
        "--disable-session-crashed-bubble".into(), "--hide-crash-restore-bubble".into(), "--disable-field-trial-config".into(),
        "--ash-no-nudges".into(), "--test-type".into(), "--no-sandbox".into(), "--media-router=0".into(),
        "--install-autogenerated-theme=13,17,23".into(), "--start-maximized".into(),
        // pup-bridge#109: hide navigator.webdriver (the top bot-manager signal on st.com/analog/arrow). The
        // standard, well-tested anti-automation flag; it does not affect the toolbar extension or CDP driving.
        "--disable-blink-features=AutomationControlled".into(),
        // 2.0.498: --disable-quic is BACK. 2.0.483 turned QUIC on to match Chrome's transport; that theory was
        // wrong (the CDP port was the discriminator, real Chrome failed identically) and QUIC binds UDP sockets on
        // 0.0.0.0, which is exactly what makes Windows Firewall prompt "allow Google Chrome for Testing" (measured
        // on winvm: three UDP 0.0.0.0 sockets per launch, none with QUIC off). No prompt beats HTTP/3.
        "--disable-quic".into(), "--disable-async-dns".into(),
        // 2.0.737 (pup-bridge#137): no WebRTC UDP restriction. disable_non_proxied_udp never stopped the Windows
        // Firewall prompt (#91: that came from network-service sockets, and ab's firewall rule handles it), and it
        // broke WebRTC media apps such as Google's Project Genie (no UDP candidates, the call never connects).
        format!("--disable-features={DISABLE_FEATURES}"),
    ];
    if high_fps { v.extend(["--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"].map(String::from)); }
    if insecure_dev { v.push("--disable-web-security".into()); }
    if !extensions.is_empty() { v.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new
 // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure
 // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.
 v.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }
    // Node v1.8.51: `--no-startup-window` is THE fix for the focus-steal war AND for the orphan
    // window. Chrome starts with NO window; pup creates the one it wants over CDP (Target.createTarget,
    // off-screen, then parked). The Rust port launched with "about:blank" instead, so every profile
    // birthed an extra untracked about:blank window that pup never stamped: a stock Chrome for Testing
    // button with the attention tint on the taskbar (John, 2026-09-06). Never pass a URL here.
    v.push("--no-startup-window".into());
    v.extend(platform_switches());
    v
}

/// Node: prepareCleanProfile (the Preferences the profile must carry before Chrome opens it).
pub fn prepare_profile(profile_dir: &Path, pin_extensions: bool, host: Option<&dyn Host>) {
    let default = profile_dir.join("Default");
    let _ = fs::create_dir_all(&default);
    let prefs_path = default.join("Preferences");
    let mut prefs: Value = fs::read_to_string(&prefs_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
    if !prefs.is_object() { prefs = json!({}); }
    prefs["profile"]["exit_type"] = json!("Normal");
    prefs["profile"]["exited_cleanly"] = json!(true);
    // 2.0.737 (pup-bridge#137): Chrome's default WebRTC routing, written back over the old disable_non_proxied_udp so
    // existing profiles are repaired too (it broke WebRTC streaming apps and never helped the firewall prompt).
    prefs["webrtc"]["ip_handling_policy"] = json!("default");
    prefs["webrtc"]["nonproxied_udp_enabled"] = json!(true);
    prefs["webrtc"]["multiple_routes_enabled"] = json!(true);
    if pin_extensions {
        // the toolbar order IS the pinned_extensions array order (Node v2.0.150): foreign pins kept, ours appended in canonical order
        let ours: Vec<&str> = EXT_IDS.iter().map(|(_, id)| *id).collect();
        let prev: Vec<Value> = prefs["extensions"]["pinned_extensions"].as_array().cloned().unwrap_or_default();
        let mut pinned: Vec<Value> = prev.into_iter().filter(|x| x.as_str().map(|s| !ours.contains(&s)).unwrap_or(false)).collect();
        for (_, id) in EXT_IDS { pinned.push(json!(id)); }
        prefs["extensions"]["pinned_extensions"] = Value::Array(pinned);
    }
    prefs["browser"]["promotional_tabs_enabled"] = json!(false);
    // 2.0.492: Google's installers register extensions MACHINE-WIDE (HKCU/HKLM ...\Chrome\Extensions), and
    // Chrome offers them to every NEW profile it creates on that machine — the user sees an "Action required"
    // bubble: "Another program on your computer added an extension" (John hit this in a pup window, 2026-09-12,
    // for "Application Launcher For Drive"). pup owns these profiles and never wants a third party's extension
    // in them. Pre-initialise the extension alert state so the bubble does not greet a fresh pup profile.
    // Best-effort: it suppresses the prompt, it does not un-register the machine-wide entry (that would be a
    // system-wide policy change, which pup must never make to the user's machine).
    prefs["extensions"]["alerts"]["initialized"] = json!(true);
    // 2.0.553: the alerts flag above does NOT gate the "Another program on your computer added an
    // extension" dialog (John hit it again on 2026-09-14 for "Application Launcher For Drive"). Chrome's
    // ExternalInstallManager gates that per-extension on extensions.settings.<id>.ack_external, so
    // pre-acknowledge every machine-wide id. Profile-scoped only: pup never edits the machine's registry.
    if let Some(h) = host {
        for id in h.external_extension_ids() {
            prefs["extensions"]["settings"][&id]["ack_external"] = json!(true);
        }
    }
    prefs["extensions"]["ui"]["developer_mode"] = json!(true);
    prefs["session"]["restore_on_startup"] = json!(5);
    prefs["session"]["startup_urls"] = json!([]);
    prefs["profile"]["password_manager_leak_detection"] = json!(false);
    prefs["credentials_enable_service"] = json!(false);   // pup runs its own vault; no Chrome save bubbles
    prefs["profile"]["password_manager_enabled"] = json!(false);
    let _ = fs::write(&prefs_path, serde_json::to_string(&prefs).unwrap_or_default());
    // Chrome refuses to start on a profile whose lock names a dead process; Node cleaned these too.
    for lock in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = fs::remove_file(profile_dir.join(lock)); }
    // 2.0.477: pup OWNS every tab (it creates them over CDP); Chrome must never restore a session on its own. A
    // killed Chrome leaves tab/window restore state that the next launch replays INTO pup's first window as a
    // phantom extra tab — reproducible every other isolated restart on all three boxes (sr-b came back with 4
    // tabs, 0 live). Wipe the session-restore files (NOT cookies or Local Storage, which pup needs) so there is
    // nothing to restore. exited_cleanly=true above already asks Chrome not to; this removes the data outright.
    for f in ["Current Session", "Current Tabs", "Last Session", "Last Tabs"] { let _ = fs::remove_file(default.join(f)); }
    let _ = fs::remove_dir_all(default.join("Sessions"));
}

#[derive(Debug, Clone)]
pub struct Launched { pub pid: u32, pub cdp_port: u16, pub candidate: Candidate, pub profile_dir: PathBuf, pub reused: bool }

/// If the profile's DevToolsActivePort names a live Chrome, reuse it (Node: attemptReconnectProfile).
pub fn existing_port(profile_dir: &Path) -> Option<u16> {
    let f = profile_dir.join("DevToolsActivePort");
    let port: u16 = fs::read_to_string(&f).ok()?.lines().next()?.trim().parse().ok()?;
    ureq::AgentBuilder::new().timeout(Duration::from_millis(1500)).build().get(&format!("http://127.0.0.1:{port}/json/version")).call().ok()?;
    Some(port)
}

/// pup-bridge#109: launch for EXTENSION-DRIVEN mode — the same Chrome for Testing, staged
/// extensions and pup-owned profile, but deliberately NO `--remote-debugging-port`. The debug port
/// is what several vendor edges wall (measured: st.com/mouser RST at HTTP/2, arrow denies over CDP;
/// all three load from this launch). pup talks to it through the extension's poll channel instead.
/// Returns the browser pid; there is no CDP handle by design.
/// The ONE Chrome feature-suppression list, shared by BOTH lanes. 2.0.505: the ext lane carried a
/// hand-trimmed copy that had lost the startup-promo ids, so John saw "Chromium now launches when
/// Windows starts so you can begin browsing instantly" on an ext window. Anything either lane needs
/// suppressed goes HERE; unknown feature names are ignored harmlessly by Chrome.
const DISABLE_FEATURES: &str = "OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider,SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker";

pub fn launch_extdrive(host: &dyn Host, cand: &Candidate, profile_dir: &Path, extensions: &[PathBuf], url: &str) -> Result<u32, String> {
    launch_extdrive_opts(host, cand, profile_dir, extensions, url, false, true)
}
/// `stock`: 2.0.544, an experiment lever for edges that still deny the lane after every header matches the
/// installed Chrome (analog.com, 2026-09-13). Keeps ONLY the flags that stop the Windows Firewall prompt
/// (no QUIC, no Cast/mDNS listener); everything else is Chrome's default, so the TLS ClientHello (ECH and the
/// other feature-gated extensions) is what real Chrome sends.
pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Path, extensions: &[PathBuf], url: &str, stock: bool, field_trials: bool) -> Result<u32, String> {
    if stock {
        let _ = fs::create_dir_all(profile_dir);
        prepare_profile(profile_dir, !extensions.is_empty(), Some(host));
        let mut args: Vec<String> = vec![
            format!("--user-data-dir={}", profile_dir.display()),
            "--no-first-run".into(), "--no-default-browser-check".into(),
            "--disable-quic".into(), "--media-router=0".into(),
            // 2.0.617 (John, 2026-09-18: "being asked even that once is too much"). The cold start is now
            // INVISIBLE: no startup window at all, so Chrome never draws or activates one, and the first pup
            // window is created by the extension, minimized, and shown already at the bottom. 2.0.611 had to
            // revert --no-startup-window because a windowless Chrome EXITED before its worker called home
            // (measured on his laptop: dead within 6 s; it survived on the conference-room box). The missing
            // half is --keep-alive-for-test, which holds the browser process open with no windows: measured on
            // the same laptop, alive at 6 s with the flag, dead without it. Both flags, or neither.
            "--no-startup-window".into(), "--keep-alive-for-test".into(),
            // 2.0.576 CalculateNativeWinOcclusion: stock Chrome stops painting a window that is COMPLETELY covered,
            // so every capture of a pup window buried under the user's maximized browser failed (0/20, 2026-09-14),
            // and the debugger screencast produced no frame either. The CDP lane has always disabled it (see
            // DISABLE_FEATURES). Compositor-only: nothing on the wire changes, which is what "stock" is for here.
            // firewall guards, plus the UI-only startup-promo features (2.0.546: the "Chromium now launches when
            // Windows starts" bar came back with the stock set; these change nothing on the wire). Field trials
            // stay ON here on purpose: --disable-field-trial-config alters feature rollouts and with them the
            // request fingerprint, which is the thing this launch exists to keep stock.
            "--disable-features=CalculateNativeWinOcclusion,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider,WebRtcHideLocalIpsWithMdns,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker".into(),
            "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),
            "--start-maximized".into(), "--disable-infobars".into(),
            // 2.0.574: the extension attaches chrome.debugger for ONE capture when a window is fully occluded;
            // this hides Chrome's "started debugging this browser" bar for that moment. UI-only, not on the wire.
            "--silent-debugger-extension-api".into(),
        ];
        // 2.0.547: the "Chromium now launches when Windows starts" promo is field-trial gated; disabling the
        // named features did not remove it (measured, 2.0.546). laneFieldTrials:off adds the flag that did in
        // 2.0.505; whether it costs anything at analog.com is measured, not assumed.
        if !field_trials { args.push("--disable-field-trial-config".into()); }
        if !extensions.is_empty() { args.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new
 // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure
 // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.
 args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }
        // 2.0.632: NO url on the command line when there is none. A url argument OVERRIDES --no-startup-window
        // (the CDP lane's note said "Never pass a URL here"), and the cold lane was launched with "about:blank":
        // that made an unowned about:blank window that sat ON TOP for 18 s until the orphan sweep closed it,
        // on every cold start (measured, 2.0.631).
        args.extend(platform_switches());
        if !url.is_empty() { args.push(url.to_string()); }
        return host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind));
    }
    let _ = fs::create_dir_all(profile_dir);
    prepare_profile(profile_dir, !extensions.is_empty(), Some(host));
    let mut args: Vec<String> = vec![
        format!("--user-data-dir={}", profile_dir.display()),
        "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-default-apps".into(),
        "--allow-browser-signin=false".into(), "--disable-signin-promo".into(),
        // 2.0.504, both missing flags the CDP lane's chrome_switches() has always passed:
        //  --start-maximized: without it the window opened at whatever size the profile remembered
        //    (John measured ~60% of his screen width). The extension's `fit` action corrects any
        //    drift afterwards without raising the window.
        //  --disable-infobars: suppresses CfT's "v148 ... is only for automated testing. For regular
        //    browsing, use a standard version of Chrome" bar, which John saw on an ext window.
        //    docs/WHY-CFT.md has always said pup hides it with this flag. It is NOT wanted for any
        //    bot-detection reason and costs nothing: infobars are browser UI, invisible to the page.
        "--start-maximized".into(), "--disable-infobars".into(),
        "--silent-debugger-extension-api".into(),   // 2.0.574: see the stock list above
        // 2.0.634: the 2.0.617 pair went into the STOCK branch only, and stock is off by default, so this
        // (the default) lane still got Chrome's real startup window: a New Tab page, activated by Chrome,
        // on top of the user for the whole session at every cold start (measured on ConfRoomROG, 2.0.633).
        // Same contract as the stock list: no startup window, and keep-alive so a windowless Chrome does
        // not exit before its worker calls home (AdomLapper: dead within 6 s without it). Both or neither.
        "--no-startup-window".into(), "--keep-alive-for-test".into(),
        // 2.0.493: keep the NETWORK-QUIETING flags. Dropping them (2.0.488) re-introduced the Windows Firewall
        // prompt "Do you want to allow public and private networks to access this app? — Google Chrome for
        // Testing" on winvm (John, 2026-09-12): Chrome's mDNS/Cast discovery binds a UDP port on ALL interfaces
        // and Windows prompts. These have nothing to do with the debug port this mode exists to omit.
        "--media-router=0".into(), "--disable-background-networking".into(), "--disable-sync".into(), "--disable-quic".into(),
        format!("--disable-features={DISABLE_FEATURES}"),
        // --disable-field-trial-config turns off the experiment config that GATES promos like the
        // launch-on-startup bar, and is also the version determinism WHY-CFT promises.
        "--disable-field-trial-config".into(),
    ];
    if !extensions.is_empty() {
        args.push(format!("--load-extension={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(",")));
        // 2.0.556: registry-installed extensions (Acrobat, Drive launcher) otherwise install into every new

        // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure

        // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled.

        args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(",")));
    }
    // 2.0.633: same rule as the stock branch above (2.0.632 only patched that one; this is the DEFAULT branch).
    // An empty argument was handed to Chrome as "" and it opened its working directory as a file listing,
    // on top of the user, for the whole session (measured on ConfRoomROG: "Index of C:\Users\john\.cache\puppeteer\").
    args.extend(platform_switches());
    if !url.is_empty() { args.push(url.to_string()); }
    host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind))
}

/// Spawn-verified launch: the browser is started detached, the DevTools port is read from the
/// profile's DevToolsActivePort (written by Chrome), and /json/version must answer.
pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Result<Launched, String> {
    let _ = fs::create_dir_all(profile_dir);
    let port_file = profile_dir.join("DevToolsActivePort");
    let _ = fs::remove_file(&port_file);
    // extensions ride ONLY Chrome for Testing (branded Chrome 137+ dropped --load-extension)
    let exts: Vec<PathBuf> = if cand.kind == "chrome-for-testing" { extensions.to_vec() } else { Vec::new() };
    prepare_profile(profile_dir, !exts.is_empty(), Some(host));
    let args = chrome_switches(profile_dir, high_fps, insecure_dev, &exts);
    let t0 = Instant::now();
    let pid = host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("spawn {}: {e}", cand.executable_path.display()))?;
    while t0.elapsed() < Duration::from_secs(30) {
        if let Ok(s) = fs::read_to_string(&port_file) {
            if let Some(p) = s.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) {
                if ureq::AgentBuilder::new().timeout(Duration::from_millis(1500)).build().get(&format!("http://127.0.0.1:{p}/json/version")).call().is_ok() {
                    return Ok(Launched { pid, cdp_port: p, candidate: cand.clone(), profile_dir: profile_dir.to_path_buf(), reused: false });
                }
            }
        }
        if !host.alive(pid) && t0.elapsed() > Duration::from_secs(3) { return Err(format!("{} exited during launch (pid {pid})", cand.kind)); }
        std::thread::sleep(Duration::from_millis(200));
    }
    Err(format!("{} did not publish a DevTools port within 30s", cand.kind))
}

#[cfg(test)]
mod tests {
    use super::*;
    use pup_host::HostPaths;
    struct H(PathBuf);
    impl pup_host::HostWindows for H { fn script_launch(&self, _: &std::path::Path, _: &str, _: bool) -> (String, String) { (String::new(), String::new()) } fn windows_for_pids(&self, _: &[u32], _: Option<&str>) -> Vec<pup_host::HostWindow> { vec![] } fn is_alive(&self, _: i64) -> bool { false } fn tag(&self, _: i64, _: &str) -> bool { false } fn has_tag(&self, _: i64, _: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _: i64) -> bool { false } fn set_bounds_no_activate(&self, _: i64, _: i32, _: i32, _: i32, _: i32) -> bool { false } fn foreground(&self) -> i64 { 0 } fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None } }
    impl pup_host::HostProcess for H { fn alive(&self, _: u32) -> bool { false } fn spawn_detached(&self, _: &Path, _: &[String], _: Option<&Path>) -> std::io::Result<u32> { Ok(1) } fn kill(&self, _: u32) -> bool { false } }
    impl pup_host::HostSecrets for H { fn secret_get(&self, _: &str, _: &str) -> Option<String> { None } fn secret_set(&self, _: &str, _: &str, _: &str) -> Result<(), String> { Err("n/a".into()) } fn secret_delete(&self, _: &str, _: &str) -> bool { false } }
    impl pup_host::HostPaths for H { fn home(&self) -> PathBuf { self.0.clone() } fn browser_roots(&self) -> Vec<PathBuf> { vec![] } fn cft_platform(&self) -> &'static str { "linux64" } fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") } }

    #[test] fn prune_and_detect() {
        let tmp = std::env::temp_dir().join(format!("pup-browser-test-{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        let h = H(tmp.clone());
        let root = h.cft_cache_dir().join("chrome");
        fs::create_dir_all(root.join("linux64-1.0.0.0").join("chrome-linux64")).unwrap();
        fs::write(root.join("linux64-1.0.0.0").join("chrome-linux64").join("chrome"), b"x").unwrap();
        fs::create_dir_all(root.join("linux64-9.9.9.9").join("chrome-linux64")).unwrap();   // no exe: junk
        fs::create_dir_all(root.join("chrome")).unwrap();                                    // stray
        let removed = prune_broken_cache_dirs(&h, None);
        assert_eq!(removed.len(), 2, "{removed:?}");
        assert!(root.join("linux64-1.0.0.0").exists());
        let d = detect_cft(&h).unwrap(); assert_eq!(d.build_id.as_deref(), Some("1.0.0.0")); assert_eq!(d.kind, "chrome-for-testing");
        let _ = fs::remove_dir_all(&tmp);
    }
    #[test] fn switches_have_one_disable_features() {
        let s = chrome_switches(Path::new("/tmp/p"), false, false, &[]);
        assert_eq!(s.iter().filter(|x| x.starts_with("--disable-features=")).count(), 1);
        assert!(s.iter().any(|x| x == "--remote-debugging-port=0"));
        assert!(!s.iter().any(|x| x == "--disable-web-security"));
        assert!(chrome_switches(Path::new("/tmp/p"), true, true, &[]).iter().any(|x| x == "--disable-web-security"));
        assert!(chrome_switches(Path::new("/tmp/p"), false, false, &[PathBuf::from("/a"), PathBuf::from("/b")]).iter().any(|x| x == "--load-extension=/a,/b"));
    }
    #[test] fn unpack_real_zip_and_reject_junk() {
        let tmp = std::env::temp_dir().join(format!("pup-unpack-test-{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("src").join("chrome-linux64")).unwrap();
        fs::write(tmp.join("src").join("chrome-linux64").join("chrome"), vec![7u8; 100000]).unwrap();
        let zip = tmp.join("a.zip");
        let ok = std::process::Command::new("python3").args(["-c", "import shutil,sys; shutil.make_archive(sys.argv[1][:-4],'zip',sys.argv[2])", zip.to_str().unwrap(), tmp.join("src").to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);
        if !ok { return; }   // no python on this box: skip
        let st = Arc::new(Mutex::new(InstallState::default()));
        let dest = tmp.join("linux64-1.2.3.partial");
        let m = unpack_supervised(&zip, &dest, &st).expect("unpack");
        assert!(dest.join("chrome-linux64").join("chrome").is_file(), "via {m}");
        fs::write(tmp.join("bad.zip"), b"not a zip").unwrap();
        assert!(unpack_supervised(&tmp.join("bad.zip"), &tmp.join("x.partial"), &st).is_err());
        assert!(!tmp.join("x.partial").exists());
        let _ = fs::remove_dir_all(&tmp);
    }
}