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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use crate::image::{read_png_dimensions, read_png_meta, PngMeta};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub id: u64,
pub timestamp: DateTime<Utc>,
pub filename: String,
pub description: String,
pub w: u32,
pub h: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub orig_w: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orig_h: Option<u32>,
pub bytes: u64,
pub kb: u64,
pub source: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub png_meta: Option<PngMeta>,
/// Free-form capture metadata (e.g. Adom Desktop coordMap, dpiScale,
/// screenRect, screen resolution). Additive — absent for plain injects.
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
/// Owned-popup screenshots attached to this shot (autofill dropdowns,
/// confirm bubbles, native dialogs). Each carries its own filename +
/// coordMap. Additive — absent for plain single-image injects.
#[serde(skip_serializing_if = "Option::is_none")]
pub popups: Option<serde_json::Value>,
/// Which AI THREAD produced this shot (from the read-hook's session
/// metadata, or any injector passing --thread). Human-readable title.
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
/// WHY the shot exists: the user's prompt the thread was serving when it
/// captured/read this image (extracted programmatically from the session
/// transcript's last-prompt record - zero AI tokens).
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
/// Filename of the stored FULL-RESOLUTION copy ({stem}__full.png) when the
/// injector provided the original (safe/full pairs). Viewable via the zoom.
#[serde(skip_serializing_if = "Option::is_none")]
pub full: Option<String>,
}
struct Channel {
entries: Vec<Entry>,
next_id: u64,
tx: broadcast::Sender<Entry>,
}
/// In-memory AND on-disk cap of shots per channel (SHOTLOG_MAX_SHOTS, default 200).
/// When a channel exceeds it, the oldest shot is dropped AND its files deleted -
/// without this the disk grew unbounded (3.4 GB before retention existed).
#[allow(dead_code)]
fn max_shots() -> usize {
std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
}
/// Days of inactivity before a whole channel is pruned (SHOTLOG_RETENTION_DAYS,
/// default 30; 0 disables the sweep).
#[allow(dead_code)]
fn retention_days() -> u64 {
std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok()).unwrap_or(30)
}
/// Delete a shot's files: the PNG, its JSON sidecar, and any child-window PNGs.
fn delete_entry_files(entry: &Entry) {
let p = std::path::Path::new(&entry.path);
let _ = fs::remove_file(p);
let _ = fs::remove_file(p.with_extension("json"));
if let (Some(f), Some(dir)) = (&entry.full, p.parent()) {
let _ = fs::remove_file(dir.join(f));
}
if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) {
if let Some(dir) = p.parent() {
for c in arr {
if let Some(f) = c.get("filename").and_then(|x| x.as_str()) {
let _ = fs::remove_file(dir.join(f));
}
}
}
}
}
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,
/// Janitor: newest N shots kept per channel on disk (oldest deleted as new
/// ones arrive). Env SHOTLOG_MAX_SHOTS overrides when set.
#[serde(default = "default_max_shots")]
pub max_shots: usize,
/// Janitor: prune whole channels idle this many days (0 = never).
/// Env SHOTLOG_RETENTION_DAYS overrides when set.
#[serde(default = "default_retention_days")]
pub retention_days: u64,
/// Orange update cues when NEW SHOTS arrive: the webview tab highlight and
/// the pup taskbar flash. Off = still pulls the tab forward, just no flash.
#[serde(default = "default_true")]
pub update_alerts: bool,
/// Width of the skinny webview panel as a percent of the workspace (10-50).
#[serde(default = "default_webview_width")]
pub webview_width: u8,
/// In the single-window viewer: automatically switch to the channel tab
/// that just received a shot (vs sit where you are with a badge + flash).
#[serde(default = "default_true")]
pub update_autoswitch: bool,
}
fn default_canvas() -> String { "webview".into() }
fn default_true() -> bool { true }
fn default_max_shots() -> usize { 200 }
fn default_retention_days() -> u64 { 30 }
fn default_webview_width() -> u8 { 20 }
impl Default for AppPrefs {
fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true,
max_shots: default_max_shots(), retention_days: default_retention_days(),
update_alerts: true, webview_width: default_webview_width(),
update_autoswitch: 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)]
pub struct ViewerInfo {
pub surface: String,
pub visible: bool,
/// Optional host handle the viewer reported (e.g. the pup sessionId from
/// ?session=... in the viewer URL) so the broker can cue that exact window.
pub session: Option<String>,
}
pub struct Store {
channels: RwLock<HashMap<String, Channel>>,
/// Global feed: every new entry from EVERY channel, for the single-window
/// multi-tab viewer (/ws/_all).
pub all_tx: broadcast::Sender<(String, Entry)>,
/// Control frames for the app (raw JSON strings), e.g. {type:"show",channel}
/// - the consolidation path: switch the existing window's internal tab
/// instead of spawning another surface.
pub ctrl_tx: broadcast::Sender<String>,
base_dir: PathBuf,
/// 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 {
pub fn new(base_dir: PathBuf) -> Arc<Self> {
fs::create_dir_all(&base_dir).ok();
// Canonicalize so all paths are absolute
let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);
// Canvas notes are IN-MEMORY ONLY (user's explicit clicks this session);
// every channel otherwise follows the global default. Remove the legacy
// persisted file so old recorded "choices" can never resurrect.
let _ = fs::remove_file(base_dir.join(".canvas-prefs.json"));
let prefs: HashMap<String, String> = HashMap::new();
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 (all_tx, _) = broadcast::channel(256);
let (ctrl_tx, _) = broadcast::channel(64);
let store = Arc::new(Self {
channels: RwLock::new(HashMap::new()),
all_tx,
ctrl_tx,
base_dir,
presence: RwLock::new(HashMap::new()),
canvas_prefs: RwLock::new(prefs),
app_prefs: RwLock::new(app_prefs),
});
store
}
/// Recover existing screenshots from disk on startup.
pub async fn recover(&self) {
let entries_dir = &self.base_dir;
let Ok(read_dir) = fs::read_dir(entries_dir) else {
return;
};
for dir_entry in read_dir.flatten() {
if !dir_entry.path().is_dir() {
continue;
}
let channel_name = dir_entry.file_name().to_string_lossy().to_string();
let channel_dir = dir_entry.path();
let mut files: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();
if let Ok(inner) = fs::read_dir(&channel_dir) {
for f in inner.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) == Some("png") {
// Skip owned-popup attachment PNGs ({stem}__popupN.png) — they
// belong to a primary shot's sidecar, not a standalone entry.
if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup") || n.contains("__full")).unwrap_or(false) {
continue;
}
if let Ok(meta) = fs::metadata(&p) {
files.push((p, meta));
}
}
}
}
// Sort by modification time ascending
files.sort_by_key(|(_, m)| m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH));
let (tx, _) = broadcast::channel(64);
let mut entries = Vec::new();
let mut next_id = 1u64;
for (path, meta) in &files {
let bytes_raw = fs::read(path).unwrap_or_default();
let (w, h) = read_png_dimensions(&bytes_raw);
let file_bytes = meta.len();
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let modified: DateTime<Utc> = meta
.modified()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.into();
// Read sidecar metadata if it exists
let meta_path = path.with_extension("json");
let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context, full) = if let Ok(json_str) = fs::read_to_string(&meta_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {
let desc = v["description"].as_str().unwrap_or("").to_string();
let src = v["source"].as_str().unwrap_or("recovered").to_string();
let ts = v["timestamp"].as_str()
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
.unwrap_or(modified);
let ow = v["orig_w"].as_u64().map(|n| n as u32);
let oh = v["orig_h"].as_u64().map(|n| n as u32);
let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };
let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };
let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);
let cx = v.get("context").and_then(|t| t.as_str()).map(String::from);
let fl = v.get("full").and_then(|t| t.as_str()).map(String::from);
(desc, src, ts, ow, oh, cm, pp, th, cx, fl)
} else {
(String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)
}
} else {
(String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)
};
let png_meta = read_png_meta(&bytes_raw);
entries.push(Entry {
id: next_id,
timestamp,
filename: filename.clone(),
description,
w,
h,
orig_w,
orig_h,
bytes: file_bytes,
kb: file_bytes / 1024,
source,
path: path.to_string_lossy().to_string(),
png_meta,
meta: cap_meta,
popups,
thread,
context,
full,
});
next_id += 1;
}
// Keep only the newest max_shots() - and DELETE the older shots'
// files, which previously lingered on disk forever.
let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200);
if entries.len() > cap {
let trimmed: Vec<Entry> = entries.drain(..entries.len() - cap).collect();
for e in &trimmed { delete_entry_files(e); }
}
let mut channels = self.channels.write().await;
channels.insert(
channel_name,
Channel {
entries,
next_id,
tx,
},
);
}
}
/// Add a new screenshot entry. Saves to disk and broadcasts via SSE.
#[allow(clippy::too_many_arguments)]
pub async fn add_entry(
&self,
channel_name: &str,
image_bytes: Vec<u8>,
filename: String,
description: String,
source: String,
w: u32,
h: u32,
orig_w: Option<u32>,
orig_h: Option<u32>,
meta: Option<serde_json::Value>,
popups: Option<serde_json::Value>,
thread: Option<String>,
context: Option<String>,
orig_path: Option<String>,
) -> Entry {
let channel_dir = self.base_dir.join(channel_name);
fs::create_dir_all(&channel_dir).ok();
let file_path = channel_dir.join(&filename);
fs::write(&file_path, &image_bytes).ok();
// Safe/full pairs: copy the ORIGINAL next to the stored (safe) shot so
// the full resolution stays viewable even after the source cache is
// janitored elsewhere. Skipped when the original IS the injected file.
let full = orig_path.as_deref().and_then(|op| {
let ob = fs::read(op).ok()?;
if ob == image_bytes { return None; }
let fname = format!("{}__full.png", filename.trim_end_matches(".png"));
fs::write(channel_dir.join(&fname), &ob).ok()?;
Some(fname)
});
let file_bytes = image_bytes.len() as u64;
let png_meta = read_png_meta(&image_bytes);
// Save metadata sidecar JSON
let meta_path = file_path.with_extension("json");
let mut meta_json = serde_json::json!({
"description": description,
"source": source,
"w": w, "h": h,
"bytes": file_bytes,
"timestamp": Utc::now().to_rfc3339(),
});
if let (Some(ow), Some(oh)) = (orig_w, orig_h) {
meta_json["orig_w"] = serde_json::json!(ow);
meta_json["orig_h"] = serde_json::json!(oh);
}
if let Some(m) = &meta { meta_json["meta"] = m.clone(); }
if let Some(p) = &popups { meta_json["popups"] = p.clone(); }
if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); }
if let Some(c) = &context { meta_json["context"] = serde_json::json!(c); }
if let Some(f) = &full { meta_json["full"] = serde_json::json!(f); }
fs::write(&meta_path, meta_json.to_string()).ok();
let mut channels = self.channels.write().await;
let ch = channels
.entry(channel_name.to_string())
.or_insert_with(|| {
let (tx, _) = broadcast::channel(64);
Channel {
entries: Vec::new(),
next_id: 1,
tx,
}
});
let entry = Entry {
id: ch.next_id,
timestamp: Utc::now(),
filename,
description,
w,
h,
orig_w,
orig_h,
bytes: file_bytes,
kb: file_bytes / 1024,
source,
path: file_path.to_string_lossy().to_string(),
png_meta,
meta,
popups,
thread,
context,
full,
};
ch.next_id += 1;
ch.entries.push(entry.clone());
let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())
.unwrap_or_else(|| { // prefs read is async; channels lock is held - use try_read
self.app_prefs.try_read().map(|p| p.max_shots).unwrap_or(200)
});
while ch.entries.len() > cap {
let old = ch.entries.remove(0);
delete_entry_files(&old);
}
// Broadcast to SSE listeners (ignore errors — no receivers is fine)
let _ = ch.tx.send(entry.clone());
let _ = self.all_tx.send((channel_name.to_string(), entry.clone()));
entry
}
/// Get all entries for a channel.
pub async fn get_entries(&self, channel_name: &str) -> Vec<Entry> {
let channels = self.channels.read().await;
channels
.get(channel_name)
.map(|ch| ch.entries.clone())
.unwrap_or_default()
}
/// Number of live viewers (WebSocket subscribers) for a channel.
pub async fn viewer_count(&self, channel_name: &str) -> usize {
let channels = self.channels.read().await;
channels.get(channel_name).map(|ch| ch.tx.receiver_count()).unwrap_or(0)
}
/// Register (or update) a connected viewer with the surface it reported
/// (webview/pup/mobile/browser) and whether it's currently on-screen.
pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String, visible: bool, session: Option<String>) {
let mut p = self.presence.write().await;
p.entry(channel_name.to_string())
.or_default()
.insert(id, ViewerInfo { surface, visible, session });
}
/// Update just the on-screen (Page Visibility) state of an existing viewer.
pub async fn set_viewer_visibility(&self, channel_name: &str, id: u64, visible: bool) {
let mut p = self.presence.write().await;
if let Some(v) = p.get_mut(channel_name).and_then(|m| m.get_mut(&id)) {
v.visible = visible;
}
}
/// Drop a viewer on disconnect.
pub async fn unregister_viewer(&self, channel_name: &str, id: u64) {
let mut p = self.presence.write().await;
if let Some(m) = p.get_mut(channel_name) {
m.remove(&id);
if m.is_empty() { p.remove(channel_name); }
}
}
/// Full presence for a channel — one entry per connected viewer.
/// Presence for a channel INCLUDING single-window app viewers (registered
/// under "_all"), who watch every channel at once.
pub async fn viewer_infos(&self, channel_name: &str) -> Vec<ViewerInfo> {
let p = self.presence.read().await;
let mut v: Vec<ViewerInfo> = p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default();
if channel_name != "_all" {
if let Some(m) = p.get("_all") {
v.extend(m.values().cloned());
}
}
v
}
/// Surfaces of the live viewers for a channel, e.g. ["pup","mobile"].
pub async fn viewer_surfaces(&self, channel_name: &str) -> Vec<String> {
let p = self.presence.read().await;
p.get(channel_name).map(|m| m.values().map(|v| v.surface.clone()).collect()).unwrap_or_default()
}
/// Get a broadcast receiver for a channel's SSE stream.
pub async fn subscribe(&self, channel_name: &str) -> broadcast::Receiver<Entry> {
let mut channels = self.channels.write().await;
let ch = channels
.entry(channel_name.to_string())
.or_insert_with(|| {
let (tx, _) = broadcast::channel(64);
Channel {
entries: Vec::new(),
next_id: 1,
tx,
}
});
ch.tx.subscribe()
}
/// Update the description of an existing entry and broadcast the update.
pub async fn update_description(&self, channel_name: &str, entry_id: u64, description: String) {
let mut channels = self.channels.write().await;
if let Some(ch) = channels.get_mut(channel_name) {
if let Some(entry) = ch.entries.iter_mut().find(|e| e.id == entry_id) {
entry.description = description.clone();
// Update the sidecar JSON on disk
let meta_path = std::path::Path::new(&entry.path).with_extension("json");
if let Ok(json_str) = fs::read_to_string(&meta_path) {
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&json_str) {
v["description"] = serde_json::json!(description);
v["described_by"] = serde_json::json!("claude -p \"...\" --model haiku --image <path>");
v["described_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
fs::write(&meta_path, v.to_string()).ok();
}
}
let _ = ch.tx.send(entry.clone());
}
}
}
/// Delete a single entry by filename.
pub async fn delete_entry(&self, channel_name: &str, filename: &str) {
let mut channels = self.channels.write().await;
if let Some(ch) = channels.get_mut(channel_name) {
ch.entries.retain(|e| e.filename != filename);
}
}
/// Clear in-memory entries for a channel (log only, files stay on disk).
pub async fn clear_log(&self, channel_name: &str) -> usize {
let mut channels = self.channels.write().await;
if let Some(ch) = channels.get_mut(channel_name) {
let count = ch.entries.len();
ch.entries.clear();
count
} else {
0
}
}
/// Clear entries AND delete all files for a channel.
pub async fn clear_all(&self, channel_name: &str) -> usize {
let count = self.clear_log(channel_name).await;
let channel_dir = self.base_dir.join(channel_name);
if channel_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&channel_dir) {
for entry in entries.flatten() {
let p = entry.path();
// Remove EVERY per-shot file, not just the PNGs: the .json
// sidecars and __popup child-window PNGs must go too, or the
// folder fills with orphaned metadata.
match p.extension().and_then(|e| e.to_str()) {
Some("png") | Some("json") => { fs::remove_file(&p).ok(); }
_ => {}
}
}
}
}
count
}
/// List all channel names.
pub async fn channel_names(&self) -> Vec<String> {
let channels = self.channels.read().await;
channels.keys().cloned().collect()
}
/// Lightweight per-channel summary for the `channels` overview:
/// (name, shot count, last timestamp, last source). No entry cloning.
pub async fn channel_summaries(&self) -> Vec<(String, usize, Option<DateTime<Utc>>, Option<String>)> {
let channels = self.channels.read().await;
channels.iter().map(|(k, ch)| {
let last = ch.entries.last();
(k.clone(), ch.entries.len(), last.map(|e| e.timestamp), last.map(|e| e.source.clone()))
}).collect()
}
/// Get the base directory path.
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
/// Note the user's explicit canvas CLICK for a channel - IN MEMORY ONLY.
/// Never persisted: a broker restart (or user reboot) forgets all notes and
/// every channel returns to the global default.
pub async fn set_canvas_pref(&self, channel: &str, canvas: &str) {
self.canvas_prefs.write().await.insert(channel.to_string(), canvas.to_string());
}
/// Forget a channel's note so it follows the global default again.
pub async fn clear_canvas_pref(&self, channel: &str) {
self.canvas_prefs.write().await.remove(channel);
}
/// Forget EVERY in-memory canvas note. Used when the human changes the
/// global default: the newest explicit intent wins, so stale click-notes
/// must never override it.
pub async fn clear_all_canvas_prefs(&self) -> usize {
let mut m = self.canvas_prefs.write().await;
let n = m.len();
m.clear();
n
}
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()
}
/// Janitor knobs: env vars override, otherwise the UI-editable prefs.
pub async fn effective_max_shots(&self) -> usize {
std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())
.unwrap_or(self.app_prefs.read().await.max_shots)
}
pub async fn effective_retention_days(&self) -> u64 {
std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok())
.unwrap_or(self.app_prefs.read().await.retention_days)
}
/// 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.
pub async fn retention_sweep(&self) -> Vec<String> {
let days = self.effective_retention_days().await;
if days == 0 { return Vec::new(); }
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(days * 86_400);
let mut pruned = Vec::new();
let Ok(read_dir) = fs::read_dir(&self.base_dir) else { return pruned };
for d in read_dir.flatten() {
let path = d.path();
if !path.is_dir() { continue; }
// newest mtime of any file inside (or the dir itself when empty)
let newest = fs::read_dir(&path).ok()
.map(|it| it.flatten()
.filter_map(|f| f.metadata().ok().and_then(|m| m.modified().ok()))
.max())
.flatten()
.or_else(|| fs::metadata(&path).ok().and_then(|m| m.modified().ok()));
if let Some(t) = newest {
if t < cutoff {
let name = d.file_name().to_string_lossy().to_string();
// Never prune a channel someone is actively viewing.
if !self.viewer_infos(&name).await.is_empty() { continue; }
if fs::remove_dir_all(&path).is_ok() {
self.channels.write().await.remove(&name);
pruned.push(name);
}
}
}
}
pruned
}
}