|
| 1 | +# Favorites (backend) details |
| 2 | + |
| 3 | +User-editable favorites. The volume switcher's "Favorites" section is fully user-owned: add, remove, |
| 4 | +rename, reorder. This module owns the ordered `favorites.json` store; the IPC layer |
| 5 | +(`commands/favorites.rs`) is a thin pass-through. Read [`CLAUDE.md`](CLAUDE.md) first for the |
| 6 | +must-knows. |
| 7 | + |
| 8 | +## What it replaces |
| 9 | + |
| 10 | +Favorites used to be a hardcoded `Vec<LocationInfo>` of four folders, computed fresh on every |
| 11 | +`get_favorites()` call with no write path. Now `get_favorites()` (both the macOS `volumes/mod.rs` and |
| 12 | +the Linux `volumes_linux/mod.rs` twins) reads `favorites::store::list()` and maps each entry to a |
| 13 | +`LocationInfo` with `category: Favorite`. The frontend already tells favorites apart from volumes via |
| 14 | +`category`, so no extra "user-removable" flag is needed: every favorite is now a user favorite. |
| 15 | + |
| 16 | +## On-disk shape |
| 17 | + |
| 18 | +`favorites.json` in the app data dir: |
| 19 | + |
| 20 | +```json |
| 21 | +{ |
| 22 | + "_schemaVersion": 1, |
| 23 | + "favorites": [ |
| 24 | + { "id": "9f1c…", "path": "/Applications", "name": "Applications" }, |
| 25 | + { "id": "a83e…", "path": "/Users/me/Desktop", "name": "Desktop" } |
| 26 | + ] |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +- `id`: a random UUID minted on add, never derived from `path`. The switcher exposes it as |
| 31 | + `LocationInfo.id = "fav-<id>"`. |
| 32 | +- `path`: the absolute filesystem path. |
| 33 | +- `name`: the display label. Defaults to the path's file name on add; the user can override via |
| 34 | + rename. |
| 35 | + |
| 36 | +Order in the array is the display order. |
| 37 | + |
| 38 | +## Seed-once contract |
| 39 | + |
| 40 | +`favorites.json` existing means "already initialized." Four states: |
| 41 | + |
| 42 | +- **File absent** (`read_store_from_path` returns `None`): first launch. Seed the platform defaults |
| 43 | + and write them. This is the only path that ever writes defaults. |
| 44 | +- **File present, non-empty**: read verbatim. |
| 45 | +- **File present, empty list**: the user cleared every favorite. Read verbatim (stays empty). Never |
| 46 | + re-seed. |
| 47 | +- **File present, corrupt or wrong `_schemaVersion`**: quarantine to `<name>.broken`, then read as |
| 48 | + `Some(empty)` (NOT `None`). A broken file is "initialized but unreadable," so we must not re-seed |
| 49 | + the defaults over a user who had intentionally cleared their list. |
| 50 | + |
| 51 | +Existing beta users (data dir present, no `favorites.json` yet) hit the absent branch on the first |
| 52 | +launch after the update and get the platform defaults seeded, so there's no regression from the old |
| 53 | +hardcoded behavior. |
| 54 | + |
| 55 | +Platform defaults (`default_favorites`, platform-native per `design-principles.md`): |
| 56 | + |
| 57 | +- macOS: `/Applications`, `~/Desktop`, `~/Documents`, `~/Downloads` (the previous hardcoded four). |
| 58 | +- Linux: Home, `~/Desktop`, `~/Documents`, `~/Downloads` (matching the old `volumes_linux` |
| 59 | + favorites). |
| 60 | + |
| 61 | +## Operations (pure core) |
| 62 | + |
| 63 | +All in `store.rs`, unit-tested without disk or an `AppHandle`: |
| 64 | + |
| 65 | +- `add(path, name?)`: dedups by normalized path. A re-add moves the existing entry to the end and |
| 66 | + keeps its id (applying a `name` override if given). A fresh add appends with a UUID id and a label |
| 67 | + defaulting to the path's file name. Move-to-end (not move-to-top) because favorites are a curated, |
| 68 | + ordered list, not a recency stack: a re-add shouldn't reshuffle the user's deliberate ordering more |
| 69 | + than necessary. |
| 70 | +- `remove(id)`: drops by id. No-op if absent. |
| 71 | +- `rename(id, name)`: updates the label by id. No-op if absent. |
| 72 | +- `reorder(ordered_ids)`: reorders to match the given id list. Unknown ids are ignored; favorites |
| 73 | + whose ids are missing from the list are appended in their current relative order, so a partial or |
| 74 | + stale order from the frontend never drops an entry. |
| 75 | + |
| 76 | +`normalize_for_dedup` strips a single trailing `/` (but keeps root `/`). Case-sensitivity is a known |
| 77 | +limitation, same as `go_to_path/history.rs`: on case-insensitive APFS `/Users/x/Foo` and |
| 78 | +`/Users/x/foo` compare unequal (worst case: a duplicate-looking row). We don't `canonicalize()` (it |
| 79 | +would resolve symlinks and require the path to exist). |
| 80 | + |
| 81 | +## Persistence and concurrency |
| 82 | + |
| 83 | +- In-memory cache: `OnceLock<Mutex<Option<FavoritesStore>>>`. `None` until first access loads (and |
| 84 | + lazily seeds) from disk. |
| 85 | +- `DISK_LOCK` (a separate `Mutex<()>`) serializes the read-modify-write cycle so concurrent commands |
| 86 | + can't clobber each other. `load_or_seed` re-checks the cache under the disk lock so two concurrent |
| 87 | + first-access callers can't both seed. |
| 88 | +- Atomic writes via `config::durable_write_json` (write-tmp + fsync + rename + parent-dir fsync), |
| 89 | + the same data-loss-class discipline the rest of the app uses. |
| 90 | +- The disk lock is never held across an `.await`; the in-memory guard is always dropped before any |
| 91 | + `fs` call. (The commands themselves run the store calls inside `spawn_blocking`, so even the |
| 92 | + synchronous store API never blocks the IPC thread.) |
| 93 | + |
| 94 | +## IPC contract (`commands/favorites.rs`) |
| 95 | + |
| 96 | +Thin async pass-throughs, each `blocking_result_with_timeout` (5s, the write tier) since the store |
| 97 | +write touches the filesystem. After persisting, each re-emits `volumes-changed` via |
| 98 | +`volume_broadcast::emit_volumes_changed()` so both panes' switchers refresh live |
| 99 | +(subscribe-don't-poll). Listing rides the existing `list_volumes` / `volumes-changed` path, so |
| 100 | +there's no `list_favorites` command. |
| 101 | + |
| 102 | +- `add_favorite(path: String, name: Option<String>) -> Result<(), IpcError>` |
| 103 | +- `remove_favorite(id: String) -> Result<(), IpcError>` |
| 104 | +- `rename_favorite(id: String, name: String) -> Result<(), IpcError>` |
| 105 | +- `reorder_favorites(ordered_ids: Vec<String>) -> Result<(), IpcError>` |
| 106 | + |
| 107 | +Registered in both `ipc.rs` (runtime `generate_handler`) and `ipc_collectors.rs` (specta types). |
| 108 | + |
| 109 | +## FDA-pending skip (macOS) |
| 110 | + |
| 111 | +`volumes::get_favorites` must not stat a TCC-protected path while the FDA gate is pending: even |
| 112 | +`Path::exists()` trips a macOS TCC popup for the protected-folder service once the bundle is |
| 113 | +registered with tccd, which is exactly the onboarding-flood the FDA modal exists to prevent. The read |
| 114 | +maps each favorite, skipping the existence check when the FDA gate is pending AND |
| 115 | +`restricted_paths::tcc_paths::is_potentially_tcc_restricted(path)` is true (and assuming such a |
| 116 | +protected favorite exists). Non-protected paths are still checked (for example `/Applications` can be |
| 117 | +absent on slim systems). This now applies to ANY user-added path, not just the old hardcoded three. |
| 118 | +Linux has no TCC, so its twin existence-checks everything and there's no gate. |
| 119 | + |
| 120 | +## Local-filesystem paths only (v1) |
| 121 | + |
| 122 | +Favorites are local filesystem paths for now. Network and MTP favorites are deferred (mount-state |
| 123 | +complexity). The store doesn't enforce this; the add surfaces in the frontend gate it. |
0 commit comments