Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 265 additions & 1 deletion src-tauri/Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -55,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()
Expand All @@ -72,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, ());
Expand All @@ -88,6 +91,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");
Expand Down
19 changes: 18 additions & 1 deletion src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tauri::Wry>, MenuRegistry)> {
Expand Down Expand Up @@ -113,14 +114,30 @@ pub fn build_menu(app: &tauri::AppHandle) -> tauri::Result<(Menu<tauri::Wry>, 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);
map.insert("reveal-in-finder".to_string(), reveal);
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))))
}
Expand Down
104 changes: 104 additions & 0 deletions src-tauri/src/updater.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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::<u64>().ok()?;
let minor = parts[1].parse::<u64>().ok()?;
let patch = parts[2].parse::<u64>().ok()?;
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,
}
}

#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
}

#[tauri::command]
pub async fn check_for_update() -> Option<String> {
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::*;

#[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);
}

#[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"));
}
}
4 changes: 4 additions & 0 deletions src/lib/components/Toast.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
{#each $toast as msg (msg.id)}
<div class="toast" role="alert" in:fly={{ y: 10, duration: 200 }} out:fly={{ y: -4, duration: 150 }}>
<span>{msg.message}</span>
{#if msg.action}
<button class="action" on:click={msg.action.handler}>{msg.action.label}</button>
{/if}
<button on:click={() => toast.dismiss(msg.id)} aria-label="Dismiss">✕</button>
</div>
{/each}
Expand All @@ -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; }
</style>
6 changes: 6 additions & 0 deletions src/lib/stores/toastStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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));
},
Expand Down
22 changes: 22 additions & 0 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
}),
]);
});

Expand Down
42 changes: 41 additions & 1 deletion src/test/Toast.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
});
});
});
Loading
Loading