Skip to content

Commit e50004a

Browse files
committed
Icons: Persist folder icons across restarts
Adds the on-disk warm tier to per-folder icons so real-folder icons survive restarts but invalidate when a folder is re-iconed. - New `icons::disk_cache`: JSON sidecar files under `<data_dir>/icon-cache/` (env-resolved via `CMDR_DATA_DIR`), keyed by an FNV-1a digest of the icon id, each holding `{ token, data_url }` where `token` is the folder's mtime (whole epoch seconds). - `get_icons` consults `disk_cache::load` on a hot-cache miss before the cold NSWorkspace fetch (tiering: in-memory LRU → on-disk → NSWorkspace), and `disk_cache::store`s each fresh fetch. A re-icon bumps the folder mtime → token mismatch → re-fetch, so durability and correct invalidation come for free. - `clear_directory_icon_cache` now also wipes the disk cache (`disk_cache::clear_all`): macOS tints folder glyphs by appearance, which the mtime token can't catch, so a theme/accent change drops the warm tier wholesale. - Graceful in every failure mode (missing/corrupt sidecar, dead-mount mtime, write error → a miss, never a panic); writes are temp+rename atomic. Pure `load_in`/`store_in` seams keep the tests hermetic against a temp dir. - Tests: write→read-back hit, mtime-bump miss, corrupt-sidecar miss, missing-folder no-cache, atomic-write no-tmp-leftover, digest stability.
1 parent 389829b commit e50004a

3 files changed

Lines changed: 356 additions & 7 deletions

File tree

apps/desktop/src-tauri/src/icons/CLAUDE.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ data URL. The id namespace, by tier:
1919
| C | `path:{dir}` / `pkg:{dir}` | per-path icons (volumes, packages, custom-icon folders) | the real path (8 MB thread) |
2020
|| `git:{branch,tag,commit,fork}` | git-portal virtual entries | rendered by the FE via Lucide, never here |
2121

22-
`dir` / `ext:*` / `file` / `symlink*` / `special:*` are inherently **bounded**, so they're uncapped and persist to
23-
localStorage. `path:*` / `pkg:*` are **unbounded** (grow with folders visited), so they're LRU-capped (`PATH_KEY_CAP`)
24-
and never persisted. See `clear_directory_icon_cache` for which keys a theme/accent change drops (`dir`, `symlink-dir`,
25-
`path:*`, `special:*` — all appearance-tinted by macOS).
22+
`dir` / `ext:*` / `file` / `symlink*` / `special:*` are inherently **bounded**, so they're uncapped in the in-memory
23+
cache and persist to localStorage on the FE. `path:*` / `pkg:*` are **unbounded** (grow with folders visited), so
24+
they're LRU-capped (`PATH_KEY_CAP`) and never persisted to localStorage. The Rust side also keeps a persistent on-disk
25+
warm tier for the real-folder ids (`special:*` / `pkg:*` / `path:*`), keyed by folder mtime — see § Persistent on-disk
26+
cache. See `clear_directory_icon_cache` for which keys a theme/accent change drops (`dir`, `symlink-dir`, `path:*`,
27+
`pkg:*`, `special:*` — all appearance-tinted by macOS — plus the whole disk cache).
2628

2729
## Tier B — special system folders (`special_folders.rs`)
2830

@@ -75,6 +77,28 @@ FDA-gated, or timed out — purely additive.
7577
`pkg:*` shares the `path:*` lifecycle: both are matched by `is_per_path_key`, LRU-capped together under one
7678
`PATH_KEY_CAP` budget, and never persisted to localStorage on the FE.
7779

80+
## Persistent on-disk cache (`disk_cache.rs`)
81+
82+
Real-folder icons (`special:*`, `pkg:*`, `path:*`) rarely change, so they persist across restarts in a warm on-disk
83+
tier under `<data_dir>/icon-cache/` (env-resolved via `CMDR_DATA_DIR`, like the secret store). Each entry is a small
84+
JSON sidecar named by an FNV-1a digest of the icon id (so arbitrary path characters never produce an unsafe filename),
85+
holding `{ token, data_url }`.
86+
87+
**Staleness token = the folder's own mtime** (whole epoch seconds). On a hot-cache miss, `get_icons` calls
88+
`disk_cache::load` BEFORE the cold NSWorkspace fetch; a hit promotes the icon into the in-memory LRU. When the user
89+
re-icons a folder in Finder, the folder's mtime bumps (Finder rewrites the icon resource / `com.apple.FinderInfo`), so
90+
the stored token no longer matches and we re-fetch — durability plus correct invalidation without watching anything. A
91+
missing/corrupt sidecar, an unresolvable mtime (dead mount), or any I/O error is a graceful miss; writes are temp+rename
92+
atomic and best-effort.
93+
94+
**Theme/accent change wipes the disk cache too** (`disk_cache::clear_all`, called from `clear_directory_icon_cache`):
95+
macOS tints folder glyphs by appearance, which the mtime token can't catch (the folder didn't change, the system did),
96+
so we drop the warm tier wholesale and let icons re-fetch with the new tint. The tier (in-memory hot LRU → on-disk warm
97+
→ NSWorkspace cold) keeps the common case instant while staying honest about appearance and re-icon changes.
98+
99+
The pure `load_in` / `store_in` (explicit cache dir) underpin the public `load` / `store` (process-wide `CACHE_DIR`),
100+
so tests run hermetically against a temp dir instead of the real data dir.
101+
78102
## Threading + FDA
79103

80104
Per-path / per-special NSWorkspace fetches run on dedicated 8 MB-stack OS threads (`fetch_path_icons`), never rayon —
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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

Comments
 (0)