//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification. //! //! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model //! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while //! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and //! `tray-action` (`read`, `mute-toggle`). //! //! Clicking a desktop notification: the notification plugin has no click callback on Windows, macOS or Linux (its //! actions are mobile only). So `notify` remembers where the notification points (`set_pending`) and `show_main` //! hands it to the window as a `tray-route` the next time the window is shown from the tray or the Dock. use std::sync::Mutex; use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::Value; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Runtime}; pub const ID_OPEN: &str = "tray-open"; pub const ID_READ: &str = "tray-read"; pub const ID_MUTE: &str = "tray-mute"; pub const ID_UPDATE: &str = "tray-update"; pub const ID_SETTINGS: &str = "tray-settings"; pub const ID_QUIT: &str = "tray-quit"; pub const ID_APP_PREFIX: &str = "tray-app:"; /// Apps listed in the submenu (more would not fit a menu anyway). pub const MAX_APPS: usize = 30; /// Windows cuts a tooltip at 127 UTF-16 units; stay under it everywhere. pub const MAX_TIP: usize = 120; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "camelCase", default)] pub struct TrayApp { /// The app's key in the window (opens it). pub key: String, pub name: String, /// Plain words: "App Store: In review · Play: Live". pub state: String, } /// The menu words in the window's language. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct TrayLabels { pub open: String, pub read_now: String, pub apps: String, pub no_apps: String, pub mute: String, pub unmute: String, pub update: String, pub settings: String, pub quit: String, } impl Default for TrayLabels { fn default() -> TrayLabels { TrayLabels { open: "Open storebound".into(), read_now: "Read the stores now".into(), apps: "Apps".into(), no_apps: "No apps yet".into(), mute: "Mute notifications for 1 hour".into(), unmute: "Unmute notifications".into(), update: "Check for updates".into(), settings: "Settings".into(), quit: "Quit storebound".into(), } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "camelCase", default)] pub struct TraySummary { pub tooltip: String, /// Apps that need the owner (rejected, halted): above 0 the icon gets a red dot. pub attention: u32, /// Notifications are muted right now. pub muted: bool, pub apps: Vec, pub labels: TrayLabels, } /// One menu entry, before it is drawn. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Entry { Item { id: String, label: String, enabled: bool }, Separator, Sub { label: String, items: Vec }, } fn clean(s: &str, max: usize) -> String { let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); let one = one.split_whitespace().collect::>().join(" "); if one.chars().count() <= max { return one; } let mut cut: String = one.chars().take(max.saturating_sub(1)).collect(); cut.push('…'); cut } /// The tooltip: the window's text, one line, short; "storebound" when it sent none. pub fn tooltip_text(s: &TraySummary) -> String { let t = clean(&s.tooltip, MAX_TIP); if t.is_empty() { "storebound".into() } else { t } } /// The context menu: Open, Read now, Apps (one entry per app with its state), Mute / Unmute, Check for updates, /// Settings, Quit. pub fn menu_model(s: &TraySummary) -> Vec { let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true }; let l = &s.labels; let apps: Vec = if s.apps.is_empty() { vec![Entry::Item { id: "tray-none".into(), label: clean(&l.no_apps, 90), enabled: false }] } else { s.apps .iter() .filter(|a| !a.key.is_empty() && !a.key.chars().any(char::is_control)) .take(MAX_APPS) .map(|a| { let name = clean(&a.name, 40); let state = clean(&a.state, 60); let label = if state.is_empty() { name } else { format!("{name} · {state}") }; Entry::Item { id: format!("{ID_APP_PREFIX}{}", a.key.chars().take(200).collect::()), label, enabled: true } }) .collect() }; vec![ item(ID_OPEN, &l.open), item(ID_READ, &l.read_now), Entry::Separator, Entry::Sub { label: clean(&l.apps, 40), items: apps }, Entry::Separator, item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }), item(ID_UPDATE, &l.update), item(ID_SETTINGS, &l.settings), Entry::Separator, item(ID_QUIT, &l.quit), ] } /// What a menu click does. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { /// Show the window. Open, Quit, /// Tell the window (no window needed): `tray-action` with this word. Event(&'static str), /// Show the window on this route (JSON for `tray-route`). Route(String), } pub fn action_of(id: &str) -> Option { match id { ID_OPEN => Some(Action::Open), ID_READ => Some(Action::Event("read")), ID_MUTE => Some(Action::Event("mute-toggle")), ID_UPDATE => Some(Action::Route(serde_json::json!({ "screen": "settings", "section": "updates" }).to_string())), ID_SETTINGS => Some(Action::Route(serde_json::json!({ "screen": "settings" }).to_string())), ID_QUIT => Some(Action::Quit), _ => id.strip_prefix(ID_APP_PREFIX).filter(|k| !k.is_empty()).map(|k| Action::Route(serde_json::json!({ "screen": "app", "appId": k, "tab": "releases" }).to_string())), } } /// A notification target the window may be sent to later: a small JSON object of strings with a known `screen`. pub fn valid_route(json: &str) -> bool { if json.len() > 400 { return false; } let Ok(Value::Object(o)) = serde_json::from_str::(json) else { return false }; let screens = ["portfolio", "app", "apps", "accounts", "ignored", "dependencies", "settings"]; o.len() <= 4 && o.values().all(|v| v.as_str().is_some_and(|s| s.len() <= 300 && !s.chars().any(char::is_control))) && o.get("screen").and_then(Value::as_str).is_some_and(|s| screens.contains(&s)) } /// The default icon with a red dot (white ring) in the top right corner, for "something needs you". RGBA in, RGBA out. pub fn with_attention_dot(rgba: &[u8], w: u32, h: u32) -> Vec { let mut out = rgba.to_vec(); if w == 0 || h == 0 || out.len() != (w as usize) * (h as usize) * 4 { return out; } let side = w.min(h) as f32; let r = side * 0.27; let (cx, cy) = (w as f32 - r - side * 0.02, r + side * 0.02); let blend = |px: &mut [u8], color: [f32; 3], c: f32| { if c <= 0.0 { return; } let a = px[3] as f32 / 255.0; let oa = a + (1.0 - a) * c; for i in 0..3 { // the dot covers what is under it by `c`; the alpha of the icon only grows px[i] = (px[i] as f32 * (1.0 - c) + color[i] * c).round().clamp(0.0, 255.0) as u8; } px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8; }; for y in 0..h { for x in 0..w { let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); let i = ((y * w + x) * 4) as usize; let px = &mut out[i..i + 4]; blend(px, [255.0, 255.0, 255.0], (r + 0.5 - d).clamp(0.0, 1.0)); blend(px, [229.0, 57.0, 53.0], (r * 0.78 + 0.5 - d).clamp(0.0, 1.0)); } } out } // ---- where the last notification pointed ---- static PENDING: Mutex> = Mutex::new(None); const PENDING_FOR: Duration = Duration::from_secs(2 * 3600); /// Remembers the route (JSON) of the notification just shown. Ignores anything `valid_route` refuses. pub fn set_pending(route: &str) { if valid_route(route) { if let Ok(mut p) = PENDING.lock() { *p = Some((route.to_string(), Instant::now())); } } } /// The remembered route, once (it is forgotten after it is handed out, and after two hours). pub fn take_pending() -> Option { let (route, at) = PENDING.lock().ok()?.take()?; (at.elapsed() < PENDING_FOR).then_some(route) } // ---- drawing ---- static LAST: Mutex> = Mutex::new(None); fn build_menu(app: &AppHandle, model: &[Entry]) -> tauri::Result> { let menu = Menu::new(app)?; for e in model { match e { Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?, Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?, Entry::Sub { label, items } => { let sub = Submenu::new(app, label, true)?; for it in items { if let Entry::Item { id, label, enabled } = it { sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?; } } menu.append(&sub)?; } } } Ok(menu) } /// The menu shown before the window has sent anything (English words). pub fn initial_menu(app: &AppHandle) -> tauri::Result> { build_menu(app, &menu_model(&TraySummary::default())) } /// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed. pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { let Some(tray) = app.tray_by_id("main") else { return Ok(()) }; let key = format!("{s:?}"); if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) { return Ok(()); } tray.set_tooltip(Some(tooltip_text(s)))?; tray.set_menu(Some(build_menu(app, &menu_model(s))?))?; if let Some(icon) = app.default_window_icon() { let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() }; tray.set_icon(Some(img))?; } if let Ok(mut l) = LAST.lock() { *l = Some(key); } Ok(()) }