app
Shotlog - Screenshot Log
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 really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.
← Commit history
Publish 0.13.0
10 files changed
+191−43
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.12.1"+version = "0.13.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.12.1"+version = "0.13.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.12.1+0.13.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+30@@ -108,6 +108,36 @@ The viewer is a real UI, held to `~/.claude/skills/adom/ui-implementation-refere - **Zoom uses the real estate**: `max-width: min(96vw, 2200px)`, image `max-height: 84vh` - never a fixed px cap (useless in both a skinny panel and a big pup window). +## HARD RULE: canvas switches are STAGED and TRACKED - never close blind++The webview<->pup switch (the header button) is a state machine, both directions:++1. `checking` - pup availability (canvas-check) before anything moves.+2. `opening <dest>` - POST /api/canvas-open opens the DESTINATION only.+3. `waiting for <dest>… Ns` - poll /api/viewer-status until the destination+ viewer's WebSocket CONNECTS (surface count rises above the pre-switch+ baseline). That connect is the ONLY acceptable "it's really open" signal -+ a tab existing is NOT enough (the page could be blank/dead).+4. Only after connect: `switched ✓` -> POST /api/canvas-close for the SOURCE.++Failure handling (all verified paths):+- open fails -> source stays, error toast with the broker's detail.+- destination never connects (30s) -> ROLLBACK: close only what we opened,+ source stays, precise toast (laptop asleep? Hydrogen closed?).+- broker unreachable / fetch throws -> nothing moves, restore button.+- destination already connected (dual-surface) -> instant confirm, then the+ source closes: the switch doubles as the dual-surface resolver.+- double-clicks: button disabled for the whole flow.++NEVER regress to fire-and-forget (open + close in parallel): a failed open+then closes the user's ONLY surface. Bit us; the user called it.++Why switches take seconds anyway: every pup verb is a cloud->relay->laptop+round-trip (~1s each) and a fresh Chrome window costs ~3-5s. Keep pup verbs+to the MINIMUM number of round-trips (branch on one list_tabs result; never+try-and-fallback chains, which serialize 3 trips). webview direction is local+Hydrogen = fast (~1-2s).+ ## HARD RULE: the header CSS is a SET - never clobber siblings We have rebuilt this header repeatedly. The recurring self-inflicted wound: editing
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.12.1",+ "version": "0.13.0", "type": "app", "title": "Adom Shotlog", "description": "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 really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.12.1",+ "version": "0.13.0", "type": "app", "title": "Adom Shotlog", "description": "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 really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
src/html/index.html+66−14@@ -611,25 +611,77 @@ header { : '<div class="tt-title">Show in webview</div>'+ '<div class="tt-p">The slim panel docked in Hydrogen. Closes this pup tab.</div>'+ '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in webview</div>';- const busy=(t)=>{ btn.disabled=true; btn.innerHTML='<span>'+t+'</span>'; };+ const busy=(t)=>{ btn.disabled=true; btn.innerHTML='<span>'+esc(t)+'</span>'; };+ const restore=()=>{ render(); btn.disabled=false; };+ const dest = toPup ? 'pup' : 'webview';+ const src = toPup ? 'webview' : 'pup';++ // Count how many viewers of a surface are currently connected.+ async function surfaceCount(){+ try { const st=await fetch('api/viewer-status?channel='+encodeURIComponent(channel)).then(r=>r.json());+ return (st.surfaces||[]).filter(x=>x===dest).length; } catch { return -1; }+ }++ // FULL TRACKED SWITCH. Stages: check -> open destination -> WAIT until the+ // destination viewer's WebSocket actually connects (the full signal) ->+ // only then close this (source) surface. Any failure leaves this surface+ // open and reports exactly what happened. Nothing is ever closed blind. btn.addEventListener('click', async ()=>{- busy('checking…');+ if(btn.disabled) return;+ btn.disabled=true;+ let opened=false; try{+ // Stage 0: availability (pup needs Adom Desktop). if(toPup){+ busy('checking pup…'); const chk=await fetch('api/canvas-check').then(r=>r.json());- if(!chk.available){- showToast(chk.hint||'pup is not available on this machine yet.', 9000);- render(); btn.disabled=false; return;- }+ if(!chk.available){ showToast(chk.hint||'pup is not available on this machine yet.', 9000); restore(); return; } }- busy(toPup?'popping out…':'docking…');- showToast(toPup ? 'Opening in pup — this tab will close.' : 'Docking to webview — this tab will close.', 5000);- const r=await fetch('api/canvas-switch',{method:'POST',- headers:{'Content-Type':'application/json'},- body:JSON.stringify({channel, to: toPup?'pup':'webview'})}).then(x=>x.json());- if(!r.ok){ showToast('switch failed: '+(r.error||r.detail||'unknown'), 7000); render(); btn.disabled=false; }- // on success this surface gets closed by the swap - nothing more to do here- }catch(e){ showToast('switch failed: '+e.message, 7000); render(); btn.disabled=false; }+ const baseline=await surfaceCount();+ if(baseline<0){ showToast('cannot reach the shotlog broker - switch aborted, nothing changed.', 7000); restore(); return; }++ // Stage 1: open the destination (nothing gets closed yet).+ busy('opening '+dest+'…');+ const op=await fetch('api/canvas-open',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({channel, to:dest})}).then(r=>r.json());+ if(!op.ok){+ showToast('could not open '+dest+': '+String(op.error||op.detail||'unknown').slice(0,300)+' - this tab stays open.', 10000);+ restore(); return;+ }+ opened=true;++ // Stage 2: wait for the REAL signal - the destination viewer connects+ // its WebSocket to the broker. Poll up to 30s, ticking progress.+ const T0=Date.now(), LIMIT=30000;+ let connected = baseline>0; // already watching there? instant confirm+ while(!connected && Date.now()-T0<LIMIT){+ busy('waiting for '+dest+'… '+Math.round((Date.now()-T0)/1000)+'s');+ await new Promise(r=>setTimeout(r,800));+ const n=await surfaceCount();+ if(n>baseline) connected=true;+ }+ if(!connected){+ // Rollback: destination never came up. Close ONLY what we just opened+ // (best-effort), keep this surface, tell the human precisely.+ busy('rolling back…');+ try{ await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({channel, surface:dest})}); }catch{}+ showToast(dest+' opened but never connected (waited 30s) - rolled back, this tab stays open. '+(toPup?'Is the pup window blocked or the laptop asleep?':'Is Hydrogen open?'), 12000);+ restore(); return;+ }++ // Stage 3: destination is LIVE. Now (and only now) close this surface.+ busy('switched ✓');+ showToast(dest+' is live - closing this '+src+' tab.', 4000);+ await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({channel, surface:src})}).catch(()=>{});+ // If this page is that tab, it is about to die - nothing more to do.+ // If the close missed (renamed tab etc.), re-enable so retry is possible.+ setTimeout(restore, 4000);+ }catch(e){+ showToast('switch failed: '+e.message+(opened?' ('+dest+' may have opened - check before retrying)':' - nothing changed.'), 9000);+ restore();+ } }); })();
src/open.rs+24−24@@ -188,35 +188,35 @@ pub fn open_pup(channel: &str, port: u16, swap: bool) { } }; - // One shotlog pup window for ALL channels, one tab PER channel (idempotent):- // if this channel's tab already exists, switch to it; else add a tab; else- // open the window.+ // One shotlog pup window for ALL channels, one tab PER channel (idempotent).+ // SPEED: each `ad` call is a cloud->laptop round-trip (~1s), so branch off+ // the ONE list_tabs result instead of try-and-fallback chains:+ // session missing -> open_window (2 trips total)+ // tab exists -> switch_tab (2 trips)+ // session, no tab -> open_tab (2 trips) let needle = format!("/log/{channel}/"); let (listed, list_out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]); let mut opened = false;- if listed {- if let Ok(v) = serde_json::from_str::<serde_json::Value>(&list_out) {- if let Some(tab) = v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| {+ if !listed || list_out.contains("session_not_found") {+ let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");+ opened = ad(&["browser_open_window", &win_json]).0;+ } else {+ let existing = serde_json::from_str::<serde_json::Value>(&list_out).ok()+ .and_then(|v| v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| { tabs.iter().find(|t| t.get("url").and_then(|u| u.as_str()).map(|u| u.contains(&needle)).unwrap_or(false))- }) {- if let Some(tid) = tab.get("tabId").or_else(|| tab.get("id")).and_then(|x| x.as_str()) {- let sw = format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}");- opened = ad(&["browser_switch_tab", &sw]).0;- }- }- }- }- if !opened {- let tab_json = format!("{{\"sessionId\":\"shotlog\",\"url\":\"{url}\"}}");- let (ok, out) = ad(&["browser_open_tab", &tab_json]);- opened = if ok {- true- } else if out.contains("session_not_found") || out.contains("not found") {- let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");- ad(&["browser_open_window", &win_json]).0+ .and_then(|t| t.get("tabId").or_else(|| t.get("id")).and_then(|x| x.as_str()).map(String::from))+ }));+ if let Some(tid) = existing {+ let sw = format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}");+ opened = ad(&["browser_switch_tab", &sw]).0; } else {- false- };+ let tab_json = format!("{{\"sessionId\":\"shotlog\",\"url\":\"{url}\"}}");+ let (ok, out) = ad(&["browser_open_tab", &tab_json]);+ opened = ok || (out.contains("session_not_found") && {+ let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");+ ad(&["browser_open_window", &win_json]).0+ });+ } } if !opened {
src/server.rs+66@@ -77,6 +77,8 @@ pub fn create_router(state: Arc<AppState>) -> Router { .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("/prompt", get(serve_prompt)) .route("/api/paste", post(post_paste)) .route("/api/open", post(open_in_vscode))@@ -249,6 +251,70 @@ struct CanvasSwitchRequest { to: String, } +#[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(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"}));+ }+ 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).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"}));+ 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(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 v = tokio::task::spawn_blocking(move || {+ if req.surface == "webview" {+ let ok = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {}", req.channel)])+ .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"}));+ 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`