diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs index c41616a..9a89617 100644 --- a/src-tauri/src/autostart.rs +++ b/src-tauri/src/autostart.rs @@ -133,6 +133,21 @@ fn repaired_entry(text: &str, exec: &str, exists: impl Fn(&str) -> bool) -> Opti Some(text.lines().map(|l| if l.starts_with("Exec=") { exec } else { l }).collect::>().join("\n") + "\n") } +/// False when the desktop's own autostart settings switched our entry off: those keep the file and add +/// `Hidden=true` / `X-GNOME-Autostart-enabled=false`, so the file existing is not "on". Turning it on +/// again rewrites the whole entry, which drops them. +#[cfg(any(target_os = "linux", test))] +fn entry_enabled(text: &str) -> bool { + // Only `[Desktop Entry]` keys count (an action group may say Hidden=true), and the spec + // allows spaces around `=`. + let mut in_entry = true; + !text.lines().map(str::trim).any(|l| { + if l.starts_with('[') { in_entry = l == "[Desktop Entry]"; return false; } + let Some((key, value)) = l.split_once('=') else { return false }; + in_entry && matches!((key.trim(), value.trim()), ("Hidden", "true") | ("X-GNOME-Autostart-enabled", "false")) + }) +} + /// Held across reading and writing the entry, so the startup repair and the Settings toggle can't interleave. #[cfg(target_os = "linux")] static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -157,7 +172,10 @@ pub fn refresh() { pub fn refresh() {} #[cfg(target_os = "linux")] -pub fn get() -> bool { config_home().is_some_and(|c| desktop_path(&c).exists()) } +pub fn get() -> bool { + let _lock = LOCK.lock(); + config_home().and_then(|c| std::fs::read_to_string(desktop_path(&c)).ok()).is_some_and(|text| entry_enabled(&text)) +} #[cfg(target_os = "linux")] pub fn set(on: bool) -> Result<(), String> { @@ -270,6 +288,18 @@ mod tests { assert!(exec_path(" X-Exec=\"/apps/x\"").is_none()); } + #[test] + fn a_desktop_entry_switched_off_by_the_desktop_is_not_on() { + let text = desktop_entry("ed("/opt/dealdrop"), Path::new("/i.png")); + assert!(entry_enabled(&text)); + assert!(!entry_enabled(&(text.clone() + "Hidden=true\n"))); + assert!(!entry_enabled(&text.replace("enabled=true", "enabled=false"))); + assert!(entry_enabled("Hidden=false\nX-GNOME-Autostart-enabled=true\n")); + assert!(!entry_enabled(&(text.clone() + "Hidden = true\n"))); + assert!(!entry_enabled(&text.replace("enabled=true", "enabled = false"))); + assert!(entry_enabled(&(text.clone() + "\n[Desktop Action Quit]\nHidden=true\n"))); + } + #[test] fn autostart_file_is_written_and_removed() { let base = std::env::temp_dir().join(format!("dealdrop-autostart-test-{}", std::process::id())); diff --git a/src-tauri/src/stores.rs b/src-tauri/src/stores.rs index 7bf98b6..6d77f40 100644 --- a/src-tauri/src/stores.rs +++ b/src-tauri/src/stores.rs @@ -208,9 +208,11 @@ async fn send_auth(url: &str, access: &str) -> R { pub async fn gog_wishlist(access: &str) -> R> { let v = send_auth("https://embed.gog.com/user/wishlist.json", access).await?; - let ids = parse_gog_wishlist_ids(&v); + let ids = parse_gog_wishlist_ids(&v)?; if ids.is_empty() { return Ok(vec![]); } - let (_, country, currency, _) = gog_user(access).await; + // A failed profile call must fail the wishlist (the caller keeps the last good rows), not quietly + // re-price everything in US/USD: that would pass the USD-only checks on cheapest offers and targets. + let (_, country, currency, _) = parse_gog_user(&send_auth("https://embed.gog.com/userData.json", access).await?); let mut out = vec![]; for chunk in ids.chunks(50) { @@ -225,9 +227,15 @@ pub async fn gog_wishlist(access: &str) -> R> { Ok(out) } -fn parse_gog_wishlist_ids(v: &Value) -> Vec { - v["wishlist"].as_object().into_iter().flatten() - .filter(|(_, on)| **on != false && **on != 0).map(|(k, _)| k.clone()).collect() +/// An object of ids (value false/0 = not wanted). An empty wishlist may come back as `[]`; any other +/// shape is an answer we don't understand, so the wishlist fails and the UI shows it instead of going empty. +fn parse_gog_wishlist_ids(v: &Value) -> R> { + match &v["wishlist"] { + Value::Object(map) => Ok(map.iter() + .filter(|(_, on)| **on != false && **on != 0).map(|(k, _)| k.clone()).collect()), + Value::Array(a) if a.is_empty() => Ok(vec![]), + _ => Err("GOG sent a wishlist DealDrop doesn't understand".into()), + } } fn parse_gog_wishlist_products(products: &Value, prices: &Value) -> Vec { @@ -493,11 +501,17 @@ mod unit { #[test] fn gog_wishlist_ids_skip_off_entries() { let v = json!({"wishlist": {"10": true, "20": false, "30": 0, "40": 1}}); - let mut ids = super::parse_gog_wishlist_ids(&v); + let mut ids = super::parse_gog_wishlist_ids(&v).unwrap(); ids.sort(); assert_eq!(ids, vec!["10".to_string(), "40".to_string()]); - assert!(super::parse_gog_wishlist_ids(&json!({})).is_empty()); - assert!(super::parse_gog_wishlist_ids(&json!({"wishlist": []})).is_empty()); + // An empty wishlist can come back as [] — a real empty list, not a broken answer. + assert!(super::parse_gog_wishlist_ids(&json!({"wishlist": []})).unwrap().is_empty()); + // Anything else is a shape we don't know: the wishlist must fail, not go silently empty. + let e = "GOG sent a wishlist DealDrop doesn't understand"; + assert_eq!(super::parse_gog_wishlist_ids(&json!({})).unwrap_err(), e); + assert_eq!(super::parse_gog_wishlist_ids(&json!({"wishlist": null})).unwrap_err(), e); + assert_eq!(super::parse_gog_wishlist_ids(&json!({"wishlist": ["10"]})).unwrap_err(), e); + assert_eq!(super::parse_gog_wishlist_ids(&serde_json::Value::Null).unwrap_err(), e); } #[test] @@ -813,7 +827,7 @@ mod unit { let prices = json!({"_embedded": {"items": [{"_embedded": {"product": {"id": 7}, "prices": [{"basePrice": "999 USD", "finalPrice": "500 USD"}]}}]}}); for m in mutations(&products) { for w in super::parse_gog_wishlist_products(&m, &prices) { sane("gog products", &w.price_info); } } for m in mutations(&prices) { for w in super::parse_gog_wishlist_products(&products, &m) { sane("gog prices", &w.price_info); } } - for m in mutations(&json!({"wishlist": {"7": true, "8": 0}})) { super::parse_gog_wishlist_ids(&m); } + for m in mutations(&json!({"wishlist": {"7": true, "8": 0}})) { let _ = super::parse_gog_wishlist_ids(&m); } let offer = json!({"title": "G", "id": "o1", "offerId": "o1", "productSlug": "g/home", "keyImages": [{"type": "Thumbnail", "url": "u"}], "price": {"totalPrice": {"discountPrice": 500, "originalPrice": 1000, "currencyInfo": {"decimals": 2}, "fmtPrice": {"discountPrice": "$5.00"}}}}); let sales = json!({"data": {"Catalog": {"searchStore": {"elements": [offer.clone()]}}}}); diff --git a/src-tauri/src/tui.rs b/src-tauri/src/tui.rs index ec89c9c..af93aab 100644 --- a/src-tauri/src/tui.rs +++ b/src-tauri/src/tui.rs @@ -2,7 +2,7 @@ use crate::sources::{self, Deal, Free}; use crate::stores; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -210,6 +210,11 @@ pub fn run() -> Result<(), Box> { let Event::Key(key) = event::read()? else { continue; }; + // Windows reports the key release too: without this every key would act twice. + // Repeats stay, so holding Down keeps moving. + if key.kind == KeyEventKind::Release { + continue; + } match key_action(key) { Action::Quit => break, Action::Refresh => {