From 9fcbe237bdfcc19263ace656cda1f83495919257 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 7 Mar 2026 23:02:57 +0200 Subject: [PATCH] Fix 16 medium-severity bugs from audit CLI: add UTC suffix to timestamps, validate image extensions on single-file input, canonicalize watch paths for reliable matching, derive counter_enabled from template presence, warn when undo count exceeds available batches. Core: apply space/special-char transforms in template rename path, warn on metadata preservation for unsupported formats, derive AVIF speed from compress preset quality level. GTK: use buffer size for apples-to-apples compress preview comparison, shorten approximate format labels, cache file sizes to avoid repeated syscalls on checkbox toggle, add batch-update guard to prevent O(n^2) in select/deselect all, use widget names for reliable progress/log lookup, add unique suffix for duplicate download filenames. --- pixstrip-cli/src/main.rs | 51 +++++++++++---- pixstrip-core/src/executor.rs | 12 ++-- pixstrip-core/src/preset.rs | 5 +- pixstrip-gtk/src/app.rs | 14 ++-- pixstrip-gtk/src/processing.rs | 3 + pixstrip-gtk/src/steps/step_compress.rs | 16 +++-- pixstrip-gtk/src/steps/step_images.rs | 85 ++++++++++++++++++++----- 7 files changed, 136 insertions(+), 50 deletions(-) diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 38b332e..3805d4d 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -252,7 +252,15 @@ fn cmd_process(args: CmdProcessArgs) { let files = discovery::discover_images(&path, args.recursive); source_files.extend(files); } else if path.is_file() { - source_files.push(path); + if path.extension() + .and_then(|e| e.to_str()) + .and_then(pixstrip_core::types::ImageFormat::from_extension) + .is_some() + { + source_files.push(path); + } else { + eprintln!("Warning: '{}' is not a recognized image format, skipping", path_str); + } } else { eprintln!("Warning: '{}' not found, skipping", path_str); } @@ -338,7 +346,7 @@ fn cmd_process(args: CmdProcessArgs) { suffix: args.rename_suffix.unwrap_or_default(), counter_start: 1, counter_padding: 3, - counter_enabled: true, + counter_enabled: args.rename_template.is_some(), counter_position: 3, template: args.rename_template, case_mode: 0, @@ -398,16 +406,26 @@ fn cmd_process(args: CmdProcessArgs) { if result.failed > 0 { println!(" Failed: {}", result.failed); } - println!( - " Size: {} -> {} ({:.0}% reduction)", - format_bytes(result.total_input_bytes), - format_bytes(result.total_output_bytes), - if result.total_input_bytes > 0 { - (1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0 - } else { - 0.0 - } - ); + if result.total_input_bytes > 0 && result.total_output_bytes > result.total_input_bytes { + let increase = (result.total_output_bytes as f64 / result.total_input_bytes as f64 - 1.0) * 100.0; + println!( + " Size: {} -> {} (+{:.0}% increase)", + format_bytes(result.total_input_bytes), + format_bytes(result.total_output_bytes), + increase, + ); + } else { + println!( + " Size: {} -> {} ({:.0}% reduction)", + format_bytes(result.total_input_bytes), + format_bytes(result.total_output_bytes), + if result.total_input_bytes > 0 { + (1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0 + } else { + 0.0 + } + ); + } println!(" Time: {}", format_duration(result.elapsed_ms)); println!(" Output: {}", output_dir.display()); @@ -533,6 +551,9 @@ fn cmd_undo(count: usize) { } let undo_count = count.min(entries.len()); + if count > entries.len() { + eprintln!("Warning: requested {} batches but only {} available", count, entries.len()); + } let to_undo = entries.split_off(entries.len() - undo_count); let mut total_trashed = 0; let mut failed_entries = Vec::new(); @@ -598,6 +619,7 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { std::process::exit(1); } let watch_path = PathBuf::from(path); + let watch_path = watch_path.canonicalize().unwrap_or(watch_path); if !watch_path.exists() { eprintln!("Watch folder does not exist: {}", path); std::process::exit(1); @@ -711,7 +733,8 @@ fn cmd_watch_remove(path: &str) { let original_len = watches.len(); let target = PathBuf::from(path); - watches.retain(|w| w.path != target); + let target_canonical = target.canonicalize().unwrap_or(target.clone()); + watches.retain(|w| w.path != target && w.path != target_canonical); if watches.len() == original_len { println!("Watch folder not found: {}", path); @@ -1082,7 +1105,7 @@ fn format_timestamp(ts: &str) -> String { month += 1; } - format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, d + 1, hours, minutes, seconds) + format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", year, month, d + 1, hours, minutes, seconds) } #[cfg(test)] diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index c4f1566..95ca7e6 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -358,10 +358,12 @@ impl PipelineExecutor { .ok() .and_then(|r| r.with_guessed_format().ok()) .and_then(|r| r.into_dimensions().ok()); - // Apply regex find/replace on stem (matching the pixel path) + // Apply rename transformations on stem (matching the pixel path) let working_stem = crate::operations::rename::apply_regex_replace( stem, &rename.regex_find, &rename.regex_replace, ); + let working_stem = crate::operations::rename::apply_space_replacement(&working_stem, rename.replace_spaces); + let working_stem = crate::operations::rename::apply_special_chars(&working_stem, rename.special_chars); let new_name = crate::operations::rename::apply_template_full( template, &working_stem, ext, rename.counter_start.saturating_add(index as u32), @@ -512,10 +514,12 @@ impl PipelineExecutor { let dims = Some((img.width(), img.height())); let original_ext = source.path.extension() .and_then(|e| e.to_str()); - // Apply regex on the stem before template expansion + // Apply rename transformations on the stem before template expansion let working_stem = crate::operations::rename::apply_regex_replace( stem, &rename.regex_find, &rename.regex_replace, ); + let working_stem = crate::operations::rename::apply_space_replacement(&working_stem, rename.replace_spaces); + let working_stem = crate::operations::rename::apply_special_chars(&working_stem, rename.special_chars); let new_name = crate::operations::rename::apply_template_full( template, &working_stem, @@ -611,9 +615,9 @@ impl PipelineExecutor { if !copy_metadata_from_source(&source.path, &output_path) { eprintln!("Warning: failed to copy metadata to {}", output_path.display()); } + } else { + eprintln!("Warning: metadata cannot be preserved for {} format ({})", output_format.extension(), output_path.display()); } - // For non-JPEG/TIFF formats, metadata is lost during re-encoding - // and cannot be restored. This is a known limitation. } crate::operations::MetadataConfig::StripAll => { // Already stripped by re-encoding - nothing to do diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index 5072a78..e0ba599 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -63,7 +63,10 @@ impl Preset { overwrite_behavior: crate::operations::OverwriteAction::default(), preserve_directory_structure: false, progressive_jpeg: false, - avif_speed: 6, + avif_speed: self.compress.as_ref().map(|c| match c { + crate::operations::CompressConfig::Preset(p) => p.avif_speed(), + _ => 6, + }).unwrap_or(6), output_dpi: 72, } } diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 8b2a6e7..736aa1e 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -109,6 +109,7 @@ pub struct AppState { pub wizard: Rc>, pub loaded_files: Rc>>, pub excluded_files: Rc>>, + pub file_sizes: Rc>>, pub output_dir: Rc>>, pub job_config: Rc>, pub detailed_mode: bool, @@ -321,6 +322,7 @@ fn build_ui(app: &adw::Application) { wizard: Rc::new(RefCell::new(WizardState::new())), loaded_files: Rc::new(RefCell::new(Vec::new())), excluded_files: Rc::new(RefCell::new(std::collections::HashSet::new())), + file_sizes: Rc::new(RefCell::new(std::collections::HashMap::new())), output_dir: Rc::new(RefCell::new(None)), detailed_mode: app_cfg.skill_level.is_advanced(), batch_queue: Rc::new(RefCell::new(BatchQueue::default())), @@ -1397,6 +1399,7 @@ fn update_images_count_label(ui: &WizardUi, count: usize) { &loaded_box, &ui.state.loaded_files, &ui.state.excluded_files, + &ui.state.file_sizes, ); } } @@ -2576,14 +2579,10 @@ fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: if let Some(page) = nav_view.visible_page() { walk_widgets(&page.child(), &|widget| { if let Some(label) = widget.downcast_ref::() { - if label.css_classes().iter().any(|c| c == "heading") - && (label.label().contains("images") || label.label().contains("0 /")) - { + if label.widget_name() == "processing-count-label" { label.set_label(&format!("{} / {} images", current, total)); } - if label.css_classes().iter().any(|c| c == "dim-label") - && (label.label().contains("Estimating") || label.label().contains("ETA") || label.label().contains("Almost") || label.label().contains("Current")) - { + if label.widget_name() == "processing-eta-label" { if current < total { label.set_label(&format!("{} - {}", eta, file)); } else { @@ -2599,8 +2598,7 @@ fn add_log_entry(nav_view: &adw::NavigationView, current: usize, total: usize, f if let Some(page) = nav_view.visible_page() { walk_widgets(&page.child(), &|widget| { if let Some(bx) = widget.downcast_ref::() - && bx.spacing() == 2 - && bx.orientation() == gtk::Orientation::Vertical + && bx.widget_name() == "processing-log-box" { let entry = gtk::Label::builder() .label(format!("[{}/{}] {} - Done", current, total, file)) diff --git a/pixstrip-gtk/src/processing.rs b/pixstrip-gtk/src/processing.rs index 5957498..ade748d 100644 --- a/pixstrip-gtk/src/processing.rs +++ b/pixstrip-gtk/src/processing.rs @@ -24,6 +24,7 @@ pub fn build_processing_page() -> adw::NavigationPage { .css_classes(["heading"]) .halign(gtk::Align::Start) .build(); + progress_label.set_widget_name("processing-count-label"); let progress_bar = gtk::ProgressBar::builder() .fraction(0.0) @@ -39,6 +40,7 @@ pub fn build_processing_page() -> adw::NavigationPage { .css_classes(["dim-label"]) .halign(gtk::Align::Start) .build(); + eta_label.set_widget_name("processing-eta-label"); // Activity log let log_group = adw::PreferencesGroup::builder() @@ -55,6 +57,7 @@ pub fn build_processing_page() -> adw::NavigationPage { .orientation(gtk::Orientation::Vertical) .spacing(2) .build(); + log_box.set_widget_name("processing-log-box"); log_scrolled.set_child(Some(&log_box)); log_group.add(&log_scrolled); diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index 39e28c3..3d6e069 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -937,8 +937,6 @@ enum PreviewResult { } fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> PreviewResult { - let original_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - let img = match image::open(path) { Ok(img) => img, Err(e) => return PreviewResult::Error(e.to_string()), @@ -959,6 +957,10 @@ fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> Preview return PreviewResult::Error("Failed to encode original".into()); } + // Use the preview buffer size for comparison (not on-disk size) + // so both original and compressed refer to the same downscaled image + let original_size = orig_buf.len() as u64; + // Encode compressed in the requested format let mut comp_buf = Vec::new(); let format_label; @@ -1008,9 +1010,9 @@ fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> Preview } } PreviewCompression::WebP(quality) => { - // image 0.25 only has lossless WebP encoding, so approximate with - // JPEG at equivalent quality for the visual preview - format_label = format!("WebP Q{} (approx)", quality); + // image crate only has lossless WebP encoding, so approximate with + // JPEG at equivalent quality for the visual preview (size estimate only) + format_label = format!("~WebP Q{}", quality); let cursor = std::io::Cursor::new(&mut comp_buf); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(cursor, quality); let rgb = preview_img.to_rgb8(); @@ -1027,8 +1029,8 @@ fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> Preview } } PreviewCompression::Avif(quality) => { - // AVIF encoding not available in image crate - approximate with JPEG - format_label = format!("AVIF Q{} (approx)", quality); + // AVIF encoding not available in image crate - approximate with JPEG (size estimate only) + format_label = format!("~AVIF Q{}", quality); let cursor = std::io::Cursor::new(&mut comp_buf); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(cursor, quality); let rgb = preview_img.to_rgb8(); diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index fd300f2..7ce5ee9 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -36,6 +36,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { { let loaded_files = state.loaded_files.clone(); let excluded = state.excluded_files.clone(); + let sizes = state.file_sizes.clone(); let stack_ref = stack.clone(); let subfolder_choice = subfolder_choice.clone(); drop_target.connect_drop(move |target, value, _x, _y| { @@ -49,7 +50,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { add_images_flat(&path, &mut files); let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } else { let choice = *subfolder_choice.borrow(); match choice { @@ -58,24 +59,25 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { add_images_from_dir(&path, &mut files); let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } Some(false) => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } None => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); + let sz = sizes.clone(); let stack_ref = stack_ref.clone(); let subfolder_choice = subfolder_choice.clone(); let dir_path = path.clone(); @@ -88,6 +90,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { &dir_path, &loaded_files, &excluded, + &sz, &stack_ref, &subfolder_choice, ); @@ -103,7 +106,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { } let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); return true; } } @@ -118,6 +121,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { { let loaded_files = state.loaded_files.clone(); let excluded = state.excluded_files.clone(); + let sizes = state.file_sizes.clone(); let stack_ref = stack.clone(); uri_drop.connect_drop(move |_target, value, _x, _y| { if let Ok(text) = value.get::() { @@ -141,6 +145,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { if is_image_url { let loaded = loaded_files.clone(); let excl = excluded.clone(); + let sz = sizes.clone(); let sr = stack_ref.clone(); // Download in background thread let (tx, rx) = std::sync::mpsc::channel::>(); @@ -159,7 +164,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { } let count = files.len(); drop(files); - refresh_grid(&sr, &loaded, &excl, count); + refresh_grid(&sr, &loaded, &excl, &sz, count); glib::ControlFlow::Break } Ok(None) => glib::ControlFlow::Break, @@ -259,6 +264,7 @@ fn show_subfolder_prompt( dir: &std::path::Path, loaded_files: &Rc>>, excluded: &Rc>>, + file_sizes: &Rc>>, stack: >k::Stack, subfolder_choice: &Rc>>, ) { @@ -273,6 +279,7 @@ fn show_subfolder_prompt( let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); + let file_sizes = file_sizes.clone(); let stack = stack.clone(); let subfolder_choice = subfolder_choice.clone(); let dir = dir.to_path_buf(); @@ -284,7 +291,7 @@ fn show_subfolder_prompt( add_images_from_subdirs(&dir, &mut files); let count = files.len(); drop(files); - refresh_grid(&stack, &loaded_files, &excluded, count); + refresh_grid(&stack, &loaded_files, &excluded, &file_sizes, count); } }); @@ -302,6 +309,7 @@ fn refresh_grid( stack: >k::Stack, loaded_files: &Rc>>, excluded: &Rc>>, + file_sizes: &Rc>>, count: usize, ) { if count > 0 { @@ -310,7 +318,7 @@ fn refresh_grid( stack.set_visible_child_name("empty"); } if let Some(loaded_widget) = stack.child_by_name("loaded") { - rebuild_grid_model(&loaded_widget, loaded_files, excluded); + rebuild_grid_model(&loaded_widget, loaded_files, excluded, file_sizes); } } @@ -319,15 +327,25 @@ pub fn rebuild_grid_model( widget: >k::Widget, loaded_files: &Rc>>, excluded: &Rc>>, + file_sizes: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); let count = files.len(); let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count(); + // Populate size cache for any new files + { + let mut sizes = file_sizes.borrow_mut(); + for p in files.iter() { + sizes.entry(p.clone()).or_insert_with(|| { + std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) + }); + } + } + let sizes = file_sizes.borrow(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) - .filter_map(|p| std::fs::metadata(p).ok()) - .map(|m| m.len()) + .map(|p| sizes.get(p).copied().unwrap_or(0)) .sum(); let size_str = format_size(total_size); @@ -386,15 +404,16 @@ fn update_count_label( widget: >k::Widget, loaded_files: &Rc>>, excluded: &Rc>>, + file_sizes: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); + let sizes = file_sizes.borrow(); let count = files.len(); let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) - .filter_map(|p| std::fs::metadata(p).ok()) - .map(|m| m.len()) + .map(|p| sizes.get(p).copied().unwrap_or(0)) .sum(); let size_str = format_size(total_size); update_heading_label(widget, count, included_count, &size_str); @@ -531,6 +550,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { .spacing(0) .build(); + // Guard flag to prevent O(n^2) update cascade during batch checkbox operations + let batch_updating: Rc> = Rc::new(std::cell::Cell::new(false)); + // Toolbar let toolbar = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -675,6 +697,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { { let excluded = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); + let cached_sizes = state.file_sizes.clone(); + let batch_flag = batch_updating.clone(); factory.connect_bind(move |_factory, list_item| { let Some(list_item) = list_item.downcast_ref::() else { return }; let Some(item) = list_item.item().and_downcast::() else { return }; @@ -733,6 +757,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { // Wire checkbox toggle let excl = excluded.clone(); let loaded_ref = loaded.clone(); + let sizes_ref = cached_sizes.clone(); + let batch_guard = batch_flag.clone(); let file_path = path.clone(); let handler_id = check.connect_toggled(move |btn| { { @@ -743,12 +769,16 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { excl.insert(file_path.clone()); } } + // Skip per-toggle label update during batch select/deselect + if batch_guard.get() { + return; + } // Update count label if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { - update_count_label(&loaded_widget, &loaded_ref, &excl); + update_count_label(&loaded_widget, &loaded_ref, &excl, &sizes_ref); } }); // Store handler id so we can disconnect in unbind @@ -817,14 +847,18 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); + let sizes = state.file_sizes.clone(); + let batch_flag = batch_updating.clone(); select_all_button.connect_clicked(move |btn| { excl.borrow_mut().clear(); if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { + batch_flag.set(true); set_all_checkboxes_in(&loaded_widget, true); - update_count_label(&loaded_widget, &loaded, &excl); + batch_flag.set(false); + update_count_label(&loaded_widget, &loaded, &excl, &sizes); } }); } @@ -833,6 +867,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); + let sizes = state.file_sizes.clone(); + let batch_flag = batch_updating.clone(); deselect_all_button.connect_clicked(move |btn| { { let files = loaded.borrow(); @@ -845,8 +881,10 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { + batch_flag.set(true); set_all_checkboxes_in(&loaded_widget, false); - update_count_label(&loaded_widget, &loaded, &excl); + batch_flag.set(false); + update_count_label(&loaded_widget, &loaded, &excl, &sizes); } }); } @@ -930,7 +968,22 @@ fn download_image_url(url: &str) -> Option { .unwrap_or("downloaded.jpg"); let filename = if sanitized.is_empty() { "downloaded.jpg" } else { sanitized }; - let dest = temp_dir.join(filename); + // Avoid overwriting previous downloads with the same filename + let dest = { + let base = temp_dir.join(filename); + if base.exists() { + let stem = std::path::Path::new(filename).file_stem() + .and_then(|s| s.to_str()).unwrap_or("downloaded"); + let ext = std::path::Path::new(filename).extension() + .and_then(|e| e.to_str()).unwrap_or("jpg"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()).unwrap_or(0); + temp_dir.join(format!("{}_{}.{}", stem, ts, ext)) + } else { + base + } + }; // Use GIO for the download (synchronous, runs in background thread) let gfile = gtk::gio::File::for_uri(url);