1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
// chrome.js — Chrome-for-Testing (CfT) self-heal for the Adom Desktop Puppeteer bridge.
//
// WHY THIS EXISTS (the cold-start bug it fixes):
//   HD bundles AD; installing HD installs AD; AD ships THIS bridge as a pre-installed
//   seed. But the HD/AD installer pre-installs NEITHER Node NOR Chrome for Testing.
//   So on a fresh PC, the first "open chip-fetcher in pup" used to:
//     - (Node missing) → AD returns node_not_found → desktop_install_node  [already good]
//     - (Node present, CfT missing) → server.js blindly spawned a nonexistent Chrome
//       binary 3×, then threw a plain dev-string ("...run npx puppeteer browsers
//       install chrome..."). No errorCode, no auto-install, no actionable hint → the
//       AI flailed and a human had to guess "they have no Chrome for Testing".
//
// WHAT THIS MODULE DOES:
//   - detectChrome(): validates the ACTUAL executable exists (not "some dir exists" —
//     the old coarse check let a partial/corrupt cache read as present and deferred
//     failure to puppeteer.launch()).
//   - installChrome(): installs CfT PROGRAMMATICALLY via the bundled @puppeteer/browsers
//     (no npx, no npm-registry round-trip; only the CfT bytes come from Google's CDN),
//     pinned to the EXACT buildId this puppeteer expects so launch() can find it.
//   - ensureChromeReady(): the gate the bridge calls before opening a window — returns
//     immediately with {state:'installing'} on a cold box (non-blocking) so the first
//     call answers with a pollable status instead of hanging 2–3 min.
//   - readiness(): the snapshot powering pup_readiness + the /status led/summary/tooltip.
//
// Everything here is self-contained in the bridge (no adom-desktop core changes).

'use strict';

const path = require('path');
const fs = require('fs');
const os = require('os');

let puppeteer = null;
try { puppeteer = require('puppeteer'); } catch (e) { /* surfaced via readiness().puppeteerLoadError */ }
const _puppeteerLoadError = puppeteer ? null : 'puppeteer module failed to require — node_modules likely missing (AD runs `npm install` on bridge spawn; if this persists the bundled deps are broken)';

// ── install-state singleton ──────────────────────────────────────────────────
// Shared across every command so concurrent open calls don't double-install and
// pup_readiness / the status chip can report live progress.
const state = {
  phase: 'unknown',      // 'unknown' | 'detecting' | 'installing' | 'ready' | 'failed'
  buildId: null,
  executablePath: null,
  progressPct: 0,        // 0..100 download progress
  bytesDownloaded: 0,
  bytesTotal: 0,
  startedAt: null,
  finishedAt: null,
  error: null,           // last failure message (cleared on success)
  errorCode: null,       // 'chrome_download_failed' | 'chrome_unsupported_platform' | ...
};
let _installInFlight = null; // dedup: the in-flight install promise, if any

function fileExists(p) {
  try { return !!p && fs.statSync(p).isFile(); } catch { return false; }
}

// Free disk (MB) on the volume holding `dir` (defaults to the CfT cache). Walks up
// to a path that exists (cacheDir may not exist yet on a fresh box). null if we
// can't tell (very old Node without statfsSync) — callers must treat null as "unknown",
// never as "zero", so we don't block installs on boxes where we simply can't measure.
function diskFreeMb(dir) {
  for (const d of [dir, cacheDir(), os.homedir()].filter(Boolean)) {
    try { const s = fs.statfsSync(d); return Math.floor((s.bavail * s.bsize) / (1024 * 1024)); } catch { /* try next */ }
  }
  return null;
}
// Chrome for Testing is a ~150 MB download that unpacks to ~600 MB — need headroom.
const CFT_MIN_FREE_MB = 800;

// Puppeteer's default cache (AD sets no PUPPETEER_CACHE_DIR) → ~/.cache/puppeteer.
function cacheDir() {
  return process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
}

// The buildId THIS puppeteer is pinned to. executablePath() returns the EXPECTED
// path even when the browser isn't installed yet; the build dir encodes the id,
// e.g. .../chrome/win64-140.0.7259.2/chrome-win64/chrome.exe. Installing 'stable'
// instead could fetch a DIFFERENT version than this puppeteer supports → launch()
// would look for the pinned id and not find it. So we pin to exactly this.
function expectedBuildId() {
  try {
    const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;
    if (!ep) return null;
    const m = ep.replace(/\\/g, '/').match(/\/chrome\/(?:win64|win32|mac(?:_arm)?|linux)-([0-9][0-9.]*)\//i);
    return m ? m[1] : null;
  } catch { return null; }
}

// Validate a USABLE Chrome for Testing is present (the actual exe, not a stray dir).
function detectChrome() {
  // 1) Where puppeteer expects it for the pinned build.
  try {
    const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;
    if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'puppeteer.executablePath', buildId: expectedBuildId() };
  } catch { /* fall through */ }
  // 2) Any installed Chrome in the cache, via @puppeteer/browsers (newest wins).
  try {
    const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
    const bid = expectedBuildId();
    if (bid) {
      const ep = computeExecutablePath({ browser: Browser.CHROME, buildId: bid, cacheDir: cacheDir() });
      if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'computeExecutablePath', buildId: bid };
    }
  } catch { /* fall through */ }
  return { installed: false, executablePath: null, buildId: expectedBuildId() };
}

// ── System browsers (the FAST path — no download) ─────────────────────────────
// Every Windows PC ships with Edge, and most have Chrome — both are Chromium and
// both are drivable by puppeteer. Preferring an ALREADY-INSTALLED browser (launched
// with a FRESH isolated profile) means the common case needs NO ~150 MB Chrome-for-
// Testing download. listSystemBrowsers probes the standard install locations and
// returns EVERY drivable system browser it finds, in preference order (Chrome, then Edge).
function listSystemBrowsers() {
  const pf   = process.env['PROGRAMFILES']      || 'C:\\Program Files';
  const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
  const lad  = process.env['LOCALAPPDATA']      || '';
  const j = (base, ...rest) => (base ? path.join(base, ...rest) : null);
  const chromePaths = [
    j(pf,   'Google', 'Chrome', 'Application', 'chrome.exe'),
    j(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
    j(lad,  'Google', 'Chrome', 'Application', 'chrome.exe'),
  ].filter(Boolean);
  const edgePaths = [
    j(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
    j(pf,   'Microsoft', 'Edge', 'Application', 'msedge.exe'),
  ].filter(Boolean);
  const posix = [
    ['chrome', '/usr/bin/google-chrome'], ['chrome', '/usr/bin/chromium'], ['chrome', '/usr/bin/chromium-browser'],
    ['chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
    ['chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')],
    ['edge',   '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
    ['edge',   path.join(os.homedir(), 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')],
  ];
  const out = [];
  const seen = new Set();
  const push = (kind, p) => { if (p && !seen.has(p) && fileExists(p)) { seen.add(p); out.push({ kind, executablePath: p, source: 'system' }); } };
  for (const p of chromePaths) push('chrome', p);
  for (const p of edgePaths)   push('edge', p);
  for (const [kind, p] of posix) push(kind, p);
  return out;
}

// Back-compat: the FIRST system browser (or {found:false}).
function detectSystemBrowser() {
  const list = listSystemBrowsers();
  return list.length ? { found: true, kind: list[0].kind, executablePath: list[0].executablePath } : { found: false };
}

// ── Persisted browser default ─────────────────────────────────────────────────
// So the bridge doesn't re-probe/re-verify on every open. Two ways it gets set:
//   • markVerified(): after a launch actually SUCCEEDS, we cache that exact browser
//     (forced:false) — the auto default. Cheap re-use next time.
//   • setDefaultBrowser(): the AI PINS a browser via pup_use (forced:true) — we
//     honor it strictly (but still fall back if it can't spawn, so it stays airtight).
// Stored as JSON next to the CfT cache so it survives bridge restarts.
function defaultFile() { return path.join(cacheDir(), 'pup-browser-default.json'); }
function readDefault() {
  try { return JSON.parse(fs.readFileSync(defaultFile(), 'utf8')); } catch { return null; }
}
function writeDefault(obj) {
  try { fs.mkdirSync(cacheDir(), { recursive: true }); fs.writeFileSync(defaultFile(), JSON.stringify(obj, null, 2)); return true; }
  catch (e) { console.error(`[chrome] could not persist browser default: ${e.message}`); return false; }
}
function clearDefault() { try { fs.unlinkSync(defaultFile()); } catch {} }

// Cache the browser that just launched successfully as the auto-default. Never
// overrides an explicit (forced) choice the AI made via pup_use.
function markVerified(cand) {
  if (!cand || !cand.executablePath) return;
  const d = readDefault();
  if (d && d.forced) return;
  writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: cand.source || 'system', buildId: cand.buildId || null, forced: false, verifiedAt: Date.now() });
}

// Every drivable browser, in the order pup will TRY them:
//   [stored default, if still present] → installed Chrome → installed Edge → cached CfT.
// Each entry is a fully-launchable {kind, executablePath, source}. The launch path
// tries them top-to-bottom and spawn-verifies each, so a broken top pick falls through.
function launchCandidates() {
  // v1.9.90 (John's call, verified against this code): NATIVE-FIRST. The installed browser comes
  // FIRST — installed Chrome, else Edge (ships on every Windows PC) — driven with a FRESH
  // --user-data-dir AND the noBrowserIdentityFlags (server.js) that kill the sign-in / "set up a
  // work profile" merge dialog at launch. Those flags + the isolated profile make a native browser
  // EXACTLY as clean as CfT: the old v1.8.71 "CfT-first because the branded browsers' identity
  // bleeds in" reason is DEAD — I proved (and my own v1.8.95 comment admits) CfT shows the SAME
  // dialog, and the flags that suppress it apply to ANY Chromium. Native is the user's battle-tested,
  // auto-updated build with NO 150MB download and NO fleet dep-bump to move it, and every taskbar
  // icon/identity override works on it identically (OS-window level, browser-agnostic).
  // Chrome-for-Testing is the GENUINE LAST RESORT now — a box with no installed Chromium at all
  // (e.g. a Mac with no Chrome). pup_use {browser:"cft"} still pins CfT explicitly for the one
  // case it earns its weight: a managed/enterprise box whose policies interfere with automation.
  const all = [];
  all.push(...listSystemBrowsers());        // installed Chrome, then Edge — FIRST (zero download, already isolated)
  const cft = detectChrome();
  if (cft.installed) all.push({ kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId }); // LAST resort

  const def = readDefault();
  if (def) {
    let idx = -1;
    if (def.executablePath) idx = all.findIndex(c => c.executablePath === def.executablePath);
    if (idx < 0 && def.kind)  idx = all.findIndex(c => c.kind === def.kind);
    if (idx > 0) { const [d] = all.splice(idx, 1); all.unshift(d); }
    else if (idx < 0 && def.executablePath && fileExists(def.executablePath)) {
      all.unshift({ kind: def.kind || 'custom', executablePath: def.executablePath, source: def.source || 'default', buildId: def.buildId });
    }
    // A forced-but-not-yet-present default (e.g. 'cft' still downloading) simply
    // doesn't reorder — the system browsers serve as the fallback until it arrives.
  }
  return all;
}

// The browser pup will actually launch, in preference order:
//   [pinned/verified default] → installed Chrome → installed Edge → cached Chrome-for-Testing.
// Launch uses a fresh --user-data-dir, so a system browser is driven CLEAN — it never
// touches the user's real tabs/logins (that's native-mode's job). `candidates` is the
// full ordered fallback list; the launch path spawn-verifies each in turn.
function resolveBrowser() {
  const candidates = launchCandidates();
  const def = readDefault();
  const top = candidates[0];
  if (!top) return { found: false, kind: 'none', executablePath: null, source: null, candidates: [], forced: !!(def && def.forced) };
  return {
    found: true, kind: top.kind, executablePath: top.executablePath, source: top.source, buildId: top.buildId || null,
    candidates, forced: !!(def && def.forced), defaultBrowser: def || null,
  };
}

// pup_use: set (or clear) the browser pup drives by default.
//   browser: 'auto' | 'chrome' | 'edge' | 'cft' | 'chrome-for-testing'  (or pass executablePath)
// 'cft' installs Chrome for Testing in the background if it isn't cached, and pins it
// as the default so once it lands every open uses it. Returns a pickSummary.
function setDefaultBrowser({ browser, executablePath, install, elevate } = {}) {
  const b = String(browser || '').toLowerCase().replace(/[\s_-]/g, '');
  if (executablePath) {
    if (!fileExists(executablePath)) return { ok: false, errorCode: 'pup_not_found', error: `No executable at ${executablePath}.`, ...pickSummary() };
    writeDefault({ kind: 'custom', executablePath, source: 'explicit', forced: true, verifiedAt: Date.now() });
    return { ok: true, ...pickSummary() };
  }
  if (b === 'auto' || b === 'clear' || b === 'reset' || b === 'default' || b === '') {
    clearDefault();
    return { ok: true, cleared: true, ...pickSummary() };
  }
  if (b === 'chrome' || b === 'edge') {
    const cand = listSystemBrowsers().find(c => c.kind === b);
    if (cand) {
      writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });
      return { ok: true, ...pickSummary() };
    }
    // Not installed. For Chrome we can download+install the REAL browser on request.
    if (b === 'chrome' && install) {
      writeDefault({ kind: 'chrome', executablePath: null, source: 'system', forced: true, pendingInstall: true, verifiedAt: Date.now() });
      installChromeStable({ elevate }); // background; pup_use polling + readiness track it
      return {
        ok: true, installing: true, installingChrome: true,
        _hint: process.platform === 'darwin'
          ? "Installing Google Chrome in the background (official dmg → Applications; silent, no prompt). Poll pup_readiness: chromeStablePhase goes installing → ready. pup keeps driving its current browser meanwhile, so nothing is blocked."
          : "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll pup_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.",
        ...pickSummary(),
      };
    }
    const avail = listSystemBrowsers().map(c => c.kind).join(', ') || 'none';
    const extra = b === 'chrome'
      ? " Pass {browser:'chrome', install:true} to download & install Google Chrome (~90 MB, user-scoped — silent on a normal PC; on a locked-down box it prompts the user for a Windows approval via a notification), or use browser:'cft' / browser:'edge'."
      : " Edge ships with every Windows PC — if it's genuinely missing, use browser:'chrome' (install:true) or browser:'cft'.";
    return { ok: false, errorCode: 'pup_not_found', error: `No installed ${b} found. Installed system browsers: ${avail}.${extra}`, ...pickSummary() };
  }
  if (b === 'cft' || b === 'chromefortesting') {
    const det = detectChrome();
    if (det.installed) {
      writeDefault({ kind: 'chrome-for-testing', executablePath: det.executablePath, source: 'cache', buildId: det.buildId, forced: true, verifiedAt: Date.now() });
      return { ok: true, ...pickSummary() };
    }
    // Not cached — pin the intent and start the download; once it lands it becomes default.
    writeDefault({ kind: 'chrome-for-testing', executablePath: null, source: 'cache', forced: true, pendingInstall: true, verifiedAt: Date.now() });
    ensureChromeReady({ background: true });
    return { ok: true, installing: true, ...pickSummary() };
  }
  return { ok: false, errorCode: 'bad_browser', error: `Unknown browser '${browser}'. Use 'chrome', 'edge', 'cft', or 'auto' (or pass an explicit executablePath).`, ...pickSummary() };
}

// Compact snapshot of the browser decision — reused by readiness + every pup_* hint.
function pickSummary() {
  const r = resolveBrowser();
  const d = readDefault();
  return {
    picked: r.found ? { kind: r.kind, source: r.source, executablePath: r.executablePath } : null,
    candidates: (r.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),
    defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,
  };
}

// Programmatic install of the pinned CfT build. Resolves to { ok, executablePath, buildId }
// or { ok:false, errorCode, error }. Deduped via _installInFlight.
function installChrome({ onProgress } = {}) {
  if (_installInFlight) return _installInFlight;

  state.phase = 'installing';
  state.progressPct = 0;
  state.bytesDownloaded = 0;
  state.bytesTotal = 0;
  state.error = null;
  state.errorCode = null;
  state.startedAt = Date.now();
  state.finishedAt = null;

  _installInFlight = (async () => {
    try {
      const browsers = require('@puppeteer/browsers');
      const { install, Browser, detectBrowserPlatform, resolveBuildId } = browsers;
      const platform = detectBrowserPlatform();
      if (!platform) {
        state.phase = 'failed';
        state.errorCode = 'chrome_unsupported_platform';
        state.error = `Could not detect a Chrome-for-Testing platform for ${process.platform}/${process.arch}.`;
        return { ok: false, errorCode: state.errorCode, error: state.error };
      }
      // Pin to puppeteer's expected build; fall back to the channel's stable id.
      let buildId = expectedBuildId();
      if (!buildId) {
        try { buildId = await resolveBuildId(Browser.CHROME, platform, 'stable'); } catch { /* leave null */ }
      }
      if (!buildId) {
        state.phase = 'failed';
        state.errorCode = 'chrome_buildid_unresolved';
        state.error = 'Could not determine which Chrome-for-Testing build to install (puppeteer.executablePath() gave no build id and resolveBuildId failed — network or a broken puppeteer install).';
        return { ok: false, errorCode: state.errorCode, error: state.error };
      }
      // Disk-space pre-flight — a CfT download that runs the volume out of space
      // fails deep in the unzip with a cryptic error. Catch it up front with an
      // actionable hint. null free (can't measure) → proceed (don't block).
      const freeMb = diskFreeMb(cacheDir());
      if (freeMb != null && freeMb < CFT_MIN_FREE_MB) {
        state.phase = 'failed';
        state.errorCode = 'chrome_install_no_disk';
        state.error = `Not enough free disk to install Chrome for Testing: ${freeMb} MB free on the drive holding ${cacheDir()}, need ~${CFT_MIN_FREE_MB} MB (150 MB download unpacks to ~600 MB). Free up space, or just install/use Chrome or Edge (Edge already ships with Windows) — that needs no download.`;
        console.error(`[chrome] ${state.error}`);
        return { ok: false, errorCode: state.errorCode, error: state.error, diskFreeMb: freeMb };
      }

      state.buildId = buildId;
      // v1.9.75 — SELF-HEAL A HALF-POPULATED CACHE DIR (wiki issue #15, AdityaAngajala).
      // @puppeteer/browsers REFUSES to install when the versioned folder already exists but the
      // executable inside it does not:
      //   "The browser folder ...\\chrome\\win64-146.0.7680.76 exists but the executable
      //    ...\\chrome-win64\\chrome.exe is missing"
      // Easy state to reach: an interrupted download, a bridge killed mid-unzip, or npm's
      // allow-scripts gate skipping puppeteer's postinstall partway. Once in it, EVERY later
      // install attempt throws the same error forever and pup has no browser of its own — the
      // user's only escape is knowing to pin a system browser. detectChrome() already reports
      // installed:false here (it validates the real exe), so the dir is known junk. Delete it
      // and let the install proceed instead of failing in a loop.
      try {
        const { computeExecutablePath } = require('@puppeteer/browsers');
        const expectedExe = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir: cacheDir() });
        if (expectedExe && !fileExists(expectedExe)) {
          let dir = path.dirname(expectedExe);
          const versioned = path.basename(dir).includes(buildId) ? dir : path.dirname(dir);
          if (versioned && versioned.includes(buildId) && fs.existsSync(versioned)) {
            console.log(`[chrome] cache dir for ${buildId} exists but its executable is missing — removing the half-populated folder and re-downloading: ${versioned}`);
            fs.rmSync(versioned, { recursive: true, force: true });
          }
        }
      } catch (e) { console.log(`[chrome] half-populated-cache check skipped: ${e.message}`); }
      console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`);

      const installed = await install({
        browser: Browser.CHROME,
        buildId,
        cacheDir: cacheDir(),
        downloadProgressCallback: (downloaded, total) => {
          state.bytesDownloaded = downloaded;
          state.bytesTotal = total;
          state.progressPct = total ? Math.round((downloaded / total) * 100) : 0;
          if (onProgress) { try { onProgress(state.progressPct, downloaded, total); } catch {} }
        },
      });

      const ep = (installed && installed.executablePath) || detectChrome().executablePath;
      if (!fileExists(ep)) {
        state.phase = 'failed';
        state.errorCode = 'chrome_install_incomplete';
        state.error = `Chrome for Testing install reported success but no executable was found at ${ep || '(unknown path)'}.`;
        return { ok: false, errorCode: state.errorCode, error: state.error };
      }
      state.phase = 'ready';
      state.executablePath = ep;
      state.progressPct = 100;
      state.finishedAt = Date.now();
      state.error = null;
      state.errorCode = null;
      console.log(`[chrome] Chrome for Testing ${buildId} ready at ${ep}`);
      return { ok: true, executablePath: ep, buildId };
    } catch (e) {
      state.phase = 'failed';
      const msg = (e && e.message) || String(e);
      // Map the common low-level failures to actionable codes so the AI's hint is specific.
      if (!state.errorCode) {
        if (/ENOSPC|no space left|not enough space/i.test(msg)) state.errorCode = 'chrome_install_no_disk';
        else if (/EACCES|EPERM|permission/i.test(msg))          state.errorCode = 'chrome_install_permission';
        else if (/ENOTFOUND|ETIMEDOUT|ECONNRESET|network|getaddrinfo|proxy|tunneling/i.test(msg)) state.errorCode = 'chrome_download_network';
        else state.errorCode = 'chrome_download_failed';
      }
      state.error = state.errorCode === 'chrome_install_no_disk'
        ? `Ran out of disk installing Chrome for Testing (${diskFreeMb(cacheDir())} MB free). Free up space, or use Chrome/Edge (no download). Raw: ${msg}`
        : msg;
      state.finishedAt = Date.now();
      console.error(`[chrome] install failed (${state.errorCode}): ${state.error}`);
      return { ok: false, errorCode: state.errorCode, error: state.error };
    } finally {
      _installInFlight = null;
    }
  })();

  return _installInFlight;
}

// The gate the bridge calls before launching a window.
//   background:true  → kick the install off and return immediately (non-blocking),
//                      so the FIRST cold-start call answers with a pollable status
//                      instead of hanging 2–3 min while CfT downloads.
//   background:false → await the install (used by pup_prewarm when the caller
//                      explicitly wants to block until ready).
// Returns { ready, phase, progressPct, executablePath, errorCode, error }.
async function ensureChromeReady({ background = true, onProgress } = {}) {
  const det = detectChrome();
  if (det.installed) {
    state.phase = 'ready';
    state.executablePath = det.executablePath;
    state.buildId = det.buildId || state.buildId;
    return { ready: true, phase: 'ready', progressPct: 100, executablePath: det.executablePath };
  }
  if (_installInFlight) {
    // already installing — return live progress
    return { ready: false, phase: 'installing', progressPct: state.progressPct, executablePath: null };
  }
  if (background) {
    installChrome({ onProgress }); // fire-and-forget; readiness() tracks it
    return { ready: false, phase: 'installing', progressPct: 0, executablePath: null };
  }
  const r = await installChrome({ onProgress });
  return {
    ready: !!r.ok,
    phase: state.phase,
    progressPct: state.progressPct,
    executablePath: r.executablePath || null,
    errorCode: r.ok ? null : r.errorCode,
    error: r.ok ? null : r.error,
  };
}

// Snapshot for pup_readiness + the /status chip. nodeInstalled is implicitly
// true (this module only runs inside the node bridge process), but we surface it
// so the AI's readiness check is a single source of truth.
function readiness() {
  const b = resolveBrowser();
  const det = detectChrome();
  const installing = !!_installInFlight || state.phase === 'installing';
  // v1.9.94 (issue #22 Finding 4) — `ready` means "a drivable browser EXISTS", nothing more. The old
  // `&& !installing` conflated "a background CfT download is in flight" with "nothing is drivable", so
  // an in-flight (or STALLED) last-resort download reported the whole subsystem not-ready even on a box
  // with system Chrome AND Edge sitting right there — and pup_open_window's gate refuses on
  // `!rd.ready`, so every open failed with chrome_for_testing_installing while readiness itself
  // reported browserSource:"system" with a valid executable path. Self-contradictory and blocking.
  // `installing` is still returned as its own field for callers that care about download progress;
  // it must never gate drivability. (Native-first also means we rarely download CfT at all now.)
  const ready = b.found;                  // a drivable browser is available (system OR cached CfT)
  const d = readDefault();
  return {
    ready,
    browserKind: b.kind,                  // 'chrome' | 'edge' | 'chrome-for-testing' | 'none'
    browserSource: b.source || null,      // 'system' | 'cache' | null
    browserExecutablePath: b.executablePath || null,
    // Full ordered fallback list the launcher will spawn-verify, + the persisted default.
    candidates: (b.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),
    defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,
    nodeInstalled: true,
    nodeVersion: process.version,
    chromeForTestingInstalled: det.installed,
    chromeExecutablePath: det.executablePath || state.executablePath || null,
    chromeBuildId: det.buildId || state.buildId || null,
    installing,
    installPhase: state.phase,
    installProgressPct: state.progressPct,
    bytesDownloaded: state.bytesDownloaded,
    bytesTotal: state.bytesTotal,
    lastError: state.error,
    lastErrorCode: state.errorCode,
    cacheDir: cacheDir(),
    diskFreeMb: diskFreeMb(),          // free space on the CfT-cache volume (null = unmeasurable)
    lowDisk: (() => { const f = diskFreeMb(); return f != null && f < CFT_MIN_FREE_MB; })(),
    installingChrome: !!_chromeStableInFlight,   // a real-Google-Chrome install is in flight
    chromeStablePhase: chromeStableState.phase,  // idle|installing|awaiting_uac|ready|failed
    chromeStableAwaitingApproval: chromeStableState.phase === 'awaiting_uac', // a UAC is up on the machine running pup; the user must click YES there
    chromeStableNotifyChannel: chromeStableState.notifyChannel === undefined ? null : !!chromeStableState.notifyChannel, // could the bridge actually notify the user? (false on AD <1.9.84 that doesn't expose notify_user to the direct-API — the AI must relay the approval ask)
    chromeStableNotifyPeers: chromeStableState.notifyPeers == null ? null : chromeStableState.notifyPeers, // # of peer ADs also pinged (cross-AD target:"all") so the user is told even if attending another device
    chromeStableError: chromeStableState.error,
    puppeteerLoadError: _puppeteerLoadError,
  };
}

// ── Real Google Chrome (stable) installer ─────────────────────────────────────
// When the user explicitly wants CHROME (not CfT) on an Edge-only box, fetch Google's
// official offline installer and run it. Two phases (installChromeStable):
//   1. QUIET user-scoped `/silent /install` → drops into %LOCALAPPDATA%\Google\Chrome
//      with NO prompt on a normal PC (this is the common, unchanged path).
//   2. If that doesn't land (a locked-down box / VM where the installer needs admin),
//      ESCALATE: raise the UAC elevated AND drive the user to it with an AD toast fired
//      from this bridge via the direct-API — re-notifying with a sticky one-tap
//      'Approve now' if the prompt expires. See _elevatedInstallWithNotify.
const chromeStableState = { phase: 'idle', error: null, errorCode: null, startedAt: null, finishedAt: null };
let _chromeStableInFlight = null;
const CHROME_STABLE_URL = 'https://dl.google.com/chrome/install/standalonesetup64.exe';

function _download(url, destPath, redirects = 0) {
  return new Promise((resolve, reject) => {
    if (redirects > 5) return reject(new Error('too many redirects fetching the Chrome installer'));
    const https = require('https');
    const file = fs.createWriteStream(destPath);
    https.get(url, (resp) => {
      if ([301, 302, 303, 307, 308].includes(resp.statusCode) && resp.headers.location) {
        file.close(); try { fs.unlinkSync(destPath); } catch {}
        return resolve(_download(resp.headers.location, destPath, redirects + 1));
      }
      if (resp.statusCode !== 200) { file.close(); try { fs.unlinkSync(destPath); } catch {} return reject(new Error(`HTTP ${resp.statusCode} fetching ${url}`)); }
      resp.pipe(file);
      file.on('finish', () => file.close(() => resolve(destPath)));
      file.on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });
    }).on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });
  });
}

// ── Talking back to Adom Desktop from inside the bridge ───────────────────────
// AD spawned us; it also runs a loopback-only direct-API on the SAME machine
// (`POST /command {app,command,args}`, same dispatcher as the CLI/WS path). That
// lets THIS bridge fire a native toast + poll the clicked button on its OWN — no
// AI turn in the loop — which is exactly what an elevation prompt needs: nudge
// the user to the UAC, and if they walk away, re-nudge with a one-tap retry.
// Resolution order: env ADOM_DIRECT_API_URL → ~/.adom/direct-api-port → default.
let _adApiBase = undefined;
function _resolveAdApiBase() {
  if (_adApiBase !== undefined) return _adApiBase;
  _adApiBase = null;
  try {
    // v1.9.75: ADOM_DIRECT_API_URL is NOT always AD. An HD-spawned bridge gets that env
    // pointed at HD's control API (127.0.0.1:47084), which does not serve notify_user etc.
    // So the env is a CANDIDATE, not the answer: _adApiCandidates() lists env → port-file →
    // default, and abCommand verifies the picked base actually serves the verb surface
    // (via _probeAdNotifyCap-style /commands checks) before trusting it. Here we keep the
    // historical single-pick for callers that just need "a base": env first, then the
    // ~/.adom/direct-api-port file AD writes, then the documented default.
    const env = process.env.ADOM_DIRECT_API_URL;
    if (env) { _adApiBase = env.replace(/\/+$/, ''); return _adApiBase; }
    const home = process.env.USERPROFILE || os.homedir();
    const pf = path.join(home, '.adom', 'direct-api-port');
    if (fileExists(pf)) {
      const hp = fs.readFileSync(pf, 'utf8').trim(); // "127.0.0.1:47200"
      if (hp) { _adApiBase = `http://${hp}`; return _adApiBase; }
    }
  } catch {}
  _adApiBase = 'http://127.0.0.1:47200'; // documented default; harmless if unreachable
  return _adApiBase;
}
// Every plausible AD base, in trust order. Used by the verified resolver below.
function _adApiCandidates() {
  const out = [];
  try { const env = process.env.ADOM_DIRECT_API_URL; if (env) out.push(env.replace(/\/+$/, '')); } catch {}
  try {
    const home = process.env.USERPROFILE || os.homedir();
    const pf = path.join(home, '.adom', 'direct-api-port');
    if (fileExists(pf)) { const hp = fs.readFileSync(pf, 'utf8').trim(); if (hp) out.push(`http://${hp}`); }
  } catch {}
  out.push('http://127.0.0.1:47200');
  return [...new Set(out)];
}
// v1.9.75: resolve a base VERIFIED to be AD (its /commands lists notify_user). Fixes the
// HD-hosted case where the env points at HD's control API: the probe misses there and we
// fall through to the port file AD itself writes. Cached; null when nothing verifies.
let _adVerifiedBase = undefined;
async function _resolveVerifiedAdBase() {
  if (_adVerifiedBase !== undefined) return _adVerifiedBase;
  _adVerifiedBase = null;
  for (const base of _adApiCandidates()) {
    try {
      const cmds = await new Promise((resolve) => {
        const http = require('http'); const u = new URL(base + '/commands');
        const req = http.get({ hostname: u.hostname, port: u.port, path: u.pathname }, (resp) => {
          let d = ''; resp.on('data', c => d += c);
          resp.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } });
        });
        req.on('error', () => resolve(null));
        req.setTimeout(3000, () => { try { req.destroy(); } catch {}; resolve(null); });
      });
      const top = (cmds && cmds.topLevel) || [];
      const dlist = (cmds && cmds.desktop && (cmds.desktop.commands || cmds.desktop)) || [];
      if ((Array.isArray(top) && top.includes('notify_user')) || (Array.isArray(dlist) && dlist.includes('notify_user'))) {
        _adVerifiedBase = base;
        if (base !== _resolveAdApiBase()) console.log(`[chrome] AD base: env candidate is not AD — using verified ${base}`);
        break;
      }
    } catch {}
  }
  return _adVerifiedBase;
}
// Call an AD verb via the direct-API. AD ≥1.9.84: `app` is inferred and the FULL verb
// surface is reachable (notify_user, notify_response, targets, …). Best-effort: resolves
// null on any failure so a missing/old AD never blocks the install (the UAC still appears).
//   opts.target  → route to a PEER AD: "<clientName>" | "all" | "self"/"local" (default).
//   opts.noToken → internal: retry unattributed after a stale-token 403.
// Sends the spawn-time ADOM_BRIDGE_TOKEN as X-Adom-Bridge-Token for Activity-Log
// attribution (OPTIONAL — a stale token 403s, so we retry once without it).
// v1.9.104 — DELEGATION CHAIN (AD >=1.9.183 caller-identity contract). Every AD verb pup calls back
// is work done ON BEHALF OF an AI thread, and until 1.9.183 AD dropped the caller headers on this
// loopback leg, so the user's Activity Log credited "pup" and lost the thread that asked. AD fixed its
// half; the bridge half is ours and it is NOT automatic:
//   • FORWARD the X-Adom-Caller-{Thread,Container,Reason} we were handed (opts.caller), and
//   • ADD X-Adom-Caller-Delegate naming OURSELVES — that is what turns the user-visible attribution
//     into "chip-fetcher tab 3 (via pup)" instead of a bare "via bridge".
// The delegate is always correct so it is unconditional. The thread is only forwarded when the caller
// actually gave us one: per the contract, explicit args win and a bridge must NOT forward a STALE
// identity when acting on its own behalf (our timers, sweeps and health polls pass no caller, so they
// read honestly as pup's own work). All of it is self-reported and authorizes nothing.
const AD_DELEGATE_NAME = 'pup';
function abCommand(command, args, opts = {}) {
  let { target, timeoutMs = 8000, noToken = false, caller = null } = opts;
  // #350: accept a bare thread string (opts.callerThread/Container/Reason) as shorthand for opts.caller,
  // so a handler holding only _callerThread can attribute a callback without building an object. The
  // chokepoint below already turns caller into X-Adom-Caller-* headers + X-Adom-Caller-Delegate: pup.
  if (!caller && (opts.callerThread || opts.callerContainer || opts.callerReason)) {
    caller = { thread: opts.callerThread || null, container: opts.callerContainer || null, reason: opts.callerReason || null };
  }
  return new Promise(async (resolve) => {
    let base;
    // v1.9.75: prefer the VERIFIED-AD base (see _resolveVerifiedAdBase) so an HD-spawned
    // bridge whose env points at HD's control API still reaches the real AD via the port
    // file. Falls back to the historical single-pick if nothing verifies (probe cached).
    try { base = (await _resolveVerifiedAdBase()) || _resolveAdApiBase(); } catch { try { base = _resolveAdApiBase(); } catch { return resolve(null); } }
    if (!base) return resolve(null);
    try {
      const http = require('http');
      const payload = { command, args: args || {} };
      if (target) payload.target = target;
      const u = new URL(base + '/command');
      const headers = { 'Content-Type': 'application/json' };
      const tok = process.env.ADOM_BRIDGE_TOKEN;
      if (tok && !noToken) headers['X-Adom-Bridge-Token'] = tok;
      headers['X-Adom-Caller-Delegate'] = AD_DELEGATE_NAME;   // name ourselves: "<thread> (via pup)"
      try {
        // v1.9.114 — CRITICAL: AD (>=1.9.183) REFUSES any command with no caller identity, including a
        // bridge's own loopback callbacks. I previously sent NOTHING when pup was acting on its own
        // behalf, reasoning that "no caller" was the honest answer. AD's answer is a refusal, and the
        // blast radius was total: EVERY desktop_set_window_identity was rejected
        // ("needs to know WHO is asking"), so no pup window got branded and all of them fell back to
        // Chrome's icon — including OTHER threads' windows. The contract's own words are "send your own
        // caller block when the work is genuinely your own", so that is what we do now: forward the
        // originating thread when we have one, else name PUP as the caller. Never send nothing.
        const thread = (caller && caller.thread) ? String(caller.thread).slice(0, 120) : 'pup bridge (self)';
        headers['X-Adom-Caller-Thread'] = thread;
        if (caller && caller.container) headers['X-Adom-Caller-Container'] = String(caller.container).slice(0, 120);
        if (caller && caller.reason)    headers['X-Adom-Caller-Reason']    = String(caller.reason).slice(0, 300);
        // Belt and braces: AD's documented precedence puts an explicit args `caller` ABOVE headers, and
        // a header-only path regressed once before. Send both so a future header change cannot silently
        // un-brand every window again.
        if (payload.args && typeof payload.args === 'object' && !Array.isArray(payload.args) && !payload.args.caller) {
          payload.args.caller = { aiThread: thread };
          if (caller && caller.container) payload.args.caller.containerName = String(caller.container).slice(0, 120);
        }
      } catch (e) {}
      const body = JSON.stringify(payload);
      headers['Content-Length'] = Buffer.byteLength(body);
      const req = http.request(
        { hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers },
        (resp) => {
          let d = ''; resp.on('data', c => d += c);
          resp.on('end', () => {
            if (resp.statusCode === 403 && tok && !noToken) return resolve(abCommand(command, args, { ...opts, noToken: true }));
            try { resolve(JSON.parse(d)); } catch { resolve(null); }
          });
        }
      );
      req.on('error', () => resolve(null));
      req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {}; resolve(null); });
      req.write(body); req.end();
    } catch { resolve(null); }
  });
}
function _adGet(pathname, timeoutMs = 6000) {
  return new Promise((resolve) => {
    let base; try { base = _resolveAdApiBase(); } catch { return resolve(null); }
    if (!base) return resolve(null);
    try {
      const http = require('http'); const u = new URL(base + pathname);
      const req = http.get({ hostname: u.hostname, port: u.port, path: u.pathname },
        (resp) => { let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } }); });
      req.on('error', () => resolve(null));
      req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {}; resolve(null); });
    } catch { resolve(null); }
  });
}
// Probe whether this AD exposes notify_user through the direct-API. AD ≥1.9.84 lists it
// in /commands.topLevel; AD ≤1.9.79 didn't expose it at all (top-level verbs returned
// "Unknown desktop command"), so a bridge couldn't notify. We cache the answer: {ok} if
// reachable, null if not. This gates the notify-first UAC flow vs. the honest degradation
// (raise once + relay hint) on an AD too old to notify.
let _adNotifyCap = undefined; // undefined=unprobed | null=unavailable | {ok:true}
async function _probeAdNotifyCap() {
  if (_adNotifyCap !== undefined) return _adNotifyCap;
  _adNotifyCap = null;
  try {
    const cmds = await _adGet('/commands');
    if (cmds) {
      const top = cmds.topLevel || [];
      const dlist = (cmds.desktop && (cmds.desktop.commands || cmds.desktop)) || [];
      const has = (a) => Array.isArray(a) && (a.includes('notify_user') || a.includes('desktop_notify_user'));
      if (has(top) || has(dlist)) _adNotifyCap = { ok: true };
    }
  } catch {}
  return _adNotifyCap;
}
const adNotify = (args, target) => abCommand('notify_user', args, { target });
const adNotifyResponse = (id) => abCommand('notify_response', { id });
// Peer ADs on the same relay (OTHER machines the user might be sitting at). [] if none/old
// AD. The direct-API `targets` returns {clients:[…]} and INCLUDES self, so we exclude our
// own hostname/clientName. (Shape-tolerant: also accepts targets/data.* for other builds.)
async function adPeerNames() {
  try {
    const r = await abCommand('targets', {});
    const list = (r && (r.clients || r.targets || (r.data && (r.data.clients || r.data.targets)))) || [];
    let self = ''; try { self = String(os.hostname() || '').toLowerCase(); } catch {}
    const names = list.map(t => t && (t.clientName || t.name || t.hostname)).filter(Boolean)
      .filter(n => String(n).toLowerCase() !== self);
    return [...new Set(names)];
  } catch { return []; }
}
const _sleep = (ms) => new Promise(r => setTimeout(r, ms));

// PowerShell single-quote escaping (double any embedded quote).
function _psq(s) { return `'${String(s).replace(/'/g, "''")}'`; }

// Raise a UAC by launching the installer ELEVATED. Start-Process -Verb RunAs pops
// the Windows security prompt on the user's desktop. Resolves an exit code:
//   0   → prompt accepted (or already elevated); installer launched
//   3   → user declined / cancelled / the prompt expired
//  <=0? → spawn failure
function _raiseElevatedInstall(installerPath) {
  return new Promise((resolve) => {
    try {
      const { spawn } = require('child_process');
      const ps = `try { Start-Process -FilePath ${_psq(installerPath)} -ArgumentList '/silent','/install' -Verb RunAs -WindowStyle Hidden -ErrorAction Stop; exit 0 } catch { exit 3 }`;
      const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { windowsHide: true });
      let done = false; const fin = (c) => { if (!done) { done = true; resolve(c); } };
      child.on('error', () => fin(-1));
      child.on('exit', (code) => fin(code == null ? 0 : code));
      setTimeout(() => fin(0), 20000); // Start-Process returns fast; guard a hung shell
    } catch { resolve(-1); }
  });
}

// Locked-down box (e.g. a VM) path: the quiet user-scoped install didn't land, so
// Chrome needs elevation. TWO modes, gated on whether AD actually lets us notify:
//
//  (A) AD notify IS reachable → notify-FIRST: fire a sticky "Approve now" toast and
//      WAIT for the user to tap it BEFORE raising the UAC. This is the correct order —
//      raising the UAC first puts up the secure desktop, which HIDES the toast (the bug
//      that made the first cut fire prompts the user was never told about). On tap we
//      raise one UAC; if it expires we re-notify (sticky) and wait again. Autonomous,
//      zero AI turns.
//
//  (B) AD notify is NOT reachable (AD ≤1.9.79 doesn't expose notify_user to the direct-
//      API) → we CANNOT tell the user, so we DO NOT spam UACs. Raise the prompt exactly
//      ONCE, mark awaiting_uac with a clear hint the AI/caller must relay, and poll. No
//      blind re-raise loop. (Pending the AD change, this is the honest degradation.)
async function _elevatedInstallWithNotify(installerPath) {
  const id = 'pup-chrome-install';
  chromeStableState.phase = 'awaiting_uac';
  chromeStableState.error = null; chromeStableState.errorCode = null;
  const cap = await _probeAdNotifyCap();
  chromeStableState.notifyChannel = !!cap;

  const pollChrome = async (ms) => {
    const end = Date.now() + ms;
    while (Date.now() < end) { await _sleep(2500); const c = listSystemBrowsers().find(x => x.kind === 'chrome'); if (c) return c; }
    return null;
  };

  // (B) No way to notify — one prompt, no spam, clear state for the caller to relay.
  if (!cap) {
    console.log('[chrome] AD notify NOT reachable via direct-API — raising the UAC ONCE (no re-arm loop) and surfacing awaiting_uac for the caller to relay');
    chromeStableState.awaitingApprovalSince = Date.now();
    await _raiseElevatedInstall(installerPath);
    return await pollChrome(150 * 1000); // ~2.5 min for the user to click the single prompt
  }

  // (A) Notify-first with re-arm on tap. The UAC lives on THIS box (you can only approve
  // it here), so the actionable "Approve now" toast is LOCAL. If the user is attending a
  // different device (peer AD on the relay — e.g. pup on a VM, user on their laptop), we
  // ALSO ping every peer so they're told where to go. Cross-AD `target:"all"` routes the
  // ping via the relay. The local toast's tap re-raises the UAC here; peer toasts are
  // informational ("go to <host>"). Autonomous — zero AI turns.
  const host = (() => { try { return os.hostname(); } catch { return 'this PC'; } })();
  const peers = await adPeerNames();
  chromeStableState.notifyPeers = peers.length;
  const pingPeers = (title, body, level) => peers.length
    ? adNotify({ id: 'pup-chrome-peer', level: level || 'warning', scenario: 'reminder', buttons: [{ label: 'OK' }], title, body }, 'all')
    : Promise.resolve();
  const doneLocal = () => adNotify({ id, level: 'success', title: 'Chrome is installed ✓', body: 'Google Chrome is ready — pup will drive it from now on.' });
  const donePeers = () => pingPeers('Chrome installed ✓', `Chrome finished installing on ${host}.`, 'success');

  const OVERALL_MS = 12 * 60 * 1000;
  const t0 = Date.now();
  let first = true;
  while (Date.now() - t0 < OVERALL_MS) {
    await adNotify({
      id, level: 'warning', scenario: 'reminder', buttons: [{ label: 'Approve now' }],
      title: first ? 'Chrome needs your approval' : 'Chrome still needs your approval',
      body: `Tap 'Approve now', then click YES on the Windows security prompt to finish installing Google Chrome on ${host}.`,
    });
    await pingPeers(`Chrome needs approval on ${host}`,
      `pup is installing Chrome on ${host}. Go to that machine, tap 'Approve now', then click YES on the Windows prompt.`);
    first = false;
    // WAIT for the LOCAL tap before raising the UAC (so the toast isn't hidden by the secure desktop).
    let tapped = false;
    const waitEnd = Date.now() + 4 * 60 * 1000; // re-notify (sticky) if untouched for 4 min
    while (Date.now() < waitEnd) {
      await _sleep(2500);
      const c = listSystemBrowsers().find(x => x.kind === 'chrome');
      if (c) { await doneLocal(); await donePeers(); return c; }
      const resp = await adNotifyResponse(id);
      if (resp && resp.pending === false && resp.action) { tapped = true; break; }
    }
    if (!tapped) continue; // untouched → loop top re-fires the sticky toasts
    await _raiseElevatedInstall(installerPath);   // they opted in → one UAC now
    const c = await pollChrome(130 * 1000);
    if (c) { await doneLocal(); await donePeers(); return c; }
    // UAC expired/declined → loop top re-notifies and waits for another tap.
  }
  return null;
}

// opts.elevate: 'auto' (default) = quiet first, escalate to the notified UAC path if
// that doesn't land; true = skip quiet, go straight to the UAC path; false = quiet
// only, no prompt (old behaviour — fail with a hint if it needs admin).
function installChromeStable(opts = {}) {
  if (_chromeStableInFlight) return _chromeStableInFlight;
  const elevate = opts.elevate === undefined ? 'auto' : opts.elevate;
  _chromeStableInFlight = (async () => {
    chromeStableState.phase = 'installing'; chromeStableState.error = null; chromeStableState.errorCode = null;
    chromeStableState.startedAt = Date.now(); chromeStableState.finishedAt = null;
    try {
      const pre = listSystemBrowsers().find(x => x.kind === 'chrome');
      if (pre) { chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now(); return { ok: true, executablePath: pre.executablePath, alreadyInstalled: true }; }
      // ── macOS ── official universal dmg → mount → copy the .app. User-scoped first
      // (~/Applications needs no rights); /Applications when writable (admin users — the
      // normal Mac case). No UAC/elevation dance exists or is needed here.
      if (process.platform === 'darwin') {
        const dmg = path.join(os.tmpdir(), `chrome_installer_${process.pid}.dmg`);
        console.log(`[chrome] downloading Google Chrome dmg → ${dmg}`);
        await _download('https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg', dmg);
        const { execFileSync } = require('child_process');
        let mountPoint = null;
        try {
          const out = execFileSync('/usr/bin/hdiutil', ['attach', '-nobrowse', '-readonly', '-plist', dmg], { encoding: 'utf8', timeout: 60_000 });
          const m = out.match(/<key>mount-point<\/key>\s*<string>([^<]+)<\/string>/);
          mountPoint = m ? m[1] : null;
          if (!mountPoint) throw new Error('could not resolve the dmg mount point');
          const srcApp = path.join(mountPoint, 'Google Chrome.app');
          if (!fs.existsSync(srcApp)) throw new Error(`no Google Chrome.app inside the dmg at ${mountPoint}`);
          const dests = ['/Applications', path.join(os.homedir(), 'Applications')];
          let installedTo = null, lastErr = null;
          for (const destDir of dests) {
            try {
              fs.mkdirSync(destDir, { recursive: true });
              execFileSync('/usr/bin/ditto', [srcApp, path.join(destDir, 'Google Chrome.app')], { timeout: 120_000 });
              installedTo = path.join(destDir, 'Google Chrome.app');
              break;
            } catch (e) { lastErr = e; }
          }
          if (!installedTo) throw lastErr || new Error('copy failed');
          console.log(`[chrome] Google Chrome installed at ${installedTo}`);
        } finally {
          if (mountPoint) { try { execFileSync('/usr/bin/hdiutil', ['detach', mountPoint, '-quiet'], { timeout: 30_000 }); } catch {} }
          try { fs.unlinkSync(dmg); } catch {}
        }
        const found = listSystemBrowsers().find(x => x.kind === 'chrome');
        if (!found) {
          chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_not_detected';
          chromeStableState.error = 'Copied Google Chrome.app but it did not detect afterward — check /Applications and ~/Applications.';
          return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };
        }
        chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();
        return { ok: true, executablePath: found.executablePath };
      }
      if (process.platform !== 'win32') {
        chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_unsupported_os';
        chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows and macOS.';
        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };
      }
      const freeMb = diskFreeMb();
      if (freeMb != null && freeMb < 500) {
        chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_install_no_disk';
        chromeStableState.error = `Not enough disk to install Chrome: ${freeMb} MB free, need ~500 MB.`;
        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error, diskFreeMb: freeMb };
      }
      const tmp = path.join(os.tmpdir(), `chrome_installer_${process.pid}.exe`);
      console.log(`[chrome] downloading Google Chrome installer → ${tmp}`);
      await _download(CHROME_STABLE_URL, tmp);

      let found = null;
      // Phase 1 — quiet, user-scoped silent install. On a normal box this lands with
      // NO prompt; only if it doesn't do we escalate. (Skipped when elevate===true.)
      if (elevate !== true) {
        console.log('[chrome] running Chrome installer silently (/silent /install) ...');
        const { spawn } = require('child_process');
        try { spawn(tmp, ['/silent', '/install'], { windowsHide: true }); } catch {}
        for (let i = 0; i < 30; i++) { found = listSystemBrowsers().find(x => x.kind === 'chrome'); if (found) break; await _sleep(1500); } // ~45s
      }

      // Phase 2 — escalate: Chrome needs elevation, so drive the user to the UAC with
      // AD toasts + a re-arm-on-tap loop. (elevate:false opts out and just fails.)
      if (!found && elevate !== false) {
        console.log('[chrome] quiet install did not yield chrome.exe — escalating to an elevated install with a user prompt');
        found = await _elevatedInstallWithNotify(tmp);
      }

      try { fs.unlinkSync(tmp); } catch {}
      if (!found) {
        chromeStableState.phase = 'failed';
        if (elevate === false) {
          chromeStableState.errorCode = 'chrome_stable_not_detected';
          chromeStableState.error = 'Ran the Chrome installer but chrome.exe did not appear (elevation was declined via elevate:false). pup still works with Edge in the meantime.';
        } else if (chromeStableState.notifyChannel) {
          chromeStableState.errorCode = 'chrome_stable_needs_approval';
          chromeStableState.error = 'Chrome needs a Windows approval (UAC) that was not accepted in time. A sticky "Approve now" notification is on the user\'s desktop — tapping it re-opens the prompt. pup still works with Edge in the meantime.';
          await adNotify({ id: 'pup-chrome-install', level: 'warning', scenario: 'reminder', buttons: [{ label: 'Approve now' }], title: 'Chrome install paused', body: "Ran out of time waiting for approval. Tap 'Approve now' whenever you're back and click YES — Chrome finishes in seconds." });
        } else {
          // No notify channel on this AD — we raised ONE UAC and could not tell the user.
          // The CALLER (AI) must relay this; do NOT silently spam more prompts.
          chromeStableState.errorCode = 'chrome_stable_needs_approval_uncced';
          chromeStableState.error = 'Chrome needs a Windows approval (UAC) on THIS machine, but this AD build does not expose notify_user to bridges via the direct-API, so pup could not pop a notification. A UAC was raised once. RELAY TO THE USER: "Go to the machine running pup and click YES on the Google Chrome installer prompt", then re-call pup_use {browser:"chrome", install:true}. pup drives Edge meanwhile.';
        }
        return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };
      }
      chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();
      console.log(`[chrome] Google Chrome installed at ${found.executablePath}`);
      return { ok: true, executablePath: found.executablePath };
    } catch (e) {
      chromeStableState.phase = 'failed';
      const msg = (e && e.message) || String(e);
      chromeStableState.errorCode = /ENOSPC|space/i.test(msg) ? 'chrome_install_no_disk'
        : /ENOTFOUND|ETIMEDOUT|ECONNRESET|network|proxy|getaddrinfo/i.test(msg) ? 'chrome_download_network'
        : 'chrome_stable_install_failed';
      chromeStableState.error = msg;
      console.error(`[chrome] Chrome stable install failed (${chromeStableState.errorCode}): ${msg}`);
      return { ok: false, errorCode: chromeStableState.errorCode, error: msg };
    } finally {
      _chromeStableInFlight = null;
      // Reconcile the pending-chrome default with the ACTUAL outcome, so readiness never
      // keeps advertising `default:chrome, pendingInstall:true` for a Chrome that never
      // landed. On success → pin the real exe; on failure → revert to auto (Edge/CfT).
      try {
        const d = readDefault();
        if (d && d.forced && d.pendingInstall && d.kind === 'chrome') {
          if (chromeStableState.phase === 'ready') {
            const c = listSystemBrowsers().find(x => x.kind === 'chrome');
            if (c) writeDefault({ kind: 'chrome', executablePath: c.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });
          } else {
            clearDefault();
          }
        }
      } catch {}
    }
  })();
  return _chromeStableInFlight;
}
function chromeStableStatus() { return { ...chromeStableState, inFlight: !!_chromeStableInFlight }; }

// ── Startup self-warm ─────────────────────────────────────────────────────────
// Called once when the bridge process boots (server.js). Cheap browser detection +
// disk logging so the first open is instant. If the box has NO Chrome/Edge AND no
// cached CfT — the only case that would otherwise force a mid-open download — start
// fetching CfT NOW in the background (guarded on disk) so the user doesn't wait later.
function warmup() {
  // v1.8.77: pup OWNS its browser provisioning (John: "isn't that your decision? why would
  // you need ad to change anything?"). CfT is pup's browser, PERIOD (demarcation doctrine),
  // so warmup ensures it exists on EVERY box — not only browser-less ones. The old
  // skip-when-Chrome-exists logic here was the dead installed-browser-first doctrine living
  // on in pup's own code. warmup runs at every bridge spawn (AD install/update, machine
  // start), so a fresh box downloads CfT in the background at INSTALL time and the user's
  // first "open in pup" finds it cached — no first-use wait, no AD policy involved.
  // AD's job ends at "pup is installed"; the rest is ours. (Its v1.9.76 prewarmer policy is
  // now redundant for pup either way — the install singleton dedupes if both fire.)
  try {
    const cands = launchCandidates();
    const free = diskFreeMb();
    const cft = detectChrome();
    console.log(`[warmup] browsers: ${cands.map(c => `${c.kind}(${c.source})`).join(', ') || 'NONE'} | cft cached: ${cft.installed} | disk free: ${free == null ? '?' : free + 'MB'}`);
    if (cft.installed) return; // already cached — nothing to do
    // v1.9.90 NATIVE-FIRST: only fetch CfT when the box has NO installed Chromium at all. If a native
    // Chrome or Edge is present (Edge ships on every Windows PC), pup drives THAT — no 150MB download,
    // ever. CfT is downloaded only as the genuine last resort for a Chromium-less box (e.g. a Mac with
    // no Chrome). This replaces the old "CfT on every box" doctrine that pulled 150MB even on machines
    // that already had a perfectly good, battle-tested browser.
    const hasNative = cands.some(c => c.kind === 'chrome' || c.kind === 'edge');
    if (hasNative) {
      console.log('[warmup] native Chrome/Edge present — native-first: NOT fetching Chrome for Testing (no download needed)');
      return;
    }
    if (free != null && free < CFT_MIN_FREE_MB) {
      console.log(`[warmup] no native browser + CfT missing + low disk (${free}MB) — NOT auto-fetching; readiness reports lowDisk and opens will surface the disk hint`);
      return;
    }
    console.log('[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser');
    ensureChromeReady({ background: true });
  } catch (e) { console.error(`[warmup] ${e && e.message}`); }
}

module.exports = {
  detectChrome, detectSystemBrowser, listSystemBrowsers, launchCandidates, resolveBrowser,
  installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir, diskFreeMb,
  setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,
  installChromeStable, chromeStableStatus, warmup,
  abCommand, // v1.8.40: reused by server.js to call AD verbs (e.g. desktop_taskbar) via the direct-API
};