app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
//! Browser transport for adom-chip-fetcher.
//!
//! adom-chip-fetcher drives the user's desktop browser to source CAD. There are two
//! transports, and we DEFAULT to the first when it's available:
//!
//! 1. **native-browser** (preferred) — the `adom-browser-extension` bridge
//! (`nbrowser_*` verbs). Drives the user's REAL signed-in Chrome/Edge, so
//! their DigiKey/Mouser/SnapEDA logins, distributor pricing, and captcha
//! human-trust all work. Several sites hard-block Puppeteer (DigiKey is the
//! clearest) but serve the native session normally.
//! 2. **pup** (fallback) — Puppeteer (`browser_*` verbs), a cold profile where
//! we have to recreate auth and can hit bot-walls.
//!
//! ISOLATION RULE (John, 2026-06-23): when using the native browser we open ONE
//! new window for the session and manage every site as a TAB inside it, so we
//! never touch the user's other Chrome/Edge windows. That falls out of the
//! session-keyed pattern: `open_window(session)` makes the dedicated window the
//! first time; every later `open_tab(session, …)` adds a tab to *that* window.
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::process::Command;
use std::sync::OnceLock;
/// Resolved transport: which verb family to use and which desktop to target.
struct Transport {
/// Verb prefix: "nbrowser_" (native) or "browser_" (pup).
prefix: &'static str,
/// `--target <name>` to disambiguate when 2+ desktops are connected. `None`
/// lets adom-desktop auto-route (fine for a single desktop).
target: Option<String>,
/// True when we're on the user's real signed-in browser.
native: bool,
}
static TRANSPORT: OnceLock<Transport> = OnceLock::new();
fn transport() -> &'static Transport {
TRANSPORT.get_or_init(detect_transport)
}
/// True when sourcing runs in the user's real signed-in browser.
pub fn is_native() -> bool {
transport().native
}
/// Human label for the active surface (for dashboards / logs).
pub fn surface_label() -> String {
let t = transport();
if t.native {
format!("native browser{}", t.target.as_ref().map(|x| format!(" ({x})")).unwrap_or_default())
} else {
"pup (cold profile)".to_string()
}
}
fn detect_transport() -> Transport {
// Explicit override: CHIP_FETCHER_BROWSER=native|pup
match std::env::var("CHIP_FETCHER_BROWSER").ok().as_deref() {
Some("pup") => return Transport { prefix: "browser_", target: None, native: false },
Some("native") => {
let target = find_native().unwrap_or(None);
return Transport { prefix: "nbrowser_", target, native: true };
}
_ => {}
}
// Auto: prefer the native browser whenever its bridge is live + drivable.
match find_native() {
Some(target) => {
eprintln!(
"[adom-chip-fetcher] sourcing in an ISOLATED window of your signed-in browser \
(native-browser bridge{}). Real logins, your distributor pricing, no bot-walls.",
target.as_ref().map(|t| format!(", {t}")).unwrap_or_default()
);
Transport { prefix: "nbrowser_", target, native: true }
}
None => {
eprintln!(
"[adom-chip-fetcher] native-browser bridge not found — falling back to pup (cold profile). \
Install the Adom browser extension for your REAL logins + to skip bot-walls that \
block headless Chrome (DigiKey blocks it outright): \
https://wiki.adom.inc/adom/adom-browser-extension"
);
Transport { prefix: "browser_", target: None, native: false }
}
}
}
/// Probe for a live, drivable `native-browser` bridge.
/// Returns `Some(target)` when found (`target=None` ⇒ no explicit `--target`
/// needed), or `None` when absent. The multi-desktop case is handled by reading
/// the `connected[]` list that `bridge_list` returns on `ambiguous_target`, then
/// probing each desktop by name.
fn find_native() -> Option<Option<String>> {
let (present, connected) = bridge_probe(None);
if present {
return Some(None);
}
for name in connected {
if bridge_probe(Some(&name)).0 {
return Some(Some(name));
}
}
None
}
/// `(native_browser_running, connected_desktop_names)`. The names are non-empty
/// only when the call came back `ambiguous_target` (2+ desktops). `bridge_list`
/// — unlike `ping` — includes `connected[]` in that error, so we read it here.
fn bridge_probe(target: Option<&str>) -> (bool, Vec<String>) {
let mut cmd = Command::new("adom-desktop");
if let Some(t) = target {
cmd.arg("--target").arg(t);
}
cmd.arg("bridge_list");
let v: Value = match cmd.output() {
Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or(Value::Null),
_ => return (false, vec![]),
};
let present = v
.get("bridges")
.and_then(|b| b.as_array())
.map(|arr| {
arr.iter().any(|br| {
br.get("name").and_then(|n| n.as_str()) == Some("native-browser")
&& br.get("paused").and_then(|p| p.as_bool()) != Some(true)
&& (br.get("status").and_then(|s| s.as_str()) == Some("running")
|| br.get("instanceCount").and_then(|c| c.as_u64()).unwrap_or(0) >= 1)
})
})
.unwrap_or(false);
let connected = connected_from(&v);
(present, connected)
}
/// Pull the `connected[]` desktop-name list out of an `ambiguous_target` response.
fn connected_from(v: &Value) -> Vec<String> {
v.get("connected")
.and_then(|c| c.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default()
}
// ── public browser ops (transport-agnostic) ─────────────────────────────────
/// Open the session's ISOLATED window at `url` (first call) — every later
/// `open_tab` on the same session lands in this same window. On the native
/// browser we drive the active signed-in profile, so the cold-profile `profile`
/// arg is ignored there.
pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> {
let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
let mut args = json!({ "sessionId": session_id, "url": url });
if !transport().native {
args["profile"] = json!(profile);
}
call_browser("open_window", &args)
}
/// Add a tab to the session's existing isolated window.
pub fn open_tab(session_id: &str, url: &str) -> Result<Value> {
let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
call_browser("open_tab", &json!({ "sessionId": session_id, "url": url }))
}
/// True when the session already has a live window (≥1 tab). Used to decide
/// whether to add a tab vs. spawn the window.
pub fn session_live(session_id: &str) -> bool {
let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {
Ok(v) => v,
Err(_) => return false,
};
// native: {output:"{tabs:[…]}"}, pup: {tabs:[…]}
let tabs = v.get("tabs").cloned().or_else(|| {
v.get("output")
.and_then(|o| o.as_str())
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.and_then(|p| p.get("tabs").cloned())
});
tabs.and_then(|t| t.as_array().map(|a| !a.is_empty())).unwrap_or(false)
}
/// True when any tab in the session's window has a URL containing `needle`.
pub fn session_has_url(session_id: &str, needle: &str) -> bool {
let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {
Ok(v) => v,
Err(_) => return false,
};
let tabs = v.get("tabs").cloned().or_else(|| {
v.get("output")
.and_then(|o| o.as_str())
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.and_then(|p| p.get("tabs").cloned())
});
tabs.and_then(|t| t.as_array().cloned())
.map(|arr| {
arr.iter().any(|tab| {
tab.get("url").and_then(|u| u.as_str()).map(|u| u.contains(needle)).unwrap_or(false)
})
})
.unwrap_or(false)
}
/// Open `url` in the session's ONE isolated window — as a TAB when the window
/// already exists (e.g. the dashboard is tab 1), else create the window. This
/// is the rule for native browsing: every source lands as a tab beside the
/// dashboard, never in a separate window and never touching the user's others.
pub fn open_site(session_id: &str, profile: &str, url: &str) -> Result<Value> {
// Native work window: dashboard is ALWAYS tab 1; vendor pages are tabs beside it.
if is_native() && session_id == WORK_SESSION {
ensure_work_window()?;
return open_tab(session_id, url);
}
if session_live(session_id) {
open_tab(session_id, url)
} else {
open_window(session_id, profile, url)
}
}
pub fn eval(session_id: &str, expr: &str) -> Result<Value> {
let v = call_browser("eval", &json!({ "sessionId": session_id, "expr": expr }))?;
// native returns {output:{result}}, pup returns {result}; unwrap either.
if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
if let Ok(parsed) = serde_json::from_str::<Value>(out) {
return Ok(parsed.get("result").cloned().unwrap_or(parsed));
}
}
Ok(v.get("result").cloned().unwrap_or(v))
}
/// Close the session's isolated window (cleanup after a run). Best-effort.
pub fn close_window(session_id: &str) -> Result<Value> {
call_browser("close_window", &json!({ "sessionId": session_id }))
}
// ── credential awareness: adom-chip-fetcher reads the browser's saved-login store ──
// across ALL profiles (Chrome + Edge), maps hosts to the sourcing ladder, and
// auto-switches to the profile that's signed in to the vendor it's about to
// scrape — so the whole flow self-drives without asking the user anything.
#[derive(Clone)]
pub struct Cred {
pub browser: String,
pub profile: String,
pub host: String,
pub username: String,
}
static CREDS: OnceLock<Vec<Cred>> = OnceLock::new();
/// Every saved login the browser knows about (origins + usernames; never
/// passwords). Cached for the run.
pub fn credentials() -> &'static Vec<Cred> {
CREDS.get_or_init(|| query_credentials().unwrap_or_default())
}
fn query_credentials() -> Result<Vec<Cred>> {
let v = call_browser("credentials", &json!({}))?;
let out = v.get("output").and_then(|o| o.as_str())
.ok_or_else(|| anyhow::anyhow!("nbrowser_credentials: no output"))?;
let parsed: Value = serde_json::from_str(out)?;
let arr = parsed.as_array().cloned()
.or_else(|| parsed.get("credentials").and_then(|c| c.as_array()).cloned())
.unwrap_or_default();
Ok(arr.iter().map(|r| Cred {
browser: r.get("browser").and_then(|x| x.as_str()).unwrap_or("").to_string(),
profile: r.get("profile").and_then(|x| x.as_str()).unwrap_or("").to_string(),
host: r.get("host").and_then(|x| x.as_str()).unwrap_or("").to_lowercase(),
username: r.get("username").and_then(|x| x.as_str()).unwrap_or("").to_string(),
}).collect())
}
/// Ladder-vendor host fragments → friendly name.
pub const VENDOR_HOSTS: &[(&str, &str)] = &[
("ultralibrarian", "UltraLibrarian"),
("snapeda", "SnapEDA"), ("snapmagic", "SnapEDA"),
("componentsearchengine", "SamacSys"), ("samacsys", "SamacSys"),
("mouser", "Mouser"), ("digikey", "DigiKey"),
("st.com", "ST"), ("ti.com", "TI"), ("nordicsemi", "Nordic"),
("nxp.com", "NXP"), ("microchip", "Microchip"), ("arrow.com", "Arrow"),
];
/// Hosts whose downloads are authenticated by adom-chip-fetcher's OWN credential
/// vault (an `Authorization: Basic` header on the XHR), NOT by the browser's
/// saved cookie session. For these we must NOT switch the active browser to
/// wherever a saved login happens to live — the vault works in any browser, so
/// we stay on the user's preferred browser (Chrome). This is why John saw Edge:
/// his CSE login is saved in Edge, so the auto-pick dragged the whole run there
/// even though CSE never needed that browser login.
pub const VAULT_AUTH_HOSTS: &[&str] = &["componentsearchengine", "samacsys"];
/// The user's preferred browser to drive (John: "drive this in my Chrome").
/// Profile keys are "<browser>:<email>"; we bias profile selection toward this.
pub const PREFERRED_BROWSER: &str = "chrome";
/// If `url`'s host matches a ladder vendor, return the profile (email) that's
/// signed in to it — preferring the profile with the broadest vendor coverage,
/// so multi-vendor runs stay on one rich profile when possible.
pub fn profile_for_url(url: &str) -> Option<String> {
let lower = url.to_lowercase();
// Vault-authed source → never switch browser; stay on the preferred one.
if VAULT_AUTH_HOSTS.iter().any(|h| lower.contains(*h)) {
return None;
}
let frag = VENDOR_HOSTS.iter().map(|(h, _)| *h).find(|h| lower.contains(*h))?;
let creds = credentials();
// count each profile's total ladder-vendor coverage (for tie-breaking)
use std::collections::HashMap;
let mut coverage: HashMap<&str, usize> = HashMap::new();
for c in creds {
if VENDOR_HOSTS.iter().any(|(h, _)| c.host.contains(*h)) {
*coverage.entry(c.profile.as_str()).or_default() += 1;
}
}
creds.iter()
.filter(|c| c.host.contains(frag))
.max_by_key(|c| coverage.get(c.profile.as_str()).copied().unwrap_or(0))
.map(|c| c.profile.clone())
}
/// Switch the native browser's active profile for the next nav/fetch.
pub fn use_profile(email: &str) -> Result<Value> {
call_browser("use_profile", &json!({ "profile": email }))
}
// ── one-window scrape: a dedicated scrape TAB inside the "adom-chip-fetcher" window ──
// In native mode every vendor page is driven in ONE tab of the single
// adom-chip-fetcher window (tab 1 = dashboard, never touched). This keeps scraping in
// the same isolated window instead of spawning a second "adom-chip-fetcher-scrape" one.
const WORK_SESSION: &str = "adom-chip-fetcher";
fn scrape_tab_file() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());
std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/scrape-tab")
}
fn extract_tab_id(v: &Value) -> Option<String> {
if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
if let Ok(p) = serde_json::from_str::<Value>(out) {
if let Some(id) = p.get("tabId").and_then(|x| x.as_str()) {
return Some(id.to_string());
}
}
}
v.get("tabId").and_then(|x| x.as_str()).map(String::from)
}
fn work_tab_ids() -> Vec<String> {
let v = match call_browser("list_tabs", &json!({ "sessionId": WORK_SESSION })) {
Ok(v) => v,
Err(_) => return vec![],
};
let tabs = v.get("tabs").cloned().or_else(|| {
v.get("output").and_then(|o| o.as_str())
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.and_then(|p| p.get("tabs").cloned())
});
tabs.and_then(|t| t.as_array().cloned())
.map(|arr| arr.iter().filter_map(|t| t.get("tabId").and_then(|x| x.as_str().map(String::from))).collect())
.unwrap_or_default()
}
fn dashboard_url() -> String {
if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
format!("https://{host}/proxy/8786/")
} else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {
tmpl.replace("{{port}}", "8786")
} else {
"http://127.0.0.1:8786/".to_string()
}
}
/// John's rule: NEVER open the work window without the adom-chip-fetcher dashboard as
/// the FIRST tab. Every path that adds a vendor/scrape tab calls this first, so
/// the window is always created with the dashboard as tab 1.
pub fn ensure_work_window() -> Result<()> {
if !is_native() {
return Ok(());
}
if session_has_url(WORK_SESSION, "/proxy/8786/") || session_has_url(WORK_SESSION, ":8786/") {
return Ok(());
}
// Drive the user's PREFERRED browser (Chrome). Switch the bridge's active
// profile to a Chrome profile before opening, so the work window — and every
// scrape tab in it — lives in Chrome, not whichever browser happens to hold
// the most vendor logins.
prefer_chrome_profile();
// No dashboard tab present → open the window WITH the dashboard as tab 1.
open_window(WORK_SESSION, WORK_SESSION, &dashboard_url())?;
Ok(())
}
/// Switch the bridge's active profile to a Chrome profile if one exists, so the
/// work window opens in Chrome (John's stated preference). No-op if the bridge
/// reports no Chrome profile.
pub fn prefer_chrome_profile() {
let v = match call_browser("profiles", &json!({})) {
Ok(v) => v,
Err(_) => return,
};
let out = v.get("output").and_then(|o| o.as_str())
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.unwrap_or(v);
let active = out.get("active").and_then(|a| a.as_str()).unwrap_or("");
if active.starts_with(PREFERRED_BROWSER) {
return; // already on Chrome
}
if let Some(profs) = out.get("profiles").and_then(|p| p.as_array()) {
if let Some(chrome) = profs.iter().find(|p| {
p.get("browser").and_then(|b| b.as_str()) == Some(PREFERRED_BROWSER)
&& p.get("live").and_then(|l| l.as_bool()).unwrap_or(true)
}) {
if let Some(key) = chrome.get("profile").and_then(|p| p.as_str()) {
let _ = use_profile(key);
eprintln!("[adom-chip-fetcher] driving your Chrome profile '{key}'");
}
}
}
}
/// The dedicated scrape tab in the work window — reuse the stored one if it's
/// still alive, else open a fresh tab (beside the dashboard) and remember it.
pub fn ensure_scrape_tab() -> Result<String> {
ensure_work_window()?; // dashboard is always tab 1 before any scrape tab
let alive = work_tab_ids();
if let Ok(stored) = std::fs::read_to_string(scrape_tab_file()) {
let id = stored.trim().to_string();
if !id.is_empty() && alive.contains(&id) {
return Ok(id);
}
}
let v = call_browser("open_tab", &json!({ "sessionId": WORK_SESSION, "url": "about:blank" }))?;
let id = extract_tab_id(&v).ok_or_else(|| anyhow::anyhow!("no scrape tabId in {v}"))?;
if let Some(dir) = scrape_tab_file().parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(scrape_tab_file(), &id);
Ok(id)
}
/// Navigate the dedicated scrape tab (native) or the pup scrape window (fallback).
pub fn scrape_navigate(url: &str) -> Result<Value> {
let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
if is_native() {
// Self-drive: if this is a known vendor, switch to the profile that's
// signed in to it before loading the page — no questions asked.
if let Some(prof) = profile_for_url(&url) {
if use_profile(&prof).is_ok() {
eprintln!("[adom-chip-fetcher] using your signed-in profile '{prof}' for this vendor");
}
}
let tab = ensure_scrape_tab()?;
call_browser("navigate", &json!({ "sessionId": WORK_SESSION, "tabId": tab, "url": url }))
} else {
// pup: a separate scrape window is the documented two-window pattern.
let r = call_browser("navigate", &json!({ "sessionId": "adom-chip-fetcher-scrape", "url": url }));
if r.is_err() {
return call_browser("open_window", &json!({ "sessionId": "adom-chip-fetcher-scrape", "profile": "adom-chip-fetcher", "url": url }));
}
r
}
}
pub fn scrape_eval(expr: &str) -> Result<Value> {
let args = if is_native() {
let tab = ensure_scrape_tab()?;
json!({ "sessionId": WORK_SESSION, "tabId": tab, "expr": expr })
} else {
json!({ "sessionId": "adom-chip-fetcher-scrape", "expr": expr })
};
let v = call_browser("eval", &args)?;
if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
if let Ok(parsed) = serde_json::from_str::<Value>(out) {
return Ok(parsed.get("result").cloned().unwrap_or(parsed));
}
}
Ok(v.get("result").cloned().unwrap_or(v))
}
/// Fetch a URL's BYTES through the user's signed-in browser (real cookies, so
/// login-gated CAD works too) and write them to `dest`. No download, no native
/// viewer, no extra window — the bytes come back base64 in one call. Returns
/// (bytes_written, content_type).
pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, String)> {
let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
let v = call_browser("fetch_url", &json!({ "sessionId": WORK_SESSION, "url": url }))?;
let out = v.get("output").and_then(|o| o.as_str())
.ok_or_else(|| anyhow::anyhow!("fetch_url: no output ({v})"))?;
let o: Value = serde_json::from_str(out)
.map_err(|e| anyhow::anyhow!("fetch_url: bad json ({e}): {out:.120}"))?;
if !o.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {
anyhow::bail!("fetch_url failed (HTTP {})", o.get("status").and_then(|s| s.as_i64()).unwrap_or(0));
}
let body = o.get("body").and_then(|b| b.as_str()).ok_or_else(|| anyhow::anyhow!("fetch_url: no body"))?;
let ctype = o.get("contentType").and_then(|c| c.as_str()).unwrap_or("").to_string();
let bytes = b64_decode(body);
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).ok();
}
std::fs::write(dest, &bytes)?;
Ok((bytes.len(), ctype))
}
/// Base64-encode (for the CSE basic-auth Authorization header).
fn b64_encode(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for ch in data.chunks(3) {
let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
/// Fetch a Component Search Engine CAD zip for `mpn` via XHR + basic-auth.
/// XHR (unlike fetch()) accepts an Authorization header AND doesn't trigger a
/// browser download — so this bypasses BOTH the fetch() credential ban and the
/// browser's multiple-download block. The scrape tab is navigated to CSE first
/// (same-origin). Chains entry → partID → zipForm → ga/model.php. Returns the
/// raw zip bytes (`.step` + `.kicad_sym` + `.kicad_mod`).
pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec<u8>> {
// Navigate CLEAN (no inline-credential wrapping) — auth is the XHR header,
// and an inline-cred URL breaks the page + leaks the password into the URL bar.
let tab = ensure_scrape_tab()?;
call_browser("navigate", &json!({
"sessionId": WORK_SESSION, "tabId": tab,
"url": "https://mouser.componentsearchengine.com/"
}))?;
std::thread::sleep(std::time::Duration::from_secs(4));
let auth = b64_encode(format!("{user}:{pass}").as_bytes());
let mpn_js = serde_json::to_string(mpn).unwrap();
let mfr_js = serde_json::to_string(mfr).unwrap();
let expr = format!(
"(function(){{return new Promise(function(res){{var A='Basic {auth}';var MPN={mpn_js};var MFR={mfr_js};\
function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);x.setRequestHeader('Authorization',A);\
if(ty)x.responseType=ty;x.onload=function(){{cb(x)}};x.onerror=function(){{res(JSON.stringify({{err:'xhr'}}))}};x.send()}}\
var base='https://mouser.componentsearchengine.com/';\
xhr(base+'entry_u_newDesign.php?mna='+encodeURIComponent(MFR)+'&mpn='+encodeURIComponent(MPN)+'&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB',null,function(a){{\
var m=(a.responseText.match(/partID=(\\d+)/)||[]);var pid=m[1];if(!pid)return res(JSON.stringify({{err:'no-partID'}}));\
xhr(base+'preview_newDesign.php?o3=0&partID='+pid+'&ev=0&fmt=zip&pna=Mouser',null,function(b){{\
var f=new DOMParser().parseFromString(b.responseText,'text/html').getElementById('zipForm');if(!f)return res(JSON.stringify({{err:'no-zipForm'}}));\
var q=[].slice.call(f.querySelectorAll('input,select')).map(function(i){{return encodeURIComponent(i.name)+'='+encodeURIComponent(i.value)}}).filter(function(s){{return s.length>1}}).join('&');\
xhr(base+'ga/model.php?'+q,'arraybuffer',function(c){{var u=new Uint8Array(c.response);var s='';\
for(var i=0;i<u.length;i+=8192)s+=String.fromCharCode.apply(null,u.subarray(i,i+8192));\
res(JSON.stringify({{head:[u[0],u[1],u[2],u[3]],len:u.length,b64:btoa(s)}}))}})}})}})}})}})()",
auth = auth, mpn_js = mpn_js, mfr_js = mfr_js
);
// Cold CSE calls flake (the entry XHR fires before the session cookie
// fully settles → spurious "no-partID"). Retry the whole chain a few
// times, re-warming the page each round, before giving up.
let mut last_err = String::from("no attempt");
for attempt in 0..4 {
if attempt > 0 {
// re-warm: the homepage navigate primes the session cookie
let _ = call_browser("navigate", &json!({
"sessionId": WORK_SESSION, "tabId": tab,
"url": "https://mouser.componentsearchengine.com/"
}));
std::thread::sleep(std::time::Duration::from_secs(3));
}
let v = scrape_eval(&expr)?;
let s = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());
let r: Value = serde_json::from_str(&s).unwrap_or(v);
let head: Vec<u64> = r.get("head").and_then(|h| h.as_array())
.map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
if head == [80, 75, 3, 4] {
let b64 = r.get("b64").and_then(|b| b.as_str())
.ok_or_else(|| anyhow::anyhow!("CSE: no zip body"))?;
return Ok(b64_decode(b64));
}
last_err = format!("head {head:?}, err {:?}", r.get("err"));
}
anyhow::bail!("CSE returned non-zip after 4 attempts ({last_err})")
}
/// Minimal base64 decoder (the binary has no base64 crate; it has b64 encode in
/// main.rs). Skips whitespace/newlines, stops at padding.
fn b64_decode(s: &str) -> Vec<u8> {
let mut t = [255u8; 256];
for (i, c) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".iter().enumerate() {
t[*c as usize] = i as u8;
}
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let (mut buf, mut bits) = (0u32, 0u32);
for &c in s.as_bytes() {
if c == b'=' { break; }
let v = t[c as usize];
if v == 255 { continue; }
buf = (buf << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
out
}
pub fn scrape_screenshot() -> Result<Value> {
if is_native() {
let tab = ensure_scrape_tab()?;
call_browser("screenshot", &json!({ "sessionId": WORK_SESSION, "tabId": tab }))
} else {
call_browser("screenshot", &json!({ "sessionId": "adom-chip-fetcher-scrape" }))
}
}
/// Pull a file from the user's desktop into a local container directory.
/// Desktop-level verb (not browser-scoped) — same on both transports.
pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> {
let v = call_desktop("pull_file", &json!({ "filePaths": [remote_path], "saveTo": save_to }))?;
let local = v["files"][0]["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("pull_file returned no path: {v}"))?;
Ok(std::path::PathBuf::from(local))
}
/// Block until a file matching `glob` lands in `path` on the user's desktop.
pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Value> {
call_desktop(
"desktop_watch_files",
&json!({ "path": path, "glob": glob, "timeoutMs": timeout_ms }),
)
}
// ── call plumbing ────────────────────────────────────────────────────────────
/// Call a browser verb — prefixes with the active transport's verb family and
/// injects `--target` when one is resolved.
fn call_browser(suffix: &str, args: &Value) -> Result<Value> {
let t = transport();
let verb = format!("{}{}", t.prefix, suffix);
call_with_recovery(&verb, args, t.target.as_deref(), true)
}
/// Call a desktop-level verb (no transport prefix).
fn call_desktop(action: &str, args: &Value) -> Result<Value> {
call_with_recovery(action, args, transport().target.as_deref(), true)
}
/// Run `adom-desktop [--target T] <action> <args>` and parse the JSON result.
///
/// Recovers from two transient conditions:
/// - `session_not_found` / `session_disconnected`: rescan + retry once.
/// - `ambiguous_target` (2+ desktops, no target passed): discover the native
/// target (or first connected) and retry once with `--target`.
fn call_with_recovery(
action: &str,
args: &Value,
target: Option<&str>,
allow_recovery: bool,
) -> Result<Value> {
let mut cmd = Command::new("adom-desktop");
if let Some(t) = target {
cmd.arg("--target").arg(t);
}
cmd.arg(action).arg(args.to_string());
let out = cmd
.output()
.with_context(|| format!("running adom-desktop {action}"))?;
if !out.status.success() {
anyhow::bail!(
"adom-desktop {action} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let v: Value = serde_json::from_slice(&out.stdout)
.with_context(|| format!("parsing adom-desktop {action} stdout"))?;
if !allow_recovery {
return Ok(v);
}
// Recovery 1: ambiguous target — pick a concrete desktop and retry.
if target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {
let pick = find_native()
.and_then(|t| t)
.or_else(|| connected_from(&v).into_iter().next());
if let Some(name) = pick {
println!("WARN: adom-desktop {action} ambiguous_target; retrying on --target {name}");
return call_with_recovery(action, args, Some(&name), false);
}
}
// Recovery 2: session lost — rescan, then retry once if it came back.
if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {
if code == "session_not_found" || code == "session_disconnected" {
let sess = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");
println!("WARN: adom-desktop {action} returned {code} for session {sess:?}; running rescan");
let prefix = transport().prefix;
let mut rescan = Command::new("adom-desktop");
if let Some(t) = target {
rescan.arg("--target").arg(t);
}
let rescan = rescan.args([&format!("{prefix}rescan"), "{}"]).output();
let live = rescan.ok().and_then(|o| {
if !o.status.success() {
return None;
}
let r: Value = serde_json::from_slice(&o.stdout).ok()?;
r.get("liveSessions").and_then(|l| l.as_array()).map(|arr| {
arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()
})
});
let live = live.unwrap_or_default();
if !sess.is_empty() && live.iter().any(|s| s == sess) {
println!("OK: rescan recovered session {sess:?}; retrying {action}");
return call_with_recovery(action, args, target, false);
}
println!("WARN: rescan did not recover {sess:?} (live: {live:?})");
}
}
Ok(v)
}