app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
//! The HTTP bridge server — what ab spawns and talks to.
//!
//! Contract (Adom Bridge SDK):
//! * `--port N` off argv; bind `ADOM_BIND_HOST` (127.0.0.1), never 0.0.0.0 —
//! a wildcard bind pops the Windows firewall dialog and exposes the bridge
//! off-box;
//! * bind FIRST, before anything slow. A failed bind means another instance
//! already owns the port, so we say so and exit 0 — that is the whole
//! single-instance guard, and it only works if nothing enables address
//! reuse (nothing here does);
//! * `GET /status` and `/health` → 200 JSON with the led/summary/tooltip the
//! status chip renders verbatim;
//! * `POST /command` → the verb result, relayed to the caller as-is;
//! * anything else → 404.
//!
//! Requests are served on a small fixed pool of threads rather than one per
//! connection: COM work serializes on the com host anyway, but `swx_progress`
//! must still be able to answer WHILE a 90-second interference check is in
//! flight — that is the entire reason for more than one worker.
use crate::verbs::{self, Ctx};
use serde_json::{json, Value};
use std::io::Read;
use std::net::TcpListener;
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
/// Fallback when nothing passes `--port` (ab always does; bridge.json declares
/// `"port": 0`, so ab picks a free one and tells us).
pub const DEFAULT_PORT: u16 = 24695;
const WORKERS: usize = 4;
pub fn run(port: Option<u16>) {
let port = port.unwrap_or(DEFAULT_PORT);
let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
println!(
"[swx] starting v{} on port {port} (pid {}, mock={})",
crate::BRIDGE_VERSION,
std::process::id(),
crate::is_mock()
);
// Bind before doing anything slow: a duplicate instance must fail here and
// exit cleanly rather than race the real one.
let listener = match TcpListener::bind((host.as_str(), port)) {
Ok(l) => l,
Err(e) => {
println!(
"[swx] port {port} already in use ({e}) — another instance owns it; exiting cleanly."
);
return;
}
};
let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
let server = match Server::from_listener(listener, None) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("[swx] failed to start HTTP server: {e}");
return;
}
};
// MOVE THE DATA OUT OF ab's DIRECTORIES, BEFORE ANYTHING READS OR WRITES IT.
// The legacy roots are 0.8.x's install cache, which is re-extracted on every
// update (measured 2026-09-30: every 09-17 recovery folder was wiped by the
// 0.8.9 auto-install of 2026-09-18), and 0.9.0's `Adom Bridge\solidworks-data`,
// inside the directory ab's installer owns. It runs AFTER the bind, so a
// duplicate instance that is about to exit never touches it, and BEFORE
// `Ctx::new`, which restores the ownership registry from the data root — a
// registry read before the migration would start empty, and one written
// before it would make the migration copy the real one BESIDE it rather than
// into place.
if let Some(report) = crate::scratch::migrate_legacy() {
let count = |k: &str| report[k].as_array().map(|a| a.len()).unwrap_or(0);
println!(
"[swx] migrated {} file(s) from older bridges' locations into {} ({} changed since \
an earlier copy; {} merged into the live file with both versions kept beside it; {} \
failed verification and were NOT copied; originals left in place)",
count("copied"),
crate::scratch::root().display(),
count("changedSinceCopy"),
count("merged"),
count("failed"),
);
// A MERGE IS ITS OWN LINE IN THE LEDGER too: the live ledger or registry
// was rewritten as the union of two versions, and both versions are kept
// beside it under the names this records.
if count("merged") > 0 {
crate::ledger::record(
"legacy_files_merged",
serde_json::json!({
"merges": report["merged"].clone(),
"why": "two versions of the ledger or the ownership registry — from the two \
older locations, or one that changed after its copy — were MERGED: \
ledger lines united and ordered by `at`, registry claims united. A \
rule that keeps one version drops the other's history.",
}),
);
}
// A CHANGE IS ITS OWN LINE IN THE LEDGER, so the record of "something
// wrote to an older location after we copied it" can be found without
// reading every migration report.
if count("changedSinceCopy") > 0 {
crate::ledger::record(
"legacy_file_changed_since_copy",
serde_json::json!({
"files": report["changedSinceCopy"].clone(),
"why": "a file in an older bridge's location changed after it was copied \
into the data root — a rollback's ledger entries, a rewritten \
registry — so it was copied again, beside the first copy, never \
over it",
}),
);
}
crate::ledger::record("data_migrated_from_install_cache", report);
}
// AND SAY SO IF THE ROOT FELL BACK TO THE TEMP DIRECTORY — every sweep refuses
// there (`data_root_is_temp`), and this is the line that explains why.
if crate::scratch::root_is_temp_fallback() {
println!(
"[swx] WARNING data root {} is in the OS temp directory (neither LOCALAPPDATA nor \
USERPROFILE is set), which Windows empties on a schedule: every safe-close sweep \
will refuse with data_root_is_temp rather than put the only copy of unsaved work \
there.",
crate::scratch::root().display()
);
}
// AND SAY SO IF AN OVERRIDE PUT RECOVERY BACK INSIDE AN INSTALL LOCATION.
// Every sweep refuses to write there (`recovery_inside_install_dir`); this is
// the line in the log that explains why before anybody asks.
if let Err(why) =
crate::scratch::refuse_inside_install(&crate::scratch::root().join("recovery"))
{
println!(
"[swx] WARNING recovery is inside an install location, so every safe-close sweep \
will refuse: {why}"
);
}
// Detection AFTER the bind — it touches the registry and shells out to
// tasklist, which is exactly the kind of slow the port must not wait on.
let detection = crate::detect::detect();
println!(
"[swx] detection: installed={} exe={:?} running={}",
detection.installed, detection.exe, detection.running
);
let ctx = Arc::new(Ctx::new(detection));
crate::scratch::prune();
let (data_root, source) = crate::scratch::root_and_source();
println!("[swx] data root {} (from {})", data_root.display(), source.name());
println!("[swx] listening on http://{host}:{bound} — /status and /command");
let mut workers = Vec::new();
for i in 0..WORKERS {
let server = Arc::clone(&server);
let ctx = Arc::clone(&ctx);
workers.push(
std::thread::Builder::new()
.name(format!("swx-http-{i}"))
.spawn(move || loop {
match server.recv() {
// A handler panic must cost ONE request, never the worker
// thread — otherwise WORKERS panics kill every worker and
// the process ab supervises silently dies. The inner
// dispatch is also panic-caught (so the caller gets JSON);
// this outer guard covers a panic anywhere else in handle
// (header parsing, the response write) where `req` is gone
// and tiny_http closes the connection on its own.
Ok(req) => {
let ctx = Arc::clone(&ctx);
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
handle(&ctx, req)
}))
.is_err()
{
crate::caller::clear();
eprintln!("[swx] worker recovered from a panic; still serving");
}
}
Err(e) => {
eprintln!("[swx] accept failed: {e}");
return;
}
}
})
.expect("spawn worker"),
);
}
for w in workers {
let _ = w.join();
}
ctx.com.shutdown();
}
/// The health payload. `led`/`summary`/`tooltip` are rendered verbatim on ab's
/// status chip — yellow (not red) with SOLIDWORKS absent, because the BRIDGE is
/// fine and the host app is simply not there yet.
pub fn status_payload(ctx: &Ctx) -> Value {
let d = ctx.detection();
let (led, summary, tooltip) = if crate::is_mock() {
(
"yellow",
"mock mode (no SOLIDWORKS)".to_string(),
"SOLIDWORKS bridge is serving the MOCK backend — for development only. Verbs answer \
with canned data; nothing touches a real document."
.to_string(),
)
} else if d.installed {
(
"green",
"SOLIDWORKS ready".to_string(),
format!("SOLIDWORKS bridge up — {}", d.exe.clone().unwrap_or_default()),
)
} else {
(
"yellow",
"SOLIDWORKS not installed".to_string(),
"Bridge up; SOLIDWORKS absent. A 30-day trial serial from Hawk Ridge Systems is \
pending — install once it arrives. Detection and file-search verbs work today."
.to_string(),
)
};
json!({
"status": "ok",
"led": led,
"summary": summary,
"tooltip": tooltip,
"bridgeVersion": crate::BRIDGE_VERSION,
"solidworks": d.to_json(),
"mock": crate::is_mock(),
})
}
fn handle(ctx: &Arc<Ctx>, mut req: Request) {
let method = req.method().clone();
let url = req.url().to_string();
match (&method, url.as_str()) {
(Method::Get, "/status") | (Method::Get, "/health") => {
respond(req, 200, &status_payload(ctx));
}
(Method::Post, "/command") => {
// Read the caller headers before the body borrow starts.
let headers: Vec<(String, String)> = req
.headers()
.iter()
.map(|h| (h.field.as_str().as_str().to_string(), h.value.as_str().to_string()))
.collect();
// Cap the body: a bridge on loopback never needs megabytes of args,
// and an unbounded read is a trivial memory-exhaustion vector.
const MAX_BODY: u64 = 8 * 1024 * 1024;
let mut raw = String::new();
if let Err(e) = req.as_reader().take(MAX_BODY + 1).read_to_string(&mut raw) {
respond(req, 400, &bad_request(format!("read body: {e}")));
return;
}
if raw.len() as u64 > MAX_BODY {
respond(
req,
413,
&bad_request(format!("request body exceeds the {MAX_BODY}-byte cap")),
);
return;
}
if raw.trim().is_empty() {
raw = "{}".to_string();
}
let envelope: Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
respond(req, 400, &bad_request(format!("Invalid JSON: {e}")));
return;
}
};
let command =
envelope.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
let args = envelope.get("args").cloned().unwrap_or_else(|| json!({}));
crate::caller::set_from_request(
|name| {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.clone())
},
&args,
);
// ONE line per incoming verb — never per item inside a loop, never a
// parallel log file. ab captures stdout; this is the whole log.
let mut args_str = args.to_string();
if args_str.len() > 400 {
// Truncate on a char boundary — a Windows path with a non-ASCII
// folder name must not panic the logger.
let mut cut = 400;
while cut > 0 && !args_str.is_char_boundary(cut) {
cut -= 1;
}
args_str.truncate(cut);
args_str.push('…');
}
// ...with the memory sample (0.10.5): commit available and
// SOLIDWORKS's own share, so a long run's growth reads off the log.
println!("[swx] {command}{} | args: {args_str}{}", crate::caller::log_suffix(), crate::memory::log_suffix());
// A verb panic becomes a JSON error the caller can read (and report),
// not a dropped connection or a dead worker.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
verbs::dispatch(ctx, &command, &args)
}))
.unwrap_or_else(|panic| {
let what = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into());
eprintln!("[swx] verb '{command}' panicked: {what}");
json!({
"success": false,
"error": format!("the bridge panicked handling '{command}': {what}"),
"_hint": "This is a bug in the bridge — the worker recovered and is still \
serving. Retry, and please report it.",
"_reportIssues": crate::verbs::report_issues(),
})
});
crate::caller::clear();
respond(req, 200, &result);
}
_ => {
respond(req, 404, &json!({"error": format!("{method} {url} not found")}));
}
}
}
/// A 400/413 body with the same _hint/_reportIssues courtesy every verb failure
/// gets — a malformed request should still tell the caller how to proceed.
fn bad_request(error: String) -> Value {
json!({
"success": false,
"error": error,
"_hint": "POST /command with a JSON body {\"command\":\"swx_...\",\"args\":{...}}. \
swx_describe lists the verbs; GET /status is the health check.",
"_reportIssues": crate::verbs::report_issues(),
})
}
fn respond(req: Request, status: u16, body: &Value) {
let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
.expect("static header parses");
let response =
Response::from_data(bytes).with_status_code(StatusCode(status)).with_header(header);
if let Err(e) = req.respond(response) {
eprintln!("[swx] respond failed: {e}");
}
}