main
Colby Knox board-to-molecule 1.0.0: Rust source, six before/after examples (KiCad, Fusion, Altium), Adom 3D viewer readme eb9ea91 4d ago
//! `serve`: the gallery viewer, a dependency-free HTTP server.
//!
//! Serves the embedded viewer page, `api/examples` (every showcase bundle under the
//! gallery directory), and the bundle files. All URLs are relative so the page works
//! behind the Hydrogen webview path proxy.

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};

const INDEX: &str = include_str!("../../viewer/index.html");
const VIEWER3D: &str = include_str!("../../viewer/viewer3d.html");

fn examples(gallery: &Path) -> Value {
    let mut list = vec![];
    let order = ["kicad", "eagle", "altium"];
    if let Ok(rd) = std::fs::read_dir(gallery) {
        for e in rd.flatten() {
            let dir = e.path();
            let Ok(text) = std::fs::read_to_string(dir.join("manifest.json")) else { continue };
            let Ok(manifest) = serde_json::from_str::<Value>(&text) else { continue };
            let id = e.file_name().to_string_lossy().to_string();
            let meta: Value = std::fs::read_to_string(dir.join("meta.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
            let title = meta["title"].as_str().map(String::from).unwrap_or_else(|| id.clone());
            list.push(json!({"id": id, "title": title, "credit": meta["credit"], "order": meta["order"], "manifest": manifest}));
        }
    }
    list.sort_by_key(|v| {
        let eda = v["manifest"]["eda"].as_str().unwrap_or("");
        (order.iter().position(|o| *o == eda).unwrap_or(9), v["order"].as_i64().unwrap_or(99), v["id"].as_str().unwrap_or("").to_string())
    });
    json!({"version": env!("CARGO_PKG_VERSION"), "examples": list})
}

fn content_type(p: &Path) -> &'static str {
    match p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase().as_str() {
        "svg" => "image/svg+xml",
        "png" => "image/png",
        "glb" => "model/gltf-binary",
        "json" => "application/json",
        "html" => "text/html; charset=utf-8",
        "kicad_pcb" | "brd" => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}

fn respond(s: &mut TcpStream, code: &str, ctype: &str, body: &[u8], head_only: bool) -> std::io::Result<()> {
    write!(s, "HTTP/1.1 {code}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", body.len())?;
    if !head_only {
        s.write_all(body)?;
    }
    s.flush()
}

fn handle(mut s: TcpStream, gallery: PathBuf) -> std::io::Result<()> {
    let mut reader = BufReader::new(s.try_clone()?);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    let mut len = 0usize;
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
            break;
        }
        if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
            len = v.trim().parse().unwrap_or(0);
        }
    }
    let mut parts = line.split_whitespace();
    let method = parts.next().unwrap_or("GET").to_string();
    let raw = parts.next().unwrap_or("/");
    let path = raw.split('?').next().unwrap_or("/");
    let head = method == "HEAD";
    let decoded = percent_decode(path);
    if std::env::var_os("B2M_LOG").is_some() {
        eprintln!("[http] {method} {raw}");
    }
    match decoded.as_str() {
        "/" | "/index.html" => respond(&mut s, "200 OK", "text/html; charset=utf-8", INDEX.as_bytes(), head),
        "/viewer3d.html" => respond(&mut s, "200 OK", "text/html; charset=utf-8", VIEWER3D.as_bytes(), head),
        "/api/examples" => respond(&mut s, "200 OK", "application/json", examples(&gallery).to_string().as_bytes(), head),
        "/api/console" => {
            // Browser console relay: the page posts its errors and logs here, so an
            // agent can read what the webview saw without a devtools window.
            let mut body = vec![0u8; len.min(1 << 20)];
            std::io::Read::read_exact(&mut reader, &mut body)?;
            eprintln!("[console] {}", String::from_utf8_lossy(&body));
            respond(&mut s, "204 No Content", "text/plain", b"", true)
        }
        "/health" => respond(&mut s, "200 OK", "application/json", json!({"ok": true, "version": env!("CARGO_PKG_VERSION")}).to_string().as_bytes(), head),
        p if p.starts_with("/examples/") => {
            let rel = &p["/examples/".len()..];
            if rel.split('/').any(|c| c == ".." || c.is_empty()) {
                return respond(&mut s, "400 Bad Request", "text/plain", b"bad path", head);
            }
            let f = gallery.join(rel);
            match std::fs::read(&f) {
                Ok(b) => respond(&mut s, "200 OK", content_type(&f), &b, head),
                Err(_) => respond(&mut s, "404 Not Found", "text/plain", b"not found", head),
            }
        }
        _ => respond(&mut s, "404 Not Found", "text/plain", b"not found", head),
    }
}

fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 2 < b.len() {
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(v);
                i += 3;
                continue;
            }
        }
        out.push(b[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

pub fn serve(gallery: &Path, port: u16) -> Result<()> {
    let l = TcpListener::bind(("0.0.0.0", port)).with_context(|| format!("port {port} is in use; pass --port"))?;
    println!("OK: serving {} on http://localhost:{port}/", gallery.display());
    println!("Hint: open it in a Hydrogen webview; examples come from {}/*/manifest.json", gallery.display());
    for conn in l.incoming().flatten() {
        let g = gallery.to_path_buf();
        std::thread::spawn(move || {
            let _ = handle(conn, g);
        });
    }
    Ok(())
}