Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests

This commit is contained in:
CRTkafa 2026-10-22 22:24:05 +08:00
parent 5d8a0f5e9a
commit 03ef999dac
27 changed files with 1050 additions and 55 deletions

View file

@ -1259,11 +1259,23 @@ pub fn data_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppSt
/// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window
/// decides when, from Settings > Notifications; text is plain and short.
#[tauri::command]
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String) -> Res<()> {
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String, target: Option<String>) -> Res<()> {
guard(&window)?;
use tauri_plugin_notification::NotificationExt;
let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::<String>();
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))?;
// desktop notifications have no click callback: remember where this one points; the window goes there when it is next shown
if let Some(t) = target {
crate::tray::set_pending(&t);
}
Ok(())
}
/// The window's summary for the tray (tooltip, attention flag, apps with their state, menu words) after a read.
#[tauri::command]
pub fn tray_update(window: WebviewWindow, app: AppHandle, summary: crate::tray::TraySummary) -> Res<()> {
guard(&window)?;
crate::tray::apply(&app, &summary).map_err(|e| Fail::host(e.to_string()))
}
/// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders

View file

@ -35,6 +35,7 @@ pub const COMMANDS: &[&str] = &[
"cache_info",
"data_reveal",
"notify",
"tray_update",
"app_reset",
"update_check",
"update_install",

View file

@ -14,6 +14,7 @@ pub mod selftest;
pub mod updates;
pub mod sidecar;
pub mod store;
pub mod tray;
pub mod workspace;
include!("commands_list.rs");
@ -42,20 +43,27 @@ fn started_hidden() -> bool {
std::env::args().any(|a| a == "--hidden")
}
/// Tray icon: a click shows the window; the menu has Open and Quit (the only real quit on Windows and Linux, where
/// closing the window keeps the app running in the tray).
/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their
/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux,
/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every
/// read (`tray_update`).
fn tray(app: &tauri::App) -> tauri::Result<()> {
let open = MenuItem::with_id(app, "tray-open", "Open storebound", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "tray-quit", "Quit storebound", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &PredefinedMenuItem::separator(app)?, &quit])?;
let menu = tray::initial_menu(app.handle())?;
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false);
if let Some(icon) = app.default_window_icon() {
b = b.icon(icon.clone());
}
b.on_menu_event(|app, e| match e.id().as_ref() {
"tray-open" => show_main(app),
"tray-quit" => app.exit(0),
_ => {}
b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) {
Some(tray::Action::Open) => show_main(app),
Some(tray::Action::Quit) => app.exit(0),
Some(tray::Action::Event(word)) => {
let _ = app.emit_to(policy::MAIN, "tray-action", word);
}
Some(tray::Action::Route(route)) => {
show_main(app);
let _ = app.emit_to(policy::MAIN, "tray-route", route);
}
None => {}
})
.on_tray_icon_event(|t, e| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e {
@ -96,6 +104,10 @@ fn show_main<R: Runtime>(app: &AppHandle<R>) {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
// the last notification's target (the plugin has no click callback on desktop): the window goes there now
if let Some(route) = tray::take_pending() {
let _ = app.emit_to(policy::MAIN, "tray-route", route);
}
}
}
@ -318,6 +330,7 @@ pub fn run() {
commands::cache_info,
commands::data_reveal,
commands::notify,
commands::tray_update,
commands::app_reset,
commands::update_check,
commands::update_install,

View file

@ -0,0 +1,278 @@
//! 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(())
}

View file

@ -269,9 +269,39 @@ pub struct CacheSettings {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NotifySettings {
/// Other release state changes (submitted, in review).
pub review_state: bool,
/// A version the store rejected.
pub rejected: bool,
/// A version that passed review (ready for release).
pub approved: bool,
/// A version that went live or started rolling out.
pub live: bool,
pub low_reviews: bool,
/// A Play crash / ANR rate over the threshold, or a halted rollout.
pub crash: bool,
pub quiet_hours: QuietHours,
}
/// No notification between `from` and `to` (HH:MM, local time; the window may cross midnight).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct QuietHours {
pub enabled: bool,
pub from: String,
pub to: String,
}
impl Default for QuietHours {
fn default() -> QuietHours {
QuietHours { enabled: false, from: "23:00".into(), to: "08:00".into() }
}
}
/// "HH:MM" with a valid hour and minute.
pub fn valid_hhmm(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 5 && b[2] == b':' && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) && s[..2].parse::<u8>().is_ok_and(|h| h < 24) && s[3..].parse::<u8>().is_ok_and(|m| m < 60)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -300,7 +330,7 @@ impl Default for CacheSettings {
impl Default for NotifySettings {
fn default() -> NotifySettings {
NotifySettings { review_state: true, low_reviews: true, crash: true }
NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() }
}
}
@ -327,6 +357,9 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
if !KEEP_DAYS.contains(&s.cache.keep_days) {
return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into());
}
if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) {
return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into());
}
if !["stable", "beta"].contains(&s.channel.as_str()) {
return Err("channel: stable or beta".into());
}