From 56c2aa59cbb9e45774c7320c62f01d0da2c59bae Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Tue, 19 May 2026 11:17:44 -0500 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20compress=20PDFs=20from=20Finder=20r?= =?UTF-8?q?ight-click=20=E2=86=92=20Open=20With?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a headless compression path triggered by macOS's open-document event so users can right-click a PDF, choose "Open With → compress[pdf]", and get a native notification when the work is done — no main window, no in-app interaction needed. The flow always writes a `_compressed.pdf` sibling and never overwrites the original, even if the in-app Overwrite setting is enabled, because the Finder context has no preview/confirm step. Output folder and preset are still read from the user's saved settings, and a new "Finder right-click preset" control in the settings panel lets users choose Max/Balanced/Minimal as the default. - tauri.conf.json: declare PDF file association (role: Viewer); start the main window hidden so headless launches don't flash a UI - Cargo.toml: add tauri-plugin-single-instance and tokio (time) - compress.rs: extract compress_files_inner returning outcomes so the headless flow can tally success/failure for the notification - headless.rs (new): filter PDF paths, force Suffix naming, run compression, post a summary notification - lib.rs: install single-instance plugin, handle RunEvent::Opened, defer window-show 400ms so file-open launches stay headless - settings.rs: add default_preset field with serde default for migration - DetailPanel.svelte: new "Finder right-click preset" radios Co-Authored-By: Claude Opus 4.7 --- src-tauri/Cargo.lock | 19 ++- src-tauri/Cargo.toml | 2 + src-tauri/src/compress.rs | 58 +++++++- src-tauri/src/headless.rs | 207 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 116 ++++++++++++++- src-tauri/src/path_resolver.rs | 13 +- src-tauri/src/settings.rs | 56 +++++-- src-tauri/tauri.conf.json | 13 +- src/lib/components/DetailPanel.svelte | 24 +++ src/lib/stores/settingsStore.ts | 4 + src/test/DetailPanel.test.ts | 28 +++- src/test/settingsStore.test.ts | 2 + 12 files changed, 506 insertions(+), 36 deletions(-) create mode 100644 src-tauri/src/headless.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index df4c2f9..fa8cad0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2455,7 +2455,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-compressor" -version = "1.4.1" +version = "1.4.2" dependencies = [ "reqwest 0.12.28", "serde", @@ -2466,7 +2466,9 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tempfile", + "tokio", ] [[package]] @@ -3891,6 +3893,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5f8bf37..f060b20 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,9 +24,11 @@ tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-notification = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["time"] } [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/compress.rs b/src-tauri/src/compress.rs index db1a232..ba5a1dd 100644 --- a/src-tauri/src/compress.rs +++ b/src-tauri/src/compress.rs @@ -1,12 +1,12 @@ use crate::path_resolver::resolve_output_path; use crate::settings::Settings; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::AppHandle; use tauri::Emitter; use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Preset { Max, @@ -14,6 +14,12 @@ pub enum Preset { Minimal, } +impl Default for Preset { + fn default() -> Self { + Preset::Balanced + } +} + pub fn build_gs_args( preset: Preset, dpi_override: Option, @@ -58,12 +64,20 @@ pub struct ProgressEvent { pub error_msg: Option, } -#[tauri::command] -pub async fn compress_files( +#[derive(Debug, Clone)] +pub struct CompressOutcome { + pub file: String, + pub saved_bytes: Option, + pub error: Option, +} + +pub async fn compress_files_inner( app: AppHandle, jobs: Vec, settings: Settings, -) -> Result<(), String> { +) -> Vec { + let mut outcomes = Vec::with_capacity(jobs.len()); + for job in &jobs { let _ = app.emit( "compress:progress", @@ -95,6 +109,7 @@ pub async fn compress_files( Ok(()) => { if let Err(e) = std::fs::rename(&tmp_path, &output_path) { let _ = std::fs::remove_file(&tmp_path); + let msg = e.to_string(); let _ = app.emit( "compress:progress", ProgressEvent { @@ -102,26 +117,37 @@ pub async fn compress_files( status: "error".into(), saved_bytes: None, compressed_size: None, - error_msg: Some(e.to_string()), + error_msg: Some(msg.clone()), }, ); + outcomes.push(CompressOutcome { + file: job.path.clone(), + saved_bytes: None, + error: Some(msg), + }); continue; } let compressed_size = std::fs::metadata(&output_path) .map(|m| m.len() as i64) .unwrap_or(0); + let saved = original_size - compressed_size; let _ = app.emit( "compress:progress", ProgressEvent { file: job.path.clone(), status: "done".into(), - saved_bytes: Some(original_size - compressed_size), + saved_bytes: Some(saved), compressed_size: Some(compressed_size), error_msg: None, }, ); + outcomes.push(CompressOutcome { + file: job.path.clone(), + saved_bytes: Some(saved), + error: None, + }); } Err(msg) => { let _ = std::fs::remove_file(&tmp_path); @@ -133,12 +159,28 @@ pub async fn compress_files( status: "error".into(), saved_bytes: None, compressed_size: None, - error_msg: Some(msg), + error_msg: Some(msg.clone()), }, ); + outcomes.push(CompressOutcome { + file: job.path.clone(), + saved_bytes: None, + error: Some(msg), + }); } } } + + outcomes +} + +#[tauri::command] +pub async fn compress_files( + app: AppHandle, + jobs: Vec, + settings: Settings, +) -> Result<(), String> { + compress_files_inner(app, jobs, settings).await; Ok(()) } diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs new file mode 100644 index 0000000..bc02d9b --- /dev/null +++ b/src-tauri/src/headless.rs @@ -0,0 +1,207 @@ +use crate::compress::{compress_files_inner, CompressJob}; +use crate::is_pdf; +use crate::settings::{load_settings_from_path, settings_file_path, NamingMode, Settings}; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; +use tauri_plugin_notification::NotificationExt; + +/// Right-click → Open With invocations always write to a `_compressed.pdf` +/// sibling, never overwrite. The in-app overwrite setting is a deliberate +/// choice made at the UI; Finder context has no preview/confirm, so we +/// refuse to destroy the original from here even if the user enabled +/// overwrite in the app. +pub fn settings_for_headless(mut s: Settings) -> Settings { + s.naming = NamingMode::Suffix; + s +} + +pub fn filter_pdf_paths(paths: Vec) -> Vec { + paths + .into_iter() + .filter_map(|p| { + let s = p.to_string_lossy().into_owned(); + if Path::new(&s).is_file() && is_pdf(&s) { + Some(s) + } else { + None + } + }) + .collect() +} + +fn human_bytes(bytes: i64) -> String { + let abs = bytes.unsigned_abs() as f64; + let (val, unit) = if abs >= 1_073_741_824.0 { + (abs / 1_073_741_824.0, "GB") + } else if abs >= 1_048_576.0 { + (abs / 1_048_576.0, "MB") + } else if abs >= 1024.0 { + (abs / 1024.0, "KB") + } else { + (abs, "B") + }; + let sign = if bytes < 0 { "-" } else { "" }; + format!("{sign}{val:.1} {unit}") +} + +pub fn summary_message( + succeeded: usize, + failed: usize, + total_saved_bytes: i64, +) -> (String, String) { + let title = match (succeeded, failed) { + (0, _) => "Compression failed".to_string(), + (n, 0) if n == 1 => "Compressed 1 PDF".to_string(), + (n, 0) => format!("Compressed {n} PDFs"), + (n, f) => format!("Compressed {n} of {} PDFs", n + f), + }; + let body = if succeeded == 0 { + format!("{failed} file(s) failed") + } else if failed == 0 { + format!("Saved {}", human_bytes(total_saved_bytes)) + } else { + format!( + "Saved {} \u{2022} {failed} failed", + human_bytes(total_saved_bytes) + ) + }; + (title, body) +} + +pub async fn compress_paths_headless(app: AppHandle, paths: Vec) { + let pdfs = filter_pdf_paths(paths); + if pdfs.is_empty() { + return; + } + + let settings = settings_for_headless( + load_settings_from_path(&settings_file_path(&app)).unwrap_or_default(), + ); + let preset = settings.default_preset; + + let jobs: Vec = pdfs + .into_iter() + .map(|path| CompressJob { + path, + preset, + dpi_override: None, + }) + .collect(); + + let outcomes = compress_files_inner(app.clone(), jobs, settings).await; + + let mut succeeded = 0usize; + let mut failed = 0usize; + let mut total_saved = 0i64; + for o in &outcomes { + if o.error.is_some() { + failed += 1; + } else { + succeeded += 1; + total_saved += o.saved_bytes.unwrap_or(0); + } + } + + let (title, body) = summary_message(succeeded, failed, total_saved); + let _ = app.notification().builder().title(title).body(body).show(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn filter_pdf_paths_keeps_only_valid_pdfs() { + let tmp = TempDir::new().unwrap(); + let pdf = tmp.path().join("a.pdf"); + let mut f = std::fs::File::create(&pdf).unwrap(); + f.write_all(b"%PDF-1.4 hello").unwrap(); + + let fake = tmp.path().join("b.pdf"); + std::fs::write(&fake, b"not a pdf").unwrap(); + + let txt = tmp.path().join("c.txt"); + std::fs::write(&txt, b"text").unwrap(); + + let missing = tmp.path().join("ghost.pdf"); + + let kept = filter_pdf_paths(vec![pdf.clone(), fake, txt, missing]); + assert_eq!(kept, vec![pdf.to_string_lossy().into_owned()]); + } + + #[test] + fn filter_pdf_paths_empty_input_returns_empty() { + assert!(filter_pdf_paths(vec![]).is_empty()); + } + + #[test] + fn human_bytes_formats_units() { + assert_eq!(human_bytes(0), "0.0 B"); + assert_eq!(human_bytes(2048), "2.0 KB"); + assert_eq!(human_bytes(5 * 1_048_576), "5.0 MB"); + assert_eq!(human_bytes(3 * 1_073_741_824), "3.0 GB"); + } + + #[test] + fn human_bytes_handles_negative() { + assert_eq!(human_bytes(-2048), "-2.0 KB"); + } + + #[test] + fn summary_for_single_success() { + let (title, body) = summary_message(1, 0, 1_048_576); + assert_eq!(title, "Compressed 1 PDF"); + assert_eq!(body, "Saved 1.0 MB"); + } + + #[test] + fn summary_for_multiple_all_succeed() { + let (title, body) = summary_message(3, 0, 3 * 1_048_576); + assert_eq!(title, "Compressed 3 PDFs"); + assert_eq!(body, "Saved 3.0 MB"); + } + + #[test] + fn summary_for_partial_failure() { + let (title, body) = summary_message(2, 1, 2 * 1_048_576); + assert_eq!(title, "Compressed 2 of 3 PDFs"); + assert!(body.contains("Saved 2.0 MB")); + assert!(body.contains("1 failed")); + } + + #[test] + fn summary_for_total_failure() { + let (title, body) = summary_message(0, 2, 0); + assert_eq!(title, "Compression failed"); + assert_eq!(body, "2 file(s) failed"); + } + + #[test] + fn settings_for_headless_forces_suffix_naming() { + let user_chose_overwrite = Settings { + naming: NamingMode::Overwrite, + ..Settings::default() + }; + let forced = settings_for_headless(user_chose_overwrite); + assert!(matches!(forced.naming, NamingMode::Suffix)); + } + + #[test] + fn settings_for_headless_preserves_output_mode_and_preset() { + use crate::compress::Preset; + use crate::settings::OutputMode; + let user = Settings { + naming: NamingMode::Overwrite, + output_mode: OutputMode::CustomFolder, + output_folder: Some("/Users/me/out".into()), + default_preset: Preset::Max, + ..Settings::default() + }; + let forced = settings_for_headless(user); + assert!(matches!(forced.output_mode, OutputMode::CustomFolder)); + assert_eq!(forced.output_folder.as_deref(), Some("/Users/me/out")); + assert_eq!(forced.default_preset, Preset::Max); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1978444..da5eec7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,23 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ pub mod compress; pub mod finder; +pub mod headless; pub mod menu; pub mod path_resolver; pub mod settings; pub mod updater; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +/// Set true as soon as any file-open invocation (RunEvent::Opened or a +/// second-instance argv with PDFs) is observed during startup. The deferred +/// window-show task reads this to decide whether to surface the main window. +#[derive(Default)] +pub struct LaunchState { + pub headless: Arc, +} + #[derive(serde::Serialize)] struct FileMeta { size: u64, @@ -49,18 +61,38 @@ pub fn check_path_writable(path: String) -> bool { pub fn run() { use crate::compress::compress_files; use crate::finder::reveal_in_finder; + use crate::headless::compress_paths_headless; 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::updater::check_for_update; - use tauri::{Emitter, Manager}; + use std::path::PathBuf; + use tauri::{Emitter, Manager, RunEvent}; + + let launch_state = LaunchState::default(); + let headless_flag_for_single_instance = launch_state.headless.clone(); tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init( + move |app, argv, _cwd| { + // A second launch (e.g. user opens another PDF via "Open With" + // while we're already running) forwards its argv here. + let paths = argv_pdf_paths(&argv); + if !paths.is_empty() { + headless_flag_for_single_instance.store(true, Ordering::Relaxed); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + compress_paths_headless(app, paths).await; + }); + } + }, + )) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) + .manage(launch_state) .setup(|app| { let (menu, registry, auto_update_item) = build_menu(app.handle())?; app.set_menu(menu)?; @@ -83,6 +115,22 @@ pub fn run() { let _ = app.emit(name, ()); }); app.manage(registry); + + // The window is configured with `visible: false`. If no file-open + // event arrives within a short grace window, surface it. If one + // does arrive, the Opened-event handler keeps the flag set and + // we stay headless for this launch. + let handle = app.handle().clone(); + let flag = handle.state::().headless.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + if !flag.load(Ordering::Relaxed) { + if let Some(window) = handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -95,8 +143,34 @@ pub fn run() { set_menu_item_enabled, check_for_update, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + if let RunEvent::Opened { urls } = event { + let paths: Vec = + urls.iter().filter_map(|u| u.to_file_path().ok()).collect(); + if paths.is_empty() { + return; + } + app.state::() + .headless + .store(true, Ordering::Relaxed); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + compress_paths_headless(app, paths).await; + }); + } + }); +} + +/// Extract PDF paths from a process argv vector, skipping the program name +/// and any non-`.pdf` tokens. Used by the single-instance forwarder. +pub fn argv_pdf_paths(argv: &[String]) -> Vec { + argv.iter() + .skip(1) + .filter(|s| s.to_ascii_lowercase().ends_with(".pdf")) + .map(std::path::PathBuf::from) + .collect() } #[cfg(test)] @@ -166,4 +240,40 @@ mod lib_tests { "/nonexistent/path/that/cannot/exist".to_string() )); } + + #[test] + fn argv_pdf_paths_skips_program_name() { + let argv = vec![ + "/Applications/compress[pdf].app/Contents/MacOS/pdf-compressor".to_string(), + "/tmp/a.pdf".to_string(), + ]; + assert_eq!( + argv_pdf_paths(&argv), + vec![std::path::PathBuf::from("/tmp/a.pdf")] + ); + } + + #[test] + fn argv_pdf_paths_filters_non_pdf_args() { + let argv = vec![ + "binary".to_string(), + "/tmp/a.pdf".to_string(), + "--flag".to_string(), + "/tmp/b.txt".to_string(), + "/tmp/c.PDF".to_string(), + ]; + assert_eq!( + argv_pdf_paths(&argv), + vec![ + std::path::PathBuf::from("/tmp/a.pdf"), + std::path::PathBuf::from("/tmp/c.PDF"), + ] + ); + } + + #[test] + fn argv_pdf_paths_empty_when_no_pdfs() { + let argv = vec!["binary".to_string(), "--version".to_string()]; + assert!(argv_pdf_paths(&argv).is_empty()); + } } diff --git a/src-tauri/src/path_resolver.rs b/src-tauri/src/path_resolver.rs index cc234fc..557bf62 100644 --- a/src-tauri/src/path_resolver.rs +++ b/src-tauri/src/path_resolver.rs @@ -23,12 +23,7 @@ mod tests { use super::*; fn same_source_suffix() -> Settings { - Settings { - output_mode: OutputMode::SameAsSource, - output_folder: None, - naming: NamingMode::Suffix, - auto_update_check: false, - } + Settings::default() } #[test] @@ -46,7 +41,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/home/user/output".into()), naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; let result = resolve_output_path("/home/user/docs/report.pdf", &settings); assert_eq!( @@ -61,7 +56,7 @@ mod tests { output_mode: OutputMode::SameAsSource, output_folder: None, naming: NamingMode::Overwrite, - auto_update_check: false, + ..Settings::default() }; let result = resolve_output_path("/home/user/docs/report.pdf", &settings); assert_eq!(result, PathBuf::from("/home/user/docs/report.pdf")); @@ -73,7 +68,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/out".into()), naming: NamingMode::Overwrite, - auto_update_check: false, + ..Settings::default() }; 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 fa95fe0..dec7d24 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,3 +1,4 @@ +use crate::compress::Preset; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::Manager; @@ -23,6 +24,8 @@ pub struct Settings { pub naming: NamingMode, #[serde(default)] pub auto_update_check: bool, + #[serde(default)] + pub default_preset: Preset, } impl Default for Settings { @@ -32,6 +35,7 @@ impl Default for Settings { output_folder: None, naming: NamingMode::Suffix, auto_update_check: false, + default_preset: Preset::default(), } } } @@ -95,8 +99,7 @@ mod tests { let s = Settings { output_mode: OutputMode::SameAsSource, output_folder: None, - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_ok()); } @@ -107,8 +110,7 @@ mod tests { let s = Settings { output_mode: OutputMode::CustomFolder, output_folder: Some(tmp.path().to_string_lossy().into_owned()), - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_ok()); } @@ -118,8 +120,7 @@ mod tests { let s = Settings { output_mode: OutputMode::CustomFolder, output_folder: Some("relative/path".into()), - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_err()); } @@ -129,8 +130,7 @@ mod tests { let s = Settings { output_mode: OutputMode::CustomFolder, output_folder: Some("/nonexistent/path/that/should/not/exist/xyz".into()), - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_err()); } @@ -143,8 +143,7 @@ mod tests { let s = Settings { output_mode: OutputMode::CustomFolder, output_folder: Some(file.to_string_lossy().into_owned()), - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_err()); } @@ -154,8 +153,7 @@ mod tests { let s = Settings { output_mode: OutputMode::CustomFolder, output_folder: Some("/tmp/has\nnewline".into()), - naming: NamingMode::Suffix, - auto_update_check: false, + ..Settings::default() }; assert!(validate_settings(&s).is_err()); } @@ -169,7 +167,7 @@ mod tests { output_mode: OutputMode::CustomFolder, output_folder: Some("/my/folder".into()), naming: NamingMode::Overwrite, - auto_update_check: false, + ..Settings::default() }; save_settings_to_path(&original, &path).unwrap(); @@ -180,6 +178,38 @@ mod tests { assert!(matches!(loaded.naming, NamingMode::Overwrite)); } + #[test] + fn default_preset_is_balanced() { + let s = Settings::default(); + assert_eq!(s.default_preset, Preset::Balanced); + } + + #[test] + fn default_preset_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_eq!(loaded.default_preset, Preset::Balanced); + } + + #[test] + fn default_preset_round_trips() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("settings.json"); + let original = Settings { + default_preset: Preset::Max, + ..Settings::default() + }; + save_settings_to_path(&original, &path).unwrap(); + let loaded = load_settings_from_path(&path).unwrap(); + assert_eq!(loaded.default_preset, Preset::Max); + } + #[test] fn load_returns_error_for_missing_file() { let tmp = TempDir::new().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b8743df..20ee411 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,11 +12,13 @@ "app": { "windows": [ { + "label": "main", "title": "compress[pdf]", "width": 760, "height": 600, "resizable": false, - "fullscreen": false + "fullscreen": false, + "visible": false } ], "security": { @@ -49,6 +51,15 @@ "active": true, "targets": "all", "externalBin": ["binaries/gs"], + "fileAssociations": [ + { + "ext": ["pdf"], + "name": "PDF Document", + "description": "Compress with compress[pdf]", + "role": "Viewer", + "mimeType": "application/pdf" + } + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/lib/components/DetailPanel.svelte b/src/lib/components/DetailPanel.svelte index 1f5349a..f29aad1 100644 --- a/src/lib/components/DetailPanel.svelte +++ b/src/lib/components/DetailPanel.svelte @@ -64,9 +64,11 @@ let outputMode: "same_as_source" | "custom_folder" = $settings.output_mode; let naming: "suffix" | "overwrite" = $settings.naming; + let defaultPreset: Preset = $settings.default_preset; $: outputMode = $settings.output_mode; $: naming = $settings.naming; + $: defaultPreset = $settings.default_preset; function onPresetChange(preset: Preset) { if (!$selectedFile) return; @@ -205,6 +207,19 @@

Original file will be replaced and cannot be recovered.

{/if} + +
+
Finder right-click preset
+

Used when you right-click a PDF in Finder and choose "Open With → compress[pdf]". The in-app preset above is set per file.

+ {#each (["max", "balanced", "minimal"] as Preset[]) as p} + + {/each} +
@@ -448,6 +463,15 @@ margin-left: 22px; line-height: 1.4; } + .field-hint { + font-size: 10px; + color: var(--text-tertiary); + line-height: 1.4; + margin: -2px 0 4px; + } + .radio-meta { + color: var(--text-tertiary); + } .folder-row { display: flex; align-items: center; gap: 8px; } .folder-path { flex: 1; font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .folder-row button { padding: 5px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); } diff --git a/src/lib/stores/settingsStore.ts b/src/lib/stores/settingsStore.ts index 6981e6f..4b8b593 100644 --- a/src/lib/stores/settingsStore.ts +++ b/src/lib/stores/settingsStore.ts @@ -1,11 +1,14 @@ import { writable } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; +export type Preset = "max" | "balanced" | "minimal"; + export interface AppSettings { output_mode: "same_as_source" | "custom_folder"; output_folder: string | null; naming: "suffix" | "overwrite"; auto_update_check: boolean; + default_preset: Preset; } const DEFAULT_SETTINGS: AppSettings = { @@ -13,6 +16,7 @@ const DEFAULT_SETTINGS: AppSettings = { output_folder: null, naming: "suffix", auto_update_check: false, + default_preset: "balanced", }; function createSettingsStore() { diff --git a/src/test/DetailPanel.test.ts b/src/test/DetailPanel.test.ts index 312003a..83a3722 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", auto_update_check: false }); + await settings.save({ output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false, default_preset: "balanced" }); vi.clearAllMocks(); }); @@ -122,4 +122,30 @@ describe("DetailPanel", () => { settings: expect.objectContaining({ output_folder: "/Users/me/Documents" }), }); }); + + it("shows Finder right-click preset section with three options", () => { + render(DetailPanel); + expect(screen.getByText(/finder right-click preset/i)).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /^Max/i })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /^Balanced/i })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /^Minimal/i })).toBeInTheDocument(); + }); + + it("changing Finder right-click preset to max saves immediately", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("radio", { name: /^Max/i })); + expect(invoke).toHaveBeenCalledWith("save_settings", { + settings: expect.objectContaining({ default_preset: "max" }), + }); + }); + + it("changing Finder right-click preset to minimal saves immediately", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("radio", { name: /^Minimal/i })); + expect(invoke).toHaveBeenCalledWith("save_settings", { + settings: expect.objectContaining({ default_preset: "minimal" }), + }); + }); }); diff --git a/src/test/settingsStore.test.ts b/src/test/settingsStore.test.ts index 1fe17ad..ad37629 100644 --- a/src/test/settingsStore.test.ts +++ b/src/test/settingsStore.test.ts @@ -7,6 +7,7 @@ vi.mock("@tauri-apps/api/core", () => ({ output_folder: "/my/folder", naming: "overwrite", auto_update_check: true, + default_preset: "balanced", }), })); @@ -40,6 +41,7 @@ describe("settingsStore", () => { output_folder: null, naming: "suffix" as const, auto_update_check: false, + default_preset: "balanced" as const, }; await settings.save(newSettings); expect(invoke).toHaveBeenCalledWith("save_settings", { settings: newSettings }); From 71192c09ac170d3d1f6cb3e51b5be61b518810ab Mon Sep 17 00:00:00 2001 From: Jumoke Bolanle Date: Tue, 19 May 2026 13:33:35 -0500 Subject: [PATCH 2/6] refactor(settings): collapse stacked radios into segmented rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings block at the bottom of DetailPanel was reading as a SwiftUI System Settings form — three field headers, ten stacked radio rows, a long hint paragraph — which is exactly what the project's design context calls out as the thing to avoid. Replaces it with a Raycast-style preference layout: one horizontal row per setting, single-word label on the left, segmented control on the right. - Three settings collapse from ~10 rows to 3 - Real kept inside styled