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:
parent
5d8a0f5e9a
commit
03ef999dac
27 changed files with 1050 additions and 55 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub const COMMANDS: &[&str] = &[
|
|||
"cache_info",
|
||||
"data_reveal",
|
||||
"notify",
|
||||
"tray_update",
|
||||
"app_reset",
|
||||
"update_check",
|
||||
"update_install",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
278
apps/desktop/src-tauri/src/tray.rs
Normal file
278
apps/desktop/src-tauri/src/tray.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue