← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.24.2"+version = "0.24.3" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.24.2"+version = "0.24.3" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.24.2+0.24.3
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+17
@@ -329,3 +329,20 @@ hook needed. Rules that make it work on EVERY surface with one code path:   img object-fit:contain, max 100%. Never set fixed px - let HD size the 90%. Verified in pup (real fullscreen: document.fullscreenElement===#lightbox) and via the fallback overlay; Esc layers (exit fill, then close modal).++## Version awareness: header badge + self-heal + self-update hint (2026-07-23)+shotlog is ONE Rust binary = CLI + axum server (HTML via include_str!). The+BROKER (long-lived) calls home to the wiki, not the short-lived CLI:+- spawn_latest_poller(): curl the wiki manifest every 30 min -> latest_cell.+  SHOTLOG_FAKE_LATEST=<ver> pins it (test/ops seam, skips the network).+- GET /api/version -> {stamp, version, latest, update_available, update_command}.+- Header .ver-pill shows the running semver; amber when the broker is behind the+  wiki latest (a reload can't fix that - the binary must be updated).+- The viewer POLLS /api/version every 60s: if broker `stamp` != the page's+  embedded APP_VERSION, it hard-reloads with a cache-busting `_v` param. This is+  the fix for HD's WebView2 serving a STALE CACHED page when the WS handshake+  doesn't fire - the plain location.reload() the WS path used can be served from+  cache; changing the URL forces a fresh fetch. Guarded once per stamp (no loop).+- Every inject reply carries a gated self-update Hint -> `adom-wiki pkg update+  adom/adom-shotlog` then restart serve. So the AI's most common command nudges+  the update without any per-call network latency (broker already knows latest).
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.24.2",+  "version": "0.24.3",   "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.24.2",+  "version": "0.24.3",   "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+54−5
@@ -124,6 +124,13 @@ header { .hdr-btn.danger { color: var(--text2); } .hdr-btn.danger:hover { color: var(--danger); background: rgba(248,81,73,.09); border-color: rgba(248,81,73,.45); } .hdr-btn.danger.arm { color: #fff; background: var(--danger); border-color: var(--danger); }+/* Version pill: always shows what's RUNNING. Amber when a newer shotlog is+   published on the wiki (broker behind). Clicking it forces a fresh reload. */+.ver-pill { flex-shrink: 0; font: 600 10px/1 'JetBrains Mono', monospace; color: var(--text3);+  border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; cursor: pointer;+  white-space: nowrap; transition: color .15s, border-color .15s, background .15s; }+.ver-pill:hover { color: var(--text); border-color: rgba(0,184,176,.5); }+.ver-pill.stale { color: #e3b341; border-color: rgba(227,179,65,.55); background: rgba(227,179,65,.10); } .ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; margin-left: 2px;   background: var(--text3); transition: background .4s, box-shadow .4s; } .ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }@@ -476,6 +483,7 @@ header {     <button class="hdr-btn icon-only" id="filterBtn" data-tooltip="Filter shots: child-window arrays, capture endpoint, safe/full pairs, direct vs auto-captured."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></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="ver-pill" id="verPill" tabindex="0" data-tooltip="Loading version…">v…</span>     <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>   <div class="hover-popover" id="app-info" role="dialog">@@ -624,7 +632,18 @@ header { <script> (function() {   const $ = id => document.getElementById(id);-  const APP_VERSION='__SHOTLOG_VERSION__'; // stamped by the server at serve time+  const APP_VERSION='__SHOTLOG_VERSION__'; // stamped by the server at serve time (semver+bootepoch)+  const verSemver = s => String(s).split('+')[0];   // "0.24.3+123" -> "0.24.3"+  // Cache-busting reload: some hosts (HD's WebView2) serve a CACHED page on a+  // plain location.reload(), so we change the URL to force a fresh fetch.+  // Guarded once per target stamp so it can never loop.+  function hardReload(forStamp){+    const key='slReloaded-'+forStamp;+    if(sessionStorage.getItem(key)) return;+    sessionStorage.setItem(key,'1');+    try { const u=new URL(location.href); u.searchParams.set('_v', forStamp); location.replace(u.toString()); }+    catch { location.reload(); }+  }   const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');   const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');   const mPopups = $('mPopups'), mNerd = $('mNerd');@@ -1438,10 +1457,7 @@ header {               body:JSON.stringify({text:m.notice}),keepalive:true}).catch(()=>{});             else showToast(m.notice, 12000);           }-          if (stale) {-            sessionStorage.setItem('slReloaded-' + m.version, '1');-            location.reload();-          }+          if (stale) hardReload(m.version);   // cache-busting, guarded once/stamp         }       } catch {}     });@@ -1513,6 +1529,39 @@ header {     });   })(); +  // ── Version badge + "am I the latest?" self-heal ─────────────────────────+  // The header pill ALWAYS shows what's RUNNING (from the embedded stamp, so it+  // works even offline). Then we poll the broker's /api/version, which is the+  // process that CALLS HOME to the wiki:+  //   • broker redeployed since this page loaded  -> cache-busting self-reload+  //     (this is what fixes HD's WebView2 serving a stale cached page)+  //   • wiki has a newer shotlog than the broker   -> pill turns amber (the+  //     broker binary is behind; a reload can't fix that, an AI must update it)+  (function versionWatch(){+    const pill=$('verPill'); if(!pill) return;+    let updCmd='adom-wiki pkg update adom/adom-shotlog';+    const paint=(running, latest, upd)=>{+      pill.textContent='v'+verSemver(running);+      if(upd && latest){ pill.classList.add('stale');+        pill.setAttribute('data-tooltip','Running v'+verSemver(running)+' — a newer shotlog (v'+verSemver(latest)+') is published on the wiki.\nThe broker binary is behind; ask an AI to update it:\n'+updCmd+'\n(then restart the broker). Click to force a fresh reload.'); }+      else { pill.classList.remove('stale');+        pill.setAttribute('data-tooltip','shotlog v'+verSemver(running)+' — running the latest.\nClick to force a fresh reload.'); }+    };+    if(APP_VERSION.indexOf('__')!==0) paint(APP_VERSION,null,false);+    // Clicking always forces a fresh (cache-busting) reload.+    pill.addEventListener('click',()=>{ const u=new URL(location.href); u.searchParams.set('_cb', String(Date.now())); location.replace(u.toString()); });+    async function poll(){+      try{+        const r=await fetch('api/version?_='+Date.now(),{cache:'no-store'}).then(x=>x.json());+        if(r.update_command) updCmd=r.update_command;+        // Self-heal: broker was redeployed since this page loaded -> reload fresh.+        if(r.stamp && APP_VERSION.indexOf('__')!==0 && r.stamp!==APP_VERSION){ hardReload(r.stamp); return; }+        paint(r.version||APP_VERSION, r.latest, r.update_available);+      }catch{}+    }+    poll(); setInterval(poll, 60000);+  })();+   // ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────   const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');   document.body.addEventListener('click',()=>document.body.focus());
src/main.rs+4
@@ -551,6 +551,10 @@ async fn run_server(port: u16, data_dir: PathBuf) {     // every launch so the pipeline can never silently rot.     ensure_read_hook(); +    // Call home to the wiki so the viewer badge and inject hints can tell the+    // human/AI when a newer shotlog is available.+    server::spawn_latest_poller();+     let state = Arc::new(server::AppState { store, port, next_viewer_id: std::sync::atomic::AtomicU64::new(1) });     let app = server::create_router(state); 
src/server.rs+71
@@ -31,6 +31,66 @@ fn boot_stamp() -> &'static str {         format!("{}+{}", env!("CARGO_PKG_VERSION"), epoch)     }) }++// ── "Am I the latest?" — the broker calls HOME to the wiki manifest ──────────+// The broker is the long-lived process, so it (not the short-lived CLI) does+// the network check: it learns the latest published version from the wiki and+// exposes it. The web app polls /api/version to self-heal and show a badge;+// every `inject` reply carries an update hint so the AI can update itself.+fn running_version() -> &'static str { env!("CARGO_PKG_VERSION") }+fn latest_cell() -> &'static std::sync::Mutex<Option<String>> {+    static CELL: std::sync::OnceLock<std::sync::Mutex<Option<String>>> = std::sync::OnceLock::new();+    CELL.get_or_init(|| std::sync::Mutex::new(None))+}+fn semver_tuple(s: &str) -> (u64, u64, u64) {+    let mut it = s.trim().trim_start_matches('v').split('.')+        .map(|p| p.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("0").parse::<u64>().unwrap_or(0));+    (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))+}+/// Returns Some(latest) if the wiki has a newer version than we're running.+fn update_available() -> Option<String> {+    let latest = latest_cell().lock().unwrap().clone()?;+    if semver_tuple(&latest) > semver_tuple(running_version()) { Some(latest) } else { None }+}+fn update_command() -> &'static str { "adom-wiki pkg update adom/adom-shotlog" }+/// Poll the wiki manifest for the latest published version (shell to curl to+/// avoid a TLS dependency). Fires once now, then every 30 min; failures are+/// non-fatal and leave the cache untouched.+pub fn spawn_latest_poller() {+    // Test/ops seam: pin the "latest" the broker believes in, skipping the+    // network check. Lets us prove the update-available paths deterministically.+    if let Ok(fake) = std::env::var("SHOTLOG_FAKE_LATEST") {+        if !fake.is_empty() { *latest_cell().lock().unwrap() = Some(fake); return; }+    }+    std::thread::spawn(|| loop {+        if let Ok(out) = std::process::Command::new("curl")+            .args(["-s", "--max-time", "10",+                   "https://wiki.adom.inc/api/packages/adom/adom-shotlog/manifest"])+            .output()+        {+            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {+                if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {+                    *latest_cell().lock().unwrap() = Some(ver.to_string());+                    if let Some(newer) = update_available() {+                        println!("{} NOTICE update available: wiki has {newer}, running {}. `{}` then restart `adom-shotlog serve`.",+                                 ts(), running_version(), update_command());+                    }+                }+            }+        }+        std::thread::sleep(std::time::Duration::from_secs(1800));+    });+}+async fn get_version() -> Json<serde_json::Value> {+    let latest = latest_cell().lock().unwrap().clone();+    Json(serde_json::json!({+        "stamp": boot_stamp(),                 // page compares this to its embedded stamp (self-heal)+        "version": running_version(),          // the running semver+        "latest": latest,                      // latest published on the wiki (null until fetched)+        "update_available": update_available().is_some(),+        "update_command": update_command(),+    }))+} // 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");@@ -124,6 +184,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {         .route("/api/log", post(post_log))         .route("/api/notice", post(post_notice))         .route("/api/show", post(post_show))+        .route("/api/version", get(get_version))         .route("/prompt", get(serve_prompt))         .route("/api/paste", post(post_paste))         .route("/api/open", post(open_in_vscode))@@ -1173,6 +1234,16 @@ async fn post_shots(             ch = req.channel));     } +    // Self-update nudge: if the wiki has a newer shotlog, tell the AI how to+    // update itself. Gated so a burst of injects doesn't spam it.+    if let Some(newer) = update_available() {+        if hint_gate(&format!("update:{newer}"), 3) {+            hints.push(format!(+                "mini-skill [self-update]: a newer shotlog ({newer}) is published; this broker runs {}. Update it: `{}`, then restart the broker (kill the `adom-shotlog serve` pid, start `adom-shotlog serve` again). The viewer shows the version in its header.",+                running_version(), update_command()));+        }+    }+     Ok(Json(InjectResponse {         ok: true,         id: entry.id,