From 17bceed9cf7bc93a0a2d459bb7fd9e89639c1bad Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 20:25:48 -0500 Subject: [PATCH 1/9] chore(deps): add reqwest for update checking --- src-tauri/Cargo.lock | 266 ++++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + 2 files changed, 266 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb44d31..d2bd725 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -444,6 +444,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1225,8 +1231,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1236,9 +1244,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1516,6 +1526,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1954,6 +1980,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac-notification-sys" version = "0.6.12" @@ -2425,6 +2457,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" name = "pdf-compressor" version = "1.3.0" dependencies = [ + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -2691,6 +2724,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2816,6 +2904,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -2874,6 +3000,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2902,12 +3042,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3098,6 +3279,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.19.0" @@ -3337,6 +3530,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3489,7 +3688,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -3908,6 +4107,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.2" @@ -3922,6 +4136,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4241,6 +4465,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4477,6 +4707,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4533,6 +4773,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4763,6 +5012,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5318,6 +5576,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bff16c6..9fb814f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } [dev-dependencies] tempfile = "3" From b33956b82c10f8679596b89942830dddccc4bdcf Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 20:40:58 -0500 Subject: [PATCH 2/9] feat(updater): add parse_version with tests --- src-tauri/src/lib.rs | 1 + src-tauri/src/updater.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src-tauri/src/updater.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 780c247..b77058e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod finder; pub mod menu; pub mod path_resolver; pub mod settings; +pub mod updater; #[derive(serde::Serialize)] struct FileMeta { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..8cc2750 --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,42 @@ +pub fn parse_version(tag: &str) -> Option<(u64, u64, u64)> { + let s = tag.strip_prefix('v').unwrap_or(tag); + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + return None; + } + let major = parts[0].parse::().ok()?; + let minor = parts[1].parse::().ok()?; + let patch = parts[2].parse::().ok()?; + Some((major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_version_handles_v_prefix() { + assert_eq!(parse_version("v1.3.0"), Some((1, 3, 0))); + } + + #[test] + fn parse_version_handles_no_prefix() { + assert_eq!(parse_version("1.3.0"), Some((1, 3, 0))); + } + + #[test] + fn parse_version_returns_none_for_empty() { + assert_eq!(parse_version(""), None); + } + + #[test] + fn parse_version_returns_none_for_two_parts() { + assert_eq!(parse_version("v1.0"), None); + } + + #[test] + fn parse_version_returns_none_for_prerelease() { + // "v2.0.0-beta.1" splits into 4 parts on "." → None + assert_eq!(parse_version("v2.0.0-beta.1"), None); + } +} From 79fe9c6b6fdccfaa97843a77bea421d6f3b4cad7 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 21:26:23 -0500 Subject: [PATCH 3/9] feat(updater): add is_newer with tests --- src-tauri/src/updater.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 8cc2750..633d958 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -10,6 +10,13 @@ pub fn parse_version(tag: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } +pub fn is_newer(latest: &str, current: &str) -> bool { + match (parse_version(latest), parse_version(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -39,4 +46,24 @@ mod tests { // "v2.0.0-beta.1" splits into 4 parts on "." → None assert_eq!(parse_version("v2.0.0-beta.1"), None); } + + #[test] + fn is_newer_returns_true_when_latest_is_newer() { + assert!(is_newer("v1.4.0", "1.3.0")); + } + + #[test] + fn is_newer_returns_false_for_same_version() { + assert!(!is_newer("v1.3.0", "1.3.0")); + } + + #[test] + fn is_newer_returns_false_when_older() { + assert!(!is_newer("v1.2.0", "1.3.0")); + } + + #[test] + fn is_newer_returns_false_for_unparseable_latest() { + assert!(!is_newer("v2.0.0-beta.1", "1.3.0")); + } } From f68fa61c2067835235a3b222b785b975b8a20084 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 21:28:20 -0500 Subject: [PATCH 4/9] feat(updater): add check_for_update command --- src-tauri/src/lib.rs | 2 ++ src-tauri/src/updater.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b77058e..fe173b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,6 +56,7 @@ pub fn run() { use crate::finder::reveal_in_finder; use crate::menu::{build_menu, set_menu_item_enabled}; use crate::settings::{get_settings, save_settings}; + use crate::updater::check_for_update; use tauri::{Emitter, Manager}; tauri::Builder::default() @@ -89,6 +90,7 @@ pub fn run() { validate_pdf, check_path_writable_cmd, set_menu_item_enabled, + check_for_update, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 633d958..37ae6ef 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -17,6 +17,41 @@ pub fn is_newer(latest: &str, current: &str) -> bool { } } +#[derive(serde::Deserialize)] +struct GithubRelease { + tag_name: String, +} + +#[tauri::command] +pub async fn check_for_update() -> Option { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .user_agent("compress-pdf-updater") + .build() + .ok()?; + + let release: GithubRelease = client + .get("https://api.github.com/repos/JBolanle/PDFCompressor/releases/latest") + .send() + .await + .ok()? + .json() + .await + .ok()?; + + if is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) { + Some( + release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + .to_string(), + ) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; From 59363f7243c4512f9ada780fc6746024b3a81f31 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 21:39:18 -0500 Subject: [PATCH 5/9] feat(menu): add Help > Check for Updates menu item --- src-tauri/src/lib.rs | 1 + src-tauri/src/menu.rs | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe173b3..6e7a6b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,7 @@ pub fn run() { "clear-queue" => "menu:clear-queue", "compress" => "menu:compress", "reset-selected" => "menu:reset-selected", + "check-for-update" => "menu:check-for-update", _ => return, }; let _ = app.emit(name, ()); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 34b2b3d..d8a5922 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -11,6 +11,7 @@ pub const MENU_IDS: &[&str] = &[ "clear-queue", "compress", "reset-selected", + "check-for-update", ]; pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, MenuRegistry)> { @@ -113,7 +114,27 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, Me &[&minimize, &win_sep, &close_window], )?; - let menu = Menu::with_items(app, &[&app_menu, &file_menu, &queue_menu, &window_menu])?; + // ── Help menu ───────────────────────────────────────────────────────── + let check_for_updates = MenuItem::with_id( + app, + "check-for-update", + "Check for Updates\u{2026}", + true, + None::<&str>, + )?; + + let help_menu = Submenu::with_id_and_items( + app, + "help-menu", + "Help", + true, + &[&check_for_updates], + )?; + + let menu = Menu::with_items( + app, + &[&app_menu, &file_menu, &queue_menu, &window_menu, &help_menu], + )?; let mut map = HashMap::new(); map.insert("add-files".to_string(), add_files); @@ -121,6 +142,7 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, Me map.insert("clear-queue".to_string(), clear_queue); map.insert("compress".to_string(), compress); map.insert("reset-selected".to_string(), reset); + map.insert("check-for-update".to_string(), check_for_updates); Ok((menu, MenuRegistry(Mutex::new(map)))) } From c0bd84d47dc43ec38809c1c0ca20df4f7df9cdaf Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 21:41:21 -0500 Subject: [PATCH 6/9] feat(toast): add showPersistent with action support Co-Authored-By: Claude Sonnet 4.6 --- src/lib/stores/toastStore.ts | 6 ++++++ src/test/Toast.test.ts | 42 +++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/toastStore.ts b/src/lib/stores/toastStore.ts index 78906b5..af4459d 100644 --- a/src/lib/stores/toastStore.ts +++ b/src/lib/stores/toastStore.ts @@ -3,6 +3,8 @@ import { writable } from "svelte/store"; export interface ToastMessage { id: string; message: string; + action?: { label: string; handler: () => void }; + persistent?: boolean; } function createToastStore() { @@ -14,6 +16,10 @@ function createToastStore() { update((msgs) => [...msgs, { id, message }]); setTimeout(() => update((msgs) => msgs.filter((m) => m.id !== id)), 4000); }, + showPersistent(message: string, action?: { label: string; handler: () => void }) { + const id = crypto.randomUUID(); + update((msgs) => [...msgs, { id, message, action, persistent: true }]); + }, dismiss(id: string) { update((msgs) => msgs.filter((m) => m.id !== id)); }, diff --git a/src/test/Toast.test.ts b/src/test/Toast.test.ts index 0eb4c50..f42ec94 100644 --- a/src/test/Toast.test.ts +++ b/src/test/Toast.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/svelte"; import { fireEvent } from "@testing-library/svelte"; import { toast } from "$lib/stores/toastStore"; @@ -25,4 +25,44 @@ describe("Toast", () => { expect(screen.queryByText("Click to dismiss")).not.toBeInTheDocument(); }); }); + + it("showPersistent message stays after 4 s", async () => { + vi.useFakeTimers(); + render(Toast); + toast.showPersistent("Persistent message"); + await waitFor(() => screen.getByText("Persistent message")); + vi.advanceTimersByTime(5000); + await waitFor(() => { + expect(screen.getByText("Persistent message")).toBeInTheDocument(); + }); + vi.useRealTimers(); + }); + + it("showPersistent renders an action button", async () => { + render(Toast); + toast.showPersistent("Update available", { label: "Download", handler: vi.fn() }); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Download" })).toBeInTheDocument(); + }); + }); + + it("showPersistent action button calls handler on click", async () => { + const handler = vi.fn(); + render(Toast); + toast.showPersistent("Update available", { label: "Download", handler }); + await waitFor(() => screen.getByRole("button", { name: "Download" })); + await fireEvent.click(screen.getByRole("button", { name: "Download" })); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("dismiss removes a persistent toast", async () => { + render(Toast); + toast.showPersistent("Persistent"); + await waitFor(() => screen.getByText("Persistent")); + const dismissBtn = screen.getByRole("button", { name: /dismiss/i }); + await fireEvent.click(dismissBtn); + await waitFor(() => { + expect(screen.queryByText("Persistent")).not.toBeInTheDocument(); + }); + }); }); From 0fa938399ad3914af39d54bff15de9b6fd8f09be Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 22:24:01 -0500 Subject: [PATCH 7/9] feat(toast): render action button for persistent toasts --- src/lib/components/Toast.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte index f83e300..36d96e0 100644 --- a/src/lib/components/Toast.svelte +++ b/src/lib/components/Toast.svelte @@ -7,6 +7,9 @@ {#each $toast as msg (msg.id)} {/each} @@ -16,4 +19,5 @@ .toast-container { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 200; pointer-events: none; } .toast { background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 8px 12px; display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-primary); pointer-events: all; max-width: 340px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); } .toast button { background: none; border: none; color: var(--text-tertiary); cursor: pointer; font-size: 10px; padding: 2px; flex-shrink: 0; } + .toast button.action { border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 11px; padding: 2px 8px; } From 18d6ff9d5206b90794f2052451eaa13c65223c82 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 22:26:35 -0500 Subject: [PATCH 8/9] feat: check for updates on startup and via Help menu Co-Authored-By: Claude Sonnet 4.6 --- src/routes/+page.svelte | 22 ++++++++ src/test/updater.test.ts | 107 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/test/updater.test.ts diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f1a5a65..59b7cdb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,6 +12,8 @@ import { selectedFileId } from "$lib/stores/selectionStore"; import { addFiles } from "$lib/fileActions"; import { handleShortcut, type ShortcutState } from "$lib/shortcuts"; + import { openUrl } from "@tauri-apps/plugin-opener"; + import { toast } from "$lib/stores/toastStore"; $: selectedFile = $queue.find((e) => e.id === $selectedFileId) ?? null; $: isCompressing = $queue.some((e) => e.status === "processing"); @@ -97,12 +99,32 @@ onMount(async () => { settings.load(); window.addEventListener("keydown", onKeyDown); + + const version: string | null = await invoke("check_for_update"); + if (version) { + toast.showPersistent(`v${version} is available`, { + label: "Download", + handler: () => openUrl("https://github.com/JBolanle/PDFCompressor/releases/latest"), + }); + } + unlisteners = await Promise.all([ listen("menu:add-files", () => addFiles()), listen("menu:compress", () => triggerCompress()), listen("menu:reveal-in-finder", () => revealSelected()), listen("menu:clear-queue", () => clearAll()), listen("menu:reset-selected", () => resetSelected()), + listen("menu:check-for-update", async () => { + const v: string | null = await invoke("check_for_update"); + if (v) { + toast.showPersistent(`v${v} is available`, { + label: "Download", + handler: () => openUrl("https://github.com/JBolanle/PDFCompressor/releases/latest"), + }); + } else { + toast.show("You're on the latest version"); + } + }), ]); }); diff --git a/src/test/updater.test.ts b/src/test/updater.test.ts new file mode 100644 index 0000000..c590a53 --- /dev/null +++ b/src/test/updater.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import { toast } from "$lib/stores/toastStore"; + +// Capture listen handlers so tests can trigger menu events +const listeners: Record void> = {}; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (cmd: string) => { + if (cmd === "get_settings") return { output_mode: "same_as_source", output_folder: null, naming: "suffix" }; + if (cmd === "check_for_update") return null; + return null; + }), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (event: string, handler: (e: unknown) => void) => { + listeners[event] = handler; + return () => {}; + }), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(), +})); + +import Page from "../routes/+page.svelte"; +import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +describe("update checking", () => { + beforeEach(() => { + toast.clear(); + vi.clearAllMocks(); + // Reset mock to default (no update) + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "get_settings") return { output_mode: "same_as_source", output_folder: null, naming: "suffix" }; + if (cmd === "check_for_update") return null; + return null; + }); + }); + + it("shows a persistent update toast on mount when update is available", async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "get_settings") return { output_mode: "same_as_source", output_folder: null, naming: "suffix" }; + if (cmd === "check_for_update") return "1.4.0"; + return null; + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByText("v1.4.0 is available")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Download" })).toBeInTheDocument(); + }); + + it("shows no update toast on mount when already up to date", async () => { + render(Page); + // Wait for mount to settle + await waitFor(() => expect(invoke).toHaveBeenCalledWith("check_for_update")); + expect(screen.queryByText(/is available/)).not.toBeInTheDocument(); + }); + + it("opens releases page when Download is clicked", async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "get_settings") return { output_mode: "same_as_source", output_folder: null, naming: "suffix" }; + if (cmd === "check_for_update") return "1.4.0"; + return null; + }); + + render(Page); + await waitFor(() => screen.getByRole("button", { name: "Download" })); + screen.getByRole("button", { name: "Download" }).click(); + + expect(openUrl).toHaveBeenCalledWith( + "https://github.com/JBolanle/PDFCompressor/releases/latest" + ); + }); + + it("shows update toast when menu:check-for-update fires and update is available", async () => { + render(Page); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("check_for_update")); + + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "check_for_update") return "1.5.0"; + return null; + }); + + listeners["menu:check-for-update"]?.({}); + + await waitFor(() => { + expect(screen.getByText("v1.5.0 is available")).toBeInTheDocument(); + }); + }); + + it("shows 'latest version' toast when menu:check-for-update fires and up to date", async () => { + render(Page); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("check_for_update")); + + listeners["menu:check-for-update"]?.({}); + + await waitFor(() => { + expect(screen.getByText("You're on the latest version")).toBeInTheDocument(); + }); + }); +}); From 590367c93551babb556c414d75c43622f4485872 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Thu, 7 May 2026 22:35:33 -0500 Subject: [PATCH 9/9] style: fix rustfmt formatting in menu.rs --- src-tauri/src/menu.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d8a5922..245c406 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -123,13 +123,8 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, Me None::<&str>, )?; - let help_menu = Submenu::with_id_and_items( - app, - "help-menu", - "Help", - true, - &[&check_for_updates], - )?; + let help_menu = + Submenu::with_id_and_items(app, "help-menu", "Help", true, &[&check_for_updates])?; let menu = Menu::with_items( app,