From 03ef999dacee1adb7757c29ed19e27a1978e9cae Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 22:24:05 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 7 + apps/desktop/src-tauri/capabilities/main.json | 1 + .../autogenerated/tray_update.toml | 11 + apps/desktop/src-tauri/src/commands.rs | 16 +- apps/desktop/src-tauri/src/commands_list.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 31 +- apps/desktop/src-tauri/src/tray.rs | 278 ++++++++++++++++++ apps/desktop/src-tauri/src/workspace.rs | 35 ++- apps/desktop/src-tauri/tests/tray.rs | 122 ++++++++ apps/desktop/src/App.tsx | 8 +- apps/desktop/src/api.ts | 15 +- apps/desktop/src/dev/fixtures.ts | 2 +- apps/desktop/src/i18n/en.ts | 41 ++- apps/desktop/src/i18n/tr.ts | 41 ++- apps/desktop/src/lib/normalize.ts | 8 +- apps/desktop/src/lib/notify.ts | 72 ++++- apps/desktop/src/lib/tray.ts | 48 +++ apps/desktop/src/nav.ts | 12 + apps/desktop/src/screens/Settings.tsx | 11 + apps/desktop/src/store.tsx | 156 ++++++++-- .../state/d1-missing-folders.host.json | 8 + .../tests/fixtures/state/d3-full.host.json | 8 + .../tests/fixtures/state/empty.host.json | 8 + .../fixtures/state/shared-profiles.host.json | 8 + apps/desktop/tests/identity.test.ts | 2 +- apps/desktop/tests/notifications.test.ts | 153 ++++++++++ cli/lib/desktop-protocol.ts | 2 + 27 files changed, 1050 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml create mode 100644 apps/desktop/src-tauri/src/tray.rs create mode 100644 apps/desktop/src-tauri/tests/tray.rs create mode 100644 apps/desktop/src/lib/tray.ts create mode 100644 apps/desktop/tests/notifications.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a19ab85..0220309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped + every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each + with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour. + Opening the window after a notification lands on that app. +- **Tray:** Open, Read the stores now, an Apps menu with each app's state on both stores, Mute, Check for updates, + Settings, Quit; a tooltip with what is in review and what needs you; a red dot on the icon after a rejection or halt. + - `apple listing` refused by an approved version waiting for a manual release now names the way out: `storebound apple release --manual-release --version `. `play release --dry` shows the staged rollout share. diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 89a4039..3d1bd60 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -43,6 +43,7 @@ "allow-cache-info", "allow-data-reveal", "allow-notify", + "allow-tray-update", "allow-app-reset", "allow-update-check", "allow-update-install", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml new file mode 100644 index 0000000..1e61a83 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-tray-update" +description = "Enables the tray_update command without any pre-configured scope." +commands.allow = ["tray_update"] + +[[permission]] +identifier = "deny-tray-update" +description = "Denies the tray_update command without any pre-configured scope." +commands.deny = ["tray_update"] diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index d22ad97..445cb59 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -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) -> Res<()> { guard(&window)?; use tauri_plugin_notification::NotificationExt; let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::(); - 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 diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 3846067..abb8da4 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -35,6 +35,7 @@ pub const COMMANDS: &[&str] = &[ "cache_info", "data_reveal", "notify", + "tray_update", "app_reset", "update_check", "update_install", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dd9272d..0eb43e0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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(app: &AppHandle) { 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, diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000..e3a653f --- /dev/null +++ b/apps/desktop/src-tauri/src/tray.rs @@ -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, + 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(()) +} diff --git a/apps/desktop/src-tauri/src/workspace.rs b/apps/desktop/src-tauri/src/workspace.rs index e8e7ab3..b1917a2 100644 --- a/apps/desktop/src-tauri/src/workspace.rs +++ b/apps/desktop/src-tauri/src/workspace.rs @@ -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::().is_ok_and(|h| h < 24) && s[3..].parse::().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()); } diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs new file mode 100644 index 0000000..0707c06 --- /dev/null +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -0,0 +1,122 @@ +//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings. +use storebound_desktop::tray::*; +use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings}; + +fn summary() -> TraySummary { + TraySummary { + tooltip: "storebound · 2 in review · 1 needs you".into(), + attention: 1, + muted: false, + apps: vec![ + TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() }, + TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }, + ], + labels: TrayLabels::default(), + } +} + +fn ids(m: &[Entry]) -> Vec { + m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect() +} + +#[test] +fn menu_has_every_entry_in_order() { + let m = menu_model(&summary()); + assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]); + let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; + assert_eq!(label, "Apps"); + assert_eq!(items.len(), 2); + let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; + assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true)); + assert!(label.contains("First") && label.contains("In review"), "{label}"); + let Entry::Item { label, .. } = &items[1] else { panic!() }; + assert_eq!(label, "Second", "an app without a state shows its name only"); +} + +#[test] +fn mute_entry_follows_the_state_and_empty_apps_say_so() { + let mut s = summary(); + s.muted = true; + s.apps.clear(); + let m = menu_model(&s); + let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() }; + assert_eq!(label, "Unmute notifications"); + let Entry::Sub { items, .. } = &m[3] else { panic!() }; + assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet")); +} + +#[test] +fn tooltip_is_one_short_line() { + assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you"); + let mut s = summary(); + s.tooltip = "a\nb\t c".into(); + assert_eq!(tooltip_text(&s), "a b c"); + s.tooltip = "x".repeat(500); + assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP); + s.tooltip = " ".into(); + assert_eq!(tooltip_text(&s), "storebound"); +} + +#[test] +fn clicks_map_to_actions() { + assert_eq!(action_of(ID_OPEN), Some(Action::Open)); + assert_eq!(action_of(ID_QUIT), Some(Action::Quit)); + assert_eq!(action_of(ID_READ), Some(Action::Event("read"))); + assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle"))); + assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into()))); + assert_eq!(action_of("tray-app:"), None); + assert_eq!(action_of("other"), None); + let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() }; + assert!(valid_route(&r) && r.contains("updates")); +} + +#[test] +fn only_known_small_routes_are_remembered() { + assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#)); + assert!(valid_route(r#"{"screen":"portfolio"}"#)); + assert!(!valid_route(r#"{"screen":"evil"}"#)); + assert!(!valid_route(r#"{"screen":"app","appId":5}"#)); + assert!(!valid_route("[]")); + assert!(!valid_route("not json")); + assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500)))); + // handed out once + set_pending(r#"{"screen":"portfolio"}"#); + assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#)); + assert_eq!(take_pending(), None); + set_pending(r#"{"screen":"evil"}"#); + assert_eq!(take_pending(), None); +} + +#[test] +fn attention_dot_changes_only_the_corner() { + let (w, h) = (32u32, 32u32); + let base: Vec = std::iter::repeat([10u8, 20, 30, 255]).take((w * h) as usize).flatten().collect(); + let out = with_attention_dot(&base, w, h); + assert_eq!(out.len(), base.len()); + let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] }; + assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays"); + let dot = at(&out, 25, 6); + assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}"); + // transparent icon: the dot is opaque, the rest stays clear + let clear = vec![0u8; (w * h * 4) as usize]; + let out = with_attention_dot(&clear, w, h); + assert_eq!(at(&out, 2, 30)[3], 0); + assert_eq!(at(&out, 25, 6)[3], 255); + // a wrong buffer size is returned unchanged + assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]); +} + +#[test] +fn quiet_hours_settings_are_checked_and_default_off() { + let d = Settings::default(); + assert!(!d.notify.quiet_hours.enabled); + assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00")); + assert!(d.notify.rejected && d.notify.approved && d.notify.live); + assert!(valid_hhmm("00:00") && valid_hhmm("23:59")); + assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd")); + let mut s = d.clone(); + s.notify.quiet_hours.from = "25:00".into(); + assert!(check_settings(&s).is_err()); + let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap(); + assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults"); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4c77cad..a72750e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,12 +1,12 @@ // Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api'; +import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { usePrefs } from './prefs-ctx'; import { Accounts } from './screens/Accounts'; import { AppFolders } from './screens/AppFolders'; @@ -67,7 +67,9 @@ export function App({ onTheme }: { onTheme: () => void }) { const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); }); // `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); }); - return () => { un.then(f => f()); unFolder.then(f => f()); }; + // the tray menu (an app, Settings, Check for updates) and the first show after a notification + const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); }); + return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); }; }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 68cce68..a431266 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -6,6 +6,7 @@ import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event'; import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol'; import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol'; import type { Overrides } from './lib/identity'; +import type { TraySummary } from './lib/tray'; export type { AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult, @@ -33,10 +34,11 @@ export interface Settings { /** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */ refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 }; cache: { keepDays: 0 | 7 | 30 | 90 | 365 }; - notify: { reviewState: boolean; lowReviews: boolean; crash: boolean }; + /** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */ + notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } }; channel: 'stable' | 'beta'; } -export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' }; +export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' }; export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings } export interface AccountApps { account: string; entry: CacheEntry | null } export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number } @@ -121,7 +123,10 @@ export const api = { setSettings: (settings: Settings) => invoke('settings_set', { settings }), cacheInfo: () => invoke('cache_info'), revealData: () => invoke('data_reveal'), - notify: (title: string, body: string) => invoke('notify', { title, body }), + /** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */ + notify: (title: string, body: string, target?: string) => invoke('notify', { title, body, target: target ?? null }), + /** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */ + trayUpdate: (summary: TraySummary) => invoke('tray_update', { summary }), /** Native confirmation first; true when everything was forgotten. */ reset: () => invoke('app_reset'), /** Offline (folders) or live; live also reads the given store-only apps (store targets). */ @@ -172,4 +177,8 @@ export const onDownload = (cb: (e: DownloadEvent) => void): Promise export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', cb); /** storebound://open?path=... selected or added an app folder (its project id). */ export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise => listen('deep-link-folder', cb); +/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */ +export const onTrayRoute = (cb: (route: string) => void): Promise => listen('tray-route', cb); +/** A tray menu click that is not navigation: read the stores now, toggle mute. */ +export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise => listen<'read' | 'mute-toggle'>('tray-action', cb); export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise => listen('update-progress', cb); diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 9acb44a..0b60aa7 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -569,7 +569,7 @@ async function answer(cmd: string, a: Record): Promise case 'overrides_set': overrides = a.overrides as Overrides; return overrides; case 'settings_set': settings = a.settings as Settings; return settings; case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 }; - case 'data_reveal': case 'notify': return null; + case 'data_reveal': case 'notify': case 'tray_update': return null; case 'app_reset': return false; case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null }; case 'update_install': return false; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c400f03..f989257 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -782,6 +782,41 @@ export const en = { 'notify.lowTitle': '{name}: {count} new low ratings', 'notify.lowTitle_one': '{name}: a new low rating', 'notify.crashTitle': '{name}: over the Play threshold', + 'notify.rejectedTitle': '{name}: rejected by {store}', + 'notify.rejectedBody': 'Version {version} was rejected.', + 'notify.approvedTitle': '{name}: approved', + 'notify.approvedBody': '{store} {version} passed review and is ready for release.', + 'notify.liveTitle': '{name}: live', + 'notify.liveBody': '{store} {version} is live.', + 'notify.haltedTitle': '{name}: rollout halted', + 'notify.haltedBody': '{store} {version} was halted.', + 'tray.open': 'Open storebound', + 'tray.readNow': 'Read the stores now', + 'tray.apps': 'Apps', + 'tray.noApps': 'No apps yet', + 'tray.noState': 'no release read yet', + 'tray.mute': 'Mute notifications for 1 hour', + 'tray.unmute': 'Unmute notifications', + 'tray.update': 'Check for updates', + 'tray.settings': 'Settings', + 'tray.quit': 'Quit storebound', + 'tray.tipReview': '{count} in review', + 'tray.tipAttention': '{count} needs you', + 'tray.mutedTip': 'muted', + 'set.nRejected': 'Rejected', + 'set.nRejectedHint': 'A version the store rejected, with its reason when the store gives one', + 'set.nApproved': 'Approved and ready for release', + 'set.nApprovedHint': 'A version passed review', + 'set.nLive': 'Live', + 'set.nLiveHint': 'A version reached the store page or a rollout', + 'set.quiet': 'Quiet hours', + 'set.quietHint': 'No notifications in this window (they are dropped, not saved for later)', + 'set.quietFrom': 'From', + 'set.quietTo': 'To', + 'set.muteHour': 'Mute for 1 hour', + 'set.unmute': 'Unmute ({until})', + 'set.muted': 'Muted until {until}', + 'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.", 'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.', 'set.sections': 'Settings sections', 'set.studios': 'Studios & accounts', @@ -854,11 +889,11 @@ export const en = { 'set.always': 'Always', 'set.notifications': 'Notifications', 'set.notificationsSub': 'After a read, when something changed since the previous one', - 'set.nReview': 'Review and release state changes', - 'set.nReviewHint': 'In review, approved, rejected, live, halted', + 'set.nReview': 'Other release state changes', + 'set.nReviewHint': 'Submitted, in review', 'set.nLow': 'New 1 or 2 star reviews', 'set.nLowHint': 'When the Reviews tab reads new ones', - 'set.nCrash': 'Crash or ANR rate over the threshold', + 'set.nCrash': 'Rollout halted, or crash / ANR over the threshold', 'set.nCrashHint': 'Google Play bad behavior thresholds', 'set.testNotify': 'Send a test notification', 'set.testTitle': 'storebound', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index afc278c..0fc5ff5 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -784,6 +784,41 @@ export const tr: Record = { 'notify.lowTitle': '{name}: {count} yeni düşük puan', 'notify.lowTitle_one': '{name}: yeni bir düşük puan', 'notify.crashTitle': '{name}: Play eşiğinin üstünde', + 'notify.rejectedTitle': '{name}: {store} reddetti', + 'notify.rejectedBody': '{version} sürümü reddedildi.', + 'notify.approvedTitle': '{name}: onaylandı', + 'notify.approvedBody': '{store} {version} incelemeyi geçti, yayına hazır.', + 'notify.liveTitle': '{name}: yayında', + 'notify.liveBody': '{store} {version} yayında.', + 'notify.haltedTitle': '{name}: kademeli yayın durduruldu', + 'notify.haltedBody': '{store} {version} durduruldu.', + 'tray.open': "storebound'u aç", + 'tray.readNow': 'Mağazaları şimdi oku', + 'tray.apps': 'Uygulamalar', + 'tray.noApps': 'Henüz uygulama yok', + 'tray.noState': 'henüz okunmadı', + 'tray.mute': 'Bildirimleri 1 saat sustur', + 'tray.unmute': 'Bildirimlerin sesini aç', + 'tray.update': 'Güncellemeleri denetle', + 'tray.settings': 'Ayarlar', + 'tray.quit': "storebound'dan çık", + 'tray.tipReview': '{count} incelemede', + 'tray.tipAttention': '{count} sizi bekliyor', + 'tray.mutedTip': 'sessiz', + 'set.nRejected': 'Reddedildi', + 'set.nRejectedHint': 'Mağazanın reddettiği sürüm; mağaza verirse gerekçesiyle', + 'set.nApproved': 'Onaylandı, yayına hazır', + 'set.nApprovedHint': 'Bir sürüm incelemeyi geçti', + 'set.nLive': 'Yayında', + 'set.nLiveHint': 'Bir sürüm mağaza sayfasına ya da kademeli yayına ulaştı', + 'set.quiet': 'Sessiz saatler', + 'set.quietHint': 'Bu aralıkta bildirim gelmez (sonraya saklanmaz, atılır)', + 'set.quietFrom': 'Başlangıç', + 'set.quietTo': 'Bitiş', + 'set.muteHour': '1 saat sustur', + 'set.unmute': 'Sesi aç ({until})', + 'set.muted': '{until} saatine kadar sessiz', + 'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.', 'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.', 'set.sections': 'Ayar bölümleri', 'set.studios': 'Stüdyolar ve hesaplar', @@ -856,11 +891,11 @@ export const tr: Record = { 'set.always': 'Her zaman', 'set.notifications': 'Bildirimler', 'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse', - 'set.nReview': 'İnceleme ve yayın durumu değişiklikleri', - 'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu', + 'set.nReview': 'Diğer yayın durumu değişiklikleri', + 'set.nReviewHint': 'Gönderildi, incelemede', 'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar', 'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda', - 'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde', + 'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde', 'set.nCrashHint': 'Google Play kötü davranış eşikleri', 'set.testNotify': 'Deneme bildirimi gönder', 'set.testTitle': 'storebound', diff --git a/apps/desktop/src/lib/normalize.ts b/apps/desktop/src/lib/normalize.ts index 36e7844..8b584de 100644 --- a/apps/desktop/src/lib/normalize.ts +++ b/apps/desktop/src/lib/normalize.ts @@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides { function pick(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; } const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x); const isBool = (x: unknown) => typeof x === 'boolean'; +const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x); export function normalizeSettings(v: unknown): Settings { const d = DEFAULT_SETTINGS; if (!isRec(v)) return d; const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {}; + const q = isRec(n.quietHours) ? n.quietHours : {}; return { refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool), autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) }, cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) }, - notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) }, + notify: { + reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool), + lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool), + quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) }, + }, channel: v.channel === 'beta' ? 'beta' : 'stable', }; } diff --git a/apps/desktop/src/lib/notify.ts b/apps/desktop/src/lib/notify.ts index 247f8ba..e6b48f0 100644 --- a/apps/desktop/src/lib/notify.ts +++ b/apps/desktop/src/lib/notify.ts @@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api'; import { sameVersion } from '../../../../cli/lib/portfolio'; import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model'; -export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string } +/** Which Settings > Notifications toggle a release change belongs to. */ +export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state'; +export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string } + +/** The notification kind of a transition into phase `to`.*/ +export function releaseKind(to: string): ReleaseKind { + if (to === 'rejected') return 'rejected'; + if (to === 'approved') return 'approved'; + if (to === 'live' || to === 'rollout') return 'live'; + if (to === 'halted') return 'halted'; + return 'state'; +} /** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */ export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] { @@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P if (!r.version) continue; const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version))); if (!old || old.phase === r.phase) continue; - if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase }); + // a version the developer withdrew on their own is not a store decision + if (r.state === 'DEVELOPER_REJECTED') continue; + if (WATCH.has(r.phase) || WATCH.has(old.phase)) { + const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined; + out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) }); + } } } return out; @@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k); return out; } + +// ---- when a notification may show: kind toggle, quiet hours, mute ---- + +export interface QuietHours { enabled: boolean; from: string; to: string } +export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours } +export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test'; + +/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */ +export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean { + switch (kind) { + case 'rejected': return n.rejected; + case 'approved': return n.approved; + case 'live': return n.live; + case 'halted': case 'crash': return n.crash; + case 'state': return n.reviewState; + case 'lowReviews': return n.lowReviews; + case 'test': return true; + } +} + +const minutesOf = (hhmm: string): number | null => { + const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm); + return m ? Number(m[1]) * 60 + Number(m[2]) : null; +}; + +/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */ +export function inQuietHours(q: QuietHours, now: Date): boolean { + if (!q.enabled) return false; + const from = minutesOf(q.from), to = minutesOf(q.to); + if (from === null || to === null || from === to) return false; + const cur = now.getHours() * 60 + now.getMinutes(); + return from < to ? cur >= from && cur < to : cur >= from || cur < to; +} + +/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */ +export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now; + +/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */ +export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean { + if (kind === 'test') return true; + const now = o.now ?? new Date(); + return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime()); +} + +/** + * Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most + * x16, capped at 2 hours before the jitter). `rand` is in [0, 1). + */ +export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number { + const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4); + return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2)); +} diff --git a/apps/desktop/src/lib/tray.ts b/apps/desktop/src/lib/tray.ts new file mode 100644 index 0000000..7211e2b --- /dev/null +++ b/apps/desktop/src/lib/tray.ts @@ -0,0 +1,48 @@ +// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host +// (src-tauri/src/tray.rs) only draws it. +import type { AppStatus } from '../api'; +import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol'; + +export interface TrayApp { key: string; name: string; state: string } +export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string } +export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels } + +type Phase = ReleasePhase; + +/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */ +const RANK: Record = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 }; + +/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */ +export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } { + const out: { ios?: Phase; android?: Phase } = {}; + for (const r of s?.release.all ?? []) { + const cur = out[r.store]; + if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase; + } + return out; +} + +/** True for a phase that needs the owner (rejected, halted). */ +export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted'; +export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent'; + +/** + * Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the + * caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps. + */ +export function traySummary( + apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean, + tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels }, +): TraySummary { + let review = 0, attention = 0; + const list: TrayApp[] = []; + for (const a of apps) { + const ph = trayPhases(a.status); + if (inReview(ph.ios) || inReview(ph.android)) review++; + if (needsYou(ph.ios) || needsYou(ph.android)) attention++; + const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`); + list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none }); + } + const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean); + return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels }; +} diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index b438256..a6429e1 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -34,6 +34,18 @@ export function parseStart(v: string, apps: string[]): Route | null { return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; } +/** A route sent as JSON by the host (tray menu, notification target); anything that is not a known route is null. */ +export function parseRouteJson(v: string): Route | null { + let o: unknown; + try { o = JSON.parse(v); } catch { return null; } + if (!o || typeof o !== 'object') return null; + const r = o as Record; + if (r.screen === 'portfolio') return { screen: 'portfolio' }; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null; + if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; + return null; +} + export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b); export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void } diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 96ded11..3342203 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -167,9 +167,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
+ set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, live: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, enabled: v } } }))} /> + {s.notify.quietHours.enabled &&
+ + +
} + {d.muteUntil > Date.now() &&

{t('set.muted', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}

} +

{t('set.clickNote')}

+
diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 38c8bbd..cca9d6d 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -4,18 +4,20 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload, - onProgress, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult, + onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult, } from './api'; import { t } from './i18n'; import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity'; import { isError, type HealthSnapshot, type Review } from './lib/model'; -import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify'; +import { crashCrossed, mayNotify, type NoticeKind, newLowReviews, nextReadDelay, type ReleaseChange, releaseChanges } from './lib/notify'; +import { traySummary } from './lib/tray'; import { setCrashContext } from './lib/crash'; import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize'; import { keepLines, progressOf } from './lib/progress'; import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness'; import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler'; import { fixHint } from './lib/views'; +import type { Route } from './nav'; import { withLastGood } from '../../../cli/lib/train'; import { failureText, usable } from './lib/results'; @@ -92,12 +94,17 @@ export interface Data { onRead: (key: string, fn: (e: ReadEvent) => void) => () => void; isReading: (key: string) => boolean; /** Reads everything of every app that is past its TTL (force: everything). */ - readAll: (force?: boolean) => Promise; + readAll: (force?: boolean) => Promise; + /** Notifications are muted until this time (epoch ms; 0 = not muted). */ + muteUntil: number; + /** Mutes notifications for `minutes` (0 = unmute). */ + setMute: (minutes: number) => void; /** After a write: the app's reads are read again. */ invalidate: (target: string) => void; /** The app on screen (its reads go first). */ setFocus: (key: string | null) => void; - refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise; + /** True when the read succeeded. */ + refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise; /** Reads the apps of one account (or all) from the stores. */ importApps: (account?: string) => Promise; checkAccount: (id: string) => Promise; @@ -128,6 +135,19 @@ const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at( /** A failure line with how to fix it, when the error is a known one. */ const withHint = (msg: string | undefined): string | undefined => { const h = fixHint(msg); return h && msg ? `${msg} · ${t(h)}` : msg; }; +const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play'); +/** Title and body of the notification of one release change (by kind; a rejection adds the store's reason when it has one). */ +export function releaseText(c: ReleaseChange): [string, string] { + const v = { name: c.name, store: storeName(c.store), version: c.version }; + switch (c.kind) { + case 'rejected': return [t('notify.rejectedTitle', v), [t('notify.rejectedBody', v), c.reason].filter(Boolean).join(' ')]; + case 'approved': return [t('notify.approvedTitle', v), t('notify.approvedBody', v)]; + case 'live': return [t('notify.liveTitle', v), t('notify.liveBody', v)]; + case 'halted': return [t('notify.haltedTitle', v), t('notify.haltedBody', v)]; + default: return [t('notify.releaseTitle', v), t('notify.releaseBody', { ...v, phase: t(`phase.${c.to}` as 'phase.live') })]; + } +} + export function DataProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [info, setInfo] = useState(null); @@ -153,6 +173,21 @@ export function DataProvider({ children }: { children: ReactNode }) { const settingsRef = useRef(settings); settingsRef.current = settings; + // Mute for a while (the tray's "Mute notifications for 1 hour"): kept across restarts + const [muteUntil, setMuteUntil] = useState(() => { try { return Number(localStorage.getItem('storebound.muteUntil')) || 0; } catch { return 0; } }); + const muteRef = useRef(muteUntil); + muteRef.current = muteUntil; + const setMute = useCallback((minutes: number) => { + const until = minutes > 0 ? Date.now() + minutes * 60_000 : 0; + setMuteUntil(until); muteRef.current = until; + try { localStorage.setItem('storebound.muteUntil', String(until)); } catch { /* private mode: mute lasts until the window closes */ } + }, []); + /** One desktop notification: its kind's toggle, quiet hours and mute decide; `route` is where the window opens when it is next shown from the tray. */ + const notice = useCallback((kind: NoticeKind, title: string, body: string, route?: Route): void => { + if (!mayNotify(settingsRef.current.notify, kind, { muteUntil: muteRef.current })) return; + api.notify(title, body, route ? JSON.stringify(route) : undefined).catch(() => {}); + }, []); + const dismiss = useCallback((id: string) => { setToasts(ts => ts.filter(x => x.id !== id)); const tm = timers.current.get(id); @@ -196,7 +231,7 @@ export function DataProvider({ children }: { children: ReactNode }) { const appsRef = useRef(apps); appsRef.current = apps; - const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => { + const refreshPortfolio = useCallback(async (liveRead = false, quiet = false): Promise => { const job = newJob(liveRead ? 'portfolio-live' : 'portfolio'); setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null); const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null; @@ -210,8 +245,11 @@ export function DataProvider({ children }: { children: ReactNode }) { if (liveRead) { const changes = releaseChanges(liveRef.current?.value, r); setLive({ value: r, at: r.at }); - if (settingsRef.current.notify.reviewState) for (const c of changes.slice(0, 5)) - api.notify(t('notify.releaseTitle', { name: c.name }), t('notify.releaseBody', { store: c.store === 'ios' ? 'App Store' : 'Google Play', version: c.version, phase: t(`phase.${c.to}` as 'phase.live') })).catch(() => {}); + for (const c of changes.slice(0, 5)) { + const [title, body] = releaseText(c); + const app = appsRef.current.find(a => a.target === c.project); + notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined); + } const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length; if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined } : { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined }); @@ -220,8 +258,10 @@ export function DataProvider({ children }: { children: ReactNode }) { const msg = errorText(e); setPortfolioError(msg); if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined }); + return false; } finally { setBusy(null); } - }, [toast, updateToast]); + return true; + }, [toast, updateToast, notice]); // ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ---- const schedRef = useRef(null); @@ -252,22 +292,42 @@ export function DataProvider({ children }: { children: ReactNode }) { return c; }; const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined); + const lastData = useRef(new Map()); const schedule = useCallback((target: string, method: string, args: Record, priority: Priority): Promise => { const key = readKey(target, method, args); const app = appsRef.current.find(a => a.target === target); const pf = platformsOf(app, app ? statusRef.current(app) : undefined); const lanes = lanesOf(method, pf); - const p = sched.enqueue({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call(method, target, { args }, newJob(method)) }); + // reviews and health: compared with the previous read here (not in a card), so the tray app notices them with no window open + const watch = method === 'reviews.list' || method === 'health'; + const run = async (): Promise => { + const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached(method, target, args).catch(() => null))?.value.data ?? null) : null; + const result = await api.call(method, target, { args }, newJob(method)); + if (watch && usable(method, result)) { + notifyAfterRead(method, before, result.data, notice, app?.name, app?.key); + lastData.current.set(key, result.data); + } + return result; + }; + const p = sched.enqueue({ key, lanes, accounts: accountsOf(app, lanes), priority, run }); emitRead(key, { kind: 'start' }); p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) })); return p; - }, [sched, emitRead]); + }, [sched, emitRead, notice]); - const readAll = useCallback(async (force = false) => { - if (!force && !settingsRef.current.refresh.autoRead) return; - if (force || isStale('portfolio.status', liveRef.current?.at)) { - sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {}); + /** + * Reads everything past its TTL. The portfolio read is a LIVE one (refreshPortfolio(true)): it is what releaseChanges + * compares, so a rejection is seen even when no screen asked. Resolves when that read is done: false when it failed. + */ + const readAll = useCallback(async (force = false): Promise => { + if (!force && !settingsRef.current.refresh.autoRead) return true; + let ok = true; + let portfolioRead: Promise | null = null; + const pkey = '*\u001fportfolio.status'; + if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) { + portfolioRead = sched.enqueue({ key: pkey, lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }) + .then(r => { ok = r !== false; }, () => { ok = false; }); } const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing) .sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current)); @@ -283,6 +343,8 @@ export function DataProvider({ children }: { children: ReactNode }) { schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {}); } } + await portfolioRead; + return ok; }, [sched, schedule, refreshPortfolio]); const invalidate = useCallback((target: string) => { @@ -353,27 +415,48 @@ export function DataProvider({ children }: { children: ReactNode }) { useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]); // Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after - // a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits). + // a while, and every N minutes (with a little jitter) EVEN WHILE THE WINDOW IS HIDDEN: the app lives in the tray, and + // that is where a rejection has to be noticed. One pass at a time; after a failed pass the wait doubles (nextReadDelay). const launched = useRef(false); useEffect(() => { if (!ready || launched.current || !apps.length) return; launched.current = true; if (settings.refresh.autoRead) readAll(false).catch(() => {}); }, [ready, apps.length, settings.refresh.autoRead, readAll]); + const passing = useRef(false); useEffect(() => { if (!settings.refresh.autoRead) return; let hiddenAt = 0; + const pass = async (): Promise => { + if (passing.current) return true; + passing.current = true; + try { return await readAll(false); } catch { return false; } finally { passing.current = false; } + }; const vis = () => { if (document.hidden) { hiddenAt = Date.now(); return; } - if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {}); + if (hiddenAt && Date.now() - hiddenAt > 60_000) pass().catch(() => {}); hiddenAt = 0; }; document.addEventListener('visibilitychange', vis); const m = settings.refresh.everyMinutes; - const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null; - return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); }; + let tm: ReturnType | null = null; + let stopped = false, failures = 0; + const next = () => { + if (stopped || !m) return; + tm = setTimeout(async () => { failures = (await pass()) ? 0 : failures + 1; next(); }, nextReadDelay(m, failures)); + }; + next(); + return () => { stopped = true; document.removeEventListener('visibilitychange', vis); if (tm) clearTimeout(tm); }; }, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]); + // the tray: tooltip, attention icon and the apps list follow every read; its menu asks for a read or the mute + const readRef = useRef(readAll); + readRef.current = readAll; + useEffect(() => { + const un = onTrayAction(a => { if (a === 'read') readRef.current(true).catch(() => {}); else setMute(isMutedNow() ? 0 : 60); }); + return () => { un.then(f => f()); }; + }, [setMute]); + const isMutedNow = () => muteRef.current > Date.now(); // icons: cached first, then read (offline, cheap) useEffect(() => { for (const p of projects) { @@ -419,14 +502,33 @@ export function DataProvider({ children }: { children: ReactNode }) { const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]); const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]); statusRef.current = statusFor; + + useEffect(() => { + if (!ready) return; + const muted = muteUntil > Date.now(); + const sum = traySummary( + apps.filter(a => !a.ignored).map(a => ({ key: a.key, name: a.name, status: liveFor(a) ?? statusFor(a) })), muted, + { phaseText: p => t(`phase.${p}` as 'phase.live'), store: s => (s === 'ios' ? 'App Store' : 'Play'), none: t('tray.noState'), muted: t('tray.mutedTip'), + tipReview: n => t('tray.tipReview', { count: n }), tipAttention: n => t('tray.tipAttention', { count: n }), + labels: { open: t('tray.open'), readNow: t('tray.readNow'), apps: t('tray.apps'), noApps: t('tray.noApps'), mute: t('tray.mute'), unmute: t('tray.unmute'), update: t('tray.update'), settings: t('tray.settings'), quit: t('tray.quit') } }, + ); + api.trayUpdate(sum).catch(() => {}); + }, [ready, apps, live, portfolio, muteUntil, statusFor, liveFor]); + // the mute ends by itself: the tray menu follows + useEffect(() => { + if (muteUntil <= Date.now()) return; + const tm = setTimeout(() => setMuteUntil(0), muteUntil - Date.now() + 500); + return () => clearTimeout(tm); + }, [muteUntil]); + const readerState = useMemo(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]); const value = useMemo(() => ({ ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, - importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, + importBusy, icons, reader: readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey, toasts, toast, updateToast, dismiss, cancelJob, - }), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, + }), [readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey, toasts, toast, updateToast, dismiss, cancelJob]); return {children}; @@ -481,16 +583,19 @@ export function useWrite(projectId: string | null) { return { busy, run, isBusy: (method: string, args: Record = {}) => busy === `${method}:${JSON.stringify(args)}` }; } +type Notice = (kind: NoticeKind, title: string, body: string, route?: Route) => void; + /** Notifications a fresh read may trigger (Settings > Notifications), compared with the read it replaces. */ -function notifyAfterRead(method: string, prev: unknown, next: unknown, settings: Settings, appName: string | undefined): void { +function notifyAfterRead(method: string, prev: unknown, next: unknown, notice: Notice, appName: string | undefined, appKey: string | undefined): void { const name = appName ?? ''; - if (method === 'reviews.list' && settings.notify.lowReviews && Array.isArray(next)) { + const route = (tab: 'reviews' | 'health'): Route | undefined => (appKey ? { screen: 'app', appId: appKey, tab } : undefined); + if (method === 'reviews.list' && Array.isArray(next)) { const fresh = newLowReviews(prev as Review[] | null, next as Review[]); - if (fresh.length) api.notify(t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim()).catch(() => {}); + if (fresh.length) notice('lowReviews', t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim(), route('reviews')); } - if (method === 'health' && settings.notify.crash && next && typeof next === 'object') { + if (method === 'health' && next && typeof next === 'object') { const crossed = crashCrossed(prev as HealthSnapshot | null, next as HealthSnapshot); - if (crossed.length && !isError((next as HealthSnapshot).play)) api.notify(t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', ')).catch(() => {}); + if (crossed.length && !isError((next as HealthSnapshot).play)) notice('crash', t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', '), route('health')); } } @@ -528,7 +633,6 @@ export function useRead(target: string, method: string, o: { network: boolean else { const r = e.result; if (usable(method, r)) { - notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName); set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command }); } else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command })); } diff --git a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json index fc2b8b4..e644176 100644 --- a/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json +++ b/apps/desktop/tests/fixtures/state/d1-missing-folders.host.json @@ -33,8 +33,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/d3-full.host.json b/apps/desktop/tests/fixtures/state/d3-full.host.json index 42fd2c9..72ab459 100644 --- a/apps/desktop/tests/fixtures/state/d3-full.host.json +++ b/apps/desktop/tests/fixtures/state/d3-full.host.json @@ -78,8 +78,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/empty.host.json b/apps/desktop/tests/fixtures/state/empty.host.json index cfb10be..505375f 100644 --- a/apps/desktop/tests/fixtures/state/empty.host.json +++ b/apps/desktop/tests/fixtures/state/empty.host.json @@ -16,8 +16,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/fixtures/state/shared-profiles.host.json b/apps/desktop/tests/fixtures/state/shared-profiles.host.json index 38ac38f..2103add 100644 --- a/apps/desktop/tests/fixtures/state/shared-profiles.host.json +++ b/apps/desktop/tests/fixtures/state/shared-profiles.host.json @@ -75,8 +75,16 @@ }, "channel": "stable", "notify": { + "approved": true, "crash": true, + "live": true, "lowReviews": true, + "quietHours": { + "enabled": false, + "from": "23:00", + "to": "08:00" + }, + "rejected": true, "reviewState": true }, "refresh": { diff --git a/apps/desktop/tests/identity.test.ts b/apps/desktop/tests/identity.test.ts index fd80815..2c2f3f3 100644 --- a/apps/desktop/tests/identity.test.ts +++ b/apps/desktop/tests/identity.test.ts @@ -128,7 +128,7 @@ describe('notifications', () => { test('release phase changes, only against a previous read', () => { expect(releaseChanges(null, status('review'))).toEqual([]); expect(releaseChanges(status('review'), status('review'))).toEqual([]); - expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved' }]); + expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved', kind: 'approved' }]); expect(releaseChanges(status('draft'), status('testing'))).toEqual([]); }); test('new 1-2 star reviews and threshold crossings', () => { diff --git a/apps/desktop/tests/notifications.test.ts b/apps/desktop/tests/notifications.test.ts new file mode 100644 index 0000000..2f29d47 --- /dev/null +++ b/apps/desktop/tests/notifications.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test'; +import type { PortfolioResult } from '../src/api'; +import { DEFAULT_SETTINGS } from '../src/api'; +import { inQuietHours, isMuted, kindEnabled, mayNotify, nextReadDelay, releaseChanges } from '../src/lib/notify'; +import { normalizeSettings } from '../src/lib/normalize'; +import { parseRouteJson } from '../src/nav'; +import { traySummary, trayPhases } from '../src/lib/tray'; + +type Rel = { store: 'ios' | 'android'; version: string; state: string; phase: string; reason?: string }; +const snap = (...rels: Rel[]): PortfolioResult => ({ + apps: [{ project: '/a', ok: true, app: { id: '/a', name: 'A', platforms: { ios: null, android: null } }, summary: null, check: null, catalog: null, storeErrors: {}, errors: [], + release: { source: 'live' as const, current: null, next: null, all: rels.map(r => ({ build: '1', fraction: null, track: null, ...r })) } }], + at: 'x', +} as unknown as PortfolioResult); +const ios = (state: string, phase: string, extra: Partial = {}): Rel => ({ store: 'ios', version: '2.0', state, phase, ...extra }); +const play = (state: string, phase: string): Rel => ({ store: 'android', version: '2.0', state, phase }); + +describe('releaseChanges: transitions the owner waits for', () => { + test('App Store in review -> rejected', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('REJECTED', 'rejected'))); + expect(c).toHaveLength(1); + expect(c[0]).toMatchObject({ store: 'ios', version: '2.0', from: 'review', to: 'rejected', kind: 'rejected' }); + }); + test('metadata rejected counts, and the reason text comes along', () => { + const c = releaseChanges(snap(ios('WAITING_FOR_REVIEW', 'review')), snap(ios('METADATA_REJECTED', 'rejected', { reason: ' Guideline 2.1 - missing demo account ' }))); + expect(c[0]).toMatchObject({ kind: 'rejected', reason: 'Guideline 2.1 - missing demo account' }); + }); + test('Play in review -> rejected', () => { + const c = releaseChanges(snap(play('inProgress', 'review')), snap(play('rejected', 'rejected'))); + expect(c[0]).toMatchObject({ store: 'android', kind: 'rejected' }); + }); + test('approved (pending developer release)', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('PENDING_DEVELOPER_RELEASE', 'approved'))); + expect(c[0]).toMatchObject({ to: 'approved', kind: 'approved' }); + }); + test('live, from review and from approved; a rollout counts as live', () => { + expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); + expect(releaseChanges(snap(ios('PENDING_DEVELOPER_RELEASE', 'approved')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live'); + expect(releaseChanges(snap(play('inProgress', 'review')), snap(play('inProgress', 'rollout')))[0]!.kind).toBe('live'); + }); + test('halted rollout', () => { + expect(releaseChanges(snap(play('inProgress', 'rollout')), snap(play('halted', 'halted')))[0]!.kind).toBe('halted'); + }); + test('submitted -> in review is the generic kind; nothing changed or a first read is silent', () => { + expect(releaseChanges(snap(ios('PREPARE_FOR_SUBMISSION', 'draft')), snap(ios('WAITING_FOR_REVIEW', 'review')))[0]!.kind).toBe('state'); + expect(releaseChanges(snap(ios('REJECTED', 'rejected')), snap(ios('REJECTED', 'rejected')))).toEqual([]); + expect(releaseChanges(null, snap(ios('REJECTED', 'rejected')))).toEqual([]); + }); + test('a version the developer withdrew is not a store decision', () => { + expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('DEVELOPER_REJECTED', 'rejected')))).toEqual([]); + }); + test('one store rejected while the other is live: only the rejected one', () => { + const c = releaseChanges(snap(ios('IN_REVIEW', 'review'), play('completed', 'live')), snap(ios('REJECTED', 'rejected'), play('completed', 'live'))); + expect(c.map(x => x.store)).toEqual(['ios']); + }); +}); + +describe('who may notify: toggles, quiet hours, mute', () => { + const n = DEFAULT_SETTINGS.notify; + const at = (h: number, m = 0) => new Date(2026, 9, 22, h, m); + test('each kind has its own toggle (halted shares the crash toggle)', () => { + expect(kindEnabled({ ...n, rejected: false }, 'rejected')).toBe(false); + expect(kindEnabled({ ...n, rejected: false }, 'approved')).toBe(true); + expect(kindEnabled({ ...n, approved: false }, 'approved')).toBe(false); + expect(kindEnabled({ ...n, live: false }, 'live')).toBe(false); + expect(kindEnabled({ ...n, crash: false }, 'halted')).toBe(false); + expect(kindEnabled({ ...n, crash: false }, 'crash')).toBe(false); + expect(kindEnabled({ ...n, lowReviews: false }, 'lowReviews')).toBe(false); + expect(kindEnabled({ ...n, reviewState: false }, 'state')).toBe(false); + }); + test('quiet hours are off by default and cross midnight', () => { + expect(inQuietHours(n.quietHours, at(23, 30))).toBe(false); + const q = { enabled: true, from: '23:00', to: '08:00' }; + expect(inQuietHours(q, at(22, 59))).toBe(false); + expect(inQuietHours(q, at(23, 0))).toBe(true); + expect(inQuietHours(q, at(3))).toBe(true); + expect(inQuietHours(q, at(7, 59))).toBe(true); + expect(inQuietHours(q, at(8, 0))).toBe(false); + expect(inQuietHours(q, at(12))).toBe(false); + }); + test('quiet hours inside one day, equal ends and bad times', () => { + const q = { enabled: true, from: '13:00', to: '14:30' }; + expect(inQuietHours(q, at(13, 0))).toBe(true); + expect(inQuietHours(q, at(14, 30))).toBe(false); + expect(inQuietHours({ enabled: true, from: '09:00', to: '09:00' }, at(9))).toBe(false); + expect(inQuietHours({ enabled: true, from: '9am', to: '10:00' }, at(9, 30))).toBe(false); + }); + test('mute for an hour', () => { + const now = at(12).getTime(); + expect(isMuted(0, now)).toBe(false); + expect(isMuted(now + 3_600_000, now)).toBe(true); + expect(isMuted(now - 1, now)).toBe(false); + }); + test('mayNotify combines them; a test notification always shows', () => { + const q = { ...n, quietHours: { enabled: true, from: '23:00', to: '08:00' } }; + expect(mayNotify(n, 'rejected', { now: at(12) })).toBe(true); + expect(mayNotify(q, 'rejected', { now: at(2) })).toBe(false); + expect(mayNotify(n, 'rejected', { now: at(12), muteUntil: at(12).getTime() + 1000 })).toBe(false); + expect(mayNotify({ ...n, rejected: false }, 'rejected', { now: at(12) })).toBe(false); + expect(mayNotify(q, 'test', { now: at(2), muteUntil: at(2).getTime() + 1000 })).toBe(true); + }); + test('settings from an older file get the new notify fields', () => { + const s = normalizeSettings({ notify: { reviewState: false, quietHours: { enabled: true, from: 'x', to: '07:30' } } }); + expect(s.notify).toMatchObject({ reviewState: false, rejected: true, approved: true, live: true, quietHours: { enabled: true, from: '23:00', to: '07:30' } }); + expect(normalizeSettings({ refresh: { everyMinutes: 30 } }).refresh.everyMinutes).toBe(30); + expect(DEFAULT_SETTINGS.refresh.everyMinutes).toBe(15); + }); +}); + +describe('background reading pace', () => { + test('interval with jitter, doubled per failure, capped', () => { + expect(nextReadDelay(15, 0, 0.5)).toBe(15 * 60_000); + expect(nextReadDelay(15, 0, 0)).toBe(Math.round(15 * 60_000 * 0.9)); + expect(nextReadDelay(15, 0, 0.999)).toBeLessThanOrEqual(Math.round(15 * 60_000 * 1.1)); + expect(nextReadDelay(15, 1, 0.5)).toBe(30 * 60_000); + expect(nextReadDelay(15, 2, 0.5)).toBe(60 * 60_000); + expect(nextReadDelay(15, 9, 0.5)).toBe(2 * 3_600_000); + expect(nextReadDelay(5, -3, 0.5)).toBe(5 * 60_000); + }); +}); + +describe('tray summary and routes', () => { + const app = (key: string, name: string, ...rels: Rel[]) => ({ key, name, status: snap(...rels).apps[0] }); + const tr = { phaseText: (p: string) => p, store: (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Play'), none: '-', muted: 'muted', + tipReview: (n: number) => `${n} in review`, tipAttention: (n: number) => `${n} needs you`, + labels: { open: 'o', readNow: 'r', apps: 'a', noApps: 'n', mute: 'm', unmute: 'u', update: 'U', settings: 's', quit: 'q' } }; + test('counts, attention and one-word states', () => { + const s = traySummary([ + app('1', 'One', ios('IN_REVIEW', 'review'), play('completed', 'live')), + app('2', 'Two', ios('REJECTED', 'rejected')), + app('3', 'Three', ios('READY_FOR_SALE', 'live'), ios('PREPARE_FOR_SUBMISSION', 'draft')), + { key: '4', name: 'Four', status: undefined }, + ], false, tr); + expect(s.tooltip).toBe('storebound · 1 in review · 1 needs you'); + expect(s.attention).toBe(1); + expect(s.apps.map(a => a.state)).toEqual(['App Store: review · Play: live', 'App Store: rejected', 'App Store: live', '-']); + }); + test('muted shows in the tooltip; nothing going on is just the name', () => { + expect(traySummary([app('1', 'One', ios('READY_FOR_SALE', 'live'))], true, tr).tooltip).toBe('storebound · muted'); + expect(traySummary([], false, tr).tooltip).toBe('storebound'); + }); + test('the most pressing release of a store wins', () => { + expect(trayPhases(snap(ios('READY_FOR_SALE', 'live'), { ...ios('IN_REVIEW', 'review'), version: '3.0' }).apps[0]).ios).toBe('review'); + }); + test('routes from the host are checked', () => { + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"reviews"}')).toEqual({ screen: 'app', appId: 'k', tab: 'reviews' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"nope"}')).toEqual({ screen: 'app', appId: 'k', tab: 'releases' }); + expect(parseRouteJson('{"screen":"settings","section":"updates"}')).toEqual({ screen: 'settings', section: 'updates' }); + expect(parseRouteJson('{"screen":"app"}')).toBeNull(); + expect(parseRouteJson('{"screen":"x"}')).toBeNull(); + expect(parseRouteJson('junk')).toBeNull(); + }); +}); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 5594864..b1ad271 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -171,6 +171,8 @@ export interface StoreRelease { version: string | null; /** iOS build number (CFBundleVersion) / highest Android versionCode. */ build: string | null; + /** The store's reason text of a rejection, when the status carries one (the state word alone does not). */ + reason?: string | null; /** The store's own state word (READY_FOR_SALE, inProgress ...). */ state: string; phase: ReleasePhase;