app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
//! The shared tiny_http request loop.
//!
//! `serve` starts a thread-per-request HTTP server on `cfg.port`,
//! registers the instance in `~/.adom/adom-video-post/instances/`,
//! opens a Hydrogen webview tab pointed at the server, and spins
//! on incoming requests until a `/shutdown` request arrives or
//! the process is killed.
//!
//! Built-in routes are dispatched BEFORE delegating to the app's
//! `Routes::handle`, so apps cannot override them (nor would they
//! want to — they're app-neutral infrastructure).
use super::{console, eval, registry, tab, Routes, WebappConfig};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server};
/// The eval-client JS block that gets injected into every UI in
/// place of the `<!-- EVAL_CLIENT -->` marker at serve time.
const EVAL_CLIENT_JS: &str = include_str!("eval_client.js");
/// Blocking entrypoint. Returns on clean shutdown or on bind error.
pub fn serve(cfg: WebappConfig, routes: Arc<dyn Routes>) -> Result<(), String> {
let addr = format!("127.0.0.1:{}", cfg.port);
let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;
// Register in the instance directory so `adom-video-post list` / ctrl
// can find us. Any error here is non-fatal — we log and continue.
let registry_entry = registry::InstanceEntry {
pid: std::process::id(),
port: cfg.port,
subcommand: cfg.subcommand.clone(),
title: cfg.title.clone(),
};
let registry_path = match registry::register(®istry_entry) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("warning: could not register instance: {}", e);
None
}
};
// Open the Hydrogen webview tab. If this fails, we tear down
// the server and return — a webapp with no UI is pointless.
//
// Append an epoch query param so the Hydrogen webview iframe
// doesn't reuse a cached copy of the UI HTML from a previous
// instance that was running on this same port. Combined with
// the Cache-Control: no-store on serve_ui, this guarantees
// the first fetch of a freshly-restarted server lands a fresh
// response, no reload needed.
let base_url = tab::resolve_url(cfg.port);
let sep = if base_url.contains('?') { '&' } else { '?' };
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let url = format!("{}{}v={}", base_url, sep, epoch);
println!("webapp URL: {}", url);
if cfg.no_open {
// --no-open: HTTP server only, no Hydrogen tab. The user is
// driving the UI in pup or via browser_eval and explicitly
// does NOT want a webview tab to appear.
println!("webapp running headless on port {} (--no-open)", cfg.port);
} else if let Err(e) = tab::open(
&cfg.tab_name,
&url,
&cfg.display_icon,
cfg.panel_id_override.as_deref(),
) {
if let Some(p) = ®istry_path {
registry::unregister(p);
}
return Err(e);
} else {
println!("webapp tab '{}' open on port {}", cfg.tab_name, cfg.port);
}
// Each subcommand gets its own console + eval queue.
let console = console::new();
let eval_queue = eval::new();
let should_exit = Arc::new(AtomicBool::new(false));
// Each request gets its own worker thread. Without this, a
// long-poll endpoint like /eval/pending (which waits up to 25s
// on a Condvar) would block every other request on the single
// request loop — and since the UI's eval_client poll loop keeps
// a /eval/pending request open continuously, nothing else would
// ever respond. Spawning per-request is fine because tiny_http's
// `Request::respond` is thread-safe and the handlers are
// self-contained.
loop {
if should_exit.load(Ordering::Relaxed) {
break;
}
// Short timeout so the loop can notice should_exit promptly.
let request = match server.recv_timeout(std::time::Duration::from_millis(250)) {
Ok(Some(r)) => r,
Ok(None) => continue,
Err(e) => {
eprintln!("webapp accept error: {}", e);
continue;
}
};
let routes_for_thread = Arc::clone(&routes);
let console_for_thread = Arc::clone(&console);
let eval_for_thread = Arc::clone(&eval_queue);
let should_exit_for_thread = Arc::clone(&should_exit);
std::thread::spawn(move || {
let method = request.method().clone();
let url_full = request.url().to_string();
let url_path = url_full
.split('?')
.next()
.unwrap_or(&url_full)
.to_string();
let handled = dispatch(
request,
&method,
&url_path,
&url_full,
&routes_for_thread,
&console_for_thread,
&eval_for_thread,
&should_exit_for_thread,
);
if let Err(e) = handled {
eprintln!("webapp error on {} {}: {}", method, url_path, e);
}
});
}
// Clean shutdown: remove the webview tab and the registry entry.
let _ = tab::remove(&cfg.tab_name);
if let Some(p) = ®istry_path {
registry::unregister(p);
}
println!("webapp shut down");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn dispatch(
request: Request,
method: &Method,
url_path: &str,
url_full: &str,
routes: &Arc<dyn Routes>,
console: &console::SharedConsole,
eval_queue: &eval::SharedEval,
should_exit: &Arc<AtomicBool>,
) -> Result<(), String> {
// --- Built-in routes, handled before the app sees anything.
match (method, url_path) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
return serve_ui(request, routes.ui_html());
}
(Method::Get, "/favicon.svg") | (Method::Get, "/favicon.ico") => {
return serve_favicon(request, routes.favicon_svg());
}
(Method::Get, "/console") => {
return console::handle_get(request, console, url_full);
}
(Method::Post, "/console") => {
return console::handle_post(request, console);
}
(Method::Delete, "/console") => {
return console::handle_delete(request, console);
}
(Method::Post, "/eval") => {
return eval::handle_post(request, eval_queue);
}
(Method::Get, "/eval/pending") => {
return eval::handle_get_pending(request, eval_queue);
}
(Method::Post, "/shutdown") | (Method::Get, "/shutdown") => {
let _ = request.respond(text_response(200, "OK"));
should_exit.store(true, Ordering::Relaxed);
return Ok(());
}
_ => {}
}
// /eval/:id/result is dynamic — match the prefix/suffix. Both
// GET (caller polls for a result) and POST (UI submits a result)
// land here.
if url_path.starts_with("/eval/") && url_path.ends_with("/result") {
let id = &url_path["/eval/".len()..url_path.len() - "/result".len()];
match method {
Method::Get => return eval::handle_get_result(request, eval_queue, id),
Method::Post => return eval::handle_post_result(request, eval_queue, id),
_ => {}
}
}
// --- Nothing matched. Delegate to the app.
match routes.handle(request, method, url_path) {
super::RouteOutcome::Handled(result) => result,
super::RouteOutcome::NotHandled(req) => req
.respond(text_response(404, "not found"))
.map_err(|e| e.to_string()),
}
}
fn serve_ui(request: Request, ui_html: &str) -> Result<(), String> {
let injected = ui_html.replace("<!-- EVAL_CLIENT -->", EVAL_CLIENT_JS);
// Cache-Control: no-store is essential. Without it, restarting
// the server on the same port causes the Hydrogen webview to
// reuse the previously-cached HTML from the dead instance —
// meaning code changes don't land until the user hits reload.
// That's an explicit no-no per the "never ask the user to
// refresh" rule. Force-fresh every fetch.
let resp = Response::from_string(injected)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap(),
)
.with_header(
Header::from_bytes(&b"Cache-Control"[..], &b"no-store, must-revalidate"[..]).unwrap(),
);
request.respond(resp).map_err(|e| e.to_string())
}
fn serve_favicon(request: Request, svg: &str) -> Result<(), String> {
// Each subcommand's `Routes::favicon_svg()` returns its own SVG
// bytes — this is what ends up in the Hydrogen tab chip after
// page load, per the hydrogen-tab-icons skill.
let resp = Response::from_string(svg)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"image/svg+xml"[..]).unwrap())
.with_header(
Header::from_bytes(&b"Cache-Control"[..], &b"no-cache"[..]).unwrap(),
);
request.respond(resp).map_err(|e| e.to_string())
}
fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body)
.with_status_code(status)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap())
}