storebound/apps/desktop/src-tauri/src/tray.rs

278 lines
11 KiB
Rust

//! 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<TrayApp>,
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<Entry> },
}
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::<Vec<_>>().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<Entry> {
let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true };
let l = &s.labels;
let apps: Vec<Entry> = 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::<String>()), 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<Action> {
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::<Value>(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<u8> {
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<Option<(String, Instant)>> = 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<String> {
let (route, at) = PENDING.lock().ok()?.take()?;
(at.elapsed() < PENDING_FOR).then_some(route)
}
// ---- drawing ----
static LAST: Mutex<Option<String>> = Mutex::new(None);
fn build_menu<R: Runtime>(app: &AppHandle<R>, model: &[Entry]) -> tauri::Result<Menu<R>> {
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<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
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<R: Runtime>(app: &AppHandle<R>, 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(())
}