app
Adom Parts Search
Public Made by Adomby adom
One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
//! `adom-parts-search app` — Hydrogen webview showing unified parts search
//! across all three vendors in a 3-column grid with product photos.
//!
//! The app owns its own HTTP server (tiny_http). When the UI POSTs to
//! /search, the server delegates to vendors::search_all which fires
//! three parallel HTTP calls to the existing vendor backends.
pub mod tab;
use crate::vendors;
use crate::{note, ok, warn, AppArgs};
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tiny_http::{Header, Method, Response, Server};
const UI_HTML: &str = include_str!("ui.html");
const ICON_SVG: &str = include_str!("../../docs/icon.svg");
const CONSOLE_MAX: usize = 500;
struct AppState {
mouser_api: Option<String>,
digikey_api: Option<String>,
jlcpcb_api: Option<String>,
dev: bool,
console: Mutex<VecDeque<Value>>,
last_query: Mutex<Option<String>>,
last_results: Mutex<Option<Value>>,
last_query_ts: Mutex<u128>,
pending_eval: Mutex<Option<(String, String)>>,
eval_results: Mutex<std::collections::HashMap<String, Value>>,
}
fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {
// Every response — HTML, JSON, SVG — carries the embed-friendly CSP
// so Hydrogen's "Some sites block embedding" warning doesn't trip on
// any request it sniffs (it probes more than just /).
Response::from_string(body.to_string())
.with_status_code(code)
.with_header(Header::from_bytes("Content-Type", "application/json").unwrap())
.with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())
.with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap())
}
fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {
use std::io::Read;
let mut body = String::new();
req.as_reader().read_to_string(&mut body).map_err(|e| format!("read body: {}", e))?;
if body.is_empty() { return Ok(Value::Null); }
serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))
}
pub fn run(args: AppArgs) -> Result<(), String> {
ok(format!("adom-parts-search app starting on port {}", args.port));
note!(" backends:");
note!(" mouser: {}", args.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()));
note!(" digikey: {}", args.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()));
note!(" jlcpcb: {}", args.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()));
let state = Arc::new(AppState {
mouser_api: args.mouser_api.clone(),
digikey_api: args.digikey_api.clone(),
jlcpcb_api: args.jlcpcb_api.clone(),
dev: args.dev,
console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),
last_query: Mutex::new(None),
last_results: Mutex::new(None),
last_query_ts: Mutex::new(0),
pending_eval: Mutex::new(None),
eval_results: Mutex::new(std::collections::HashMap::new()),
});
let addr = format!("0.0.0.0:{}", args.port);
let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;
if !args.no_tab {
let proxy_url = tab::proxy_url(args.port);
match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {
Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),
Err(e) => warn(format!("couldn't open tab: {}. Manually: {}", e, proxy_url)),
}
}
note!(" {}Press ctrl-c or POST /shutdown to stop.{}", crate::DIM, crate::RESET);
for mut req in server.incoming_requests() {
let url = req.url().to_string();
let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();
let method = req.method().clone();
let state_ref = Arc::clone(&state);
let result: (u16, Value) = match (method.clone(), path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
// Explicitly declare we are embed-safe so Hydrogen's webview
// doesn't show its "Some sites block embedding" banner. We
// allow framing from any origin (the container's Coder proxy
// hostname varies per user) but require same-origin for
// everything else. No `X-Frame-Options` — modern browsers
// prefer `frame-ancestors` and emitting both can contradict.
let _ = req.respond(
Response::from_string(UI_HTML).with_status_code(200)
.with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())
.with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())
.with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),
);
continue;
}
(Method::Get, "/favicon.svg") => {
let _ = req.respond(
Response::from_string(ICON_SVG).with_status_code(200)
.with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())
.with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),
);
continue;
}
(Method::Get, "/state") => {
let q = state_ref.last_query.lock().unwrap().clone();
let r = state_ref.last_results.lock().unwrap().clone();
let ts = *state_ref.last_query_ts.lock().unwrap();
(200, json!({
"dev": state_ref.dev,
"last_query": q,
"last_query_ts": ts as u64,
"last_results": r,
"backends": {
"mouser": state_ref.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()),
"digikey": state_ref.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()),
"jlcpcb": state_ref.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()),
}
}))
}
(Method::Post, "/search") => match read_body(&mut req) {
Ok(body) => {
let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();
let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32;
let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);
if keyword.trim().is_empty() {
(400, json!({"error": "keyword required"}))
} else {
let agg = vendors::search_all(
&keyword, limit, in_stock,
state_ref.mouser_api.as_deref(),
state_ref.digikey_api.as_deref(),
state_ref.jlcpcb_api.as_deref(),
);
let v = serde_json::to_value(&agg).unwrap_or(json!({}));
*state_ref.last_query.lock().unwrap() = Some(keyword);
*state_ref.last_results.lock().unwrap() = Some(v.clone());
*state_ref.last_query_ts.lock().unwrap() = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()).unwrap_or(0);
(200, v)
}
}
Err(e) => (400, json!({"error": e})),
},
(Method::Post, "/console") => match read_body(&mut req) {
Ok(v) => {
let mut c = state_ref.console.lock().unwrap();
if c.len() >= CONSOLE_MAX { c.pop_front(); }
c.push_back(v);
(200, json!({"ok": true}))
}
Err(e) => (400, json!({"error": e})),
},
(Method::Get, "/console") => {
let c: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();
(200, json!({"messages": c}))
}
(Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {
Ok(v) => {
let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();
let id = format!("ev{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos());
*state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));
(200, json!({"id": id}))
}
Err(e) => (400, json!({"error": e})),
},
(Method::Get, "/eval/pending") if state_ref.dev => {
let p = state_ref.pending_eval.lock().unwrap().take();
match p {
Some((id, code)) => (200, json!({"id": id, "code": code})),
None => (200, json!({})),
}
}
(Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {
let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();
match read_body(&mut req) {
Ok(v) => {
let r = v.get("result").cloned().unwrap_or(Value::Null);
state_ref.eval_results.lock().unwrap().insert(id, r);
(200, json!({"ok": true}))
}
Err(e) => (400, json!({"error": e})),
}
}
(Method::Post, "/shutdown") => {
if !args.no_tab { let _ = tab::remove_webview_tab(&args.tab_name); }
let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));
ok("shutdown requested, exiting");
std::process::exit(0);
}
_ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),
};
let _ = req.respond(json_resp(result.0, result.1));
}
Ok(())
}