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.14.0
11 files changed
+267−11
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.13.3"+version = "0.14.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.13.3"+version = "0.14.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.13.3+0.14.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
hooks/shotlog-on-read.sh+4@@ -11,6 +11,10 @@ # - Self-heals the broker from the canonical cwd if it is not running # Always exits 0 so it can never block a Read. +# User preference: the read-hook can be toggled OFF in the viewer's+# Preferences dialog (broker writes this flag file). No-op when disabled.+[ -f "$HOME/.claude/hooks/.shotlog-read-hook-off" ] && exit 0+ f="$(jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)" [ -z "$f" ] && exit 0
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.13.3",+ "version": "0.14.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.13.3",+ "version": "0.14.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+76@@ -277,6 +277,26 @@ header { text-decoration: none; margin-left: auto; } .helper-card .h-link:hover { text-decoration: underline; } +/* Preferences dialog widgets (inside the .helper shell) */+.pref-row { display: flex; align-items: center; gap: 14px; padding: 11px 0;+ border-top: 1px solid var(--border-muted); }+.pref-row:first-of-type { border-top: none; }+.pref-info { flex: 1; min-width: 0; }+.pref-name { font: 600 13px 'Familjen Grotesk', sans-serif; color: var(--text); }+.pref-desc { font: 12px/1.45 'Satoshi', sans-serif; color: var(--text2); margin-top: 2px; }+.toggle { -webkit-appearance: none; appearance: none; width: 40px; height: 22px; border-radius: 999px;+ background: var(--overlay); border: 1px solid var(--border); cursor: pointer; position: relative;+ flex-shrink: 0; transition: background .15s, border-color .15s; }+.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;+ border-radius: 50%; background: var(--text2); transition: transform .18s, background .18s; }+.toggle[aria-checked="true"] { background: rgba(0,184,176,.25); border-color: rgba(0,184,176,.6); }+.toggle[aria-checked="true"]::after { transform: translateX(18px); background: var(--accent-bright); }+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; }+.seg button { -webkit-appearance: none; appearance: none; border: none; background: var(--overlay);+ color: var(--text2); font: 600 12px 'Familjen Grotesk', sans-serif; padding: 6px 12px; cursor: pointer; }+.seg button + button { border-left: 1px solid var(--border); }+.seg button.on { background: rgba(0,184,176,.18); color: var(--accent-bright); }+ /* Paste progress overlay */ .paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none; align-items: center; justify-content: center; z-index: 150; }@@ -313,6 +333,7 @@ header { <div class="actions"> <button class="hdr-btn" id="canvasBtn" style="display:none"></button> <button class="hdr-btn icon-only" id="toTopBtn" data-tooltip="Scroll to newest (top)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg></button>+ <button class="hdr-btn icon-only" id="prefsBtn" data-tooltip="Preferences: the auto-capture read-hook, and whether channels default to webview or pup."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button> <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button> <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span> </div>@@ -342,6 +363,29 @@ header { </div> </div> </div>+<div class="helper" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Preferences">+ <div class="helper-card">+ <button class="h-close" id="prefsClose">✕</button>+ <div class="h-title">Preferences</div>+ <div class="pref-row">+ <div class="pref-info">+ <div class="pref-name">Auto-capture what AIs read</div>+ <div class="pref-desc">The read-hook copies every image any AI thread reads into the read-captures channel, so you always see what they saw. Turn off to stop auto-capturing (deliberate injects still work).</div>+ </div>+ <button class="toggle" id="prefHook" role="switch" aria-checked="true" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."></button>+ </div>+ <div class="pref-row">+ <div class="pref-info">+ <div class="pref-name">Default canvas</div>+ <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Pup needs Adom Desktop.</div>+ </div>+ <div class="seg" id="prefCanvas">+ <button data-v="webview">Webview</button>+ <button data-v="pup">Pup</button>+ </div>+ </div>+ </div>+</div> <div class="toast" id="toast"></div> <div class="modal" id="modal"><div class="modal-card" id="modalCard"> <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">@@ -787,6 +831,38 @@ header { }); })(); + // ── Preferences dialog: read-hook toggle + default canvas ─────────────────+ (function wirePrefs(){+ const dlg=$('prefsDlg'), hook=$('prefHook'), seg=$('prefCanvas'); if(!dlg) return;+ const close=()=>dlg.classList.remove('open');+ $('prefsClose').addEventListener('click',close);+ dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });+ document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });+ function renderPrefs(p){+ hook.setAttribute('aria-checked', String(!!p.read_hook));+ seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));+ }+ async function patch(body, okMsg){+ try{+ const r=await fetch('api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json());+ if(r.ok){ renderPrefs(r.prefs); showToast(okMsg, 3500); }+ else showToast('could not save: '+(r.error||'unknown'), 6000);+ }catch(e){ showToast('could not save preferences: '+e.message, 6000); }+ }+ hook.addEventListener('click',()=>{+ const now=hook.getAttribute('aria-checked')==='true';+ patch({read_hook: !now}, !now?'read-hook ON: images AIs read auto-capture again.':'read-hook OFF: nothing auto-captures until you turn it back on.');+ });+ seg.addEventListener('click',e=>{+ const b=e.target.closest('button[data-v]'); if(!b) return;+ patch({default_canvas: b.dataset.v}, 'channels now default to '+b.dataset.v+'.');+ });+ $('prefsBtn').addEventListener('click', async ()=>{+ try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}+ dlg.classList.add('open');+ });+ })();+ // ── WebSocket live updates ({type:'entry', entry}) ─────────────────────── let activeWs = null; // Gentle "you have unseen shots" cue: while this tab is backgrounded, count new
src/main.rs+7−1@@ -412,6 +412,10 @@ async fn main() { Commands::Open { channel, name, icon, port, skinny, ratio, pup, swap, } => {+ // Opening a canvas IS choosing it - tell the broker so force-open+ // (hook injects during presence blips) lands on the right canvas.+ let _ = http_post(port, "/api/canvas-pref",+ &serde_json::json!({"channel": channel, "canvas": if pup {"pup"} else {"webview"}})); if pup { open::open_pup(&channel, port, swap); } else {@@ -641,7 +645,9 @@ fn run_inject( Some("pup_flash") => ok(&format!( "Broker: flashing the pup taskbar orange (session{}) — the human's gentle cue is handled.", a.get("sessions").and_then(|s| s.as_array()).map(|v| format!(" {}", v.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))).unwrap_or_default())),- Some("force_open") => ok("Broker: nobody was watching — force-opening a slim 20% right-side webview now. Re-check with `shotlog viewers` before assuming the human sees it."),+ Some("force_open") => ok(&format!(+ "Broker: nobody was watching — force-opening this channel's chosen canvas ({}) now. Re-check with `shotlog viewers` before assuming the human sees it.",+ a.get("canvas").and_then(|c| c.as_str()).unwrap_or("webview"))), _ => {} } }
src/server.rs+94−5@@ -79,6 +79,9 @@ pub fn create_router(state: Arc<AppState>) -> Router { .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("/prompt", get(serve_prompt)) .route("/api/paste", post(post_paste)) .route("/api/open", post(open_in_vscode))@@ -166,6 +169,7 @@ async fn viewer_status( "visible": visible, "visible_surfaces": visible_surfaces, "pup_sessions": pup_sessions,+ "canvas_pref": state.store.canvas_pref(&channel).await, })) } @@ -251,6 +255,53 @@ struct CanvasSwitchRequest { to: String, } +#[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.canvas != "pup" && req.canvas != "webview" || req.channel.is_empty() {+ return Json(serde_json::json!({"ok": false, "error": "canvas must be pup|webview"}));+ }+ state.store.set_canvas_pref(&req.channel, &req.canvas).await;+ 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>,+}++/// 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"}));+ }+ p.default_canvas = c;+ }+ if let Some(h) = patch.read_hook {+ p.read_hook = h;+ }+ state.store.set_app_prefs(p.clone()).await;+ Json(serde_json::json!({"ok": true, "prefs": p}))+}+ #[derive(Deserialize)] struct CanvasOpenRequest { channel: String,@@ -262,13 +313,19 @@ struct CanvasOpenRequest { /// 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> {+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" };@@ -448,7 +505,7 @@ fn debounce_ok(key: &str, secs: u64) -> bool { /// 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]) {+fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str) { // 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).@@ -467,10 +524,37 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo]) return; } if infos.is_empty() && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() {- force_open_viewer(channel, port);+ // 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.+ if pref == "pup" {+ force_open_pup(channel);+ } else {+ force_open_viewer(channel, port);+ } } } +/// 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) {+ if !debounce_ok(&format!("openpup:{channel}"), 60) {+ return;+ }+ let channel = channel.to_string();+ std::thread::spawn(move || {+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());+ let ok = std::process::Command::new(exe)+ .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 {+ // pup unavailable right now - fall back so the shot isn't invisible.+ crate::open::open_webview(&channel, None, None, 8820, true, 0.2);+ }+ });+}+ /// 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) {@@ -679,6 +763,10 @@ async fn post_shots( s.into_iter().collect() }; let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err();+ let canvas_pref = match state.store.canvas_pref(&req.channel).await {+ Some(c) => c,+ None => state.store.app_prefs().await.default_canvas,+ }; let mut actions: Vec<serde_json::Value> = Vec::new(); if !pup_sessions.is_empty() { actions.push(serde_json::json!({"action":"pup_flash","sessions":pup_sessions,"cue":"orange taskbar flash"}));@@ -686,10 +774,11 @@ async fn post_shots( if has_webview { actions.push(serde_json::json!({"action":"webview_surface","tab":format!("Shots: {}", req.channel),"cue":"tab pulled to front + orange alert"})); } else if infos.is_empty() && force_open_on {- actions.push(serde_json::json!({"action":"force_open","layout":"slim 20% right-side webview"}));+ 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}));- notify_surface(&req.channel, state.port, &infos);+ notify_surface(&req.channel, state.port, &infos, &canvas_pref); // Progressive mini-skill hints — taught at the moment they're relevant, then // quiet (hint_gate caps how many times each lesson repeats).
src/store.rs+81@@ -74,6 +74,32 @@ fn delete_entry_files(entry: &Entry) { const MAX_ENTRIES: usize = 200; // legacy const; runtime cap comes from max_shots() +/// Global user preferences, persisted to .prefs.json in the data dir and+/// editable from the viewer's Preferences dialog.+#[derive(Clone, Serialize, Deserialize)]+pub struct AppPrefs {+ /// Where force-open lands when nobody is watching and the channel has no+ /// canvas preference of its own: "webview" | "pup".+ #[serde(default = "default_canvas")]+ pub default_canvas: String,+ /// The read-hook (auto-capture of every image any AI Reads). When false,+ /// the broker drops a flag file the hook script checks, so the hook+ /// becomes a no-op without touching hook registration.+ #[serde(default = "default_true")]+ pub read_hook: bool,+}+fn default_canvas() -> String { "webview".into() }+fn default_true() -> bool { true }+impl Default for AppPrefs {+ fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true } }+}++/// The flag file the read-hook script checks; present = hook disabled.+pub fn read_hook_off_flag() -> PathBuf {+ PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into()))+ .join(".claude/hooks/.shotlog-read-hook-off")+}+ /// One connected viewer: which surface it's on, and whether that surface is /// actually on-screen (Page Visibility) vs a background tab / minimized window. #[derive(Clone)]@@ -91,6 +117,13 @@ pub struct Store { /// Live viewer presence: channel -> (connection id -> viewer info). /// Surface is what the viewer reported on connect: webview | pup | mobile | browser. presence: RwLock<HashMap<String, HashMap<u64, ViewerInfo>>>,+ /// The user's CHOSEN canvas per channel ("webview" | "pup"), recorded on+ /// every open/switch and persisted to .canvas-prefs.json. Presence is+ /// instantaneous (a pup WS blip looks like "nobody watching"), so force-open+ /// must consult THIS, not just presence - or it reopens the wrong canvas+ /// and steamrolls the user's choice.+ canvas_prefs: RwLock<HashMap<String, String>>,+ app_prefs: RwLock<AppPrefs>, } impl Store {@@ -98,10 +131,24 @@ impl Store { fs::create_dir_all(&base_dir).ok(); // Canonicalize so all paths are absolute let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);+ let prefs: HashMap<String, String> = fs::read_to_string(base_dir.join(".canvas-prefs.json"))+ .ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_default();+ let app_prefs: AppPrefs = fs::read_to_string(base_dir.join(".prefs.json"))+ .ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_default();+ // Reconcile the hook flag file with the persisted pref at startup.+ let flag = read_hook_off_flag();+ if app_prefs.read_hook { let _ = fs::remove_file(&flag); }+ else { let _ = fs::write(&flag, "disabled via shotlog Preferences\n"); } let store = Arc::new(Self { channels: RwLock::new(HashMap::new()), base_dir, presence: RwLock::new(HashMap::new()),+ canvas_prefs: RwLock::new(prefs),+ app_prefs: RwLock::new(app_prefs), }); store }@@ -460,6 +507,40 @@ impl Store { &self.base_dir } + /// Record the user's chosen canvas for a channel; persisted across restarts.+ pub async fn set_canvas_pref(&self, channel: &str, canvas: &str) {+ let mut m = self.canvas_prefs.write().await;+ if m.get(channel).map(|c| c == canvas).unwrap_or(false) {+ return;+ }+ m.insert(channel.to_string(), canvas.to_string());+ if let Ok(j) = serde_json::to_string_pretty(&*m) {+ let _ = fs::write(self.base_dir.join(".canvas-prefs.json"), j);+ }+ }++ pub async fn canvas_pref(&self, channel: &str) -> Option<String> {+ self.canvas_prefs.read().await.get(channel).cloned()+ }++ pub async fn app_prefs(&self) -> AppPrefs {+ self.app_prefs.read().await.clone()+ }++ /// Update prefs, persist, and sync the read-hook flag file.+ pub async fn set_app_prefs(&self, p: AppPrefs) {+ let flag = read_hook_off_flag();+ if p.read_hook { let _ = fs::remove_file(&flag); }+ else {+ if let Some(dir) = flag.parent() { let _ = fs::create_dir_all(dir); }+ let _ = fs::write(&flag, "disabled via shotlog Preferences\n");+ }+ if let Ok(j) = serde_json::to_string_pretty(&p) {+ let _ = fs::write(self.base_dir.join(".prefs.json"), j);+ }+ *self.app_prefs.write().await = p;+ }+ /// Prune whole channels idle longer than SHOTLOG_RETENTION_DAYS (default 30): /// delete the channel directory and drop it from the in-memory store. /// Returns the pruned channel names. Runs at startup and every 6 hours.