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
|
|
@ -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 <v>`. `play release --dry` shows the staged rollout share.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"allow-cache-info",
|
||||
"allow-data-reveal",
|
||||
"allow-notify",
|
||||
"allow-tray-update",
|
||||
"allow-app-reset",
|
||||
"allow-update-check",
|
||||
"allow-update-install",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
122
apps/desktop/src-tauri/tests/tray.rs
Normal file
122
apps/desktop/src-tauri/tests/tray.rs
Normal file
|
|
@ -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<String> {
|
||||
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<u8> = 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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<AppsListResult> | 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>('settings_set', { settings }),
|
||||
cacheInfo: () => invoke<CacheInfo>('cache_info'),
|
||||
revealData: () => invoke<void>('data_reveal'),
|
||||
notify: (title: string, body: string) => invoke<void>('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<void>('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<void>('tray_update', { summary }),
|
||||
/** Native confirmation first; true when everything was forgotten. */
|
||||
reset: () => invoke<boolean>('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<UnlistenFn>
|
|||
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
|
||||
/** storebound://open?path=... selected or added an app folder (its project id). */
|
||||
export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('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<UnlistenFn> => listen<string>('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<UnlistenFn> => listen<'read' | 'mute-toggle'>('tray-action', cb);
|
||||
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -784,6 +784,41 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'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<keyof typeof en, string> = {
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides {
|
|||
function pick<T>(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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
48
apps/desktop/src/lib/tray.ts
Normal file
48
apps/desktop/src/lib/tray.ts
Normal file
|
|
@ -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<string, number> = { 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 };
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -167,9 +167,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
|
||||
<Card id="set-notifications" title={t('set.notifications')} icon="bell" sub={t('set.notificationsSub')} className="set-card">
|
||||
<div className="form">
|
||||
<Toggle id="n-rejected" label={t('set.nRejected')} hint={t('set.nRejectedHint')} checked={s.notify.rejected} onChange={v => set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} />
|
||||
<Toggle id="n-approved" label={t('set.nApproved')} hint={t('set.nApprovedHint')} checked={s.notify.approved} onChange={v => set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} />
|
||||
<Toggle id="n-live" label={t('set.nLive')} hint={t('set.nLiveHint')} checked={s.notify.live} onChange={v => set(x => ({ ...x, notify: { ...x.notify, live: v } }))} />
|
||||
<Toggle id="n-review" label={t('set.nReview')} hint={t('set.nReviewHint')} checked={s.notify.reviewState} onChange={v => set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} />
|
||||
<Toggle id="n-low" label={t('set.nLow')} hint={t('set.nLowHint')} checked={s.notify.lowReviews} onChange={v => set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} />
|
||||
<Toggle id="n-crash" label={t('set.nCrash')} hint={t('set.nCrashHint')} checked={s.notify.crash} onChange={v => set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} />
|
||||
<Toggle id="n-quiet" label={t('set.quiet')} hint={t('set.quietHint')} checked={s.notify.quietHours.enabled} onChange={v => set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, enabled: v } } }))} />
|
||||
{s.notify.quietHours.enabled && <div className="row" style={{ gap: 12 }}>
|
||||
<label className="field"><span className="legend">{t('set.quietFrom')}</span><input type="time" className="input" style={{ width: 120 }} value={s.notify.quietHours.from} onChange={e => { const v = e.target.value; if (v) set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, from: v } } })); }} /></label>
|
||||
<label className="field"><span className="legend">{t('set.quietTo')}</span><input type="time" className="input" style={{ width: 120 }} value={s.notify.quietHours.to} onChange={e => { const v = e.target.value; if (v) set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, to: v } } })); }} /></label>
|
||||
</div>}
|
||||
{d.muteUntil > Date.now() && <p className="small muted" role="status">{t('set.muted', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}</p>}
|
||||
<p className="small muted">{t('set.clickNote')}</p>
|
||||
<div className="actions"><button className="btn btn-sm" onClick={() => d.setMute(d.muteUntil > Date.now() ? 0 : 60)}><Icon name="bell" />{d.muteUntil > Date.now() ? t('set.unmute', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('set.muteHour')}</button></div>
|
||||
<div className="actions"><button className="btn btn-sm" onClick={() => api.notify(t('set.testTitle'), t('set.testBody')).catch(e => setError(errorText(e)))}><Icon name="bell" />{t('set.testNotify')}</button></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
readAll: (force?: boolean) => Promise<boolean>;
|
||||
/** 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<void>;
|
||||
/** True when the read succeeded. */
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<boolean>;
|
||||
/** Reads the apps of one account (or all) from the stores. */
|
||||
importApps: (account?: string) => Promise<void>;
|
||||
checkAccount: (id: string) => Promise<void>;
|
||||
|
|
@ -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<AppInfo | null>(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<number>(() => { 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<boolean> => {
|
||||
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<ReadScheduler | null>(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<string, unknown>());
|
||||
|
||||
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
|
||||
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<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call<ToolResult>(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<ToolResult> => {
|
||||
const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached<ToolResult>(method, target, args).catch(() => null))?.value.data ?? null) : null;
|
||||
const result = await api.call<ToolResult>(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<ToolResult>({ 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<boolean> => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return true;
|
||||
let ok = true;
|
||||
let portfolioRead: Promise<void> | null = null;
|
||||
const pkey = '*\u001fportfolio.status';
|
||||
if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) {
|
||||
portfolioRead = sched.enqueue<boolean>({ 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<boolean> => {
|
||||
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<typeof setTimeout> | 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<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
|
@ -481,16 +583,19 @@ export function useWrite(projectId: string | null) {
|
|||
return { busy, run, isBusy: (method: string, args: Record<string, unknown> = {}) => 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<T>(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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
153
apps/desktop/tests/notifications.test.ts
Normal file
153
apps/desktop/tests/notifications.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue