diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d4464..5b08268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to DealDrop are documented in this file. ## [Unreleased] +- Fixed: Settings → About → **Check for updates** says GitHub couldn't be reached when the check fails, instead of "You're on the latest version." A failed check also no longer hides an update that was already offered. - Linux: DealDrop is now a **Flatpak** (`DealDrop__x86_64.flatpak`) instead of an AppImage. Install it once by hand; settings, sign-ins and "Start when I log in" carry over from the AppImage (same `~/.config/dealdrop/`). The AppImage gets no more updates. - **Update to vX.Y.Z** in the sidebar when a newer release is out (checked at start and every 6 hours). The Flatpak downloads and installs it and restarts; Windows and macOS open the release page. - Settings → About has a **Check for updates** button that says whether you're on the latest version (or that GitHub couldn't be reached). diff --git a/renderer/app.js b/renderer/app.js index d370244..0ffc338 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2014,19 +2014,26 @@ async function copyError () { announceStatus('Copied') } +// A check that couldn't reach GitHub is not "no update": keep the offer already shown. +function updateAfterCheck (shown, answer, reached) { + return reached ? answer : shown +} + // "Update to vX.Y.Z" above the error box when GitHub has a newer release for this platform. // The Flatpak installs it and restarts; other builds open the release page. -// Returns false when the check itself failed; the 6-hourly calls stay silent about that. +// Returns false when the check itself failed; the 6-hourly calls stay silent about that, +// and an update already offered stays up. async function checkUpdate () { - let ok = true - state.update = await window.api.checkUpdate().catch(() => { ok = false; return null }) - if (ok && !state.update && state.updateError) { state.updateError = null; renderErrors() } // the failed update is gone (installed by hand, or withdrawn) + let reached = true + const answer = await window.api.checkUpdate().catch(() => { reached = false; return null }) + state.update = updateAfterCheck(state.update, answer, reached) + if (reached && !state.update && state.updateError) { state.updateError = null; renderErrors() } // the failed update is gone (installed by hand, or withdrawn) const box = document.getElementById('sidebar-update') const btn = document.getElementById('btn-update') - if (!box || !btn || btn.disabled) return ok + if (!box || !btn || btn.disabled) return reached box.style.display = state.update ? 'block' : 'none' if (state.update) btn.textContent = `Update to v${state.update.version}` - return ok + return reached } // Settings → About: a check the user asked for, so it says what happened (textContent: the version comes from the network). diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a609801..9494281 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1042,13 +1042,21 @@ fn get_autostart() -> bool { autostart::get() } #[tauri::command] fn set_autostart(on: bool) -> Result<(), String> { autostart::set(on) } +/// `Ok(None)` means this platform is up to date. An error stays an error: the UI reads `None` +/// as "You're on the latest version", so a GitHub failure must not look like that. +fn offered_update(found: Result, String>, in_flatpak: bool) -> Result, String> { + found.map(|up| up.map(|u| Update { can_install: u.bundle.is_some() && in_flatpak, ..u })) +} + /// A newer release for this platform, if any (the UI asks at start and every 6 hours). +/// Errors reject the command so Settings → About can say GitHub couldn't be reached. #[tauri::command] -async fn check_update() -> Option { - match latest_release().await { - Ok(up) => up.map(|u| Update { can_install: u.bundle.is_some() && update::in_flatpak(), ..u }), - Err(e) => { eprintln!("[update check] {e}"); None } - } +async fn check_update() -> Result, String> { + let found = match latest_release().await { + Ok(up) => Ok(up), + Err(e) => { eprintln!("[update check] {e}"); Err(e) } + }; + offered_update(found, update::in_flatpak()) } /// Flatpak only: downloads the newest release's bundle, installs it and restarts into it. @@ -1217,9 +1225,29 @@ fn main() { #[cfg(test)] mod tests { - use super::{LiveSale, days_from_civil, sale_soon_notice, sale_starting_tomorrow, SALES, clamp_cut, dedupe_free, failure, free_notice, fresh_big_deals, fresh_free, fresh_wishlist, is_hidden, keep_or_fail, keep_prices, carry_cheapest, carry_deal_lowest, normalize_hidden, normalize_targets, target_hits, target_notice, price_failure, CS_BLOCKED, normalize_title, remember_big_deals, remember_free, remember_wishlist, restore_by_prefix, safe_https, sale_key, should_prime, store_label, tray_counts, tray_tooltip, with_count, wishlist_notice, load_db, proton_suffix, Acct, Db, Deal, Free, Price, Prime, Settings, SteamProfile, Tokens, WishItem, accounts, same_refresh, BIG_DEAL_CUTS, WISHLIST_CUTS}; + use super::{LiveSale, days_from_civil, sale_soon_notice, sale_starting_tomorrow, SALES, clamp_cut, dedupe_free, failure, free_notice, fresh_big_deals, fresh_free, fresh_wishlist, is_hidden, keep_or_fail, keep_prices, carry_cheapest, carry_deal_lowest, normalize_hidden, normalize_targets, target_hits, target_notice, price_failure, CS_BLOCKED, normalize_title, remember_big_deals, remember_free, remember_wishlist, restore_by_prefix, safe_https, sale_key, should_prime, store_label, tray_counts, tray_tooltip, with_count, wishlist_notice, load_db, proton_suffix, Acct, Db, Deal, Free, Price, Prime, Settings, SteamProfile, Tokens, Update, WishItem, accounts, offered_update, same_refresh, BIG_DEAL_CUTS, WISHLIST_CUTS}; use std::collections::{HashMap, HashSet}; + #[test] + fn a_failed_update_check_is_not_the_same_as_being_up_to_date() { + let bundle = Some("https://github.com/SusieeTheLinuxUser/dealdrop/releases/download/v0.10.0/DealDrop_0.10.0_x86_64.flatpak".into()); + let up = Update { + version: "0.10.0".into(), + page: "https://github.com/SusieeTheLinuxUser/dealdrop/releases/tag/v0.10.0".into(), + bundle: bundle.clone(), + can_install: false, + }; + let flatpak = offered_update(Ok(Some(up.clone())), true).unwrap().unwrap(); + assert!(flatpak.can_install); // only the Flatpak installs the bundle itself + assert_eq!(flatpak.bundle, bundle); + let other = offered_update(Ok(Some(up)), false).unwrap().unwrap(); + assert!(!other.can_install); // Windows, macOS, and a non-Flatpak Linux build open the release page + assert!(!offered_update(Ok(Some(Update { bundle: None, ..other })), true).unwrap().unwrap().can_install); + assert_eq!(offered_update(Ok(None), true).unwrap(), None); // a real "nothing newer" + // the About button reads a rejected command as "Couldn't reach GitHub", not as up to date + assert_eq!(offered_update(Err("HTTP 503".into()), true), Err("HTTP 503".into())); + } + #[cfg(unix)] #[test] fn a_config_that_cant_be_moved_aside_stops_saving() { diff --git a/tools/preview/selfcheck.mjs b/tools/preview/selfcheck.mjs index c8c03b7..3325e0c 100644 --- a/tools/preview/selfcheck.mjs +++ b/tools/preview/selfcheck.mjs @@ -31,10 +31,11 @@ vm.runInContext([ between('function nextTheme (', '\nfunction syncSaleTheme'), between('function wishlistLowestTag (', '\n// ─── Price targets'), between('const saleKey =', '\n// Live sale →'), + between('function updateAfterCheck (', '\n// "Update to v'), between('const targetKey', '\nfunction wishlistTargetTag'), between('const nextIndex', '\n// Moves focus between'), between('const fmt$ =', '\n// CSP forbids inline onerror'), - 'globalThis.helpers = { SALES, state, filteredSorted, storeKeyFor, escHtml, updateRefreshAge, mergeUiPrefs, dealShops, parseClaimed, pruneClaimed, toggleClaimed, claimedLast, normalizeTitle, hiddenTitle, searchRank, parseSeen, newIds, mergeSeen, wishlistKey, seenIds, salesSeenIds, PAGE_SIZE, pageSlice, nextTheme, targetKey, targetPrice, targetHit, parseTarget, protonTier, protonText, protonTag, nextIndex, fmt$, salePriceDescription, dataSig, timeLeft, endsFirst, endDateLabel, startDateLabel, fmtChecked, gameStoreName, srcIcon, wishlistLowestTag, saleKey, BASE_THEMES, SALE_THEMES }', + 'globalThis.helpers = { SALES, state, filteredSorted, storeKeyFor, escHtml, updateRefreshAge, mergeUiPrefs, dealShops, parseClaimed, pruneClaimed, toggleClaimed, claimedLast, normalizeTitle, hiddenTitle, searchRank, parseSeen, newIds, mergeSeen, wishlistKey, seenIds, salesSeenIds, PAGE_SIZE, pageSlice, nextTheme, targetKey, targetPrice, targetHit, parseTarget, protonTier, protonText, protonTag, nextIndex, fmt$, salePriceDescription, dataSig, timeLeft, endsFirst, endDateLabel, startDateLabel, fmtChecked, gameStoreName, srcIcon, wishlistLowestTag, saleKey, updateAfterCheck, BASE_THEMES, SALE_THEMES }', ].join('\n'), context) const h = context.helpers @@ -360,6 +361,13 @@ assert.equal(h.saleKey({ name: 'Autumn Sale' }), 'autumn') assert.equal(h.saleKey({ name: 'Weekly Deals' }), undefined) assert.equal(h.saleKey({ name: 'Steam Scream Fest' }), undefined) // a fest, not a seasonal sale +// A failed update check keeps the offer already shown; only a reached answer replaces it +const offered = { version: '0.10.0' } +assert.equal(h.updateAfterCheck(offered, null, false), offered) +assert.equal(h.updateAfterCheck(null, null, false), null) +assert.equal(h.updateAfterCheck(offered, null, true), null) // reached, and nothing newer: the offer is gone +assert.equal(h.updateAfterCheck(null, offered, true), offered) + // Countdowns: the d/h/soon split is the floor of the exact instant delta (never a wall-clock // day count, which would shift across a DST night); relative durations keep this runnable any day for (const [ms, label] of [[2.5 * 86400000, '2d left'], [86400000 + 60000, '1d left'], [86400000 - 60000, '23h left'], [5 * 3600000 - 60000, '4h left'], [3600000 + 60000, '1h left'], [3600000 - 60000, 'ending soon']]) { @@ -381,4 +389,4 @@ for (const t of jsThemes) { for (const t of cssThemes) assert.equal(jsThemes.has(t), true, `style.css theme "${t}" is missing from BASE_THEMES/SALE_THEMES in app.js`) assert.equal(jsThemes.has('midnight'), true) // and the root default is offered in Settings -console.log('Renderer self-check passed (price targets, escaping, hidden titles, search ranking, filters, store filter, UI preference merge, claimed games, seen deals, list paging, next sale, refresh age, sale themes, J/K navigation, money and date labels, poll data signature, ProtonDB tiers, lowest-ever rule, sale keys, countdown thresholds, theme/CSS consistency).') +console.log('Renderer self-check passed (price targets, escaping, hidden titles, search ranking, filters, store filter, UI preference merge, claimed games, seen deals, list paging, next sale, refresh age, sale themes, J/K navigation, money and date labels, poll data signature, ProtonDB tiers, lowest-ever rule, sale keys, countdown thresholds, theme/CSS consistency, failed update check).')