From a7d37bd74e42906c88300d6909045420fe2b85c8 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 15:39:18 -0500 Subject: [PATCH 1/6] feat: add auto_update_check field to Settings --- src-tauri/src/path_resolver.rs | 4 ++++ src-tauri/src/settings.rs | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src-tauri/src/path_resolver.rs b/src-tauri/src/path_resolver.rs index 6e566a5..cc234fc 100644 --- a/src-tauri/src/path_resolver.rs +++ b/src-tauri/src/path_resolver.rs @@ -27,6 +27,7 @@ mod tests { output_mode: OutputMode::SameAsSource, output_folder: None, naming: NamingMode::Suffix, + auto_update_check: false, } } @@ -45,6 +46,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/home/user/output".into()), naming: NamingMode::Suffix, + auto_update_check: false, }; let result = resolve_output_path("/home/user/docs/report.pdf", &settings); assert_eq!( @@ -59,6 +61,7 @@ mod tests { output_mode: OutputMode::SameAsSource, output_folder: None, naming: NamingMode::Overwrite, + auto_update_check: false, }; let result = resolve_output_path("/home/user/docs/report.pdf", &settings); assert_eq!(result, PathBuf::from("/home/user/docs/report.pdf")); @@ -70,6 +73,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/out".into()), naming: NamingMode::Overwrite, + auto_update_check: false, }; let result = resolve_output_path("/home/user/docs/report.pdf", &settings); assert_eq!(result, PathBuf::from("/out/report.pdf")); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 0c8f736..7059bd7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -21,6 +21,8 @@ pub struct Settings { pub output_mode: OutputMode, pub output_folder: Option, pub naming: NamingMode, + #[serde(default)] + pub auto_update_check: bool, } impl Default for Settings { @@ -29,6 +31,7 @@ impl Default for Settings { output_mode: OutputMode::SameAsSource, output_folder: None, naming: NamingMode::Suffix, + auto_update_check: false, } } } @@ -93,6 +96,7 @@ mod tests { output_mode: OutputMode::SameAsSource, output_folder: None, naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_ok()); } @@ -104,6 +108,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some(tmp.path().to_string_lossy().into_owned()), naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_ok()); } @@ -114,6 +119,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("relative/path".into()), naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_err()); } @@ -124,6 +130,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/nonexistent/path/that/should/not/exist/xyz".into()), naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_err()); } @@ -137,6 +144,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some(file.to_string_lossy().into_owned()), naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_err()); } @@ -147,6 +155,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/tmp/has\nnewline".into()), naming: NamingMode::Suffix, + auto_update_check: false, }; assert!(validate_settings(&s).is_err()); } @@ -160,6 +169,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/my/folder".into()), naming: NamingMode::Overwrite, + auto_update_check: false, }; save_settings_to_path(&original, &path).unwrap(); @@ -184,4 +194,32 @@ mod tests { assert!(s.output_folder.is_none()); assert!(matches!(s.naming, NamingMode::Suffix)); } + + #[test] + fn auto_update_check_defaults_to_false() { + let s = Settings::default(); + assert!(!s.auto_update_check); + } + + #[test] + fn auto_update_check_round_trips_as_true() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("settings.json"); + let original = Settings { + auto_update_check: true, + ..Settings::default() + }; + save_settings_to_path(&original, &path).unwrap(); + let loaded = load_settings_from_path(&path).unwrap(); + assert!(loaded.auto_update_check); + } + + #[test] + fn auto_update_check_defaults_when_absent_from_json() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("settings.json"); + std::fs::write(&path, r#"{"output_mode":"same_as_source","naming":"suffix"}"#).unwrap(); + let loaded = load_settings_from_path(&path).unwrap(); + assert!(!loaded.auto_update_check); + } } From 0347f6b3489d56babf5b158233dbea21bcf97f04 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 15:43:19 -0500 Subject: [PATCH 2/6] feat: add CheckMenuItem for auto update check to Help menu --- src-tauri/src/menu.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d30a2ca..2ecb3a1 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Mutex; use tauri::include_image; -use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::menu::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; pub struct MenuRegistry(pub Mutex>>); @@ -14,7 +14,7 @@ pub const MENU_IDS: &[&str] = &[ "check-for-update", ]; -pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, MenuRegistry)> { +pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, MenuRegistry, CheckMenuItem)> { // ── App menu (compress[pdf]) ────────────────────────────────────────── let about = PredefinedMenuItem::about( app, @@ -115,6 +115,15 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, Me )?; // ── Help menu ───────────────────────────────────────────────────────── + let auto_update = CheckMenuItem::with_id( + app, + "check-for-update-auto", + "Check for Updates Automatically", + true, + false, + None::<&str>, + )?; + let help_sep = PredefinedMenuItem::separator(app)?; let check_for_updates = MenuItem::with_id( app, "check-for-update", @@ -123,8 +132,13 @@ 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, + &[&auto_update, &help_sep, &check_for_updates], + )?; let menu = Menu::with_items( app, @@ -139,7 +153,7 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, Me map.insert("reset-selected".to_string(), reset); map.insert("check-for-update".to_string(), check_for_updates); - Ok((menu, MenuRegistry(Mutex::new(map)))) + Ok((menu, MenuRegistry(Mutex::new(map)), auto_update)) } #[tauri::command] From 4bae8df56ae1bd3fb44927e43e7abfc85d758206 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 15:50:11 -0500 Subject: [PATCH 3/6] feat: initialise CheckMenuItem state and wire check-for-update-auto event --- src-tauri/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20a8bde..7efdc26 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,7 +50,7 @@ pub fn run() { use crate::compress::compress_files; use crate::finder::reveal_in_finder; use crate::menu::{build_menu, set_menu_item_enabled}; - use crate::settings::{get_settings, save_settings}; + use crate::settings::{get_settings, load_settings_from_path, save_settings, settings_file_path}; use crate::updater::check_for_update; use tauri::{Emitter, Manager}; @@ -60,8 +60,13 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .setup(|app| { - let (menu, registry) = build_menu(app.handle())?; + let (menu, registry, auto_update_item) = build_menu(app.handle())?; app.set_menu(menu)?; + + let saved = load_settings_from_path(&settings_file_path(app.handle())) + .unwrap_or_default(); + auto_update_item.set_checked(saved.auto_update_check).ok(); + app.handle().on_menu_event(|app, event| { let name = match event.id().as_ref() { "add-files" => "menu:add-files", @@ -70,6 +75,7 @@ pub fn run() { "compress" => "menu:compress", "reset-selected" => "menu:reset-selected", "check-for-update" => "menu:check-for-update", + "check-for-update-auto" => "menu:check-for-update-auto", _ => return, }; let _ = app.emit(name, ()); From 2a882d494412fb6f208da8acf54937b98db3de9b Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 15:52:01 -0500 Subject: [PATCH 4/6] feat: add auto_update_check to frontend AppSettings --- src/lib/stores/settingsStore.ts | 2 ++ src/test/settingsStore.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/settingsStore.ts b/src/lib/stores/settingsStore.ts index bffd2e5..6981e6f 100644 --- a/src/lib/stores/settingsStore.ts +++ b/src/lib/stores/settingsStore.ts @@ -5,12 +5,14 @@ export interface AppSettings { output_mode: "same_as_source" | "custom_folder"; output_folder: string | null; naming: "suffix" | "overwrite"; + auto_update_check: boolean; } const DEFAULT_SETTINGS: AppSettings = { output_mode: "same_as_source", output_folder: null, naming: "suffix", + auto_update_check: false, }; function createSettingsStore() { diff --git a/src/test/settingsStore.test.ts b/src/test/settingsStore.test.ts index e7631ac..1fe17ad 100644 --- a/src/test/settingsStore.test.ts +++ b/src/test/settingsStore.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { get } from "svelte/store"; -// Mock @tauri-apps/api/core BEFORE importing the store vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue({ output_mode: "custom_folder", output_folder: "/my/folder", naming: "overwrite", + auto_update_check: true, }), })); @@ -21,6 +21,7 @@ describe("settingsStore", () => { expect(s.output_mode).toBe("same_as_source"); expect(s.output_folder).toBeNull(); expect(s.naming).toBe("suffix"); + expect(s.auto_update_check).toBe(false); }); it("load() calls get_settings and updates the store", async () => { @@ -30,6 +31,7 @@ describe("settingsStore", () => { expect(s.output_mode).toBe("custom_folder"); expect(s.output_folder).toBe("/my/folder"); expect(s.naming).toBe("overwrite"); + expect(s.auto_update_check).toBe(true); }); it("save() calls save_settings with current value", async () => { @@ -37,6 +39,7 @@ describe("settingsStore", () => { output_mode: "same_as_source" as const, output_folder: null, naming: "suffix" as const, + auto_update_check: false, }; await settings.save(newSettings); expect(invoke).toHaveBeenCalledWith("save_settings", { settings: newSettings }); From b8e9c2a0bbdda4cbd45c8e771ad7c335042226c7 Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 15:55:05 -0500 Subject: [PATCH 5/6] feat: make update check opt-in via Help menu toggle --- src/routes/+page.svelte | 10 ++++-- src/test/DetailPanel.test.ts | 2 +- src/test/updater.test.ts | 62 ++++++++++++++++++++++++------------ 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d3f90be..7ec3a07 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -111,10 +111,12 @@ let unlisteners: Array<() => void> = []; onMount(async () => { - settings.load(); + await settings.load(); window.addEventListener("keydown", onKeyDown); - await checkAndShowUpdateToast(); + if (get(settings).auto_update_check) { + checkAndShowUpdateToast(); + } unlisteners = await Promise.all([ listen("menu:add-files", () => addFiles()), @@ -123,6 +125,10 @@ listen("menu:clear-queue", () => clearAll()), listen("menu:reset-selected", () => resetSelected()), listen("menu:check-for-update", () => checkAndShowUpdateToast(true)), + listen("menu:check-for-update-auto", () => { + const current = get(settings); + settings.save({ ...current, auto_update_check: !current.auto_update_check }); + }), ]); }); diff --git a/src/test/DetailPanel.test.ts b/src/test/DetailPanel.test.ts index 23cf749..312003a 100644 --- a/src/test/DetailPanel.test.ts +++ b/src/test/DetailPanel.test.ts @@ -19,7 +19,7 @@ describe("DetailPanel", () => { beforeEach(async () => { queue.clear(); selectedFileId.set(null); - await settings.save({ output_mode: "same_as_source", output_folder: null, naming: "suffix" }); + await settings.save({ output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false }); vi.clearAllMocks(); }); diff --git a/src/test/updater.test.ts b/src/test/updater.test.ts index c590a53..d5f3f2c 100644 --- a/src/test/updater.test.ts +++ b/src/test/updater.test.ts @@ -2,12 +2,12 @@ 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 === "get_settings") + return { output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false }; if (cmd === "check_for_update") return null; return null; }), @@ -32,47 +32,56 @@ describe("update checking", () => { beforeEach(() => { toast.clear(); vi.clearAllMocks(); - // Reset mock to default (no update) + Object.keys(listeners).forEach((k) => delete listeners[k]); vi.mocked(invoke).mockImplementation(async (cmd: string) => { - if (cmd === "get_settings") return { output_mode: "same_as_source", output_folder: null, naming: "suffix" }; + if (cmd === "get_settings") + return { output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false }; if (cmd === "check_for_update") return null; return null; }); }); - it("shows a persistent update toast on mount when update is available", async () => { + it("does not call check_for_update on mount when auto_update_check is false", async () => { + render(Page); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_settings")); + expect(invoke).not.toHaveBeenCalledWith("check_for_update"); + }); + + it("calls check_for_update on mount when auto_update_check is true", 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"; + if (cmd === "get_settings") + return { output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: true }; + if (cmd === "check_for_update") return null; return null; }); - render(Page); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("check_for_update")); + }); + it("shows update toast on mount when auto_update_check is true and 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", auto_update_check: true }; + 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 === "get_settings") + return { output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: true }; 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" ); @@ -80,7 +89,7 @@ describe("update checking", () => { 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")); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_settings")); vi.mocked(invoke).mockImplementation(async (cmd: string) => { if (cmd === "check_for_update") return "1.5.0"; @@ -96,7 +105,7 @@ describe("update checking", () => { 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")); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_settings")); listeners["menu:check-for-update"]?.({}); @@ -104,4 +113,17 @@ describe("update checking", () => { expect(screen.getByText("You're on the latest version")).toBeInTheDocument(); }); }); + + it("saves toggled auto_update_check when menu:check-for-update-auto fires", async () => { + render(Page); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_settings")); + + listeners["menu:check-for-update-auto"]?.({}); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("save_settings", { + settings: expect.objectContaining({ auto_update_check: true }), + }); + }); + }); }); From c6be850086abc8a0904471d159d221fa3a7e981b Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Fri, 8 May 2026 16:11:36 -0500 Subject: [PATCH 6/6] style: apply cargo fmt Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/lib.rs | 8 +++++--- src-tauri/src/menu.rs | 4 +++- src-tauri/src/settings.rs | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7efdc26..1978444 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,7 +50,9 @@ pub fn run() { use crate::compress::compress_files; use crate::finder::reveal_in_finder; use crate::menu::{build_menu, set_menu_item_enabled}; - use crate::settings::{get_settings, load_settings_from_path, save_settings, settings_file_path}; + use crate::settings::{ + get_settings, load_settings_from_path, save_settings, settings_file_path, + }; use crate::updater::check_for_update; use tauri::{Emitter, Manager}; @@ -63,8 +65,8 @@ pub fn run() { let (menu, registry, auto_update_item) = build_menu(app.handle())?; app.set_menu(menu)?; - let saved = load_settings_from_path(&settings_file_path(app.handle())) - .unwrap_or_default(); + let saved = + load_settings_from_path(&settings_file_path(app.handle())).unwrap_or_default(); auto_update_item.set_checked(saved.auto_update_check).ok(); app.handle().on_menu_event(|app, event| { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 2ecb3a1..5bb260c 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -14,7 +14,9 @@ pub const MENU_IDS: &[&str] = &[ "check-for-update", ]; -pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu, MenuRegistry, CheckMenuItem)> { +pub fn build_menu( + app: &tauri::AppHandle, +) -> tauri::Result<(Menu, MenuRegistry, CheckMenuItem)> { // ── App menu (compress[pdf]) ────────────────────────────────────────── let about = PredefinedMenuItem::about( app, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 7059bd7..fa95fe0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -218,7 +218,11 @@ mod tests { fn auto_update_check_defaults_when_absent_from_json() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("settings.json"); - std::fs::write(&path, r#"{"output_mode":"same_as_source","naming":"suffix"}"#).unwrap(); + std::fs::write( + &path, + r#"{"output_mode":"same_as_source","naming":"suffix"}"#, + ) + .unwrap(); let loaded = load_settings_from_path(&path).unwrap(); assert!(!loaded.auto_update_check); }