app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
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);
}
}