← Commit history
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.&#10;While green the server knows you're watching and&#10;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.