|
1 | 1 | // ============================================================== |
2 | | -// Hash D Island live-activity ping |
| 2 | +// HashNotch live-activity ping |
3 | 3 | // |
4 | 4 | // When a run finishes, HashCortX writes one short "finished" activity to |
5 | | -// Hash D Island's local feed so the notch lights up like the iPhone Dynamic |
6 | | -// Island — the same way Claude Code's hook does. Metadata only: a title and |
7 | | -// an optional model label, never any prompt or answer content. |
| 5 | +// HashNotch's local feed so the notch lights up like the iPhone Dynamic |
| 6 | +// Island — the same way Claude Code's hook does. Metadata only: a title, |
| 7 | +// never a model label and never any prompt or answer content. |
8 | 8 | // |
9 | | -// The feed is Hash D Island's documented merge-by-id contract: |
10 | | -// ~/.hashdisland/activities.json — an array of |
| 9 | +// The feed is HashNotch's documented merge-by-id contract: |
| 10 | +// ~/.hashnotch/activities.json — an array of |
11 | 11 | // { id, icon, title, subtitle?, endsAt? (ISO8601), dismissAfter?, image? } |
12 | 12 | // |
13 | 13 | // We replace our own previous activity, leave every other poster's alone, |
14 | 14 | // keep the file bounded, and write atomically so a reader never sees a |
15 | | -// half-written file. Best-effort: if Hash D Island isn't installed the file just |
| 15 | +// half-written file. Best-effort: if HashNotch isn't installed the file just |
16 | 16 | // sits there unread, and any failure is swallowed by the caller. |
17 | 17 | // ============================================================== |
18 | 18 |
|
19 | 19 | use serde_json::Value; |
20 | 20 | use std::fs; |
21 | 21 | use std::path::PathBuf; |
22 | 22 |
|
23 | | -/// Hash D Island itself only shows a handful; keep the file from ever growing. |
| 23 | +/// HashNotch itself only shows a handful; keep the file from ever growing. |
24 | 24 | const MAX_ACTIVITIES: usize = 8; |
25 | 25 |
|
| 26 | +/// The folder HashNotch reads, newest name first. |
| 27 | +/// |
| 28 | +/// The app was renamed and its folder moved with it. A poster has to land where |
| 29 | +/// the INSTALLED copy is looking, and this app cannot know which copy that is — |
| 30 | +/// so it writes to whichever folder is already on the machine, preferring the |
| 31 | +/// current name. Only when neither exists does it create the current one, which |
| 32 | +/// is the right guess for anyone installing from here on. |
| 33 | +const FEED_DIRS: [&str; 2] = [".hashnotch", ".hashdisland"]; |
| 34 | + |
| 35 | +fn feed_dir() -> PathBuf { |
| 36 | + feed_dir_in(&dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))) |
| 37 | +} |
| 38 | + |
| 39 | +/// Split out from `feed_dir` so the choice can be tested against a real |
| 40 | +/// directory rather than only against whatever this machine happens to have. |
| 41 | +fn feed_dir_in(home: &PathBuf) -> PathBuf { |
| 42 | + FEED_DIRS |
| 43 | + .iter() |
| 44 | + .map(|name| home.join(name)) |
| 45 | + .find(|dir| dir.is_dir()) |
| 46 | + .unwrap_or_else(|| home.join(FEED_DIRS[0])) |
| 47 | +} |
| 48 | + |
26 | 49 | fn feed_path() -> PathBuf { |
27 | | - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); |
28 | | - home.join(".hashdisland").join("activities.json") |
| 50 | + feed_dir().join("activities.json") |
29 | 51 | } |
30 | 52 |
|
31 | 53 | /// Where this app's logo would live, if the user has put one there. |
32 | 54 | /// |
33 | 55 | /// The notch shows a tool's own mark instead of a generic symbol when it can |
34 | 56 | /// find one. Nothing is shipped or created here: the path is simply offered, |
35 | | -/// and Hash D Island ignores it unless it names a readable image, in which case |
| 57 | +/// and HashNotch ignores it unless it names a readable image, in which case |
36 | 58 | /// the symbol is drawn as before. |
37 | 59 | fn logo_path(id: &str) -> PathBuf { |
38 | | - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); |
39 | | - home.join(".hashdisland") |
40 | | - .join("logos") |
41 | | - .join(format!("{id}.png")) |
| 60 | + feed_dir().join("logos").join(format!("{id}.png")) |
42 | 61 | } |
43 | 62 |
|
44 | 63 | #[tauri::command] |
@@ -90,3 +109,52 @@ pub fn notch_activity_post(record: Value) -> Result<(), String> { |
90 | 109 | fs::rename(&tmp, &path).map_err(|e| e.to_string())?; |
91 | 110 | Ok(()) |
92 | 111 | } |
| 112 | + |
| 113 | +#[cfg(test)] |
| 114 | +mod tests { |
| 115 | + use super::*; |
| 116 | + |
| 117 | + fn scratch(name: &str) -> PathBuf { |
| 118 | + let dir = std::env::temp_dir().join(format!("hashcortx-notch-{name}-{}", std::process::id())); |
| 119 | + let _ = fs::remove_dir_all(&dir); |
| 120 | + fs::create_dir_all(&dir).unwrap(); |
| 121 | + dir |
| 122 | + } |
| 123 | + |
| 124 | + #[test] |
| 125 | + fn the_current_folder_is_used_when_nothing_exists_yet() { |
| 126 | + let home = scratch("fresh"); |
| 127 | + assert_eq!(feed_dir_in(&home), home.join(".hashnotch")); |
| 128 | + let _ = fs::remove_dir_all(&home); |
| 129 | + } |
| 130 | + |
| 131 | + #[test] |
| 132 | + fn a_machine_still_on_the_old_folder_is_written_to_there() { |
| 133 | + let home = scratch("old"); |
| 134 | + fs::create_dir_all(home.join(".hashdisland")).unwrap(); |
| 135 | + assert_eq!(feed_dir_in(&home), home.join(".hashdisland")); |
| 136 | + let _ = fs::remove_dir_all(&home); |
| 137 | + } |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn the_current_folder_wins_when_both_are_present() { |
| 141 | + let home = scratch("both"); |
| 142 | + fs::create_dir_all(home.join(".hashdisland")).unwrap(); |
| 143 | + fs::create_dir_all(home.join(".hashnotch")).unwrap(); |
| 144 | + assert_eq!(feed_dir_in(&home), home.join(".hashnotch")); |
| 145 | + let _ = fs::remove_dir_all(&home); |
| 146 | + } |
| 147 | + |
| 148 | + #[test] |
| 149 | + fn the_notice_carries_no_model_and_no_content() { |
| 150 | + // The record the frontend now sends, verbatim. |
| 151 | + let record: Value = serde_json::from_str( |
| 152 | + r#"{"id":"hashcortx","icon":"checkmark.circle.fill","title":"HashCortX finished","dismissAfter":3}"#, |
| 153 | + ) |
| 154 | + .unwrap(); |
| 155 | + let line = serde_json::to_string(&record).unwrap(); |
| 156 | + assert!(line.contains("HashCortX finished")); |
| 157 | + // The one field the model label used to travel in. |
| 158 | + assert!(!line.contains("subtitle")); |
| 159 | + } |
| 160 | +} |
0 commit comments