app
Adom Shotlog
Public Made by Adomby adom
See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::{header, HeaderValue, Method, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use crate::describe;
use crate::image::{parse_data_url, read_png_dimensions, resize_png};
use crate::store::Store;
const INDEX_HTML: &str = include_str!("html/index.html");
const ICON_SVG: &str = include_str!("html/icon.svg");
/// Version stamp for the page<->broker reload handshake: version PLUS the
/// broker process start time. Same-version redeploys used to slip past a
/// version-only stamp, leaving humans staring at stale UIs ("dude if you make
/// changes you have to refresh your surfaces"). Any restart now makes every
/// connected page reload exactly once on reconnect (guard is keyed by stamp).
fn boot_stamp() -> &'static str {
use std::sync::OnceLock;
static STAMP: OnceLock<String> = OnceLock::new();
STAMP.get_or_init(|| {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{}+{}", env!("CARGO_PKG_VERSION"), epoch)
})
}
// Raster favicons: Chrome ignores SVG favicons for app-mode windows, pinned
// tabs, and the OS taskbar (what pup windows show) — those need PNG/ICO.
const ICON_PNG_32: &[u8] = include_bytes!("icons/favicon-32.png");
const ICON_PNG_192: &[u8] = include_bytes!("icons/favicon-192.png");
const ICON_ICO: &[u8] = include_bytes!("icons/favicon.ico");
/// Turn a description into a safe filename: lowercase, hyphens, max 60 chars
fn slugify_filename(desc: &str) -> String {
let slug: String = desc
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-");
let slug = if slug.len() > 60 { &slug[..60] } else { &slug };
let slug = slug.trim_end_matches('-');
if slug.is_empty() {
let ts = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
format!("shot-{ts}.png")
} else {
format!("{slug}.png")
}
}
/// Ensure a filename is unique in a directory by appending -2, -3, etc.
fn unique_filename(dir: &std::path::Path, filename: &str) -> String {
if !dir.join(filename).exists() {
return filename.to_string();
}
let stem = filename.trim_end_matches(".png");
for i in 2..100 {
let candidate = format!("{stem}-{i}.png");
if !dir.join(&candidate).exists() {
return candidate;
}
}
// Fallback: add timestamp
let ts = chrono::Utc::now().format("%H%M%S").to_string();
format!("{stem}-{ts}.png")
}
pub struct AppState {
pub store: Arc<Store>,
pub port: u16,
/// Monotonic id generator for viewer presence (one per WS connection).
pub next_viewer_id: std::sync::atomic::AtomicU64,
}
pub fn create_router(state: Arc<AppState>) -> Router {
let cors = CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE]);
Router::new()
.route("/", get(root_redirect))
.route("/log/", get(serve_viewer))
.route("/log/{channel}", get(log_redirect))
.route("/log/{channel}/", get(serve_viewer))
.route("/ws/_all", get(ws_all_handler))
.route("/favicon.svg", get(serve_favicon))
// The single-window app lives at /log/ — without this exact route the
// page's relative favicon.svg 308s into the channel handler and the
// browser gets HTML instead of an icon (= no favicon at all).
.route("/log/favicon.svg", get(serve_favicon))
.route("/log/{channel}/favicon.svg", get(serve_favicon))
// Raster icons at every page level (hrefs are relative so they work
// through the auth proxy, which owns the URL root).
.route("/favicon-32.png", get(serve_favicon_png32))
.route("/log/favicon-32.png", get(serve_favicon_png32))
.route("/log/{channel}/favicon-32.png", get(serve_favicon_png32))
.route("/favicon-192.png", get(serve_favicon_png192))
.route("/log/favicon-192.png", get(serve_favicon_png192))
.route("/log/{channel}/favicon-192.png", get(serve_favicon_png192))
.route("/favicon.ico", get(serve_favicon_ico))
.route("/log/favicon.ico", get(serve_favicon_ico))
.route("/log/{channel}/favicon.ico", get(serve_favicon_ico))
.route("/api/shots", get(get_shots))
.route("/api/shots", post(post_shots))
.route("/api/viewer-status", get(viewer_status))
.route("/api/channels", get(get_channels))
.route("/api/canvas-check", get(canvas_check))
.route("/api/canvas-switch", post(canvas_switch))
.route("/api/canvas-open", post(canvas_open))
.route("/api/canvas-close", post(canvas_close))
.route("/api/canvas-pref", post(set_canvas_pref))
.route("/api/prefs", get(get_prefs))
.route("/api/prefs", post(set_prefs))
.route("/api/log", post(post_log))
.route("/api/notice", post(post_notice))
.route("/api/show", post(post_show))
.route("/prompt", get(serve_prompt))
.route("/api/paste", post(post_paste))
.route("/api/open", post(open_in_vscode))
.route("/api/reveal", post(reveal_in_vscode))
.route("/api/clear-log", delete(clear_log))
.route("/api/clear-all", delete(clear_all))
.route("/api/delete", delete(delete_entry))
.route("/ws/{channel}", get(ws_handler))
.route("/shots/{channel}/{filename}", get(serve_image))
.route("/health", get(health))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB
.layer(cors)
.with_state(state)
}
async fn root_redirect() -> Redirect {
Redirect::temporary("/log/")
}
async fn log_redirect(Path(channel): Path<String>) -> Redirect {
Redirect::permanent(&format!("/log/{channel}/"))
}
/// One-shot human-facing notice, delivered as a toast by the next page that
/// completes the WS welcome (fresh for 10 min, single delivery). Set by the
/// broker itself (pup fallback / recovery) or by any AI via POST /api/notice -
/// the toast is the narration channel to the human ("tried pup, it was down,
/// fell back to webview").
fn notice_cell() -> &'static std::sync::Mutex<Option<(String, std::time::Instant)>> {
static CELL: std::sync::OnceLock<std::sync::Mutex<Option<(String, std::time::Instant)>>> = std::sync::OnceLock::new();
CELL.get_or_init(|| std::sync::Mutex::new(None))
}
fn set_notice(text: &str) {
*notice_cell().lock().unwrap() = Some((text.to_string(), std::time::Instant::now()));
println!("{} NOTICE queued for the human: {text}", ts());
}
fn take_notice() -> Option<String> {
let mut g = notice_cell().lock().unwrap();
match g.take() {
Some((t, at)) if at.elapsed().as_secs() < 600 => Some(t),
_ => None,
}
}
fn icon_response(bytes: &'static [u8], mime: &'static str) -> Response {
let mut res = bytes.into_response();
res.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
);
res
}
async fn serve_favicon_png32() -> Response { icon_response(ICON_PNG_32, "image/png") }
async fn serve_favicon_png192() -> Response { icon_response(ICON_PNG_192, "image/png") }
async fn serve_favicon_ico() -> Response { icon_response(ICON_ICO, "image/x-icon") }
async fn serve_favicon() -> Response {
let mut res = ICON_SVG.to_string().into_response();
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
);
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
);
res
}
async fn serve_viewer() -> Response {
let html = INDEX_HTML.replacen(
"<head>",
"<head><script>document.write('<base href=\"'+((location.pathname.match(/^\\/proxy\\/\\d+\\//)||['/'])[0])+'\">')</script>",
1,
);
// no-cache: the viewer UI must NEVER go stale in a long-lived webview/pup
// tab - without this, browsers heuristically cached old HTML across
// deploys and reloads showed a months-old UI.
let html = html.replacen("__SHOTLOG_VERSION__", boot_stamp(), 1);
let mut res = Html(html).into_response();
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
res
}
#[derive(Deserialize)]
struct ChannelQuery {
channel: Option<String>,
}
async fn get_shots(
State(state): State<Arc<AppState>>,
Query(q): Query<ChannelQuery>,
) -> Json<Vec<crate::store::Entry>> {
let channel = q.channel.unwrap_or_else(|| "default".into());
let entries = state.store.get_entries(&channel).await;
Json(entries)
}
async fn viewer_status(
State(state): State<Arc<AppState>>,
Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
let channel = q.channel.unwrap_or_else(|| "default".into());
let infos = state.store.viewer_infos(&channel).await;
let viewers = infos.len();
let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
let visible: usize = infos.iter().filter(|i| i.visible).count();
let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();
let pup_sessions: Vec<String> = infos.iter()
.filter(|i| i.surface == "pup").filter_map(|i| i.session.clone()).collect();
Json(serde_json::json!({
"channel": channel,
"viewers": viewers,
"has_viewers": viewers > 0,
"surfaces": surfaces,
"visible": visible,
"visible_surfaces": visible_surfaces,
"pup_sessions": pup_sessions,
"canvas_pref": state.store.canvas_pref(&channel).await,
}))
}
/// Overview of every channel: shot count, last-activity time + source, and live
/// viewer presence. Lets a human (or an AI) see how OTHER threads are using
/// shotlog on an ongoing basis. Sorted newest-activity first.
async fn get_channels(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let mut rows: Vec<serde_json::Value> = Vec::new();
for (name, shots, last, source) in state.store.channel_summaries().await {
let infos = state.store.viewer_infos(&name).await;
let visible = infos.iter().filter(|i| i.visible).count();
let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
rows.push(serde_json::json!({
"channel": name,
"shots": shots,
"last": last.map(|t| t.to_rfc3339()),
"last_epoch": last.map(|t| t.timestamp()).unwrap_or(0),
"source": source,
"viewers": infos.len(),
"visible": visible,
"surfaces": surfaces,
}));
}
rows.sort_by(|a, b| b["last_epoch"].as_i64().unwrap_or(0).cmp(&a["last_epoch"].as_i64().unwrap_or(0)));
Json(serde_json::json!(rows))
}
/// Is the pup canvas available on this machine? Checks the adom-desktop CLI and
/// the pup bridge (browser_readiness), cached for 2 minutes. Powers the viewer's
/// canvas-toggle button, including the "how to install" guidance.
async fn canvas_check() -> Json<serde_json::Value> {
let v = tokio::task::spawn_blocking(do_canvas_check)
.await
.unwrap_or_else(|_| serde_json::json!({"available": false, "reason": "internal"}));
Json(v)
}
fn do_canvas_check() -> serde_json::Value {
use std::sync::Mutex;
use std::time::Instant;
static CACHE: Mutex<Option<(Instant, serde_json::Value)>> = Mutex::new(None);
if let Some((t, v)) = CACHE.lock().unwrap().as_ref() {
if t.elapsed().as_secs() < 120 {
return v.clone();
}
}
let result = (|| {
if std::process::Command::new("adom-desktop").arg("--version").output().is_err() {
return serde_json::json!({"available": false, "reason": "no-adom-desktop",
"hint": "pup needs Adom Desktop. Install it on your computer from wiki.adom.inc/adom/adom-desktop - the pup bridge is a default bridge that auto-installs right after it."});
}
let mut cmd = std::process::Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
match cmd.args(["browser_readiness", "{}"]).output() {
Ok(o) => {
let text = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
if text.contains("ambiguous_target") {
serde_json::json!({"available": false, "reason": "multiple-desktops",
"hint": "More than one Adom Desktop is connected. Set SHOTLOG_AD_TARGET=<desktop name> where the broker runs."})
} else if text.contains("\"ready\": true") || text.contains("\"ready\":true") || text.contains("\"ok\": true") {
serde_json::json!({"available": true})
} else if text.contains("No clients") || text.contains("not connected") || text.contains("no desktop") {
serde_json::json!({"available": false, "reason": "desktop-not-connected",
"hint": "Adom Desktop is installed but not connected right now. Launch it on your computer and it links to this container automatically."})
} else {
serde_json::json!({"available": false, "reason": "pup-not-ready",
"hint": "Adom Desktop is here but the pup bridge is not ready yet (it auto-installs on first use). Give it a moment, or run: adom-desktop browser_prewarm"})
}
}
Err(e) => serde_json::json!({"available": false, "reason": "probe-failed",
"hint": format!("Could not probe pup: {e}")}),
}
})();
*CACHE.lock().unwrap() = Some((Instant::now(), result.clone()));
result
}
#[derive(Deserialize)]
struct CanvasSwitchRequest {
channel: String,
to: String,
}
#[derive(Deserialize)]
struct LogRequest {
line: String,
}
/// Append a one-line event to the broker's audit trail (stdout). Used by the
/// CLI so "who opened what, how" is greppable in ONE place.
async fn post_log(Json(req): Json<LogRequest>) -> Json<serde_json::Value> {
let mut line = req.line.replace(['\n', '\r'], " ");
line.truncate(300);
println!("{} {}", ts(), line);
Json(serde_json::json!({"ok": true}))
}
#[derive(Deserialize)]
struct ShowRequest {
channel: String,
}
/// CONSOLIDATION: "open channel X" while the app is already on screen means
/// "switch the existing window's internal tab", never "spawn another surface".
/// (One human, one shotlog window, tabs inside - two windows was confusing.)
/// Returns consolidated:false when nothing is connected, so the caller falls
/// through to the normal canvas-open machinery.
async fn post_show(
State(state): State<Arc<AppState>>,
Json(req): Json<ShowRequest>,
) -> Json<serde_json::Value> {
if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
}
let infos = state.store.viewer_infos("_all").await;
if infos.is_empty() {
return Json(serde_json::json!({"ok": true, "consolidated": false, "viewers": 0}));
}
let prefs = state.store.app_prefs().await;
let frame = serde_json::json!({
"type": "show",
"channel": req.channel,
"toast": format!("Consolidated: an AI asked to open '{}' - switched this tab instead of opening another shotlog window.", req.channel),
});
let _ = state.store.ctrl_tx.send(frame.to_string());
println!("{} DECISION show '{}' consolidated into the open app window ({} viewer(s)) - no new surface.", ts(), req.channel, infos.len());
notify_surface(&req.channel, state.port, &infos, &prefs.default_canvas, prefs.update_alerts, prefs.webview_width as f64 / 100.0);
Json(serde_json::json!({
"ok": true, "consolidated": true, "viewers": infos.len(),
"surfaces": infos.iter().map(|i| i.surface.clone()).collect::<Vec<_>>(),
}))
}
#[derive(Deserialize)]
struct NoticeRequest {
text: String,
}
/// Queue a one-shot toast for the human: the next page to complete the WS
/// welcome shows it (10 min freshness). The broker uses this for pup
/// fallback/recovery narration; AIs may POST here to explain a surface change.
async fn post_notice(Json(req): Json<NoticeRequest>) -> Json<serde_json::Value> {
let mut text = req.text.replace(['\n', '\r'], " ");
text.truncate(240);
if text.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "empty text"}));
}
set_notice(&text);
Json(serde_json::json!({"ok": true, "delivered": "on next viewer welcome (10 min freshness)"}))
}
#[derive(Deserialize)]
struct CanvasPrefRequest {
channel: String,
canvas: String,
}
/// Record which canvas the user chose for a channel (set by open/switch paths).
async fn set_canvas_pref(
State(state): State<Arc<AppState>>,
Json(req): Json<CanvasPrefRequest>,
) -> Json<serde_json::Value> {
if req.channel.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "channel required"}));
}
match req.canvas.as_str() {
"pup" | "webview" => {
println!("{} DECISION canvas note '{}'={} (explicit, in-memory)", ts(), req.channel, req.canvas);
state.store.set_canvas_pref(&req.channel, &req.canvas).await
},
// "default" = forget the channel's own choice; follow the global default.
"default" => state.store.clear_canvas_pref(&req.channel).await,
_ => return Json(serde_json::json!({"ok": false, "error": "canvas must be pup|webview|default"})),
}
Json(serde_json::json!({"ok": true}))
}
async fn get_prefs(State(state): State<Arc<AppState>>) -> Json<crate::store::AppPrefs> {
Json(state.store.app_prefs().await)
}
#[derive(Deserialize)]
struct PrefsPatch {
default_canvas: Option<String>,
read_hook: Option<bool>,
max_shots: Option<usize>,
retention_days: Option<u64>,
update_alerts: Option<bool>,
webview_width: Option<u8>,
update_autoswitch: Option<bool>,
}
/// Patch global preferences from the viewer's Preferences dialog.
async fn set_prefs(
State(state): State<Arc<AppState>>,
Json(patch): Json<PrefsPatch>,
) -> Json<serde_json::Value> {
let mut p = state.store.app_prefs().await;
if let Some(c) = patch.default_canvas {
if c != "pup" && c != "webview" {
return Json(serde_json::json!({"ok": false, "error": "default_canvas must be pup|webview"}));
}
if c != p.default_canvas {
// The human just made a NEW explicit choice - stale in-memory
// click-notes must not override it ("all channels adhere to the
// GLOBAL default"; notes only exist for clicks made SINCE).
let cleared = state.store.clear_all_canvas_prefs().await;
if cleared > 0 {
println!("{} DECISION default canvas changed to {c} - cleared {cleared} in-memory canvas note(s).", ts());
}
}
p.default_canvas = c;
}
if let Some(h) = patch.read_hook {
p.read_hook = h;
}
if let Some(n) = patch.max_shots {
if !(10..=5000).contains(&n) {
return Json(serde_json::json!({"ok": false, "error": "max_shots must be 10-5000"}));
}
p.max_shots = n;
}
if let Some(a) = patch.update_alerts {
p.update_alerts = a;
}
if let Some(a) = patch.update_autoswitch {
p.update_autoswitch = a;
}
if let Some(w) = patch.webview_width {
if !(10..=50).contains(&w) {
return Json(serde_json::json!({"ok": false, "error": "webview_width must be 10-50 (percent)"}));
}
p.webview_width = w;
}
if let Some(d) = patch.retention_days {
if d > 3650 {
return Json(serde_json::json!({"ok": false, "error": "retention_days must be 0-3650 (0 = never)"}));
}
p.retention_days = d;
}
state.store.set_app_prefs(p.clone()).await;
Json(serde_json::json!({"ok": true, "prefs": p}))
}
#[derive(Deserialize)]
struct CanvasOpenRequest {
channel: String,
to: String,
}
/// STAGE 1 of the tracked switch: open the DESTINATION surface only. The
/// viewer then polls /api/viewer-status until the destination viewer's
/// WebSocket actually CONNECTS (the full signal), and only after that calls
/// /api/canvas-close for the source. Never closes anything itself, so a
/// failure at any stage leaves the user's current surface untouched.
async fn canvas_open(
State(state): State<Arc<AppState>>,
Json(req): Json<CanvasOpenRequest>,
) -> Json<serde_json::Value> {
if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
}
if req.to != "pup" && req.to != "webview" {
return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"}));
}
// The switch expresses the user's canvas CHOICE - remember it so force-open
// (e.g. a hook inject during a presence blip) reopens the RIGHT canvas.
state.store.set_canvas_pref(&req.channel, &req.to).await;
let v = tokio::task::spawn_blocking(move || {
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
let flag = if req.to == "pup" { "--pup" } else { "--skinny" };
match std::process::Command::new(exe).env("SHOTLOG_FORCE_SURFACE", "1").args(["open", "-c", &req.channel, flag]).output() {
Ok(o) => serde_json::json!({"ok": o.status.success(),
"detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}),
Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
}
})
.await
.unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
let okf = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
let mut detail = v.get("detail").or_else(|| v.get("error")).and_then(|d| d.as_str()).unwrap_or("").replace('\n', " | ");
detail.truncate(220);
println!("{} DECISION canvas-open result ok={okf}{}", ts(),
if okf { String::new() } else { format!(" detail: {detail}") });
Json(v)
}
#[derive(Deserialize)]
struct CanvasCloseRequest {
channel: String,
surface: String,
}
/// STAGE 3 of the tracked switch (or a rollback): close ONE surface of a
/// channel. surface = "webview" closes the Hydrogen tab; "pup" closes the tab
/// in the shared shotlog pup window.
async fn canvas_close(
State(state): State<Arc<AppState>>,
Json(req): Json<CanvasCloseRequest>,
) -> Json<serde_json::Value> {
if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
}
let channel = req.channel.clone();
let surface = req.surface.clone();
let v = tokio::task::spawn_blocking(move || {
if req.surface == "webview" {
let _ = &req.channel;
let name = "Shotlog".to_string();
let out = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", &name])
.output();
let mut ok = out.as_ref().map(|o| o.status.success()
&& !String::from_utf8_lossy(&o.stdout).contains("\"error\"")).unwrap_or(false);
// Last tab in its pane: Hydrogen requires closing the whole pane.
if !ok {
let text = out.map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_default();
if text.contains("Cannot remove all tabs") {
ok = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", &name, "--mode", "all"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status().map(|s| s.success()).unwrap_or(false);
}
}
serde_json::json!({"ok": ok, "surface": "webview"})
} else if req.surface == "pup" {
let ok = crate::open::close_pup_tab(&req.channel);
serde_json::json!({"ok": ok, "surface": "pup"})
} else {
serde_json::json!({"ok": false, "error": "surface must be webview|pup"})
}
})
.await
.unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
// Closing one surface while the OTHER is live means the other is now the
// channel's canvas - keep the pref honest (covers rollbacks too).
if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {
let other = if surface == "pup" { "webview" } else { "pup" };
if state.store.viewer_infos(&channel).await.iter().any(|i| i.surface == other) {
state.store.set_canvas_pref(&channel, other).await;
}
}
Json(v)
}
/// Switch a channel between canvases from the viewer UI. For SPEED: open the
/// DESTINATION surface (blocking, so the human sees it appear), then close the
/// SOURCE surface in the BACKGROUND. The old code ran the full `--swap`
/// (open + close) sequentially before responding, so the button sat on
/// "docking…" through the slow laptop round-trip of closing the other surface.
async fn canvas_switch(
State(state): State<Arc<AppState>>,
Json(req): Json<CanvasSwitchRequest>,
) -> Json<serde_json::Value> {
if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
}
if req.to != "pup" && req.to != "webview" {
return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"}));
}
let port = state.port;
let channel = req.channel.clone();
let to = req.to.clone();
// Close the SOURCE surface in the background — do NOT make the button wait
// on it. (Closing the pup tab is a slow laptop round-trip; closing a webview
// tab is a fast Hydrogen call. Either way it's fire-and-forget.)
{
let ch = channel.clone();
let to_bg = to.clone();
tokio::spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
if to_bg == "pup" {
// moved to pup → close the webview tab (Hydrogen, fast)
let _ = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {ch}")])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status();
} else {
// moved to webview → close the pup tab (laptop round-trip)
crate::open::close_pup_tab(&ch);
}
})
.await;
});
}
// Open the DESTINATION surface (blocking) via a self-subprocess — isolated
// so open_pup's process::exit on error can never take down the broker.
let v = tokio::task::spawn_blocking(move || {
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
let flag = if to == "pup" { "--pup" } else { "--skinny" };
let _ = port; // (port defaults to 8820 inside the child)
match std::process::Command::new(exe)
.env("SHOTLOG_FORCE_SURFACE", "1")
.args(["open", "-c", &channel, flag])
.output()
{
Ok(o) => serde_json::json!({"ok": o.status.success(),
"detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}),
Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
}
})
.await
.unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
Json(v)
}
/// Paste-prompt text served at /prompt — guidance for AI agents on how to inject.
async fn serve_prompt() -> impl IntoResponse {
let body = "\
Use 'shotlog inject' for all screenshot logging.
shotlog ALWAYS records the original capture resolution. Resizing before inject is
fine and expected — oversized images crash Claude's vision analysis, so most
screenshot verbs return a shrunk copy. The original size just has to travel with
the shot.
If you inject a PRE-SHRUNK image (<=1500px), declare the original — shotlog
REJECTS the inject otherwise. One of:
--orig-w <W> --orig-h <H> (the original dimensions)
--orig-file <the original full-res image> (shotlog measures it)
--native (only if it was NOT resized)
shotlog inject -c CHANNEL -d \"what and why\" -s pup_screenshot --orig-w 1920 --orig-h 1080 SHRUNK.png
Building a screenshot tool? Have it (1) report the ORIGINAL width/height, and
(2) keep the full-res file available, so callers can pass --orig-w/--orig-h (or
--orig-file). A full-res image also works — shotlog resizes >1500px itself and
records the original.
The description becomes the filename -- say what you are looking at and why.
";
([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body)
}
/// Timestamp prefix for decision-log lines (the broker's stdout is the audit
/// trail - deploys APPEND to it, so keep every line greppable + timestamped).
fn ts() -> String {
chrono::Local::now().format("%m-%d %H:%M:%S").to_string()
}
/// Progressive-disclosure gate: a teaching hint is shown at most `max` times per
/// broker lifetime, then goes quiet. The broker is the long-lived stateful side
/// (CLI processes are one-shot), so the counters live here.
fn hint_gate(key: &str, max: u32) -> bool {
use std::sync::Mutex;
use std::collections::HashMap;
static SEEN: Mutex<Option<HashMap<String, u32>>> = Mutex::new(None);
let mut guard = SEEN.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
let c = map.entry(key.to_string()).or_insert(0);
*c += 1;
*c <= max
}
/// Per-key cooldown so a burst of injects can't spam adom-cli. Returns true if
/// the action for `key` may run now (and stamps it), false if still cooling down.
fn debounce_ok(key: &str, secs: u64) -> bool {
use std::sync::Mutex;
use std::collections::HashMap;
use std::time::Instant;
static LAST: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);
let mut guard = LAST.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
if let Some(t) = map.get(key) {
if t.elapsed().as_secs() < secs {
return false;
}
}
map.insert(key.to_string(), Instant::now());
true
}
/// Decide how to surface a new shot, based on which surface(s) are watching.
/// Never steals the human's OS foreground; all webview targeting is by the tab's
/// UNIQUE name (`Shots: <channel>`) so, with many shotlogs open in parallel, only
/// THIS channel's tab is ever touched.
/// - **webview** open for this channel → bring THAT tab **forward** (activate it)
/// so the update is seen. Done in code, on by default.
/// - **only pup / phone** (no webview) → leave their foreground alone; the
/// viewer's own in-page title cue flags the backgrounded tab and the AI is
/// hinted to flash the pup taskbar.
/// - **nobody** watching → force-open a slim ~20% webview, unless
/// `SHOTLOG_NO_FORCE_OPEN` is set.
fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool, ratio: f64) {
// Pup viewers get an automatic ORANGE TASKBAR FLASH (FlashWindowEx) on their
// hosting window — a gentle "update" cue, never a foreground steal. Works when
// the viewer URL carried ?session=<pup sessionId> (reported in the WS hello).
let pup_sessions: std::collections::HashSet<String> = infos
.iter()
.filter(|i| i.surface == "pup")
.filter_map(|i| i.session.clone())
.collect();
let has_pup = infos.iter().any(|i| i.surface == "pup");
if alerts {
for s in pup_sessions {
flash_pup_session(&s);
}
}
// In the single-window app, per-channel tab surfacing happens IN-APP
// (the viewer's own tab strip); no external pup tab switching needed.
let _ = has_pup;
let has_webview = infos.iter().any(|i| i.surface == "webview");
if has_webview {
bring_webview_forward(channel, port, alerts);
// The webview FALLBACK is a lifeboat, not a destination. If the human's
// canvas says pup and the only thing connected is a webview (classic:
// AD was offline, we fell back, AD came back), each new shot buys one
// cooled-down attempt to bring pup up - and once pup CONNECTS, the
// fallback webview closes. Staged like the UI switch: nothing closes
// until the destination is live.
if pref == "pup" && !has_pup {
recover_pup_from_fallback(channel, port);
}
return;
}
if infos.is_empty() && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() {
// Honor the channel's chosen canvas (falling back to the global default):
// a pup channel whose viewer blipped must NOT get a webview shoved at it.
println!("{} DECISION force-open '{channel}' canvas={pref} (nobody watching)", ts());
if pref == "pup" {
force_open_pup(channel, ratio);
} else {
force_open_viewer(channel, port, ratio);
}
}
}
/// Force-open the PUP canvas for a channel (its chosen canvas) when a shot
/// lands with nobody watching. Runs via a self-subprocess (open_pup can exit).
/// If pup genuinely cannot open, falls back to a webview - never invisible.
fn force_open_pup(channel: &str, fallback_ratio: f64) {
// Debounce longer than the whole retry window so attempts never overlap.
if !debounce_ok(&format!("openpup:{channel}"), 240) {
return;
}
let channel = channel.to_string();
std::thread::spawn(move || {
// PATIENT: right after a laptop reboot Adom Desktop takes a minute to
// reconnect. Retry pup before surrendering to the webview fallback -
// the user's pref says pup, so give pup a real chance to come up.
let tries: u32 = std::env::var("SHOTLOG_PUP_RETRY_N").ok().and_then(|v| v.parse().ok()).unwrap_or(6);
let wait_s: u64 = std::env::var("SHOTLOG_PUP_RETRY_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(20);
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
for attempt in 1..=tries {
let ok = std::process::Command::new(&exe)
.env("SHOTLOG_FORCE_SURFACE", "1")
.args(["open", "-c", &channel, "--pup"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status().map(|s| s.success()).unwrap_or(false);
if ok {
if attempt > 1 {
println!("OK: pup force-open for '{channel}' succeeded on attempt {attempt} (Adom Desktop is back).");
}
return;
}
println!("WARN: pup force-open for '{channel}' attempt {attempt}/{tries} failed (Adom Desktop offline / reconnecting?){}",
if attempt < tries { format!(" - retrying in {wait_s}s") } else { String::new() });
if attempt < tries {
std::thread::sleep(std::time::Duration::from_secs(wait_s));
}
}
println!("{} WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (no choice recorded).", ts());
set_notice("Tried to open pup (your default) - it was unreachable, so I fell back to this webview so shots stay visible. I'll keep retrying and move you back to pup automatically once it's up.");
crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio);
});
}
/// Recover from the webview fallback once pup is reachable again: try pup once
/// (cooldown 5 min), wait for a REAL pup viewer connect, then close the
/// fallback webview. Never closes anything while pup isn't live.
fn recover_pup_from_fallback(channel: &str, port: u16) {
if !debounce_ok("puprecover", 300) {
return;
}
let channel = channel.to_string();
std::thread::spawn(move || {
println!("{} DECISION default canvas is pup but only a webview is connected (fallback residue) - retrying pup.", ts());
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
let ok = std::process::Command::new(&exe)
.env("SHOTLOG_FORCE_SURFACE", "1")
.args(["open", "-c", &channel, "--pup"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status().map(|s| s.success()).unwrap_or(false);
if !ok {
println!("{} DECISION pup still unreachable - staying on the fallback webview.", ts());
return;
}
for _ in 0..15 {
std::thread::sleep(std::time::Duration::from_secs(2));
if crate::open::broker_has_pup_viewer(port) {
println!("{} DECISION pup is back and CONNECTED - closing the fallback webview (it was never a choice).", ts());
set_notice("pup is back - your shotlog moved here and the fallback webview was closed.");
let out = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog"])
.output();
let mut closed = out.as_ref().map(|o| o.status.success()
&& !String::from_utf8_lossy(&o.stdout).contains("\"error\"")).unwrap_or(false);
if !closed {
// Last tab in its pane: Hydrogen requires closing the pane.
let text = out.map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_default();
if text.contains("Cannot remove all tabs") {
closed = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog", "--mode", "all"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status().map(|s| s.success()).unwrap_or(false);
}
}
if !closed {
println!("{} WARN: fallback webview tab close failed - pup is live, human may see both surfaces.", ts());
}
return;
}
}
println!("{} DECISION pup open reported ok but no pup viewer connected within 30s - leaving the webview alone.", ts());
});
}
/// Orange taskbar flash on the pup window hosting a viewer. Debounced per session
/// so a burst of injects flashes once, not strobe.
fn flash_pup_session(session: &str) {
if !debounce_ok(&format!("pupflash:{session}"), 8) {
return;
}
let session = session.to_string();
std::thread::spawn(move || {
let mut cmd = std::process::Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
let _ = cmd
.args(["browser_alert_window", &format!("{{\"sessionId\":\"{session}\"}}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
});
}
/// Bring THIS channel's already-open webview tab to the front. Finds the tab by
/// its panelState URL (`/log/<channel>/` — unique per channel, so parallel
/// shotlog tabs are never cross-touched), then `move-tab` to its own pane:
/// Hydrogen auto-activates it (surfaces to top) and `--alert` paints the ORANGE
/// update highlight on the tab. Falls back to create-or-refresh if not found.
fn bring_webview_forward(channel: &str, port: u16, alerts: bool) {
if !debounce_ok(&format!("fwd:{channel}"), 2) {
return;
}
let channel = channel.to_string();
std::thread::spawn(move || {
// GENTLE activate: `workspace active-tab` brings the tab to the front
// WITHIN ITS OWN PANE — no tab moves, no iframe re-parenting, no
// workspace-tree re-render. (The old approach bounced the tab through
// the user's VS Code pane via move-tab, which re-rendered the whole
// Hydrogen page and interrupted typing. Never do that.)
let _ = &channel;
let tab = "Shotlog".to_string();
let activated = std::process::Command::new("adom-cli")
.args(["hydrogen", "workspace", "active-tab", "--name", &tab])
.output()
.map(|o| o.status.success()
&& String::from_utf8_lossy(&o.stdout).contains("204"))
.unwrap_or(false);
if activated {
// Orange update indicator on the (now-front) tab - user-toggleable.
if alerts {
let _ = std::process::Command::new("adom-cli")
.args(["hydrogen", "alert", "set", "--name", &tab, "-d", "6000"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status();
}
return;
}
// Tab not found: do NOTHING. A webview VIEWER with no Hydrogen tab is a
// plain page reporting surface=webview (e.g. a headless/test client) -
// it already receives updates over its WebSocket. Creating a tab here
// materialized phantom webview tabs against the user's pup preference.
let _ = port;
});
}
/// Force-open the viewer for a channel when a shot lands with NOBODY watching.
/// Fallback only (see notify_surface). Debounced per-channel. Defaults to a slim
/// ~20%-wide right-side panel so cross-thread shots auto-appear without taking
/// over the workspace — and CRUCIALLY passes a --panel-id (via open_webview) so
/// the tab is actually CREATED, not silently skipped.
fn force_open_viewer(channel: &str, port: u16, ratio: f64) {
if std::env::var("VSCODE_PROXY_URI").is_err() {
return; // not in an Adom container; nothing to open
}
if !debounce_ok(&format!("open:{channel}"), 60) {
return; // opened recently
}
let channel = channel.to_string();
// Best-effort; don't block the inject response on the adom-cli round-trip.
std::thread::spawn(move || {
crate::open::open_webview(&channel, None, None, port, true, ratio);
});
}
#[derive(Deserialize)]
struct InjectRequest {
channel: String,
image: String,
description: String,
source: Option<String>,
filename: Option<String>,
/// Caller-reported original dimensions (when the caller resized before sending).
orig_w: Option<u32>,
orig_h: Option<u32>,
/// Free-form capture metadata (Adom Desktop coordMap / dpiScale / screenRect /
/// screen resolution, shotId, ownedPopupCount, …). Stored verbatim on the entry.
meta: Option<serde_json::Value>,
/// Owned-popup screenshots. Each object carries an `image` data-URL plus any
/// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its
/// own PNG and replaced with `filename` in the stored array.
popups: Option<Vec<serde_json::Value>>,
/// Which AI thread produced this shot (human-readable title). The read-hook
/// fills this from the session transcript; any injector may pass it.
thread: Option<String>,
/// WHY the shot exists: the user prompt the thread was serving (the
/// read-hook extracts it programmatically from the transcript).
context: Option<String>,
/// Absolute path of the ORIGINAL full-res image (when the caller passed
/// --orig-file): the broker stores a copy beside the shot as {stem}__full.png.
orig_path: Option<String>,
}
/// Images larger than this on any edge are auto-resized (Claude crashes >1500px).
const MAX_EDGE: u32 = 1500;
/// Target edge for the auto-resize.
const RESIZE_TO: u32 = 1400;
#[derive(Serialize)]
struct InjectResponse {
ok: bool,
id: u64,
name: String,
path: String,
w: u32,
h: u32,
kb: u64,
bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
original_w: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
original_h: Option<u32>,
/// Live viewers connected at inject time, and their surfaces — so the caller
/// can immediately tell whether the human is actually seeing this shot.
viewers: usize,
surfaces: Vec<String>,
/// Of those, how many are actually ON-SCREEN (Page Visibility) vs a
/// background tab / minimized window, and which surfaces those are.
visible_viewers: usize,
visible_surfaces: Vec<String>,
/// What the broker is DOING about this shot (webview_surface / pup_flash /
/// force_open / none) — so the calling AI knows surfacing is handled and
/// never foregrounds anything itself.
#[serde(skip_serializing_if = "Option::is_none")]
surfacing: Option<serde_json::Value>,
/// Progressive teaching hints (mini-skills), revealed a few times then quiet.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
hints: Vec<String>,
}
async fn post_shots(
State(state): State<Arc<AppState>>,
Json(req): Json<InjectRequest>,
) -> Result<Json<InjectResponse>, (StatusCode, String)> {
let (_mime, raw_bytes) =
parse_data_url(&req.image).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let (incoming_w, incoming_h) = read_png_dimensions(&raw_bytes);
// Auto-resize anything over the limit, recording the TRUE original. This is
// how orig dims get captured at the source: shotlog owns the resize, so it
// always knows what the image was before shrinking. No caller pre-resize needed.
let (bytes, w, h, orig_w, orig_h) = if incoming_w > MAX_EDGE || incoming_h > MAX_EDGE {
let (resized, nw, nh) =
resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
(resized, nw, nh, Some(incoming_w), Some(incoming_h))
} else {
// Image already within limits. If the caller pre-resized externally and
// reported the original via orig_w/orig_h, honor it; otherwise none.
(raw_bytes, incoming_w, incoming_h, req.orig_w, req.orig_h)
};
let raw_name = req.filename.unwrap_or_else(|| slugify_filename(&req.description));
let channel_dir = state.store.base_dir().join(&req.channel);
let filename = unique_filename(&channel_dir, &raw_name);
let source = req.source.unwrap_or_else(|| "ai".into());
// Owned popups: save each popup's image as its own PNG next to the primary,
// strip the data-URL from the stored object, and record its filename + dims.
let popups_stored = if let Some(popups) = req.popups {
let stem = filename.trim_end_matches(".png");
let mut out = Vec::new();
std::fs::create_dir_all(&channel_dir).ok();
for (i, mut p) in popups.into_iter().enumerate() {
let Some(img) = p.get("image").and_then(|v| v.as_str()).map(|s| s.to_string()) else { continue };
let Ok((_m, pbytes)) = parse_data_url(&img) else { continue };
let (pw, ph) = read_png_dimensions(&pbytes);
let pname = format!("{stem}__popup{}.png", i + 1);
std::fs::write(channel_dir.join(&pname), &pbytes).ok();
if let Some(obj) = p.as_object_mut() {
obj.remove("image");
obj.insert("filename".into(), serde_json::json!(pname));
obj.insert("w".into(), serde_json::json!(pw));
obj.insert("h".into(), serde_json::json!(ph));
obj.insert("kb".into(), serde_json::json!(pbytes.len() / 1024));
}
out.push(p);
}
if out.is_empty() { None } else { Some(serde_json::Value::Array(out)) }
} else { None };
let had_meta = req.meta.is_some() || popups_stored.is_some();
let source_str = source.clone();
let entry = state
.store
.add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone(), req.orig_path.clone())
.await;
// Presence at inject time — the caller uses this to know if the human is
// actually watching (and on which surface / on-screen) without guessing.
let infos = state.store.viewer_infos(&req.channel).await;
let viewers = infos.len();
let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
let visible_viewers = infos.iter().filter(|i| i.visible).count();
let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();
// Decide + REPORT the surfacing action (so the calling AI knows it's handled),
// then fire it in the background. Never steals the human's foreground.
let has_webview = infos.iter().any(|i| i.surface == "webview");
let pup_sessions: Vec<String> = {
let s: std::collections::HashSet<String> = infos.iter()
.filter(|i| i.surface == "pup").filter_map(|i| i.session.clone()).collect();
s.into_iter().collect()
};
let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err();
let app_prefs = state.store.app_prefs().await;
let alerts_on = app_prefs.update_alerts;
let canvas_pref = match state.store.canvas_pref("_app").await {
Some(c) => c,
None => match state.store.canvas_pref(&req.channel).await {
Some(c) => c,
None => app_prefs.default_canvas,
},
};
let mut actions: Vec<serde_json::Value> = Vec::new();
if !pup_sessions.is_empty() && alerts_on {
actions.push(serde_json::json!({"action":"pup_flash","sessions":pup_sessions,"cue":"orange taskbar flash"}));
}
if has_webview {
actions.push(serde_json::json!({"action":"webview_surface","tab":format!("Shots: {}", req.channel),
"cue": if alerts_on {"tab pulled to front + orange alert"} else {"tab pulled to front (update alerts off)"}}));
} else if infos.is_empty() && force_open_on {
actions.push(serde_json::json!({"action":"force_open","canvas": canvas_pref,
"layout": if canvas_pref=="pup" {"tab in the shared pup window"} else {"slim 20% right-side webview"}}));
}
let surfacing = Some(serde_json::json!({"actions": actions}));
let ratio = (app_prefs.webview_width as f64 / 100.0).clamp(0.10, 0.50);
notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on, ratio);
// Progressive mini-skill hints — taught at the moment they're relevant, then
// quiet (hint_gate caps how many times each lesson repeats).
let mut hints: Vec<String> = Vec::new();
if entry.id == 1 && hint_gate(&format!("teach:new-channel:{}", req.channel), 1) {
hints.push(format!(
"mini-skill [surfacing]: first shot in '{ch}'. To open a viewer use PLAIN `shotlog open -c {ch}` - it follows the human's default canvas (webview or pup); only pass --skinny/--pup when the human explicitly asked for that canvas. I auto-open a 20% webview when nobody is watching, pull the channel's tab to the front with an orange alert on updates, and flash pup taskbars — never foreground anything yourself. Gate on visibility with `shotlog viewers -c {ch}` (exit 0=on-screen, 4=backgrounded, 3=nobody); see every channel with `shotlog channels`.",
ch = req.channel));
}
if source_str.contains("desktop_screenshot") && !had_meta && hint_gate(&format!("teach:meta:{}", req.channel), 2) {
hints.push("mini-skill [child-windows]: this source looks like an Adom Desktop window capture. AD returns the window's CHILD WINDOWS (owned dropdowns/dialogs, `screenshots[]`/`ownedPopupCount`) + coordMap in the SAME response — pass that whole JSON via `--meta` and they all render inline on this card, and the original resolution comes free.".into());
}
if infos.iter().any(|i| i.surface == "pup" && i.session.is_none()) && hint_gate(&format!("teach:pup-session:{}", req.channel), 2) {
hints.push("mini-skill [surfacing]: a pup viewer is watching WITHOUT a ?session= handle, so I cannot flash its taskbar for you. Open pup viewers with `?surface=pup&session=<pup sessionId>` and I flash that window orange automatically on every update.".into());
}
// Dual-surface check: same channel on webview AND pup is usually an accident
// (the human asked to "switch" and the AI opened pup without closing webview).
let has_webview_v = infos.iter().any(|i| i.surface == "webview");
let has_pup_v = infos.iter().any(|i| i.surface == "pup");
if has_webview_v && has_pup_v && hint_gate(&format!("teach:dual-surface:{}", req.channel), 3) {
hints.push(format!(
"you are showing '{ch}' on TWO surfaces at once (webview + pup). Did you mean to? Most users just want one surface. If you picked pup, close the webview and stop sending to it: `shotlog open -c {ch} --pup --swap` (or `adom-cli hydrogen workspace remove-tab --name \"Shots: {ch}\"`). The broker then stops pulling the webview tab forward on updates.",
ch = req.channel));
}
let webview_only = !infos.is_empty() && infos.iter().all(|i| i.surface == "webview");
if webview_only && hint_gate(&format!("teach:canvas:{}", req.channel), 2) {
hints.push(format!(
"mini-skill [surfacing]: '{ch}' is only being watched in the SKINNY webview. If the human wants to inspect images full-size (zoom in the skinny panel can't get bigger than the panel), switch canvases: `shotlog open -c {ch} --pup` opens the full-browser pup window (one shared window, a tab per channel, taskbar flashes orange on updates). Add `--swap` to close the webview tab so pup becomes the channel's canvas.",
ch = req.channel));
}
Ok(Json(InjectResponse {
ok: true,
id: entry.id,
name: entry.filename,
path: entry.path,
w: entry.w,
h: entry.h,
kb: entry.kb,
bytes: entry.bytes,
original_w: entry.orig_w,
original_h: entry.orig_h,
viewers,
surfaces,
visible_viewers,
visible_surfaces,
surfacing,
hints,
}))
}
#[derive(Deserialize)]
struct PasteRequest {
channel: String,
#[serde(rename = "dataUrl")]
data_url: String,
description: Option<String>,
// Provenance is DECLARED, never assumed. The viewer's Ctrl+V handler sends
// source:"clipboard"; programmatic clients say who they are (or default to
// "api", which renders chip-less like a CLI inject). Stamping every paste
// "clipboard" mislabeled AI-pushed shots as human pastes.
source: Option<String>,
thread: Option<String>,
context: Option<String>,
}
async fn post_paste(
State(state): State<Arc<AppState>>,
Json(req): Json<PasteRequest>,
) -> Result<Json<InjectResponse>, (StatusCode, String)> {
let (_mime, raw_bytes) =
parse_data_url(&req.data_url).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let (orig_w, orig_h) = read_png_dimensions(&raw_bytes);
let orig_bytes = raw_bytes.len() as u64;
let (bytes, w, h) =
resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
let was_resized = w != orig_w || h != orig_h;
let source = req.source.clone().unwrap_or_else(|| "api".into());
let kind = if source == "clipboard" { "Clipboard paste" } else { "API paste" };
let description = req.description.unwrap_or_else(|| {
if was_resized {
format!(
"{} (resized from {}x{}, {:.0} KB to {}x{}, {:.0} KB)",
kind, orig_w, orig_h, orig_bytes as f64 / 1024.0,
w, h, bytes.len() as f64 / 1024.0,
)
} else {
format!("{} ({}x{}, {:.0} KB)", kind, w, h, bytes.len() as f64 / 1024.0)
}
});
let raw_name = slugify_filename(&description);
let channel_dir = state.store.base_dir().join(&req.channel);
let filename = unique_filename(&channel_dir, &raw_name);
let entry = state
.store
.add_entry(
&req.channel, bytes, filename, description, source, w, h,
if was_resized { Some(orig_w) } else { None },
if was_resized { Some(orig_h) } else { None },
None, None, req.thread.clone(), req.context.clone(), None,
)
.await;
let infos = state.store.viewer_infos(&req.channel).await;
let viewers = infos.len();
let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
let visible_viewers = infos.iter().filter(|i| i.visible).count();
let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();
Ok(Json(InjectResponse {
ok: true,
id: entry.id,
name: entry.filename,
path: entry.path,
w: entry.w,
h: entry.h,
kb: entry.kb,
bytes: entry.bytes,
original_w: if was_resized { Some(orig_w) } else { None },
original_h: if was_resized { Some(orig_h) } else { None },
viewers,
surfaces,
visible_viewers,
visible_surfaces,
surfacing: None,
hints: Vec::new(),
}))
}
#[derive(Deserialize)]
struct OpenRequest {
path: String,
}
async fn open_in_vscode(
Json(req): Json<OpenRequest>,
) -> Json<serde_json::Value> {
if req.path.contains("..") {
return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));
}
// Detect if path is a directory (reveal) or file (open)
let is_dir = std::path::Path::new(&req.path).is_dir();
let cmd = if is_dir { "reveal" } else { "open" };
let result = std::process::Command::new("adom-vscode")
.args([cmd, &req.path])
.output()
.or_else(|_| {
std::process::Command::new(
"/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh",
)
.args(["--reuse-window", &req.path])
.output()
});
match result {
Ok(output) if output.status.success() => {
Json(serde_json::json!({ "ok": true }))
}
Ok(output) => {
let err = String::from_utf8_lossy(&output.stderr);
Json(serde_json::json!({ "ok": false, "error": err.to_string() }))
}
Err(e) => {
Json(serde_json::json!({ "ok": false, "error": e.to_string() }))
}
}
}
async fn reveal_in_vscode(
Json(req): Json<OpenRequest>,
) -> Json<serde_json::Value> {
if req.path.contains("..") {
return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));
}
// Ensure directory exists before revealing
std::fs::create_dir_all(&req.path).ok();
let result = std::process::Command::new("adom-vscode")
.args(["reveal", &req.path])
.output();
match result {
Ok(output) if output.status.success() => {
Json(serde_json::json!({ "ok": true }))
}
Ok(output) => {
let err = String::from_utf8_lossy(&output.stderr);
Json(serde_json::json!({ "ok": false, "error": err.to_string() }))
}
Err(e) => {
Json(serde_json::json!({ "ok": false, "error": e.to_string() }))
}
}
}
async fn clear_log(
State(state): State<Arc<AppState>>,
Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
let channel = q.channel.unwrap_or_else(|| "default".into());
let count = state.store.clear_log(&channel).await;
Json(serde_json::json!({ "ok": true, "cleared": count }))
}
async fn clear_all(
State(state): State<Arc<AppState>>,
Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
let channel = q.channel.unwrap_or_else(|| "default".into());
let count = state.store.clear_all(&channel).await;
Json(serde_json::json!({ "ok": true, "cleared": count, "files_deleted": true }))
}
#[derive(Deserialize)]
struct DeleteQuery {
channel: Option<String>,
filename: Option<String>,
}
async fn delete_entry(
State(state): State<Arc<AppState>>,
Query(q): Query<DeleteQuery>,
) -> Json<serde_json::Value> {
let channel = q.channel.unwrap_or_else(|| "default".into());
let filename = match q.filename {
Some(f) => f,
None => return Json(serde_json::json!({ "ok": false, "error": "missing filename" })),
};
// Delete the PNG and JSON sidecar from disk
let channel_dir = state.store.base_dir().join(&channel);
let png_path = channel_dir.join(&filename);
let json_path = png_path.with_extension("json");
std::fs::remove_file(&png_path).ok();
std::fs::remove_file(&json_path).ok();
// Remove from in-memory entries
state.store.delete_entry(&channel, &filename).await;
Json(serde_json::json!({ "ok": true, "deleted": filename }))
}
// WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames
/// The single-window app's WebSocket: EVERY channel's new entries as
/// {type:"entry", channel, entry}. Presence registers under "_all" (an app
/// viewer watches every channel at once).
async fn ws_all_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| ws_all_connection(socket, state))
}
async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.store.all_tx.subscribe();
let mut ctrl_rx = state.store.ctrl_tx.subscribe();
let vid = state.next_viewer_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut conn_surface: String = "browser".into();
state.store.register_viewer("_all", vid, "browser".into(), true, None).await;
let _ = socket.send(Message::Text(
{
let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});
if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }
w.to_string().into()
}
)).await;
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok((channel, entry)) => {
let json = serde_json::json!({"type": "entry", "channel": channel, "entry": entry});
if socket.send(Message::Text(json.to_string().into())).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
ctrl = ctrl_rx.recv() => {
match ctrl {
Ok(frame) => {
if socket.send(Message::Text(frame.into())).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Text(t))) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {
match v.get("type").and_then(|x| x.as_str()) {
Some("hello") => {
let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();
let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
let session = v.get("session").and_then(|x| x.as_str()).map(String::from);
conn_surface = surface.clone();
state.store.register_viewer("_all", vid, surface, visible, session).await;
}
Some("vis") => {
let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
state.store.set_viewer_visibility("_all", vid, visible).await;
}
_ => {}
}
}
}
_ => {}
}
}
}
}
state.store.unregister_viewer("_all", vid).await;
// User-closed-pup detection for the SINGLE app window: sets an in-memory
// app-level note so the next force-open uses webview this session.
if conn_surface == "pup" {
let store = state.store.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
if store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") { return; }
let verdict: Option<bool> = tokio::task::spawn_blocking(move || {
let mut cmd = std::process::Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") { cmd.args(["--target", &t]); }
match cmd.args(["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {
Ok(o) => {
let out = String::from_utf8_lossy(&o.stdout).to_string();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) {
if let Some(tabs) = v.get("tabs").and_then(|t| t.as_array()) {
return Some(!tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())
.map(|u| u.contains("/log/")).unwrap_or(false)));
}
if out.contains("session_not_found") { return Some(true); }
}
None
}
Err(_) => None,
}
}).await.unwrap_or(None);
if verdict == Some(true) && !store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") {
store.set_canvas_pref("_app", "webview").await;
println!("{} DECISION shotlog pup window closed by the user - app note set to webview until restart.", ts());
}
});
}
}
async fn ws_handler(
ws: WebSocketUpgrade,
Path(channel): Path<String>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| ws_connection(socket, channel, state))
}
async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppState>) {
let mut rx = state.store.subscribe(&channel).await;
// Register this connection's presence. The client sends {type:'hello',surface}
// right after connecting; until then we record it as an unknown surface so the
// live-viewer COUNT is right immediately even before the label arrives.
let vid = state
.next_viewer_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut conn_surface: String = "browser".into();
state.store.register_viewer(&channel, vid, "browser".into(), true, None).await;
// Version handshake: stale pages reload themselves on mismatch.
let _ = socket.send(Message::Text(
{
let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});
if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }
w.to_string().into()
}
)).await;
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok(entry) => {
let json = serde_json::json!({
"type": "entry",
"entry": entry,
});
if socket.send(Message::Text(json.to_string().into())).await.is_err() {
break; // client disconnected
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Text(t))) => {
// {type:'hello', surface, visible} on connect; {type:'vis', visible}
// whenever the tab is shown/hidden (Page Visibility).
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {
match v.get("type").and_then(|x| x.as_str()) {
Some("hello") => {
let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();
let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
let session = v.get("session").and_then(|x| x.as_str()).map(String::from);
conn_surface = surface.clone();
state.store.register_viewer(&channel, vid, surface, visible, session).await;
}
Some("vis") => {
let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
state.store.set_viewer_visibility(&channel, vid, visible).await;
}
_ => {}
}
}
}
_ => {} // ignore other client messages
}
}
}
}
state.store.unregister_viewer(&channel, vid).await;
// USER-CLOSED-PUP DETECTION. A pup viewer vanishing can be a blip (reload,
// network, laptop sleep) or the user deliberately closing the tab/window.
// Grace-wait for a reconnect, then probe whether the tab still exists:
// gone + no reconnect = the user closed it = their canvas choice CHANGED.
// Revert the channel to webview so the next auto-open lands in the slim
// panel instead of shoving a fresh pup window at them. (Swap flows are
// immune: they re-point the pref BEFORE closing, so pref!=pup here.)
if conn_surface == "pup" {
let store = state.store.clone();
let ch = channel.clone();
tokio::spawn(async move {
if store.canvas_pref(&ch).await.as_deref() != Some("pup") {
return;
}
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
if store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {
return; // reconnected - just a blip
}
let ch2 = ch.clone();
// Three-way verdict - flip ONLY on DEFINITIVE evidence of a user close:
// Some(true) = AD reachable AND (session alive with the tab absent,
// OR session_not_found while AD is up) -> user closed it
// Some(false) = tab still there -> blip/reload
// None = AD offline / error / can't tell -> a REBOOT looks
// exactly like this; never mistake it for a choice.
let verdict: Option<bool> = tokio::task::spawn_blocking(move || {
let mut cmd = std::process::Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
match cmd.args(["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {
Ok(o) => {
let out = String::from_utf8_lossy(&o.stdout).to_string();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) {
if let Some(tabs) = v.get("tabs").and_then(|t| t.as_array()) {
return Some(!tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())
.map(|u| u.contains(&format!("/log/{ch2}/"))).unwrap_or(false)));
}
if out.contains("session_not_found") {
// AD answered; the whole pup window is gone while
// the laptop is up -> the user closed the window.
return Some(true);
}
}
None // no-clients / ambiguous / unparseable -> uncertain
}
Err(_) => None,
}
})
.await
.unwrap_or(None);
if verdict == Some(true) && !store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {
store.set_canvas_pref(&ch, "webview").await;
println!("{} DECISION pup tab for '{ch}' closed by the user - in-memory note set to webview until restart.", ts());
}
});
}
}
async fn serve_image(
State(state): State<Arc<AppState>>,
Path((channel, filename)): Path<(String, String)>,
) -> Response {
if channel.contains("..") || filename.contains("..") {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let file_path = state.store.base_dir().join(&channel).join(&filename);
match tokio::fs::read(&file_path).await {
Ok(bytes) => {
let mut res = bytes.into_response();
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("image/png"),
);
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
res
}
Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
}
}
#[derive(Serialize)]
struct HealthResponse {
ok: bool,
version: String,
port: u16,
data_dir: String,
channels: Vec<String>,
}
async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
Json(HealthResponse {
ok: true,
version: env!("CARGO_PKG_VERSION").to_string(),
port: state.port,
data_dir: state.store.base_dir().to_string_lossy().to_string(),
channels: state.store.channel_names().await,
})
}