diff --git a/README.md b/README.md index 983bcbf..c2ff44f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,16 @@ xattr -dr com.apple.quarantine "/Applications/compress[pdf].app" Output goes to the same folder with a `_compressed` suffix, or to a custom folder of your choosing. +## Finder integration + +You can compress PDFs without opening the app — pick whichever route fits your workflow: + +- **Right-click → Open With → compress[pdf]** — works out of the box once the app is in `/Applications`. macOS lists compress[pdf] alongside Preview and any other PDF viewers you have installed. +- **Right-click → Compress with compress[pdf]** (Quick Action) — open the app, expand **Advanced** in the side panel, and click **Install**. The entry appears at the top of Finder's right-click menu, no submenu nesting. +- **Drop PDFs onto the Dock icon** — drag one or more PDFs onto the app icon in the Dock to start compression. + +All three routes run in the background — no window appears — and use your saved Output, Naming, and Default preset settings. A native notification reports total bytes saved when the run finishes. + ## For developers **Stack:** diff --git a/package.json b/package.json index 80bb5ff..d41ff58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tauri-app", - "version": "1.4.2", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index df4c2f9..5f1331b 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.5.0" 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..7de605e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdf-compressor" -version = "1.4.2" +version = "1.5.0" description = "Local macOS PDF compressor" authors = ["Olajumoke Bolanle"] license = "AGPL-3.0-or-later" @@ -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/resources/Compress PDF.workflow/Contents/Info.plist b/src-tauri/resources/Compress PDF.workflow/Contents/Info.plist new file mode 100644 index 0000000..8878973 --- /dev/null +++ b/src-tauri/resources/Compress PDF.workflow/Contents/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Compress with compress[pdf] + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + NSServices + + + NSMenuItem + + default + Compress with compress[pdf] + + NSMessage + runWorkflowAsService + NSRequiredContext + + NSApplicationIdentifier + com.apple.finder + + NSSendFileTypes + + com.adobe.pdf + + + + + diff --git a/src-tauri/resources/Compress PDF.workflow/Contents/document.wflow b/src-tauri/resources/Compress PDF.workflow/Contents/document.wflow new file mode 100644 index 0000000..2f06c20 --- /dev/null +++ b/src-tauri/resources/Compress PDF.workflow/Contents/document.wflow @@ -0,0 +1,201 @@ + + + + + AMApplicationBuild + 523 + AMApplicationVersion + 2.10 + AMDocumentVersion + 2 + actions + + + action + + AMAccepts + + Container + List + Optional + + Types + + com.apple.cocoa.path + + + AMActionVersion + 2.0.3 + AMApplication + + Automator + + AMParameterProperties + + COMMAND_STRING + + CheckedForUserDefaultShell + + inputMethod + + shell + + source + + + AMProvides + + Container + List + Types + + com.apple.cocoa.string + + + ActionBundlePath + /System/Library/Automator/Run Shell Script.action + ActionName + Run Shell Script + ActionParameters + + COMMAND_STRING + open -b com.pdfcompressor "$@" + CheckedForUserDefaultShell + + inputMethod + 1 + shell + /bin/sh + source + + + BundleIdentifier + com.apple.RunShellScript + CFBundleVersion + 2.0.3 + CanShowSelectedItemsWhenRun + + CanShowWhenRun + + Category + + AMCategoryUtilities + + Class Name + RunShellScriptAction + InputUUID + E1868735-446E-4151-8395-F2E12601ACB8 + Keywords + + Shell + Script + Command + Run + Unix + + OutputUUID + 338598FD-F8BC-4718-B25A-E4D487B1825B + UUID + 2EDD3871-6A3D-42D9-A702-9F8D60924836 + UnlocalizedApplications + + Automator + + arguments + + 0 + + default value + 0 + name + inputMethod + required + 0 + type + 0 + uuid + 0 + + 1 + + default value + + name + CheckedForUserDefaultShell + required + 0 + type + 0 + uuid + 1 + + 2 + + default value + + name + source + required + 0 + type + 0 + uuid + 2 + + 3 + + default value + + name + COMMAND_STRING + required + 0 + type + 0 + uuid + 3 + + 4 + + default value + /bin/sh + name + shell + required + 0 + type + 0 + uuid + 4 + + + isViewVisible + 1 + location + 309.500000:316.000000 + nibPath + /System/Library/Automator/Run Shell Script.action/Contents/Resources/Base.lproj/main.nib + + isViewVisible + 1 + + + connectors + + workflowMetaData + + serviceApplicationBundleID + com.apple.finder + serviceApplicationPath + /System/Library/CoreServices/Finder.app/ + serviceInputTypeIdentifier + com.apple.Automator.fileSystemObject.fileURL + serviceOutputTypeIdentifier + com.apple.Automator.nothing + serviceProcessesInput + 0 + workflowTypeIdentifier + com.apple.Automator.servicesMenu + + + diff --git a/src-tauri/src/compress.rs b/src-tauri/src/compress.rs index db1a232..aa1581b 100644 --- a/src-tauri/src/compress.rs +++ b/src-tauri/src/compress.rs @@ -1,15 +1,16 @@ 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, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Preset { Max, + #[default] Balanced, Minimal, } @@ -58,12 +59,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 +104,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 +112,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 +154,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..b11070c --- /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(), + (1, 0) => "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..3e89c11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,24 @@ // 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 quick_action; 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 +62,41 @@ 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::quick_action::{ + install_quick_action, is_quick_action_installed, uninstall_quick_action, + }; 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 +119,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![ @@ -94,9 +146,38 @@ pub fn run() { validate_pdf, set_menu_item_enabled, check_for_update, + install_quick_action, + is_quick_action_installed, + uninstall_quick_action, ]) - .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 +247,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/quick_action.rs b/src-tauri/src/quick_action.rs new file mode 100644 index 0000000..127bf3f --- /dev/null +++ b/src-tauri/src/quick_action.rs @@ -0,0 +1,175 @@ +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Filename of the Quick Action bundle as it ships in Resources and as it +/// must be named in ~/Library/Services/ for macOS Launch Services to pick +/// it up. Square brackets are avoided so Tauri's `bundle.resources` glob +/// doesn't read them as a character class; the user-facing menu label is +/// set separately by `NSMenuItem.default` inside Contents/Info.plist. +const WORKFLOW_BUNDLE_NAME: &str = "Compress PDF.workflow"; + +/// Absolute path where the Quick Action lives once installed. +/// `services_dir` is the user's `~/Library/Services` directory; injecting +/// it as a parameter keeps the function pure and testable. +pub fn services_install_path(services_dir: &Path) -> PathBuf { + services_dir.join(WORKFLOW_BUNDLE_NAME) +} + +/// Default `~/Library/Services` location, resolved from the OS home dir. +/// Returns None only on bizarre systems with no home directory. +fn default_services_dir() -> Option { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join("Library").join("Services")) +} + +/// Recursively copy a directory tree from `src` to `dst`. If `dst` exists +/// it is wiped first so an upgrade replaces the previous install rather +/// than mixing old + new files. +pub fn copy_workflow_bundle(src: &Path, dst: &Path) -> std::io::Result<()> { + if dst.exists() { + std::fs::remove_dir_all(dst)?; + } + copy_dir_recursive(src, dst)?; + Ok(()) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&from, &to)?; + } else { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} + +fn bundled_workflow_path(app: &AppHandle) -> Result { + let resource_dir = app.path().resource_dir().map_err(|e| e.to_string())?; + let candidate = resource_dir.join("resources").join(WORKFLOW_BUNDLE_NAME); + if candidate.exists() { + return Ok(candidate); + } + // Some Tauri builds flatten resources/ — fall back to the resource root. + let flat = resource_dir.join(WORKFLOW_BUNDLE_NAME); + if flat.exists() { + return Ok(flat); + } + Err(format!( + "Bundled Quick Action not found in resource directory: {}", + resource_dir.display() + )) +} + +#[tauri::command] +pub fn is_quick_action_installed() -> bool { + let Some(services) = default_services_dir() else { + return false; + }; + services_install_path(&services).is_dir() +} + +#[tauri::command] +pub fn install_quick_action(app: AppHandle) -> Result<(), String> { + let src = bundled_workflow_path(&app)?; + let services = default_services_dir().ok_or("Could not locate ~/Library/Services")?; + std::fs::create_dir_all(&services).map_err(|e| e.to_string())?; + let dst = services_install_path(&services); + copy_workflow_bundle(&src, &dst).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn uninstall_quick_action() -> Result<(), String> { + let services = default_services_dir().ok_or("Could not locate ~/Library/Services")?; + let dst = services_install_path(&services); + if dst.exists() { + std::fs::remove_dir_all(&dst).map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut f = std::fs::File::create(path).unwrap(); + f.write_all(contents).unwrap(); + } + + #[test] + fn services_install_path_joins_bundle_name() { + let services = PathBuf::from("/Users/x/Library/Services"); + let path = services_install_path(&services); + assert_eq!( + path, + PathBuf::from("/Users/x/Library/Services/Compress PDF.workflow") + ); + } + + #[test] + fn copy_workflow_bundle_copies_full_tree() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src.workflow"); + let dst = tmp.path().join("services").join("dst.workflow"); + + write_file(&src.join("Contents/Info.plist"), b""); + write_file(&src.join("Contents/document.wflow"), b""); + write_file(&src.join("Contents/Resources/icon.png"), b"\x89PNG\r\n"); + + copy_workflow_bundle(&src, &dst).unwrap(); + + assert!(dst.join("Contents/Info.plist").exists()); + assert!(dst.join("Contents/document.wflow").exists()); + assert!(dst.join("Contents/Resources/icon.png").exists()); + assert_eq!( + std::fs::read(dst.join("Contents/Info.plist")).unwrap(), + b"" + ); + } + + #[test] + fn copy_workflow_bundle_replaces_existing_destination() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src.workflow"); + let dst = tmp.path().join("dst.workflow"); + + // Old contents at destination — must not survive the second copy. + write_file(&dst.join("Contents/old-file.txt"), b"stale"); + + // Fresh source. + write_file(&src.join("Contents/Info.plist"), b"new"); + + copy_workflow_bundle(&src, &dst).unwrap(); + + assert!(dst.join("Contents/Info.plist").exists()); + assert!( + !dst.join("Contents/old-file.txt").exists(), + "stale file from previous install should have been removed" + ); + } + + #[test] + fn copy_workflow_bundle_creates_parent_dirs() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src.workflow"); + // Destination's parent doesn't exist yet. + let dst = tmp.path().join("nested/parent/dst.workflow"); + + write_file(&src.join("Contents/Info.plist"), b"x"); + + copy_workflow_bundle(&src, &dst).unwrap(); + assert!(dst.join("Contents/Info.plist").exists()); + } +} 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..3f3c947 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "compress[pdf]", - "version": "1.4.2", + "version": "1.5.0", "identifier": "com.pdfcompressor", "build": { "beforeDevCommand": "npm run dev", @@ -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,16 @@ "active": true, "targets": "all", "externalBin": ["binaries/gs"], + "resources": ["resources/Compress PDF.workflow/**/*"], + "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..10d9631 100644 --- a/src/lib/components/DetailPanel.svelte +++ b/src/lib/components/DetailPanel.svelte @@ -2,11 +2,13 @@ import { derived } from "svelte/store"; import { tweened } from "svelte/motion"; import { cubicOut } from "svelte/easing"; - import { fly } from "svelte/transition"; + import { fly, slide } from "svelte/transition"; + import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { queue, type Preset } from "$lib/stores/queueStore"; import { selectedFileId } from "$lib/stores/selectionStore"; import { settings } from "$lib/stores/settingsStore"; + import { toast } from "$lib/stores/toastStore"; import { formatBytes } from "$lib/notification"; import { revealInFinder } from "$lib/fileActions"; @@ -64,9 +66,48 @@ let outputMode: "same_as_source" | "custom_folder" = $settings.output_mode; let naming: "suffix" | "overwrite" = $settings.naming; + let defaultPreset: Preset = $settings.default_preset; + let advancedOpen = false; + let quickActionInstalled = false; + let quickActionBusy = false; $: outputMode = $settings.output_mode; $: naming = $settings.naming; + $: defaultPreset = $settings.default_preset; + + // Refresh install state the first time the drawer opens (or any time + // it re-opens — covers the case where the user installed/uninstalled + // the workflow via Finder while the app was open). + $: if (advancedOpen) { + refreshQuickActionState(); + } + + async function refreshQuickActionState() { + try { + quickActionInstalled = await invoke("is_quick_action_installed"); + } catch { + quickActionInstalled = false; + } + } + + async function toggleQuickAction() { + if (quickActionBusy) return; + quickActionBusy = true; + try { + if (quickActionInstalled) { + await invoke("uninstall_quick_action"); + toast.show("Quick Action removed from Finder"); + } else { + await invoke("install_quick_action"); + toast.show("Quick Action installed — right-click any PDF in Finder"); + } + await refreshQuickActionState(); + } catch (e) { + toast.show(typeof e === "string" ? e : "Quick Action update failed"); + } finally { + quickActionBusy = false; + } + } function onPresetChange(preset: Preset) { if (!$selectedFile) return; @@ -163,48 +204,101 @@ {/if}
- - -
-
Output Folder
- - - {#if $settings.output_mode === "custom_folder"} +
+ Output +
+ + +
+
+ + {#if $settings.output_mode === "custom_folder"} +
+
- {$settings.output_folder ?? "No folder selected"} + {$settings.output_folder ?? "No folder selected"}
- {/if} -
+
+ {/if} -
-
File Naming
- - - {#if naming === "overwrite"} -

Original file will be replaced and cannot be recovered.

- {/if} +
+ Naming +
+ + +
+ + {#if naming === "overwrite"} +
+ +

Original replaced; cannot be recovered.

+
+ {/if} + + + + {#if advancedOpen} +
+
+ Default preset +
+ {#each (["max", "balanced", "minimal"] as Preset[]) as p} + + {/each} +
+
+ +
+ +

Applied when you right-click a PDF in Finder and choose Open With → compress[pdf]. In-app files keep the per-file Quality preset above.

+
+ +
+ Quick Action +
+ + {quickActionInstalled ? "Installed" : "Not installed"} + + +
+
+ +
+ +

Adds a top-level Compress with compress[pdf] entry to Finder's right-click menu so you don't have to navigate the Open With submenu.

+
+
+ {/if}
@@ -416,39 +510,190 @@ .onboard-title { font-size: 13px; color: var(--text-secondary); font-weight: var(--weight-medium); } .onboard-sub { font-size: 11px; color: var(--text-tertiary); text-align: center; line-height: 1.5; } - .settings-section { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); margin-top: auto; } - .field { display: flex; flex-direction: column; gap: 6px; } - .field-label { + .settings-section { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--border-subtle); + margin-top: auto; + } + + .setting-row { + display: flex; + align-items: center; + gap: var(--space-3); + } + .setting-label { + flex: 0 0 88px; font-size: var(--text-sm); - font-weight: var(--weight-semibold); - letter-spacing: 0.01em; color: var(--text-tertiary); + letter-spacing: 0.01em; } - .radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; } - .radio-label input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } - .radio-dot { - width: 14px; - height: 14px; - border-radius: 50%; - border: 1.5px solid var(--border); - background: var(--bg-primary); - flex-shrink: 0; - transition: border-color 0.15s; + .setting-row--detail { + margin-top: -6px; } - .radio-label input[type="radio"]:checked + .radio-dot { - border-color: var(--accent); - background: var(--accent); - box-shadow: inset 0 0 0 3px var(--bg-secondary); + + .segmented { + flex: 1; + display: flex; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; + } + .segment { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 5px 8px; + font-size: var(--text-sm); + color: var(--text-tertiary); + cursor: pointer; + border-radius: 3px; + text-align: center; + transition: background 120ms ease, color 120ms ease; + user-select: none; + } + .segment input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; + } + .segment:hover { color: var(--text-secondary); } + .segment.active { + background: var(--bg-overlay); + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--border); + } + .segment code { + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + padding: 0; } - .radio-label:hover .radio-dot { border-color: var(--accent); } + .overwrite-warn { - font-size: 10px; + font-size: var(--text-xs); color: var(--warning); - margin-top: -2px; - margin-left: 22px; line-height: 1.4; + margin: 0; + } + .setting-hint { + font-size: var(--text-xs); + color: var(--text-tertiary); + line-height: 1.5; + margin: 0; + } + .setting-hint em { + font-style: normal; + color: var(--text-secondary); + } + + .advanced-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + align-self: flex-start; + background: none; + border: 0; + padding: 2px 0; + font-family: inherit; + font-size: var(--text-sm); + color: var(--text-tertiary); + cursor: pointer; + transition: color 120ms ease; + margin-top: var(--space-1); + } + .advanced-toggle:hover { color: var(--text-secondary); } + .chevron { + width: 8px; + height: 8px; + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); + } + .chevron.open { transform: rotate(90deg); } + + .advanced-content { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + .quick-action-row { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; + } + .quick-action-status { + font-size: var(--text-sm); + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .quick-action-status.installed { + color: var(--accent); + } + .quick-action-btn { + padding: 3px 12px; + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-family: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg-overlay); + color: var(--text-primary); + transition: background 120ms ease, border-color 120ms ease; + min-width: 72px; + } + .quick-action-btn:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--accent); + } + .quick-action-btn:disabled { + cursor: default; + opacity: 0.5; + } + + .folder-row { + flex: 1; + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + } + .folder-path { + flex: 1; + font-size: var(--text-sm); + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + } + .folder-row button { + padding: 3px 10px; + border-radius: 3px; + font-size: var(--text-sm); + font-family: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg-overlay); + color: var(--text-primary); + transition: background 120ms ease, border-color 120ms ease; + } + .folder-row button:hover { + background: var(--bg-tertiary); + border-color: var(--accent); } - .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..2e55af4 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(); }); @@ -69,16 +69,33 @@ describe("DetailPanel", () => { it("shows settings section when no file is selected", () => { render(DetailPanel); - expect(screen.getByText(/output folder/i)).toBeInTheDocument(); - expect(screen.getByText(/file naming/i)).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + expect(screen.getByText("Naming")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /advanced/i })).toBeInTheDocument(); }); it("shows settings section when a file is selected", () => { queue.addFile({ path: "/tmp/a.pdf", name: "a.pdf", size: 1000 }); selectedFileId.set(get(queue)[0].id); render(DetailPanel); - expect(screen.getByText(/output folder/i)).toBeInTheDocument(); - expect(screen.getByText(/file naming/i)).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + expect(screen.getByText("Naming")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /advanced/i })).toBeInTheDocument(); + }); + + it("Default preset is hidden inside the Advanced drawer by default", () => { + render(DetailPanel); + expect(screen.queryByText("Default preset")).not.toBeInTheDocument(); + const toggle = screen.getByRole("button", { name: /advanced/i }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + it("clicking Advanced reveals the Default preset section", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + expect(screen.getByText("Default preset")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /advanced/i })).toHaveAttribute("aria-expanded", "true"); }); it("changing output mode to custom_folder saves immediately", async () => { @@ -122,4 +139,96 @@ describe("DetailPanel", () => { settings: expect.objectContaining({ output_folder: "/Users/me/Documents" }), }); }); + + it("Advanced drawer reveals Default preset radios after expand", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + expect(screen.getByText("Default preset")).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 default preset to max saves immediately", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + await user.click(screen.getByRole("radio", { name: /^Max/i })); + expect(invoke).toHaveBeenCalledWith("save_settings", { + settings: expect.objectContaining({ default_preset: "max" }), + }); + }); + + it("changing default preset to minimal saves immediately", async () => { + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + await user.click(screen.getByRole("radio", { name: /^Minimal/i })); + expect(invoke).toHaveBeenCalledWith("save_settings", { + settings: expect.objectContaining({ default_preset: "minimal" }), + }); + }); + + // ── Quick Action install row ────────────────────────────────────────────── + + it("Quick Action row shows 'Not installed' when backend reports false", async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "is_quick_action_installed") return false; + return undefined; + }); + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + expect(await screen.findByText("Not installed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Install$/ })).toBeInTheDocument(); + }); + + it("Quick Action row shows 'Installed' when backend reports true", async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "is_quick_action_installed") return true; + return undefined; + }); + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + expect(await screen.findByText("Installed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Remove$/ })).toBeInTheDocument(); + }); + + it("clicking Install invokes install_quick_action and re-reads state", async () => { + let installed = false; + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "is_quick_action_installed") return installed; + if (cmd === "install_quick_action") { + installed = true; + return undefined; + } + return undefined; + }); + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + await user.click(await screen.findByRole("button", { name: /^Install$/ })); + expect(invoke).toHaveBeenCalledWith("install_quick_action"); + expect(await screen.findByText("Installed")).toBeInTheDocument(); + }); + + it("clicking Remove invokes uninstall_quick_action and re-reads state", async () => { + let installed = true; + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === "is_quick_action_installed") return installed; + if (cmd === "uninstall_quick_action") { + installed = false; + return undefined; + } + return undefined; + }); + const user = userEvent.setup(); + render(DetailPanel); + await user.click(screen.getByRole("button", { name: /advanced/i })); + await user.click(await screen.findByRole("button", { name: /^Remove$/ })); + expect(invoke).toHaveBeenCalledWith("uninstall_quick_action"); + expect(await screen.findByText("Not installed")).toBeInTheDocument(); + }); }); 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 });