← Commit history

v0.2.0: OS-aware delivery paths (bridge-detected home/OS via /api/env); robust whoami (no barrett-land fallback); universal (removed personal hardcoding)

barrett-land ·3196f2cf20 ·3mo ago ·parent 1e0e506
2 files changed +105−24
src/main.rs+96−19
@@ -42,8 +42,6 @@ const WIKI: &str = "https://wiki.adom.inc"; // Hosted headless KiCad 10 (service-kicad) — renders board SVG/GLB server-side, // so board views need no local kicad-cli and no desktop bridge. const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";-// TODO: detect the signed-in wiki user rather than hard-coding this owner.-const MY_OWNER: &str = "barrett-land";  /// The wiki component list is slow (~8s) but stable — fetch it once, serve /// instantly after. First click blocks; the rest are snappy.@@ -59,23 +57,35 @@ fn cached_list() -> String {     s } -/// The signed-in wiki identity (username + org names), from `adom-wiki whoami`,-/// cached. Falls back to MY_OWNER if the CLI isn't reachable.+/// Follow a dotted path (`a.b.c`) into a JSON value.+fn dig<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {+    let mut cur = v;+    for k in path.split('.') {+        cur = cur.get(k)?;+    }+    Some(cur)+}++/// The signed-in wiki identity (username + org names) from `adom-wiki whoami`,+/// cached. Tolerant of JSON-shape differences across CLI versions; if it can't+/// be read it leaves the identity EMPTY rather than guessing, so the Personal and+/// Organization tabs simply show nothing instead of mislabeling every user. static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None); fn whoami() -> (String, Vec<String>) {     if let Some(v) = WHOAMI.lock().unwrap().clone() {         return v;     }-    let mut me = MY_OWNER.to_string();-    let mut orgs = vec![];+    let mut me = String::new();+    let mut orgs: Vec<String> = vec![];     if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {         if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {-            let data = v.get("data");-            if let Some(u) = data.and_then(|d| d.get("user")).and_then(|u| u.get("username")).and_then(|s| s.as_str()) {-                me = u.to_string();-            }-            if let Some(a) = data.and_then(|d| d.get("orgs")).and_then(|o| o.as_array()) {-                orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).map(String::from)).collect();+            me = ["data.user.username", "data.user.name", "data.user.handle", "user.username", "data.username", "username"]+                .iter()+                .find_map(|p| dig(&v, p).and_then(|x| x.as_str()).filter(|s| !s.is_empty()))+                .unwrap_or_default()+                .to_string();+            if let Some(a) = dig(&v, "data.orgs").or_else(|| v.get("orgs")).and_then(|o| o.as_array()) {+                orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).or_else(|| o.as_str()).filter(|s| !s.is_empty()).map(String::from)).collect();             }         }     }@@ -118,6 +128,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {     match (method, path) {         (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),         (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),+        (Method::Get, "/api/env") => json_resp(&api_env().to_string()),         (Method::Get, "/") => html(INDEX_HTML),         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear@@ -622,11 +633,77 @@ fn empty_svg(msg: &str) -> String { // ---------------------------------------------------------------------------  // TODO: detect from the bridge rather than hard-coding this user's home.-const KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";-const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";-const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects"; const SCRATCH: &str = "/tmp/adom-lbr-apply"; +/// Run a command on the user's machine via the bridge, returning the command's+/// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down.+fn ad_run(cmd: &str) -> Option<String> {+    let raw = ad_shell_out(cmd)?;+    let v: Value = serde_json::from_str(&raw).ok()?;+    v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())+}++/// The user's machine `(os, home)`, detected via the bridge and cached. Files are+/// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux+/// container, so delivery paths must be built for it.+static MACHINE: Mutex<Option<(String, String)>> = Mutex::new(None);+fn machine() -> (String, String) {+    if let Some(v) = MACHINE.lock().unwrap().clone() {+        return v;+    }+    // a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.+    let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {+        Some(home) => {+            let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };+            (os.to_string(), home)+        }+        None => {+            let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());+            ("windows".to_string(), home)+        }+    };+    *MACHINE.lock().unwrap() = Some(res.clone());+    res+}++/// The path separator for the user's OS.+fn sep() -> &'static str {+    if machine().0 == "windows" { "\\" } else { "/" }+}++/// Join parts onto the user's home directory with the OS-appropriate separator.+fn home_path(parts: &[&str]) -> String {+    let mut p = machine().1.trim_end_matches(['/', '\\']).to_string();+    for x in parts {+        p.push_str(sep());+        p.push_str(x);+    }+    p+}++/// A KiCad library/project subdir on the user's machine (`sub` = Symbols /+/// Footprints / Projects).+fn kicad_dir(sub: &str) -> String {+    home_path(&["Documents", "KiCad", sub])+}++/// GET /api/env — the user's machine OS + home + OS-appropriate default delivery+/// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux.+fn api_env() -> Value {+    let (os, home) = machine();+    json!({+        "os": os,+        "home": home,+        "sep": sep(),+        "dest_folders": [+            kicad_dir("Projects"),+            home_path(&["Documents"]),+            home_path(&["Downloads"]),+            home_path(&["Desktop"]),+        ],+    })+}+ const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");  /// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,@@ -635,7 +712,7 @@ const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py"); /// library, re-point every `lib_id`/footprint reference to it, and write the /// project-local sym/fp-lib-tables. Then deliver everything to `dest`. fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {-    let dest = if dest.trim().is_empty() { format!("{KICAD_PROJ_DIR}/{board}") } else { dest.trim().to_string() };+    let dest = if dest.trim().is_empty() { format!("{}{}{board}", kicad_dir("Projects"), sep()) } else { dest.trim().to_string() };     let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));     let _ = std::fs::remove_dir_all(&work);     let _ = std::fs::create_dir_all(&work);@@ -1144,7 +1221,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:             }         }         prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);-        let remote = format!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");+        let remote = format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep());         let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());         // NEVER overwrite a library we failed to read. ad_pull returns None for         // both "absent" and "pull failed" — only proceed with a fresh library@@ -1181,7 +1258,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:             errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));             continue;         }-        if ad_send(&[local], KICAD_SYM_DIR) {+        if ad_send(&[local], &kicad_dir("Symbols")) {             for (name, _) in &blocks {                 added.push(json!({"symbol": name, "symLib": symlib}));             }@@ -1192,7 +1269,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:      for (fplib, slugs) in &by_fplib {         prog(format!("Delivering footprints → {fplib}.pretty"), total);-        let dest = format!("{KICAD_FP_DIR}/{fplib}.pretty");+        let dest = format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep());         ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));         let mut files = vec![];         for slug in slugs {
src/manage.html+9−5
@@ -173,6 +173,9 @@ const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly': // Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component // dependencies ▸ the symbol/footprint/3D viewer. let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';+// the user's machine env (OS + home + default delivery folders), from /api/env+let env={os:'linux',home:'',sep:'/',dest_folders:[]};+function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; } const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');}; const qval=()=>document.getElementById('q').value.toLowerCase(); const rowsEl=()=>document.getElementById('rows');@@ -200,6 +203,7 @@ async function loadList(fresh){   try{     const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());     all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);+    fetch('api/env').then(r=>r.json()).then(e=>{ if(e&&e.os) env=e; }).catch(()=>{});     render();   }catch(e){     rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';@@ -528,7 +532,6 @@ document.getElementById('modal-cancel').onclick=hideModal; document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();}; function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});} // recommended destination parents for delivered projects (editable in the box)-const DEST_FOLDERS=['/home/barrett-land/Documents/KiCAD/Projects','/home/barrett-land/Documents/Adom/Molecules/Other Molecules','/home/barrett-land/Documents/Adom/Electrical','/home/barrett-land/Downloads']; function openAddLib(){   const slugs=[...selected]; if(!slugs.length)return;   const plan=buildPlan(slugs,false);@@ -551,10 +554,11 @@ function openAddProj(){   document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;   document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;   // editable destination + recommended-folder presets-  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${DEST_FOLDERS.map(f=>`<option value="${f}">${f.replace('/home/barrett-land','~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;+  const homeDisp=env.home||'~';+  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${destFolders().map(f=>`<option value="${f}">${f.replace(homeDisp,'~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;   const destpath=document.getElementById('destpath'), destsel=document.getElementById('destsel');-  destpath.value=DEST_FOLDERS[0]+'/'+boardSlug;-  destsel.onchange=()=>{destpath.value=destsel.value+'/'+boardSlug;};+  destpath.value=destFolders()[0]+env.sep+boardSlug;+  destsel.onchange=()=>{destpath.value=destsel.value+env.sep+boardSlug;};   const draw=()=>{     const plan=buildPlan(slugs,mode==='project',projName);     document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> symbol + footprint library is created for this project — your personal libraries stay untouched.':'Symbols merge into your existing per-type libraries; footprints go to <b>adom_lbr.pretty</b>. The schematic library references get registered so nothing shows as broken.'}</div>`+previewTable(plan);@@ -582,7 +586,7 @@ async function doApply(plan,eda,project,board,dest){       clearInterval(poll);       const r=p.result||{}, pf=(r.project&&r.project.files||[]);       go.textContent=`✓ Added ${r.count||0}`;-      const folder=(r.project&&r.project.folder||'').replace('/home/barrett-land','~');+      const folder=(r.project&&r.project.folder||'').replace(env.home||'~','~');       bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`+         (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')+         ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');