master
John Lauer v2.0.734 (insiders): pup keeps its own browser on the current Chrome Stable, silently (John, 2026-10-25: 'New Chrome av… f278a89 16d ago
//! Preference verbs: pup_configure (get/set the ~/.adom/pup-settings.json keys, same names and
//! defaults as the Node build), pup_use (pin the browser), pup_quit_idle_browsers.

use crate::Ctx;
use pup_core::{err, ok};
use serde_json::{json, Value};
use std::time::Duration;

/// key -> (default, allowed values or "int" or "str")
pub const KEYS: &[(&str, &str, &str)] = &[
    ("taskbarMode", "tiles", "tiles|aumid|plain"), ("taskbarGrouping", "split", "split|grouped"), ("aumidIconStyle", "category", "standard|category"),
    ("aumidIcons", "on", "on|off"), ("threadTiles", "on", "on|off"), ("overlayBadges", "on", "on|off"), ("overlaySize", "75", "50|66|75|100"),
    ("jumpLists", "on", "on|off"), ("jlViewTasks", "on", "on|off"), ("jlCloseAll", "on", "on|off"), ("jlDashboard", "on", "on|off"), ("jlAnnotate", "on", "on|off"),
    ("toolbarExtension", "on", "on|off"), ("tbSnipHotkey", "on", "on|off"), ("tbFloatyPencil", "off", "on|off"), ("floatyHitPx", "24", "int"), ("tbOwnerBadge", "on", "on|off"),
    ("annCaptionBar", "on", "on|off"),
    ("taskbarActivity", "progress", "progress|flash|off"),
    ("defaultLane", "ext", "ext|cdp"),
    ("consumerBrands", "on", "on|off"),
    ("laneStockFlags", "on", "on|off"),
    ("cdpLane", "off", "on|off"),
    ("importTipBelow", "5", "int"),
    ("browserAutoUpdate", "on", "on|off"),   // 2.0.734: keep pup's browser on the current Chrome Stable   // 2.0.733: the first-window import tip shows while the vault has fewer saved logins than this   // 2.0.652 (John): the CDP lane is opt-in per box; the extension lane must solve every use case
    ("laneFieldTrials", "on", "on|off"),
    ("consumerBrandsHeader", "", "str"),
    ("fgWatch", "on", "on|off"), ("birthSettleMs", "400", "int"), ("aumidShortcut", "off", "on|off"), ("idleBrowserQuitMin", "10", "int"), ("winAttentionFlash", "off", "on|off"), ("prePermissions", "off", "on|off"), ("cdpQuiet", "off", "on|off"), ("stockFlags", "off", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"),
    ("captureLogins", "on", "on|off"), ("autoLogin", "on", "on|off"),
    ("ownerChip", "off", "on|off"), ("titleTag", "off", "on|off"), ("titlePrefix", "off", "on|off"),
    ("sleepRecovery", "on", "on|off"), ("reportIssues", "on", "on|off"), ("minimalTouch", "on", "on|off"),
];

pub fn snapshot(ctx: &Ctx) -> Value {
    let mut m = serde_json::Map::new();
    for (k, d, _) in KEYS { m.insert(k.to_string(), json!(ctx.settings.get(k, d))); }
    // taskbarMode is derived from the two underlying keys when not set explicitly (Node ladder)
    let mode = ctx.settings.all().get("taskbarMode").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| if ctx.settings.get("aumidIcons", "on") != "on" { "plain".into() } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles".into() } else { "aumid".into() });
    m.insert("taskbarMode".into(), json!(mode));
    Value::Object(m)
}

pub fn configure(ctx: &Ctx, args: &Value) -> Value {
    let mut changed = Vec::new(); let mut rejected = Vec::new();
    if let Some(o) = args.as_object() {
        for (k, v) in o {
            if k == "caller" || k.starts_with('_') { continue; }   // CLI/relay bookkeeping, not settings
            if ["openFlash", "autoFlash", "agentActivity", "fgWatch"].contains(&k.as_str()) { rejected.push(json!({ "key": k, "why": format!("{k} was removed in 2.0.292; use taskbarActivity: \"progress\"|\"flash\"|\"off\" (and notMineCaption for the foreground caption)") })); continue }
            let Some((_, _, allowed)) = KEYS.iter().find(|(kk, _, _)| kk == k) else { rejected.push(json!({ "key": k, "why": "unknown setting" })); continue };
            let sv = match v { Value::String(s) => s.clone(), Value::Bool(b) => if *b { "on".into() } else { "off".into() }, Value::Number(n) => n.to_string(), _ => { rejected.push(json!({ "key": k, "why": "value must be a string, bool or number" })); continue } };
            let okv = match *allowed { "int" => sv.parse::<i64>().is_ok(), "str" => true, list => list.split('|').any(|a| a == sv) };
            if !okv { rejected.push(json!({ "key": k, "why": format!("value must be one of {allowed}"), "got": sv })); continue }
            let _ = ctx.settings.set(k, json!(sv));
            changed.push(k.clone());
            // the taskbarMode ladder writes its two underlying keys (Node: taskbarMode maps onto them)
            if k == "taskbarMode" { let (a, t) = match sv.as_str() { "plain" => ("off", "off"), "aumid" => ("on", "off"), _ => ("on", "on") }; let _ = ctx.settings.set("aumidIcons", json!(a)); let _ = ctx.settings.set("threadTiles", json!(t)); }
        }
    }
    // live apply (Node: a mode switch re-identifies every window; a badge setting repaints every badge)
    const RESTAMP: [&str; 11] = ["taskbarMode", "aumidIconStyle", "taskbarGrouping", "threadTiles", "aumidIcons", "jumpLists", "jlViewTasks", "jlCloseAll", "jlDashboard", "jlAnnotate", "aumidShortcut"];
    if let Some(a) = ctx.arc() {
        if changed.iter().any(|k| RESTAMP.contains(&k.as_str())) { crate::identity::reapply_all(a, true); }
        else if changed.iter().any(|k| k == "overlayBadges" || k == "overlaySize") { crate::identity::reapply_all(a, false); }
    }
    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "platform": if cfg!(windows) { "win32" } else if cfg!(target_os = "macos") { "darwin" } else { "linux" }, "taskbarSupported": cfg!(windows), "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });
    ok(json!({ "success": true, "output": out.to_string(), "settings": out["settings"], "changed": out["changed"], "rejected": out["rejected"] }))
}

pub fn use_browser(ctx: &Ctx, args: &Value) -> Value {
    let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());
    let want = args.get("browser").and_then(|b| b.as_str()).unwrap_or("").to_ascii_lowercase();
    let temporary = args.get("temporary").and_then(|b| b.as_bool()).unwrap_or(false);
    let picked = match want.as_str() {
        "" | "auto" => {
            let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); let _ = std::fs::remove_file(p);
            return ok(json!({ "success": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "output": json!({ "ok": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }).to_string(), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }));
        }
        "cft" | "chrome-for-testing" => {
            match pup_browser::detect_cft(&*ctx.host) { Some(c) => c, None => { ctx.installer.ensure_background(ctx.host.clone()); return pup_core::merge(err("chrome_for_testing_installing", "Chrome for Testing is not cached yet; fetching it now", Some("Poll pup_readiness until chromeForTestingInstalled:true, then pup_use {browser:\"cft\"} again (or just open a window: it converges on its own).")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness" })); } }
        }
        "chrome" if args.get("install").and_then(|b| b.as_bool()) == Some(true) && cands.iter().all(|c| c.kind != "chrome") => {
            let elevate = match args.get("elevate") { Some(Value::Bool(b)) => Some(*b), Some(Value::String(s)) if s == "false" => Some(false), Some(Value::String(s)) if s == "true" => Some(true), _ => None };
            if let Some(a) = ctx.arc() { pup_browser::chrome_stable::install_background(a.host.clone(), elevate); }
            return pup_core::merge(err("chrome_stable_installing", "Installing real Google Chrome in the background (~90 MB). On a locked-down box it needs a Windows approval (UAC).", Some("Poll pup_readiness: chromeStablePhase goes installing -> awaiting_uac -> ready. pup drives the existing browser until then.")), json!({ "installingChrome": true, "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()) }));
        }
        "chrome" | "edge" => match cands.iter().find(|c| c.kind == want) { Some(c) => c.clone(), None => return err("browser_not_found", format!("no installed {want} found on this box"), Some("pup_readiness lists the candidates pup can see.")) },
        other => { let p = std::path::PathBuf::from(other); if p.is_file() { pup_browser::Candidate { kind: "custom".into(), source: "explicit".into(), executable_path: p, build_id: None } } else { return err("bad_browser", "browser must be chrome | edge | cft | auto, or an executable path", None) } }
    };
    pup_browser::write_default(&*ctx.host, &picked, true);
    if temporary { let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); if let Ok(s) = std::fs::read_to_string(&p) { if let Ok(mut v) = serde_json::from_str::<Value>(&s) { v["temporary"] = json!(true); let _ = std::fs::write(&p, v.to_string()); } } }
    let use_hint = if picked.kind != "chrome-for-testing" { "Pinned. NOTE: the Adom toolbar rides ONLY Chrome for Testing; windows on this browser have no toolbar. A window already open keeps its browser; new launches use the pin." } else { "Pinned Chrome for Testing as the default. The next pup_open_window drives it." };
    let out = json!({ "ok": true, "picked": picked.to_json(), "cleared": false, "temporary": temporary, "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": pup_browser::read_default(&*ctx.host), "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()), "_hint": use_hint });
    ok(json!({ "success": true, "picked": picked.to_json(), "cleared": false, "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": pup_browser::read_default(&*ctx.host), "output": out.to_string(),
        "_hint": if picked.kind != "chrome-for-testing" { "Pinned. NOTE: the Adom toolbar rides ONLY Chrome for Testing; windows on this browser have no toolbar. A window already open keeps its browser; new launches use the pin." } else { "Pinned to Chrome for Testing (the toolbar build). New launches use it." } }))
}

pub fn quit_idle_browsers(ctx: &Ctx) -> Value {
    let idle: Vec<String> = ctx.browsers.lock().unwrap().keys().filter(|p| ctx.sessions.for_profile(p).is_empty()).cloned().collect();
    let mut quit = Vec::new();
    for p in idle {
        if let Some(b) = ctx.browsers.lock().unwrap().remove(&p) {
            let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();
            if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } }
            quit.push(p);
        }
    }
    ok(json!({ "quit": quit, "count": quit.len(), "remaining": ctx.browsers.lock().unwrap().len(), "_hint": "Browsers with no pup windows were closed; the next open launches a fresh one (with any new extension code)." }))
}