diff --git a/.agents/skills/test-convt-desktop/SKILL.md b/.agents/skills/test-convt-desktop/SKILL.md index d0a79233..ed9b2b01 100644 --- a/.agents/skills/test-convt-desktop/SKILL.md +++ b/.agents/skills/test-convt-desktop/SKILL.md @@ -7,18 +7,18 @@ description: Run the headless window tests for the convt GPUI desktop app (crate `crates/convt-app` is a GPUI app built on the `gpui-kit` crate. One process runs per user; a second launch forwards its request to the first over a socket in the runtime directory and exits. The look follows the Paper design (file "convt", page "Desktop app"): Geist and Geist Mono are bundled in `crates/convt-app/assets/fonts`, the light and dark palettes live in `src/ui/theme.rs`, and the theme follows the system appearance. Windows: -- The main window (1040x640, title "convt"): a sidebar with Activity, Automations and Settings, plus a trial card in licensed builds. Activity lists running jobs and history in one list, with a defaults bar ("Add files converts right away to Images → WebP, …", Change) and Add files, which converts right away. Dropping files on the window does the same. Stop cancels a running job; Retry runs a failed or cancelled one again with the options and output folder it had, which history keeps. +- The main window (1040x640, title "convt"): a sidebar with Activity, Automations and Settings, plus a trial card in licensed builds. Activity lists running jobs and history in one list, with a defaults bar ("Add files converts right away to Photos → JPEG, Images → PNG, …", Change) and Add files, which converts right away. Photos are HEIC, AVIF, WebP and JPEG; other stills (PNG, SVG, screenshots by name) become PNG. Dropping files on the window does the same. Stop cancels a running job; Retry runs a failed or cancelled one again with the options and output folder it had, which history keeps. - Quick convert (600x560, title "Convert"): format cards, quality and size options, Codec (H.264 or HEVC, for MP4, MOV and MKV) and Keep audio for video, Background for images (Transparent where the format keeps it, White, Black, or a preset's color; ids `background` and `background-`), Save to and File name. A preset sets the controls; Balanced and Original clear what it set. It opens for files sent without a target (`convt-app `, `convt-app open -- `, "More options…" in the file manager menus, "Open With Other Application"), for every `convt://convert` link, and when a request with a target can't run silently (a file that can't be converted, an unknown preset, or a license that stops conversions). Video files show a frame as their thumbnail, here and in Activity (see "Video thumbnails"). - The document pack (`src/pack.rs`, `src/ui/pack.rs`, design in `docs/document-pack.md`): documents (Word, Excel, PowerPoint, OpenDocument, RTF, text, HTML, CSV) need LibreOffice. When nothing can convert a document, Quick convert shows a "Document support isn't installed" card with the pinned size and one Download button instead of format cards; Add files, drops, the popover and a right-click `open --to` on a document all end up there, and a silent convert never downloads. The card follows the download (progress, Cancel), then the checksum and the install; on success the registry is rebuilt, document targets appear and the target a right-click asked for is picked, but converting still takes Convert. Failures read "Couldn't download document support" (Try again), "The download didn't check out" (checksum) or, for a pack that discovery rejects, "Document support needs reinstalling" with the reason in plain words and the engines' message under it. Settings → General → Documents shows the status, Download, and Remove (asks first, waits for document jobs). A build with no pin (any source build without the `CONVT_DOCUMENT_PACK_*` variables) says it has nothing to download and offers no button. Status checks are offline; the only network request is the Download click. - Settings (620x600): General, Presets and License tabs. "Jobs at once" defaults to Auto, shown as "Auto (N)" with N from the CPU count. -- First run (420x420): the Finder step (macOS only), "Start 7-day trial" or "I have a key", and a last step. It shows until the last step is finished, and only in builds that check licenses. Closing mid-setup shows it again. After that, macOS Activity shows a recover card until the Finder extension is on. The Finder step's System Settings picture is a preview (id `finder-preview`), not a switch. +- First run (420x420): the Finder step (macOS only), "Start 7-day trial" or "I have a key", and a last step. The last step says photos become JPEG and screenshots and other images become PNG. It shows until the last step is finished, and only in builds that check licenses. Closing mid-setup shows it again. After that, macOS Activity shows a recover card until the Finder extension is on. The Finder step's System Settings picture is a preview (id `finder-preview`), not a switch. - The menu bar popover (340x520): drop bar (converts and copies the result to the clipboard), running jobs, automation switches, Open convt and Settings. It opens only from the tray icon, which GPUI can't draw yet, so only tests open it (`ui::open_popover`). `src/tray.rs` decides what the icon would show. Requests come from `convt-app [files...]`, `convt-app open [--to ] [--preset ] [--] `, `file://` URIs from file managers, `convt://convert?file=&to=` links, `convt://activate?key=` links (open the License tab with the key filled in, never activate without a click) and `convt://auth?state=&code=` links (the browser's answer to a desktop sign-in; see "Sign-in and renewal"). A command-line request with `--to`, or a preset that names a format, converts in place with no window: the output goes next to the original whatever Settings says, no notification is shown and nothing is revealed, and the app quits when the batch is done if nothing else is open. Links never convert without a click. Licensing: a build from source needs no license and never shows first run or a trial card. With the check on, the sidebar shows the trial card, Quick convert shows why conversions stopped, and the License tab has the key field, Activate and Remove license. `convt-app --help` prints the usage without opening a window. -Placeholders are kept in `src/placeholder.rs`: the example automation rules. Automation rules are only stored; nothing runs them yet. +Example automation rules live in `src/placeholder.rs` (screenshots → PNG with copy, screen recordings → MP4, HEIC → JPEG off). Each enabled rule watches **one** directory, never recursively: macOS screenshots use `com.apple.screencapture location` (and skip the watch when `target` is clipboard); recordings use the recording location or Desktop. Tests must set `Automation.folder` or `CONVT_SCREENSHOT_DIR` / `CONVT_RECORDING_DIR` — the poller never opens the real Desktop under `cfg(test)`. Drive it with `AppState::poll_automations` (twice after writing a file, so the size can settle). A checkbox per rule copies the result; the tray drop bar still copies independently. Pack tests use `TestPacks`, a scripted `pack::Backend` (`Fixture::with_packs`), never the real one: it counts installs and removes, holds a download mid-way (`hold`), fails as told (`fail_next`), and its registry adds `TestOffice`, a fake document engine, once "installed", so results don't depend on this machine's LibreOffice. Assert `packs.installs() == 0` before every click on `pack-download`; `only_the_download_button_reaches_the_installer` checks in the source that nothing else reaches `install_documents`. Ids: `pack-card` (its label is the title), `pack-body`, `pack-detail`, `pack-download`, `pack-cancel`, `pack-progress`, `pack-done`, and in Settings `pack-status`, `pack-remove`, `pack-remove-confirm`, `pack-remove-keep`. `open_quick` records the newest Quick convert window, so `window_of::` finds what the app opened. diff --git a/.changeset/desktop-defaults-automations.md b/.changeset/desktop-defaults-automations.md new file mode 100644 index 00000000..4faa843a --- /dev/null +++ b/.changeset/desktop-defaults-automations.md @@ -0,0 +1,7 @@ +--- +"@convt/desktop": minor +"@convt/cli": minor +"@convt/web": minor +--- + +New installs convert photos to JPEG and other images (including screenshots) to PNG. Automations now watch the real screenshot and screen-recording folders — one directory each, never recursively — with separate presets and an option to copy the result. diff --git a/Cargo.lock b/Cargo.lock index 1e44a36e..9d77c12b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ dependencies = [ "convt-engines", "convt-license", "convt-update", + "dirs 6.0.0", "ed25519-dalek", "futures", "gpui-kit", diff --git a/crates/convt-app/Cargo.toml b/crates/convt-app/Cargo.toml index 33b74b91..a6243f87 100644 --- a/crates/convt-app/Cargo.toml +++ b/crates/convt-app/Cargo.toml @@ -15,6 +15,7 @@ convt-core.workspace = true convt-engines.workspace = true convt-license = { workspace = true, features = ["client"] } convt-update.workspace = true +dirs.workspace = true ed25519-dalek.workspace = true futures.workspace = true gpui-kit.workspace = true diff --git a/crates/convt-app/src/automation.rs b/crates/convt-app/src/automation.rs new file mode 100644 index 00000000..f8e96556 --- /dev/null +++ b/crates/convt-app/src/automation.rs @@ -0,0 +1,605 @@ +//! Folder watching for automation rules. +//! +//! Each enabled rule looks at **one** directory, never recursively: the +//! macOS screenshot location, the screen-recording folder, or a path the +//! rule names. Home, the filesystem root and other catch-all folders only +//! match files that look like screenshots or recordings, so a Desktop watch +//! does not convert every image that lands there. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use convt_core::{Category, Format, format_by_extension, format_by_id}; +use gpui_kit::{Context, Task}; + +use crate::model::AppState; +use crate::settings::{Automation, WatchKind, looks_like_recording, looks_like_screenshot}; + +/// How often the poller lists each watched folder. +pub const POLL: Duration = Duration::from_secs(1); + +/// What a rule is waiting on, for the Automations page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WatchStatus { + /// The folder exists and will be listed on each poll. + Folder(PathBuf), + /// macOS screenshots are set to the clipboard, so nothing lands on disk. + Clipboard, + /// The folder is missing, or this build has no folder to watch (tests + /// without an override). + Waiting(String), + /// The path would watch too much (home or the filesystem root). + Skipped(&'static str), +} + +/// Snapshot of each watched directory so existing files are not converted +/// when a rule is turned on, and in-progress recordings wait until they +/// stop growing. +#[derive(Debug, Default)] +pub struct WatchState { + primed: HashSet, + seen: HashMap, + submitted: HashSet, +} + +impl WatchState { + /// Files that are new, stable and match an enabled rule, as + /// `(rule index, path)` in rule order. + pub fn drain_ready(&mut self, rules: &[Automation]) -> Vec<(usize, PathBuf)> { + let mut ready = Vec::new(); + for (index, rule) in rules.iter().enumerate() { + if !rule.is_watched() { + continue; + } + let WatchStatus::Folder(dir) = watch_status(rule) else { + continue; + }; + let Ok(entries) = list_dir(&dir) else { + continue; + }; + if self.primed.insert(dir.clone()) { + for (path, len) in &entries { + self.seen.insert(path.clone(), *len); + self.submitted.insert(path.clone()); + } + continue; + } + for (path, len) in entries { + if self.submitted.contains(&path) { + self.seen.insert(path, len); + continue; + } + match self.seen.get(&path) { + None => { + self.seen.insert(path, len); + } + Some(&prev) if prev != len => { + self.seen.insert(path, len); + } + Some(_) => { + if matches_rule(rule, &path, &dir) { + self.submitted.insert(path.clone()); + if let Some(to) = format_by_id(&rule.to) { + self.submitted.insert(path.with_extension(to.extension())); + } + ready.push((index, path)); + } else { + self.submitted.insert(path); + } + } + } + } + } + ready + } +} + +/// Polls enabled rules so a new screenshot or recording is converted. Tests +/// call [`AppState::poll_automations`] themselves; a live poll would race +/// them and could see the real Desktop. +pub fn watch(cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + if this + .update(cx, |state, cx| state.poll_automations(cx)) + .is_err() + { + break; + } + cx.background_executor().timer(POLL).await; + } + }) +} + +/// Where a rule looks, and why it might not. +pub fn watch_status(rule: &Automation) -> WatchStatus { + if let Some(folder) = rule.folder.as_ref().filter(|p| !p.as_os_str().is_empty()) { + let dir = expand_tilde(folder); + if is_too_broad(&dir) { + return WatchStatus::Skipped("This folder is too broad to watch."); + } + if dir.is_dir() { + return WatchStatus::Folder(dir); + } + return WatchStatus::Waiting(format!("{} isn't there yet.", display_path(&dir))); + } + match rule.watch_kind() { + WatchKind::Screenshot => match screenshot_location() { + ScreenshotLocation::Clipboard => WatchStatus::Clipboard, + ScreenshotLocation::Folder(dir) if is_too_broad(&dir) => { + WatchStatus::Skipped("This folder is too broad to watch.") + } + ScreenshotLocation::Folder(dir) if dir.is_dir() => WatchStatus::Folder(dir), + ScreenshotLocation::Folder(dir) => { + WatchStatus::Waiting(format!("{} isn't there yet.", display_path(&dir))) + } + ScreenshotLocation::Unknown => { + WatchStatus::Waiting("No screenshot folder is set.".into()) + } + }, + WatchKind::Recording => match recording_dir() { + Some(dir) if is_too_broad(&dir) => { + WatchStatus::Skipped("This folder is too broad to watch.") + } + Some(dir) if dir.is_dir() => WatchStatus::Folder(dir), + Some(dir) => WatchStatus::Waiting(format!("{} isn't there yet.", display_path(&dir))), + None => WatchStatus::Waiting("No screen-recording folder is set.".into()), + }, + WatchKind::Folder => match source_dir(&rule.source) { + Some(dir) if is_too_broad(&dir) => { + WatchStatus::Skipped("This folder is too broad to watch.") + } + Some(dir) if dir.is_dir() => WatchStatus::Folder(dir), + Some(dir) => WatchStatus::Waiting(format!("{} isn't there yet.", display_path(&dir))), + None => WatchStatus::Waiting(format!("No folder for {}.", rule.source)), + }, + } +} + +/// The path shown under a rule, or the reason it is waiting. +pub fn source_line(rule: &Automation) -> String { + match watch_status(rule) { + WatchStatus::Folder(dir) => display_path(&dir), + WatchStatus::Clipboard => { + "Screenshots go to the clipboard, so there is no folder to watch.".into() + } + WatchStatus::Waiting(msg) => msg, + WatchStatus::Skipped(msg) => msg.to_string(), + } +} + +fn matches_rule(rule: &Automation, path: &Path, dir: &Path) -> bool { + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with('.') || is_temp_name(n)) + { + return false; + } + let Some(from) = format_by_extension(path) else { + return false; + }; + let Some(to) = format_by_id(&rule.to) else { + return false; + }; + if from.id == to.id { + return false; + } + match rule.watch_kind() { + WatchKind::Screenshot => { + is_still(from) && (looks_like_screenshot(path) || !is_catch_all_folder(dir)) + } + WatchKind::Recording => { + from.category == Category::Video + && (looks_like_recording(path) || !is_catch_all_folder(dir)) + } + WatchKind::Folder => match format_by_id(&rule.name) { + Some(only) => from.id == only.id, + None => true, + }, + } +} + +fn is_still(format: &Format) -> bool { + matches!(format.category, Category::Image | Category::Vector) +} + +fn is_temp_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".tmp") + || lower.ends_with(".part") + || lower.ends_with(".download") + || lower.contains(".convt-") +} + +fn is_catch_all_folder(dir: &Path) -> bool { + let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { + return true; + }; + matches!( + name.to_ascii_lowercase().as_str(), + "desktop" | "downloads" | "documents" | "movies" | "pictures" | "videos" | "music" + ) || home_dir().is_some_and(|home| same_path(dir, &home)) +} + +fn is_too_broad(dir: &Path) -> bool { + dir.parent().is_none() || home_dir().is_some_and(|home| same_path(dir, &home)) +} + +fn same_path(a: &Path, b: &Path) -> bool { + fn norm(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } + norm(a) == norm(b) +} + +fn list_dir(dir: &Path) -> std::io::Result> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.is_dir() || meta.file_type().is_symlink() { + continue; + } + if !meta.is_file() { + continue; + } + out.push((path, meta.len())); + } + Ok(out) +} + +/// Where macOS (or this machine) puts still screenshots. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenshotLocation { + Folder(PathBuf), + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Clipboard, + Unknown, +} + +pub fn screenshot_location() -> ScreenshotLocation { + if let Some(dir) = env_dir("CONVT_SCREENSHOT_DIR") { + return ScreenshotLocation::Folder(dir); + } + if cfg!(test) { + return ScreenshotLocation::Unknown; + } + #[cfg(target_os = "macos")] + { + if macos_screencapture("target").is_some_and(|t| t.eq_ignore_ascii_case("clipboard")) { + return ScreenshotLocation::Clipboard; + } + if let Some(dir) = macos_screencapture("location").and_then(|s| existing_or_path(&s)) { + return ScreenshotLocation::Folder(dir); + } + } + if let Some(dir) = picture_dir() + .map(|p| p.join("Screenshots")) + .filter(|p| p.is_dir()) + { + return ScreenshotLocation::Folder(dir); + } + match desktop_dir() { + Some(dir) => ScreenshotLocation::Folder(dir), + None => ScreenshotLocation::Unknown, + } +} + +fn recording_dir() -> Option { + if let Some(dir) = env_dir("CONVT_RECORDING_DIR") { + return Some(dir); + } + if cfg!(test) { + return None; + } + #[cfg(target_os = "macos")] + { + for key in ["videolocation", "videoLocation", "recordingLocation"] { + if let Some(dir) = macos_screencapture(key).and_then(|s| existing_or_path(&s)) { + return Some(dir); + } + } + } + if let Some(dir) = video_dir() + .map(|p| p.join("Captures")) + .filter(|p| p.is_dir()) + { + return Some(dir); + } + desktop_dir() +} + +fn source_dir(source: &str) -> Option { + let source = source.trim(); + if source.is_empty() { + return None; + } + match source.to_ascii_lowercase().as_str() { + "desktop" => desktop_dir(), + "downloads" => download_dir(), + "documents" => dirs::document_dir(), + "pictures" => picture_dir(), + "movies" | "videos" => video_dir(), + _ if source.starts_with('~') || source.starts_with('/') || source.contains(':') => { + Some(expand_tilde(Path::new(source))) + } + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn existing_or_path(raw: &str) -> Option { + let path = expand_tilde(Path::new(raw.trim().trim_matches('"'))); + if path.as_os_str().is_empty() { + None + } else { + Some(path) + } +} + +fn expand_tilde(path: &Path) -> PathBuf { + let raw = path.to_string_lossy(); + if raw == "~" { + return home_dir().unwrap_or_else(|| PathBuf::from("~")); + } + if let Some(rest) = raw.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(rest); + } + path.to_path_buf() +} + +pub fn display_path(path: &Path) -> String { + if let Some(home) = home_dir() + && let Ok(rest) = path.strip_prefix(&home) + { + if rest.as_os_str().is_empty() { + return "~".into(); + } + return format!("~/{}", rest.display()); + } + path.display().to_string() +} + +fn env_dir(name: &str) -> Option { + let value = std::env::var_os(name)?; + if value.is_empty() { + return None; + } + Some(expand_tilde(Path::new(&value))) +} + +fn home_dir() -> Option { + dirs::home_dir() +} + +fn desktop_dir() -> Option { + dirs::desktop_dir().or_else(|| home_dir().map(|h| h.join("Desktop"))) +} + +fn download_dir() -> Option { + dirs::download_dir().or_else(|| home_dir().map(|h| h.join("Downloads"))) +} + +fn picture_dir() -> Option { + dirs::picture_dir() +} + +fn video_dir() -> Option { + dirs::video_dir() +} + +#[cfg(target_os = "macos")] +fn macos_screencapture(key: &str) -> Option { + let out = std::process::Command::new("defaults") + .args(["read", "com.apple.screencapture", key]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8(out.stdout).ok()?; + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::placeholder::example_automations; + use crate::settings::{Automation, is_photo_format}; + + fn rule(name: &str, to: &str, kind: WatchKind, folder: &Path) -> Automation { + Automation { + name: name.into(), + to: to.into(), + source: "test".into(), + detail: String::new(), + enabled: true, + watch: Some(kind), + folder: Some(folder.to_path_buf()), + copy_to_clipboard: Some(false), + } + } + + #[test] + fn screenshot_names_and_photo_formats() { + assert!(looks_like_screenshot(Path::new( + "/Desktop/Screenshot 2026-10-07 at 10.38.00 AM.heic" + ))); + assert!(looks_like_screenshot(Path::new("Screen Shot 1.png"))); + assert!(looks_like_recording(Path::new( + "Screen Recording 2026-10-07 at 10.38.00 AM.mov" + ))); + assert!(!looks_like_screenshot(Path::new("IMG_2041.heic"))); + assert!(is_photo_format(format_by_id("heic").unwrap())); + assert!(is_photo_format(format_by_id("webp").unwrap())); + assert!(!is_photo_format(format_by_id("png").unwrap())); + assert!(!is_photo_format(format_by_id("svg").unwrap())); + } + + #[test] + fn desktop_screenshot_rule_ignores_ordinary_photos() { + let dir = Path::new("/Users/leo/Desktop"); + let shots = Automation { + name: "Screenshots".into(), + to: "png".into(), + source: "Desktop".into(), + detail: "copy to clipboard".into(), + enabled: true, + watch: Some(WatchKind::Screenshot), + folder: None, + copy_to_clipboard: Some(true), + }; + assert!(matches_rule(&shots, &dir.join("Screenshot 1.heic"), dir)); + assert!(!matches_rule(&shots, &dir.join("IMG_2041.heic"), dir)); + assert!(!matches_rule(&shots, &dir.join("Screenshot 1.png"), dir)); + } + + #[test] + fn dedicated_screenshot_folder_takes_any_still() { + let dir = Path::new("/Users/leo/Pictures/Screenshots"); + let shots = Automation { + name: "Screenshots".into(), + to: "png".into(), + source: "Screenshots".into(), + detail: String::new(), + enabled: true, + watch: Some(WatchKind::Screenshot), + folder: None, + copy_to_clipboard: None, + }; + assert!(matches_rule(&shots, &dir.join("capture.heic"), dir)); + assert!(!matches_rule(&shots, &dir.join("notes.mp4"), dir)); + } + + #[test] + fn recording_rule_matches_screen_recordings() { + let dir = Path::new("/Users/leo/Desktop"); + let rec = Automation { + name: "Screen recordings".into(), + to: "mp4".into(), + source: "Desktop".into(), + detail: String::new(), + enabled: true, + watch: Some(WatchKind::Recording), + folder: None, + copy_to_clipboard: None, + }; + assert!(matches_rule(&rec, &dir.join("Screen Recording 1.mov"), dir)); + assert!(!matches_rule(&rec, &dir.join("holiday.mov"), dir)); + assert!(!matches_rule( + &rec, + &dir.join("Screen Recording 1.mp4"), + dir + )); + } + + #[test] + fn heic_folder_rule_only_matches_heic() { + let dir = Path::new("/Users/leo/Downloads"); + let heic = Automation { + name: "HEIC".into(), + to: "jpeg".into(), + source: "Downloads".into(), + detail: "keep original".into(), + enabled: true, + watch: Some(WatchKind::Folder), + folder: None, + copy_to_clipboard: None, + }; + assert!(matches_rule(&heic, &dir.join("IMG_2041.heic"), dir)); + assert!(!matches_rule(&heic, &dir.join("photo.webp"), dir)); + } + + #[test] + fn watch_state_primes_then_emits_stable_new_files() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("Desktop"); + std::fs::create_dir(&dir).unwrap(); + let shots = rule("Screenshots", "png", WatchKind::Screenshot, &dir); + std::fs::write(dir.join("Screenshot old.bmp"), b"old").unwrap(); + let mut state = WatchState::default(); + assert!(state.drain_ready(std::slice::from_ref(&shots)).is_empty()); + + std::fs::write(dir.join("notes.bmp"), b"skip").unwrap(); + std::fs::write(dir.join("Screenshot new.bmp"), b"new").unwrap(); + assert!(state.drain_ready(std::slice::from_ref(&shots)).is_empty()); + let ready = state.drain_ready(&[shots]); + assert_eq!(ready.len(), 1); + assert!(ready[0].1.ends_with("Screenshot new.bmp")); + } + + #[test] + fn growing_files_wait_until_the_size_settles() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path(); + let rec = rule("Screen recordings", "mp4", WatchKind::Recording, dir); + let mut state = WatchState::default(); + assert!(state.drain_ready(std::slice::from_ref(&rec)).is_empty()); + + let file = dir.join("Screen Recording 1.mov"); + std::fs::write(&file, b"one").unwrap(); + assert!(state.drain_ready(std::slice::from_ref(&rec)).is_empty()); + std::fs::write(&file, b"longer").unwrap(); + assert!(state.drain_ready(std::slice::from_ref(&rec)).is_empty()); + let ready = state.drain_ready(&[rec]); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].1, file); + } + + #[test] + fn example_rules_are_screenshot_and_recording() { + let rules = example_automations(); + assert_eq!(rules[0].name, "Screenshots"); + assert_eq!(rules[0].to, "png"); + assert_eq!(rules[0].watch_kind(), WatchKind::Screenshot); + assert!(rules[0].copies_to_clipboard()); + assert_eq!(rules[1].name, "Screen recordings"); + assert_eq!(rules[1].to, "mp4"); + assert_eq!(rules[1].watch_kind(), WatchKind::Recording); + assert!(!rules[1].copies_to_clipboard()); + } + + #[test] + fn a_legacy_exports_placeholder_is_not_watched() { + let exports = Automation { + name: "Exports".into(), + to: "mp4".into(), + source: "~/Movies/Exports".into(), + detail: "H.264 1080p".into(), + enabled: true, + watch: None, + folder: None, + copy_to_clipboard: None, + }; + assert!(!exports.is_watched()); + assert_eq!(exports.watch_kind(), WatchKind::Folder); + } + + #[test] + fn tests_do_not_resolve_the_real_desktop() { + let shots = &example_automations()[0]; + assert!(matches!(watch_status(shots), WatchStatus::Waiting(_))); + } + + #[test] + fn home_and_root_are_too_broad() { + let root = Path::new("/"); + assert!(is_too_broad(root)); + if let Some(home) = home_dir() { + assert!(is_too_broad(&home)); + assert!(!is_too_broad(&home.join("Desktop"))); + } + } +} diff --git a/crates/convt-app/src/clipboard.rs b/crates/convt-app/src/clipboard.rs new file mode 100644 index 00000000..8af0f7ee --- /dev/null +++ b/crates/convt-app/src/clipboard.rs @@ -0,0 +1,30 @@ +//! What a finished conversion puts on the clipboard: the image itself +//! when there is one the clipboard can hold, otherwise the output paths. + +use std::path::{Path, PathBuf}; + +use gpui_kit::{ClipboardItem, Image, ImageFormat}; + +pub fn clipboard_item(outputs: &[PathBuf]) -> ClipboardItem { + if let [one] = outputs + && let Some(format) = image_format(one) + && let Ok(bytes) = std::fs::read(one) + { + return ClipboardItem::new_image(&Image::from_bytes(format, bytes)); + } + let paths: Vec = outputs.iter().map(|p| p.display().to_string()).collect(); + ClipboardItem::new_string(paths.join("\n")) +} + +fn image_format(path: &Path) -> Option { + Some(match convt_core::format_by_extension(path)?.id { + "png" => ImageFormat::Png, + "jpeg" => ImageFormat::Jpeg, + "webp" => ImageFormat::Webp, + "gif" => ImageFormat::Gif, + "svg" => ImageFormat::Svg, + "bmp" => ImageFormat::Bmp, + "tiff" => ImageFormat::Tiff, + _ => return None, + }) +} diff --git a/crates/convt-app/src/main.rs b/crates/convt-app/src/main.rs index 4fb8551c..64da3666 100644 --- a/crates/convt-app/src/main.rs +++ b/crates/convt-app/src/main.rs @@ -2,6 +2,8 @@ //! its files to the running app and exits. mod account; +mod automation; +mod clipboard; mod clock; mod finder; mod history; diff --git a/crates/convt-app/src/model.rs b/crates/convt-app/src/model.rs index 6ae0a47a..1f26ca68 100644 --- a/crates/convt-app/src/model.rs +++ b/crates/convt-app/src/model.rs @@ -265,6 +265,10 @@ pub struct AppState { /// none, or macOS has not registered it. Tests set this directly. pub finder_on: Option, _finder_watch: Option>, + /// Jobs whose result should be copied when they finish. + automation_copies: HashSet, + watch: crate::automation::WatchState, + _automations: Option>, _drain: Task<()>, } @@ -348,6 +352,15 @@ impl AppState { } else { None }, + automation_copies: HashSet::new(), + watch: crate::automation::WatchState::default(), + // Tests drive the watcher themselves so a live poll cannot see + // the real Desktop or race a fixture. + _automations: if cfg!(not(test)) { + Some(crate::automation::watch(cx)) + } else { + None + }, _drain: drain, }; state.reload_presets(); @@ -430,8 +443,8 @@ impl AppState { /// the file can reach it. pub fn default_target(&self, file: &Path) -> Option<&'static Format> { let from = format_by_extension(file)?; - let to = self.settings.defaults.get(Kind::of(from)?)?; - self.registry.targets(from).contains(&to).then_some(to) + let to = self.settings.defaults.get(Kind::of_file(file, from)?)?; + (from.id != to.id && self.registry.targets(from).contains(&to)).then_some(to) } /// Converts files (and the files directly inside folders) to their @@ -695,8 +708,7 @@ impl AppState { self.registry_generation += 1; } - /// Switches an automation rule on or off. Only the setting changes: no - /// automation engine runs the rules yet. + /// Switches an automation rule on or off. pub fn set_automation(&mut self, index: usize, enabled: bool, cx: &mut Context) { self.update_settings( |s| { @@ -708,6 +720,54 @@ impl AppState { ); } + /// Whether a finished automation should copy its result. + pub fn set_automation_copy(&mut self, index: usize, copy: bool, cx: &mut Context) { + self.update_settings( + |s| { + if let Some(rule) = s.automations.get_mut(index) { + rule.copy_to_clipboard = Some(copy); + rule.detail = if copy { + "copy to clipboard".into() + } else { + "save beside original".into() + }; + } + }, + cx, + ); + } + + /// Lists each watched folder and converts new matching files. Tests call + /// this twice after writing a file: once to see it, once after it is + /// the same size. + pub fn poll_automations(&mut self, cx: &mut Context) { + let rules = self.settings.automations.clone(); + let ready = self.watch.drain_ready(&rules); + for (index, path) in ready { + let Some(rule) = rules.get(index) else { + continue; + }; + let Some(to) = format_by_id(&rule.to) else { + continue; + }; + let Some(from) = format_by_extension(&path) else { + continue; + }; + if !self.registry.targets(from).contains(&to) { + continue; + } + let copy = rule.copies_to_clipboard(); + match self.convert(std::slice::from_ref(&path), to, &Options::default(), cx) { + Ok(ids) => { + if copy { + self.automation_copies.extend(ids); + } + } + Err(e) => self.errors.push(e), + } + } + } + /// Verifies and stores a license key. pub fn activate(&mut self, key: &str, cx: &mut Context) -> Result { let result = self.licensing.activate(key).map_err(|e| e.to_string()); @@ -761,9 +821,12 @@ impl AppState { } else { &mut self.batch }; - let outcome = match entry.status { + let outcome = match &entry.status { Status::Done(outputs) => { batch.done += 1; + if self.automation_copies.remove(&entry.id) { + cx.write_to_clipboard(crate::clipboard::clipboard_item(outputs)); + } if !silent && self.settings.reveal_when_done && let Some(first) = outputs.first() @@ -774,11 +837,12 @@ impl AppState { #[cfg(test)] self.revealed.push(first.clone()); } - Outcome::Done(outputs) + Outcome::Done(outputs.clone()) } Status::Failed(e) => { + self.automation_copies.remove(&entry.id); batch.failed += 1; - Outcome::Failed(e.message) + Outcome::Failed(e.message.clone()) } _ => { batch.cancelled += 1; @@ -1034,4 +1098,28 @@ mod tests { assert!(t.formats.iter().any(|f| f.id == "webp")); assert_eq!(common_targets(®istry, &[]), Targets::default()); } + + #[test] + fn default_target_splits_photos_from_other_images() { + let registry = convt_engines::default_registry(); + let settings = Settings::default(); + let state_target = |file: &str| { + let from = format_by_extension(Path::new(file))?; + let to = settings + .defaults + .get(Kind::of_file(Path::new(file), from)?)?; + (from.id != to.id && registry.targets(from).contains(&to)).then_some(to.id) + }; + assert_eq!(state_target("share.webp"), Some("jpeg")); + assert_eq!(state_target("Screenshot 1.webp"), Some("png")); + assert_eq!(state_target("diagram.svg"), Some("png")); + assert_eq!(state_target("icon.bmp"), Some("png")); + assert_eq!(state_target("already.png"), None); + assert_eq!(state_target("already.jpg"), None); + let heic = format_by_id("heic").unwrap(); + if registry.targets(heic).iter().any(|f| f.id == "jpeg") { + assert_eq!(state_target("IMG_2041.heic"), Some("jpeg")); + assert_eq!(state_target("Screenshot 1.heic"), Some("png")); + } + } } diff --git a/crates/convt-app/src/placeholder.rs b/crates/convt-app/src/placeholder.rs index 75b38b19..ac225f93 100644 --- a/crates/convt-app/src/placeholder.rs +++ b/crates/convt-app/src/placeholder.rs @@ -1,29 +1,48 @@ -//! Placeholder values. Nothing here is real yet: the update manifest URL -//! waits for the release host, and the example automation rules stand in -//! until the automation engine exists. Keep every such value in this -//! module so it is easy to find and replace. +//! Placeholder values. Nothing here is real yet except the example +//! automation rules, which a fresh install stores and the automation +//! engine now runs. The update manifest URL still waits for the release +//! host. Keep every such value in this module so it is easy to find. -use crate::settings::Automation; +use crate::settings::{Automation, WatchKind}; /// Where the signed update manifest lives. Placeholder: nothing is published /// there yet (P11 sets up the release host). Builds from source can point at a /// local server with `CONVT_UPDATE_URL`. pub const UPDATE_MANIFEST_URL: &str = "https://convt.app/updates/manifest.json"; -/// Example rules a fresh install lists under Automations, matching the -/// design. Placeholder: the automation engine is not built, so these rules -/// are stored and can be switched on and off, but nothing runs them. +/// Example rules a fresh install lists under Automations. Existing +/// `settings.toml` files keep the rules they already saved. pub fn example_automations() -> Vec { - let rule = |name: &str, to: &str, source: &str, detail: &str, enabled| Automation { - name: name.into(), - to: to.into(), - source: source.into(), - detail: detail.into(), - enabled, - }; vec![ - rule("Screenshots", "webp", "Desktop", "copy to clipboard", true), - rule("Exports", "mp4", "~/Movies/Exports", "H.264 1080p", true), - rule("HEIC", "jpeg", "Downloads", "keep original", false), + Automation { + name: "Screenshots".into(), + to: "png".into(), + source: "Screenshots".into(), + detail: "copy to clipboard".into(), + enabled: true, + watch: Some(WatchKind::Screenshot), + folder: None, + copy_to_clipboard: Some(true), + }, + Automation { + name: "Screen recordings".into(), + to: "mp4".into(), + source: "Screen recordings".into(), + detail: "save beside original".into(), + enabled: true, + watch: Some(WatchKind::Recording), + folder: None, + copy_to_clipboard: Some(false), + }, + Automation { + name: "HEIC".into(), + to: "jpeg".into(), + source: "Downloads".into(), + detail: "keep original".into(), + enabled: false, + watch: Some(WatchKind::Folder), + folder: None, + copy_to_clipboard: Some(false), + }, ] } diff --git a/crates/convt-app/src/settings.rs b/crates/convt-app/src/settings.rs index 935653c4..93e5359a 100644 --- a/crates/convt-app/src/settings.rs +++ b/crates/convt-app/src/settings.rs @@ -6,7 +6,7 @@ use std::io; use std::path::{Path, PathBuf}; use convt_core::{Category, Format, Output, format_by_id}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -38,7 +38,7 @@ pub struct Settings { pub update_sequence: u64, /// What Add files converts each kind of file to. pub defaults: Defaults, - /// Automation rules. Only stored for now: nothing runs them yet. + /// Automation rules. Each enabled rule watches one folder. pub automations: Vec, } @@ -62,9 +62,11 @@ impl Default for Settings { } /// The format Add files picks for each kind of file, by format id. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Defaults { + /// Camera and web photos (HEIC, AVIF, WebP, JPEG). + pub photos: String, + /// Screenshots, transparent images, vectors and other stills. pub images: String, pub video: String, pub audio: String, @@ -74,7 +76,8 @@ pub struct Defaults { impl Default for Defaults { fn default() -> Self { Self { - images: "webp".into(), + photos: "jpeg".into(), + images: "png".into(), video: "mp4".into(), audio: "mp3".into(), documents: "pdf".into(), @@ -82,9 +85,40 @@ impl Default for Defaults { } } +impl<'de> Deserialize<'de> for Defaults { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct Raw { + images: Option, + photos: Option, + video: Option, + audio: Option, + documents: Option, + } + let raw = Raw::deserialize(deserializer)?; + let fresh = Defaults::default(); + // Files written before the photo / image split stored one `images` + // default for every still. Keep that value for both kinds so a + // saved WebP (or whatever they picked) is not rewritten. + let (photos, images) = match (raw.photos, raw.images) { + (Some(photos), images) => (photos, images.unwrap_or(fresh.images)), + (None, Some(legacy)) => (legacy.clone(), legacy), + (None, None) => (fresh.photos, fresh.images), + }; + Ok(Self { + photos, + images, + video: raw.video.unwrap_or(fresh.video), + audio: raw.audio.unwrap_or(fresh.audio), + documents: raw.documents.unwrap_or(fresh.documents), + }) + } +} + /// A kind of file with its own default target. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { + Photos, Images, Video, Audio, @@ -92,12 +126,19 @@ pub enum Kind { } impl Kind { - pub const ALL: [Kind; 4] = [Kind::Images, Kind::Video, Kind::Audio, Kind::Documents]; + pub const ALL: [Kind; 5] = [ + Kind::Photos, + Kind::Images, + Kind::Video, + Kind::Audio, + Kind::Documents, + ]; /// The kind a file of `format` belongs to. PDFs have none: they are /// already what documents become. pub fn of(format: &Format) -> Option { match format.category { + Category::Image | Category::Vector if is_photo_format(format) => Some(Kind::Photos), Category::Image | Category::Vector => Some(Kind::Images), Category::Video => Some(Kind::Video), Category::Audio => Some(Kind::Audio), @@ -108,8 +149,18 @@ impl Kind { } } + /// [`Self::of`], except a screenshot-named photo (HEIC from Cmd+Shift+3) + /// uses the Images default so it becomes PNG, not JPEG. + pub fn of_file(path: &Path, format: &Format) -> Option { + match Kind::of(format) { + Some(Kind::Photos) if looks_like_screenshot(path) => Some(Kind::Images), + other => other, + } + } + pub fn label(self) -> &'static str { match self { + Kind::Photos => "Photos", Kind::Images => "Images", Kind::Video => "Video", Kind::Audio => "Audio", @@ -119,6 +170,7 @@ impl Kind { pub fn id(self) -> &'static str { match self { + Kind::Photos => "photos", Kind::Images => "images", Kind::Video => "video", Kind::Audio => "audio", @@ -130,6 +182,7 @@ impl Kind { impl Defaults { pub fn get(&self, kind: Kind) -> Option<&'static Format> { format_by_id(match kind { + Kind::Photos => &self.photos, Kind::Images => &self.images, Kind::Video => &self.video, Kind::Audio => &self.audio, @@ -139,6 +192,7 @@ impl Defaults { pub fn set(&mut self, kind: Kind, to: &Format) { let slot = match kind { + Kind::Photos => &mut self.photos, Kind::Images => &mut self.images, Kind::Video => &mut self.video, Kind::Audio => &mut self.audio, @@ -148,18 +202,102 @@ impl Defaults { } } -/// A rule such as "Screenshots on the Desktop become WebP". +/// How a rule decides which new files to convert. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WatchKind { + Screenshot, + Recording, + Folder, +} + +/// A rule such as "Screenshots become PNG". #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Automation { /// What the rule watches, e.g. "Screenshots". pub name: String, /// Target format id. pub to: String, - /// Where it looks, e.g. "Desktop". + /// Where it looks, e.g. "Desktop" or "Screenshots". Display and, for + /// older files, the folder to resolve. pub source: String, /// What else it does, e.g. "copy to clipboard". pub detail: String, pub enabled: bool, + /// When set, overrides the kind inferred from [`Self::name`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub watch: Option, + /// An explicit folder. When set, the system screenshot location is ignored. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder: Option, + /// When set, overrides the "copy to clipboard" phrase in [`Self::detail`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_to_clipboard: Option, +} + +impl Automation { + pub fn watch_kind(&self) -> WatchKind { + if let Some(kind) = self.watch { + return kind; + } + let name = self.name.to_ascii_lowercase(); + if name.contains("screenshot") { + WatchKind::Screenshot + } else if name.contains("recording") { + WatchKind::Recording + } else { + WatchKind::Folder + } + } + + pub fn copies_to_clipboard(&self) -> bool { + self.copy_to_clipboard.unwrap_or_else(|| { + self.detail + .to_ascii_lowercase() + .contains("copy to clipboard") + }) + } + + /// Whether the engine should look at this rule. A legacy "Exports" + /// placeholder without an explicit watch kind or folder is left idle. + pub fn is_watched(&self) -> bool { + self.enabled + && (self.watch.is_some() + || self.folder.is_some() + || matches!( + self.watch_kind(), + WatchKind::Screenshot | WatchKind::Recording + )) + } +} + +/// Camera and web photo formats: Add files turns these into JPEG. +pub fn is_photo_format(format: &Format) -> bool { + matches!(format.id, "jpeg" | "heic" | "avif" | "webp") +} + +/// A file name that macOS, Windows or common tools use for a still capture. +pub fn looks_like_screenshot(path: &Path) -> bool { + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { + return false; + }; + let name = name.to_ascii_lowercase(); + name.starts_with("screenshot") + || name.starts_with("screen shot") + || name.starts_with("cleanshot") + || name.starts_with("simulator screen") +} + +/// A file name that macOS or Windows use for a screen recording. +pub fn looks_like_recording(path: &Path) -> bool { + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { + return false; + }; + let name = name.to_ascii_lowercase(); + name.starts_with("screen recording") + || name.starts_with("screenrecording") + || name.starts_with("recording ") + || name.starts_with("recording_") } impl Settings { @@ -237,7 +375,8 @@ mod tests { license_checked: Some("2026-10-05".into()), ..Settings::default() }; - s.defaults.set(Kind::Images, format_by_id("png").unwrap()); + s.defaults.set(Kind::Images, format_by_id("webp").unwrap()); + s.defaults.set(Kind::Photos, format_by_id("png").unwrap()); s.automations[0].enabled = false; s.save(&path).unwrap(); assert_eq!(Settings::load(&path).unwrap(), s); @@ -260,4 +399,36 @@ mod tests { std::fs::write(&path, "concurrency = \"lots\"").unwrap(); assert!(Settings::load(&path).is_err()); } + + #[test] + fn a_saved_images_default_is_not_replaced_by_the_split() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.toml"); + std::fs::write(&path, "[defaults]\nimages = \"webp\"\nvideo = \"mp4\"\n").unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.defaults.images, "webp"); + assert_eq!(s.defaults.photos, "webp"); + assert_eq!(s.defaults.video, "mp4"); + + std::fs::write(&path, "[defaults]\nphotos = \"jpeg\"\nimages = \"png\"\n").unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.defaults.photos, "jpeg"); + assert_eq!(s.defaults.images, "png"); + } + + #[test] + fn screenshot_named_photos_use_the_images_default() { + let heic = format_by_id("heic").unwrap(); + let png = format_by_id("png").unwrap(); + assert_eq!(Kind::of(heic), Some(Kind::Photos)); + assert_eq!(Kind::of(png), Some(Kind::Images)); + assert_eq!( + Kind::of_file(Path::new("IMG_2041.heic"), heic), + Some(Kind::Photos) + ); + assert_eq!( + Kind::of_file(Path::new("Screenshot 1.heic"), heic), + Some(Kind::Images) + ); + } } diff --git a/crates/convt-app/src/ui/first_run.rs b/crates/convt-app/src/ui/first_run.rs index 2b075d2d..8039b0a2 100644 --- a/crates/convt-app/src/ui/first_run.rs +++ b/crates/convt-app/src/ui/first_run.rs @@ -455,9 +455,9 @@ impl Render for FirstRunView { Step::Done => ( "You're set", if cfg!(target_os = "macos") { - "Right-click any file in Finder and pick a format. The menu bar icon shows progress and lets you drop files in.".to_string() + "Right-click a file in Finder and pick a format, or drop files on convt: photos become JPEG, screenshots and other images become PNG.".to_string() } else { - "Right-click any file in your file manager and pick a format, or drop files on the convt window.".to_string() + "Right-click a file in your file manager and pick a format, or drop files on convt: photos become JPEG, screenshots and other images become PNG.".to_string() }, Some("Back"), "Start converting", diff --git a/crates/convt-app/src/ui/main_window.rs b/crates/convt-app/src/ui/main_window.rs index 5a87d0d1..45e024a3 100644 --- a/crates/convt-app/src/ui/main_window.rs +++ b/crates/convt-app/src/ui/main_window.rs @@ -11,6 +11,7 @@ use gpui_kit::*; use super::theme::{self, Palette, mono, primary_button, text, text_button}; use super::{LICENSE_PRICE, SettingsTab, error_text, file_size, human_size, time_left}; +use crate::automation; use crate::clock::Local; use crate::finder::EXTENSION_SETTINGS; use crate::history::{Outcome, Record}; @@ -411,13 +412,24 @@ impl MainView { .overflow_y_scroll() .px(px(20.)) .pt(px(12.)) - .child(text(12., 16., p.secondary).pb(px(8.)).child( - "Rules are saved here. Running them automatically comes in a later version.", - )) + .child( + text(12., 16., p.secondary) + .id("automations-intro") + .test_support() + .aria_label( + "When a screenshot or screen recording appears in its folder, convt converts it.", + ) + .pb(px(4.)) + .child( + "When a screenshot or screen recording appears in its folder, convt converts it. Screenshots follow the folder this computer saves them to, which is not always the Desktop. Only that folder is watched, not folders inside it.", + ), + ) .children(rules.into_iter().enumerate().map(|(i, rule)| { let to = format_by_id(&rule.to).map_or(rule.to.clone(), |f| f.name.to_string()); let app = self.app.clone(); let on = rule.enabled; + let copy = rule.copies_to_clipboard(); + let source = automation::source_line(&rule); div() .flex() .items_center() @@ -430,12 +442,21 @@ impl MainView { .flex() .flex_col() .flex_1() - .gap(px(2.)) + .gap(px(4.)) .child(text(13., 16., p.text).child(format!("{} → {to}", rule.name))) - .child( - mono(11., 14., p.secondary) - .child(format!("{} · {}", rule.source, rule.detail)), - ), + .child(mono(11., 14., p.secondary).child(source)) + .child({ + let app = app.clone(); + theme::checkbox( + SharedString::from(format!("automation-{i}-copy")), + "Copy the converted file", + copy, + p, + ) + .on_click(move |_, _, cx| { + app.update(cx, |s, cx| s.set_automation_copy(i, !copy, cx)) + }) + }), ) .child( theme::switch(SharedString::from(format!("automation-{i}")), on, false, p) diff --git a/crates/convt-app/src/ui/popover.rs b/crates/convt-app/src/ui/popover.rs index e1d1b5e4..068c2e0a 100644 --- a/crates/convt-app/src/ui/popover.rs +++ b/crates/convt-app/src/ui/popover.rs @@ -18,6 +18,8 @@ use gpui_kit::*; use super::main_window::Page; use super::theme::{self, Palette, mono, text, text_button}; use super::{SettingsTab, error_text, file_size, human_size, time_left}; +use crate::automation; +use crate::clipboard::clipboard_item; use crate::jobs::{Entry, JobId, Status}; use crate::model::{self, AppState}; use crate::request::Request; @@ -252,10 +254,11 @@ impl PopoverView { .flex_1() .gap(px(2.)) .child(text(13., 16., p.text).child(format!("{} → {to}", rule.name))) - .child( - mono(11., 14., p.secondary) - .child(format!("{} · {}", rule.source, rule.detail)), - ), + .child(mono(11., 14., p.secondary).child(format!( + "{} · {}", + automation::source_line(&rule), + rule.detail + ))), ) .child( theme::switch( @@ -279,32 +282,6 @@ fn show_page(page: Page, cx: &mut App) { } } -/// What a finished drop puts on the clipboard: the image itself for one -/// image the clipboard can hold, otherwise the output paths. -fn clipboard_item(outputs: &[PathBuf]) -> ClipboardItem { - if let [one] = outputs - && let Some(format) = image_format(one) - && let Ok(bytes) = std::fs::read(one) - { - return ClipboardItem::new_image(&Image::from_bytes(format, bytes)); - } - let paths: Vec = outputs.iter().map(|p| p.display().to_string()).collect(); - ClipboardItem::new_string(paths.join("\n")) -} - -fn image_format(path: &Path) -> Option { - Some(match convt_core::format_by_extension(path)?.id { - "png" => ImageFormat::Png, - "jpeg" => ImageFormat::Jpeg, - "webp" => ImageFormat::Webp, - "gif" => ImageFormat::Gif, - "svg" => ImageFormat::Svg, - "bmp" => ImageFormat::Bmp, - "tiff" => ImageFormat::Tiff, - _ => return None, - }) -} - fn title(input: &Path, to: &str, p: &Palette) -> Div { text(13., 16., p.text) .truncate() diff --git a/crates/convt-app/src/ui/tests.rs b/crates/convt-app/src/ui/tests.rs index 56bffc69..43fec03e 100644 --- a/crates/convt-app/src/ui/tests.rs +++ b/crates/convt-app/src/ui/tests.rs @@ -424,8 +424,16 @@ impl Fixture { } } - /// A 4x4 PNG in the fixture directory. + /// A 4x4 still in the fixture directory. The name's extension picks the format. fn png(&self, name: &str) -> PathBuf { + self.rgb(name) + } + + fn bmp(&self, name: &str) -> PathBuf { + self.rgb(name) + } + + fn rgb(&self, name: &str) -> PathBuf { let path = self.dir.path().join(name); image::RgbImage::from_pixel(4, 4, image::Rgb([200, 40, 40])) .save(&path) @@ -579,6 +587,10 @@ fn is_jpeg(path: &Path) -> bool { std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(&[0xFF, 0xD8, 0xFF])) } +fn is_png(path: &Path) -> bool { + std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG")) +} + fn is_webp(path: &Path) -> bool { std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP") } @@ -822,20 +834,20 @@ fn quick_convert_explains_targets_it_cannot_reach(cx: &mut TestAppContext) { #[gpui_kit::test] fn add_files_converts_right_away_and_lists_the_results(cx: &mut TestAppContext) { let f = Fixture::new(cx); - let a = f.png("a.png"); - let b = f.png("b.png"); + let a = f.bmp("a.bmp"); + let b = f.bmp("b.bmp"); let (window, view) = f.main(cx); assert!(shown(cx, window, "empty")); assert_eq!( label(cx, window, "defaults").as_deref(), - Some("Images → WebP, Video → MP4, Audio → MP3, Documents → PDF") + Some("Photos → JPEG, Images → PNG, Video → MP4, Audio → MP3, Documents → PDF") ); - // The same file twice converts once. + // The same file twice converts once. Stills that aren't photos become PNG. view.update(cx, |v, cx| v.add(&[a.clone(), b, a], cx)); assert_eq!(f.jobs(cx), 2); wait_until(cx, "both files", |cx| f.app.read(cx).recent.len() == 2); - assert!(is_webp(&f.dir.path().join("a.webp")) && is_webp(&f.dir.path().join("b.webp"))); + assert!(is_png(&f.dir.path().join("a.png")) && is_png(&f.dir.path().join("b.png"))); let records = cx.read(|cx| f.app.read(cx).recent.clone()); for record in &records { let status = label(cx, window, &format!("record-status-{}", record.id)).unwrap(); @@ -849,12 +861,12 @@ fn add_files_converts_right_away_and_lists_the_results(cx: &mut TestAppContext) click(cx, window, "default-images-jpeg"); cx.read(|cx| assert_eq!(f.app.read(cx).settings.defaults.images, "jpeg")); assert!(f.settings_file().contains("images = \"jpeg\"")); - let c = f.png("c.png"); + let c = f.bmp("c.bmp"); view.update(cx, |v, cx| v.add(&[c], cx)); wait_until(cx, "c.jpg", |cx| f.app.read(cx).recent.len() == 3); assert!(is_jpeg(&f.dir.path().join("c.jpg"))); - // A file with no usable default (a JPEG with JPEG as the default) asks. + // A file with no usable default (a JPEG with JPEG as the images default) asks. let windows = cx.update(|cx| cx.windows().len()); let jpg = f.dir.path().join("c.jpg"); view.update(cx, |v, cx| v.add(&[jpg], cx)); @@ -871,8 +883,8 @@ fn add_files_converts_right_away_and_lists_the_results(cx: &mut TestAppContext) #[gpui_kit::test] fn a_failed_conversion_can_be_retried(cx: &mut TestAppContext) { let f = Fixture::new(cx); - let broken = f.dir.path().join("broken.png"); - std::fs::write(&broken, "not a png").unwrap(); + let broken = f.dir.path().join("broken.bmp"); + std::fs::write(&broken, "not a bmp").unwrap(); let (window, view) = f.main(cx); view.update(cx, |v, cx| v.add(std::slice::from_ref(&broken), cx)); wait_until(cx, "the failure", |cx| f.app.read(cx).recent.len() == 1); @@ -1242,8 +1254,8 @@ fn a_license_older_than_the_build_says_so(cx: &mut TestAppContext) { "{card}" ); - let png = f.png("a.png"); - view.update(cx, |v, cx| v.add(std::slice::from_ref(&png), cx)); + let bmp = f.bmp("a.bmp"); + view.update(cx, |v, cx| v.add(std::slice::from_ref(&bmp), cx)); assert_eq!(f.jobs(cx), 0); assert!( label(cx, main, "error") @@ -1251,7 +1263,7 @@ fn a_license_older_than_the_build_says_so(cx: &mut TestAppContext) { .starts_with("This build is newer") ); - let (quick, _) = f.quick(cli(vec![png], Some("jpeg"), None), cx); + let (quick, _) = f.quick(cli(vec![bmp], Some("jpeg"), None), cx); assert!(shown(cx, quick, "download")); let (settings, _) = f.settings(SettingsTab::License, cx); assert!(shown(cx, settings, "remove-license")); @@ -1304,6 +1316,8 @@ fn first_run_shows_once_in_licensed_builds(cx: &mut TestAppContext) { set_input(cx, window, &input, &key); click(cx, window, "first-run-next"); cx.read(|cx| assert_eq!(view.read(cx).step, Step::Done, "{:?}", view.read(cx).error)); + let body = label(cx, window, "first-run-body").expect("done body"); + assert!(body.contains("JPEG") && body.contains("PNG"), "{body}"); assert!(f.dir.path().join("license.key").exists()); // "Start converting" finishes first run and opens the main window. @@ -1504,22 +1518,22 @@ fn first_run_offers_sign_in_without_making_the_trial_need_it(cx: &mut TestAppCon #[gpui_kit::test] fn the_popover_converts_a_dropped_file_and_copies_it(cx: &mut TestAppContext) { let f = Fixture::new(cx); - let png = f.png("Screenshot.png"); + let bmp = f.bmp("Screenshot.bmp"); let (window, view) = cx.update(super::open_popover).unwrap(); assert!(shown(cx, window, "drop-bar")); - view.update(cx, |v, cx| v.drop_files(&[png], cx)); + view.update(cx, |v, cx| v.drop_files(&[bmp], cx)); let job = f.last_job(cx); wait_for_label(cx, window, &format!("copied-{job}"), |s| { s == "Copied to your clipboard" }); let item = cx.read_from_clipboard().expect("something was copied"); - let output = f.dir.path().join("Screenshot.webp"); - assert!(is_webp(&output)); + let output = f.dir.path().join("Screenshot.png"); + assert!(is_png(&output)); assert!( item.entries().iter().any(|e| matches!( e, ClipboardEntry::Image(image) - if image.format == ImageFormat::Webp + if image.format == ImageFormat::Png && image.bytes == std::fs::read(&output).unwrap() )), "{item:?}" @@ -1557,10 +1571,65 @@ fn automation_switches_are_saved(cx: &mut TestAppContext) { let (main, view) = window_of::(cx); cx.read(|cx| assert_eq!(view.read(cx).page, Page::Automations)); assert_eq!(label(cx, main, "automation-2").as_deref(), Some("On")); + let intro = label(cx, main, "automations-intro").expect("intro"); + assert!(intro.contains("screenshot"), "{intro}"); + assert_eq!( + label(cx, main, "automation-0-copy").as_deref(), + Some("Copy the converted file") + ); + click(cx, main, "automation-0-copy"); + cx.read(|cx| assert!(!f.app.read(cx).settings.automations[0].copies_to_clipboard())); click(cx, main, "automation-0"); cx.read(|cx| assert!(!f.app.read(cx).settings.automations[0].enabled)); } +#[gpui_kit::test] +fn a_screenshot_automation_converts_and_can_copy(cx: &mut TestAppContext) { + let f = Fixture::new(cx); + let shots = f.dir.path().join("Desktop"); + std::fs::create_dir(&shots).unwrap(); + f.app.update(cx, |s, cx| { + s.update_settings( + |set| { + set.automations[0].folder = Some(shots.clone()); + set.automations[0].copy_to_clipboard = Some(true); + set.automations[1].enabled = false; + }, + cx, + ); + s.poll_automations(cx); + }); + + let input = shots.join("Screenshot 1.bmp"); + image::RgbImage::from_pixel(4, 4, image::Rgb([200, 40, 40])) + .save(&input) + .unwrap(); + f.app.update(cx, |s, cx| s.poll_automations(cx)); + f.app.update(cx, |s, cx| s.poll_automations(cx)); + wait_until(cx, "the screenshot", |cx| f.app.read(cx).recent.len() == 1); + let output = shots.join("Screenshot 1.png"); + assert!(is_png(&output)); + let item = cx.read_from_clipboard().expect("the result was copied"); + assert!( + item.entries().iter().any(|e| matches!( + e, + ClipboardEntry::Image(image) + if image.format == ImageFormat::Png + && image.bytes == std::fs::read(&output).unwrap() + )), + "{item:?}" + ); + + // Ordinary photos in the same Desktop-like folder are left alone. + image::RgbImage::from_pixel(4, 4, image::Rgb([20, 80, 200])) + .save(shots.join("IMG_2041.bmp")) + .unwrap(); + f.app.update(cx, |s, cx| s.poll_automations(cx)); + f.app.update(cx, |s, cx| s.poll_automations(cx)); + assert_eq!(cx.read(|cx| f.app.read(cx).recent.len()), 1); + assert!(!shots.join("IMG_2041.png").exists()); +} + #[gpui_kit::test] fn every_window_renders_in_both_themes(cx: &mut TestAppContext) { let f = Fixture::licensed(cx, Some("2026-09-30"), None); @@ -1738,7 +1807,7 @@ fn quick_convert_shows_errors_the_license_banner_does_not(cx: &mut TestAppContex #[gpui_kit::test] fn the_popover_copies_every_drop_even_past_the_listed_few(cx: &mut TestAppContext) { let f = Fixture::new(cx); - let files: Vec = (0..5).map(|i| f.png(&format!("shot {i}.png"))).collect(); + let files: Vec = (0..5).map(|i| f.bmp(&format!("shot {i}.bmp"))).collect(); let (window, view) = cx.update(super::open_popover).unwrap(); view.update(cx, |v, cx| v.drop_files(&files, cx)); let jobs: Vec = @@ -2487,13 +2556,14 @@ fn documents_added_dropped_or_in_folders_reach_the_offer(cx: &mut TestAppContext let f = Fixture::with_packs(cx, packs.clone()); let docx = f.docx("Plan.docx"); let png = f.png("a.png"); + let bmp = f.bmp("a.bmp"); let folder = f.dir.path().join("folder"); std::fs::create_dir(&folder).unwrap(); std::fs::write(folder.join("Budget.xlsx"), b"PK").unwrap(); - // Add files: the PNG converts, the document opens the offer. + // Add files: the BMP converts, the document opens the offer. let (_, main) = f.main(cx); - main.update(cx, |v, cx| v.add(&[png.clone(), docx.clone()], cx)); + main.update(cx, |v, cx| v.add(&[bmp, docx.clone()], cx)); let (window, view) = last_quick(cx); cx.read(|cx| assert_eq!(view.read(cx).files, std::slice::from_ref(&docx))); assert!(shown(cx, window, "pack-download"));