|
| 1 | +//! Persistent on-disk cache for Tier-C per-path icons (`pkg:*`, `path:*`) and the |
| 2 | +//! Tier-B `special:*` icons. |
| 3 | +//! |
| 4 | +//! Folder icons rarely change, so they *should* survive restarts — but a user who |
| 5 | +//! re-icons a folder in Finder must see the update. We key each cached entry by |
| 6 | +//! `path + a staleness token` (the folder's own mtime): a re-icon bumps the |
| 7 | +//! folder's mtime (Finder rewrites the icon resource / `com.apple.FinderInfo`), |
| 8 | +//! so the stored token no longer matches and we re-fetch. This gives both |
| 9 | +//! durability and correct invalidation without watching anything. |
| 10 | +//! |
| 11 | +//! Layout: a flat directory of small JSON sidecar files under |
| 12 | +//! `<data_dir>/icon-cache/`, one per icon id, named by a hex digest of the id (so |
| 13 | +//! arbitrary path characters never leak into a filename). The in-memory |
| 14 | +//! `ICON_CACHE` LRU stays the hot tier; this is the warm tier consulted on a hot |
| 15 | +//! miss, before the cold NSWorkspace fetch. |
| 16 | +//! |
| 17 | +//! Everything here degrades gracefully: a corrupt file, a missing directory, a |
| 18 | +//! permission error, or a path with no resolvable mtime is just a cache miss. We |
| 19 | +//! never panic and never block the icon path on disk-cache failure — the feature |
| 20 | +//! is purely additive. |
| 21 | +
|
| 22 | +use std::fs; |
| 23 | +use std::path::{Path, PathBuf}; |
| 24 | +use std::sync::LazyLock; |
| 25 | +use std::time::UNIX_EPOCH; |
| 26 | + |
| 27 | +use serde::{Deserialize, Serialize}; |
| 28 | + |
| 29 | +/// One persisted icon entry. `token` is the folder's staleness token at fetch |
| 30 | +/// time (its mtime as whole seconds since the epoch); a mismatch on read means |
| 31 | +/// the folder changed and the entry is stale. |
| 32 | +#[derive(Serialize, Deserialize)] |
| 33 | +struct DiskEntry { |
| 34 | + token: u64, |
| 35 | + data_url: String, |
| 36 | +} |
| 37 | + |
| 38 | +/// Resolves the on-disk icon-cache directory, creating it on first use. |
| 39 | +/// |
| 40 | +/// Respects `CMDR_DATA_DIR` (set by `tauri-wrapper.js` in dev and by E2E |
| 41 | +/// harnesses) the same way the secret store and settings loader do, so dev / prod |
| 42 | +/// / per-worktree instances stay isolated. Resolved once and memoized. |
| 43 | +static CACHE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| { |
| 44 | + let base = if let Ok(custom) = std::env::var("CMDR_DATA_DIR") { |
| 45 | + if custom.is_empty() { |
| 46 | + return None; |
| 47 | + } |
| 48 | + PathBuf::from(custom) |
| 49 | + } else { |
| 50 | + dirs::data_dir()?.join("com.veszelovszki.cmdr") |
| 51 | + }; |
| 52 | + let dir = base.join("icon-cache"); |
| 53 | + if let Err(e) = fs::create_dir_all(&dir) { |
| 54 | + log::warn!(target: "icons", "Could not create icon-cache dir {}: {e}", dir.display()); |
| 55 | + return None; |
| 56 | + } |
| 57 | + Some(dir) |
| 58 | +}); |
| 59 | + |
| 60 | +/// The staleness token for a folder: its mtime as whole seconds since the epoch. |
| 61 | +/// `None` when the path is gone or its mtime is unreadable (a dead mount, a |
| 62 | +/// permission error) — the caller then treats the entry as un-cacheable / a miss |
| 63 | +/// rather than caching against a token that can never be reproduced. |
| 64 | +fn staleness_token(real_path: &str) -> Option<u64> { |
| 65 | + let modified = fs::metadata(real_path).ok()?.modified().ok()?; |
| 66 | + let secs = modified.duration_since(UNIX_EPOCH).ok()?.as_secs(); |
| 67 | + Some(secs) |
| 68 | +} |
| 69 | + |
| 70 | +/// Maps an icon id to its sidecar file path: `<cache-dir>/<hex-digest>.json`. We |
| 71 | +/// digest the id rather than using it verbatim so arbitrary path characters (`/`, |
| 72 | +/// spaces, unicode) never produce an invalid or traversal-prone filename. |
| 73 | +fn entry_path(dir: &Path, icon_id: &str) -> PathBuf { |
| 74 | + dir.join(format!("{}.json", digest_hex(icon_id))) |
| 75 | +} |
| 76 | + |
| 77 | +/// A small, dependency-free FNV-1a 64-bit hash, rendered as zero-padded hex. Not |
| 78 | +/// cryptographic — collision resistance only needs to be good enough that two |
| 79 | +/// distinct icon ids don't share a sidecar file in practice, and the stored entry |
| 80 | +/// is self-describing enough (token-checked) that a stray collision is just a |
| 81 | +/// miss, never wrong data. |
| 82 | +fn digest_hex(s: &str) -> String { |
| 83 | + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; |
| 84 | + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; |
| 85 | + let mut hash = FNV_OFFSET; |
| 86 | + for byte in s.as_bytes() { |
| 87 | + hash ^= u64::from(*byte); |
| 88 | + hash = hash.wrapping_mul(FNV_PRIME); |
| 89 | + } |
| 90 | + format!("{hash:016x}") |
| 91 | +} |
| 92 | + |
| 93 | +/// Loads a cached icon for `icon_id` whose folder is at `real_path`, if present |
| 94 | +/// AND still fresh (stored token == the folder's current mtime). Returns `None` |
| 95 | +/// on any miss: no file, unreadable file, malformed JSON, or a stale token. Never |
| 96 | +/// panics. |
| 97 | +pub fn load(icon_id: &str, real_path: &str) -> Option<String> { |
| 98 | + load_in(CACHE_DIR.as_ref()?, icon_id, real_path) |
| 99 | +} |
| 100 | + |
| 101 | +/// Persists `data_url` for `icon_id` under the folder's current mtime token. A |
| 102 | +/// best-effort write — any failure (no cache dir, unresolvable mtime, write |
| 103 | +/// error) is silently dropped; the in-memory cache still has the icon for this |
| 104 | +/// session, and the next session just re-fetches. |
| 105 | +pub fn store(icon_id: &str, real_path: &str, data_url: &str) { |
| 106 | + let Some(dir) = CACHE_DIR.as_ref() else { |
| 107 | + return; |
| 108 | + }; |
| 109 | + store_in(dir, icon_id, real_path, data_url); |
| 110 | +} |
| 111 | + |
| 112 | +/// Pure `load` against an explicit cache dir. Public-in-module so tests can run |
| 113 | +/// hermetically against a temp dir instead of the process-wide `CACHE_DIR` (a |
| 114 | +/// `LazyLock` whose first-touch ordering across tests isn't controllable). |
| 115 | +fn load_in(dir: &Path, icon_id: &str, real_path: &str) -> Option<String> { |
| 116 | + let token = staleness_token(real_path)?; |
| 117 | + let raw = fs::read(entry_path(dir, icon_id)).ok()?; |
| 118 | + let entry: DiskEntry = serde_json::from_slice(&raw).ok()?; |
| 119 | + if entry.token == token { |
| 120 | + Some(entry.data_url) |
| 121 | + } else { |
| 122 | + // Stale: the folder changed since we cached this icon (likely a re-icon). |
| 123 | + None |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +/// Pure `store` against an explicit cache dir. See `load_in` for the test seam. |
| 128 | +fn store_in(dir: &Path, icon_id: &str, real_path: &str, data_url: &str) { |
| 129 | + let Some(token) = staleness_token(real_path) else { |
| 130 | + return; |
| 131 | + }; |
| 132 | + let entry = DiskEntry { |
| 133 | + token, |
| 134 | + data_url: data_url.to_string(), |
| 135 | + }; |
| 136 | + if let Ok(bytes) = serde_json::to_vec(&entry) { |
| 137 | + // Recreate the dir if a `clear_all` (theme change) removed it since the |
| 138 | + // `CACHE_DIR` LazyLock first built it. Cheap and idempotent. |
| 139 | + let _ = fs::create_dir_all(dir); |
| 140 | + let path = entry_path(dir, icon_id); |
| 141 | + if let Err(e) = write_atomic(&path, &bytes) { |
| 142 | + log::debug!(target: "icons", "icon-cache write failed for {icon_id}: {e}"); |
| 143 | + } |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +/// Drops the entire on-disk cache. Called on a theme/accent change, since macOS |
| 148 | +/// tints folder icons (including `special:*` glyphs) by the current appearance: |
| 149 | +/// the mtime token can't catch that (the folder didn't change, the *system* did), |
| 150 | +/// so we wipe wholesale and let the icons re-fetch lazily. Removing the directory |
| 151 | +/// is enough; it's recreated on the next `store`. Best-effort, never panics. |
| 152 | +pub fn clear_all() { |
| 153 | + if let Some(dir) = CACHE_DIR.as_ref() |
| 154 | + && let Err(e) = fs::remove_dir_all(dir) { |
| 155 | + // ENOENT (already gone) is fine; anything else is logged and ignored. |
| 156 | + if e.kind() != std::io::ErrorKind::NotFound { |
| 157 | + log::debug!(target: "icons", "icon-cache clear failed: {e}"); |
| 158 | + } |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +/// Writes bytes to `path` via a temp-file + rename so a crash mid-write can't |
| 163 | +/// leave a half-written sidecar that would later parse as garbage (it'd just be a |
| 164 | +/// miss, but the temp+rename keeps the on-disk set always-valid, matching the |
| 165 | +/// project's safe-write convention). |
| 166 | +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { |
| 167 | + let tmp = path.with_extension("json.tmp"); |
| 168 | + fs::write(&tmp, bytes)?; |
| 169 | + fs::rename(&tmp, path) |
| 170 | +} |
| 171 | + |
| 172 | +/// Test-only seam: the current epoch seconds, used by tests to bump a folder's |
| 173 | +/// mtime deterministically without sleeping. |
| 174 | +#[cfg(test)] |
| 175 | +fn now_secs() -> u64 { |
| 176 | + std::time::SystemTime::now() |
| 177 | + .duration_since(UNIX_EPOCH) |
| 178 | + .map(|d| d.as_secs()) |
| 179 | + .unwrap_or(0) |
| 180 | +} |
| 181 | + |
| 182 | +#[cfg(test)] |
| 183 | +mod tests { |
| 184 | + use super::*; |
| 185 | + |
| 186 | + /// A hermetic test fixture: an isolated cache dir plus a target "folder" whose |
| 187 | + /// mtime stands in for a real browsed folder. Both are unique per test and |
| 188 | + /// cleaned up on drop, so tests never touch the real data dir or each other. |
| 189 | + struct Fixture { |
| 190 | + cache_dir: PathBuf, |
| 191 | + folder: String, |
| 192 | + } |
| 193 | + |
| 194 | + impl Fixture { |
| 195 | + fn new(tag: &str) -> Self { |
| 196 | + let base = std::env::temp_dir().join(format!("cmdr_icon_disk_{tag}_{}", std::process::id())); |
| 197 | + let cache_dir = base.join("cache"); |
| 198 | + let folder = base.join("folder"); |
| 199 | + let _ = fs::remove_dir_all(&base); |
| 200 | + fs::create_dir_all(&cache_dir).expect("create cache dir"); |
| 201 | + fs::create_dir_all(&folder).expect("create target folder"); |
| 202 | + let folder = folder.to_string_lossy().into_owned(); |
| 203 | + let me = Self { cache_dir, folder }; |
| 204 | + me.set_folder_mtime(now_secs()); |
| 205 | + me |
| 206 | + } |
| 207 | + |
| 208 | + /// Sets the target folder's mtime to a specific epoch-second value so the |
| 209 | + /// staleness token is deterministic (no sleeping). |
| 210 | + fn set_folder_mtime(&self, secs: u64) { |
| 211 | + let t = filetime::FileTime::from_unix_time(secs as i64, 0); |
| 212 | + filetime::set_file_mtime(&self.folder, t).expect("set mtime"); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + impl Drop for Fixture { |
| 217 | + fn drop(&mut self) { |
| 218 | + // Both dirs share the same `..` base; remove it wholesale. |
| 219 | + if let Some(base) = self.cache_dir.parent() { |
| 220 | + let _ = fs::remove_dir_all(base); |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + #[test] |
| 226 | + fn digest_is_stable_and_filename_safe() { |
| 227 | + // Same input → same digest; arbitrary path chars don't leak. |
| 228 | + let a = digest_hex("pkg:/Applications/My App.app"); |
| 229 | + let b = digest_hex("pkg:/Applications/My App.app"); |
| 230 | + assert_eq!(a, b); |
| 231 | + assert_eq!(a.len(), 16); |
| 232 | + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); |
| 233 | + // Distinct inputs → distinct digests (FNV is fine for this). |
| 234 | + assert_ne!(digest_hex("path:/a"), digest_hex("path:/b")); |
| 235 | + } |
| 236 | + |
| 237 | + #[test] |
| 238 | + fn write_then_read_back_hits() { |
| 239 | + let fx = Fixture::new("rw"); |
| 240 | + let id = format!("path:{}", fx.folder); |
| 241 | + |
| 242 | + assert_eq!(load_in(&fx.cache_dir, &id, &fx.folder), None, "cold miss before store"); |
| 243 | + store_in(&fx.cache_dir, &id, &fx.folder, "data:image/webp;base64,AAAA"); |
| 244 | + assert_eq!( |
| 245 | + load_in(&fx.cache_dir, &id, &fx.folder).as_deref(), |
| 246 | + Some("data:image/webp;base64,AAAA") |
| 247 | + ); |
| 248 | + } |
| 249 | + |
| 250 | + #[test] |
| 251 | + fn bumping_the_mtime_token_misses() { |
| 252 | + let fx = Fixture::new("stale"); |
| 253 | + let base = now_secs(); |
| 254 | + fx.set_folder_mtime(base); |
| 255 | + let id = format!("path:{}", fx.folder); |
| 256 | + |
| 257 | + store_in(&fx.cache_dir, &id, &fx.folder, "old-icon"); |
| 258 | + assert_eq!(load_in(&fx.cache_dir, &id, &fx.folder).as_deref(), Some("old-icon")); |
| 259 | + |
| 260 | + // Re-icon simulation: the folder's mtime moves forward. |
| 261 | + fx.set_folder_mtime(base + 100); |
| 262 | + assert_eq!( |
| 263 | + load_in(&fx.cache_dir, &id, &fx.folder), |
| 264 | + None, |
| 265 | + "a changed folder mtime invalidates the entry" |
| 266 | + ); |
| 267 | + } |
| 268 | + |
| 269 | + #[test] |
| 270 | + fn corrupt_sidecar_is_a_graceful_miss() { |
| 271 | + let fx = Fixture::new("corrupt"); |
| 272 | + let id = format!("path:{}", fx.folder); |
| 273 | + |
| 274 | + // Hand-write garbage where the sidecar would live. |
| 275 | + fs::write(entry_path(&fx.cache_dir, &id), b"not json at all").expect("write garbage"); |
| 276 | + |
| 277 | + assert_eq!( |
| 278 | + load_in(&fx.cache_dir, &id, &fx.folder), |
| 279 | + None, |
| 280 | + "malformed JSON must be a miss, not a panic" |
| 281 | + ); |
| 282 | + } |
| 283 | + |
| 284 | + #[test] |
| 285 | + fn missing_folder_never_caches_and_reads_miss() { |
| 286 | + let fx = Fixture::new("gone"); |
| 287 | + let id = format!("path:{}", fx.folder); |
| 288 | + // Remove the target folder so its mtime is unresolvable. |
| 289 | + let _ = fs::remove_dir_all(&fx.folder); |
| 290 | + |
| 291 | + // Store is a no-op (no token), load is a miss. |
| 292 | + store_in(&fx.cache_dir, &id, &fx.folder, "should-not-persist"); |
| 293 | + assert_eq!(load_in(&fx.cache_dir, &id, &fx.folder), None); |
| 294 | + } |
| 295 | + |
| 296 | + #[test] |
| 297 | + fn write_is_atomic_no_tmp_left_behind() { |
| 298 | + let fx = Fixture::new("atomic"); |
| 299 | + let id = format!("pkg:{}", fx.folder); |
| 300 | + store_in(&fx.cache_dir, &id, &fx.folder, "icon"); |
| 301 | + |
| 302 | + let tmp = entry_path(&fx.cache_dir, &id).with_extension("json.tmp"); |
| 303 | + assert!(!tmp.exists(), "temp file must be renamed away after an atomic write"); |
| 304 | + assert_eq!(load_in(&fx.cache_dir, &id, &fx.folder).as_deref(), Some("icon")); |
| 305 | + } |
| 306 | +} |
0 commit comments