From effb6ebf5e0373c55baa18ac38824ad9bc477480 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:16:23 +0200 Subject: [PATCH 1/2] Save clipboard fallback images on a bounded backend worker --- .../wayland/backend/event_loop/capture.rs | 4 + src/backend/wayland/state/clipboard.rs | 1 + .../wayland/state/clipboard/fallback_save.rs | 235 ++++++++++++++++++ .../wayland/state/clipboard_runtime.rs | 24 ++ src/input/state/core/base/types.rs | 4 + src/input/state/core/mod.rs | 2 +- src/input/state/core/selection.rs | 2 +- src/input/state/core/selection/clipboard.rs | 63 +++-- src/input/state/core/utility/toasts.rs | 122 +++------ src/input/state/mod.rs | 2 +- 10 files changed, 354 insertions(+), 105 deletions(-) create mode 100644 src/backend/wayland/state/clipboard/fallback_save.rs diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 589833ad..3a8c2916 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -128,6 +128,7 @@ pub(super) fn handle_pending_actions( state.poll_ocr_completion(); state.poll_session_file_dialog_completion(qh); state.poll_desktop_open_completion(); + state.poll_clipboard_fallback_save(); state.drain_clipboard_requests(); let effects = state .input_state @@ -226,6 +227,9 @@ struct FrozenTogglePassDecision { fn apply_backend_effect(state: &mut WaylandState, action: PendingBackendAction) { match action { PendingBackendAction::Screenshot(action) => state.handle_capture_action(action), + PendingBackendAction::SaveClipboardFallback { request_id } => { + state.start_clipboard_fallback_save(request_id) + } PendingBackendAction::MeasureMode => state.handle_measure_mode_action(), PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action), PendingBackendAction::BoardPdfExport(action) => { diff --git a/src/backend/wayland/state/clipboard.rs b/src/backend/wayland/state/clipboard.rs index a13254b5..78cf3fd5 100644 --- a/src/backend/wayland/state/clipboard.rs +++ b/src/backend/wayland/state/clipboard.rs @@ -13,6 +13,7 @@ use crate::input::state::ClipboardPasteRequest; use crate::input::state::{Toast, ToastPriority}; use std::time::{Duration, Instant}; +mod fallback_save; mod session_paste; use session_paste::{PastePersistenceDecision, SessionPasteWarning}; diff --git a/src/backend/wayland/state/clipboard/fallback_save.rs b/src/backend/wayland/state/clipboard/fallback_save.rs new file mode 100644 index 00000000..5ba29cc5 --- /dev/null +++ b/src/backend/wayland/state/clipboard/fallback_save.rs @@ -0,0 +1,235 @@ +use super::WaylandState; +use crate::backend::wayland::RuntimeOperationPoll; +use crate::input::state::InputState; + +impl WaylandState { + pub(in crate::backend::wayland) fn start_clipboard_fallback_save(&mut self, id: u64) { + let Some(request) = self.input_state.clipboard_fallback_save_request(id) else { + return; + }; + if let Err(failure) = self.clipboard.submit_fallback_save(request, save_image) { + let (error, id) = failure.into_parts(); + log::error!("Failed to submit clipboard fallback save: {error}"); + let message = if matches!( + error, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } + ) { + "Another image is being saved. Try again in a moment." + } else { + "Could not start the image save. Try again." + }; + self.input_state + .complete_clipboard_fallback_save(id, Err(message.into())); + } + } + + pub(in crate::backend::wayland) fn poll_clipboard_fallback_save(&mut self) { + apply_completion(&mut self.input_state, self.clipboard.poll_fallback_save()); + } +} + +fn save_image( + request: std::sync::Arc, +) -> Result { + crate::capture::file::save_screenshot(&request.image_data, &request.save_config) + .map_err(|error| request.operation.format_error(&error)) +} + +fn apply_completion( + input: &mut InputState, + completion: RuntimeOperationPoll>, +) { + match completion { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} + RuntimeOperationPoll::Ready { + context: id, + outcome, + .. + } => input.complete_clipboard_fallback_save(id, outcome), + RuntimeOperationPoll::ProducerFailed { + context: id, + reason, + .. + } => input.complete_clipboard_fallback_save(id, Err(reason)), + RuntimeOperationPoll::Disconnected { context: id, .. } => input + .complete_clipboard_fallback_save( + id, + Err("Image save worker exited without a completion".into()), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::state::clipboard_runtime::ClipboardRuntime; + use crate::backend::wayland::{RuntimeOperationIdSource, RuntimeWakeSource}; + use crate::capture::{ImageOperationKind, file::FileSaveConfig}; + use crate::input::state::{InputEffect, InputEffectDrain, PendingBackendAction}; + use std::time::Duration; + + fn queue_image( + input: &mut InputState, + path: &std::path::Path, + bytes: Vec, + exit: bool, + ) -> std::sync::Arc { + input.set_clipboard_fallback( + bytes, + FileSaveConfig { + save_directory: path.to_path_buf(), + filename_template: "fallback".into(), + format: "png".into(), + }, + ImageOperationKind::CanvasExport, + exit, + ); + input.save_pending_clipboard_to_file(); + let requests: Vec<_> = input + .drain_input_effects(InputEffectDrain::Runtime) + .into_iter() + .filter_map(|effect| match effect { + InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { + request_id, + }) => Some(request_id), + _ => None, + }) + .collect(); + assert_eq!(requests.len(), 1); + input.clipboard_fallback_save_request(requests[0]).unwrap() + } + + #[test] + fn fallback_worker_saves_real_bytes_and_only_then_requests_exit() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let request = queue_image(&mut input, temp.path(), vec![1, 2, 3], true); + runtime + .submit_fallback_save(request.clone(), save_image) + .unwrap(); + assert!(!input.should_exit); + + assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); + apply_completion(&mut input, runtime.poll_fallback_save()); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); + let path = std::fs::read_dir(temp.path()) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + assert!(input.should_exit); + assert!(input.take_explicit_exit_requested()); + assert!(!input.take_explicit_exit_requested()); + assert!(input.clipboard_fallback_save_request(request.id).is_none()); + } + + #[test] + fn failed_save_retains_bytes_for_retry_and_canvas_error_wording() { + let temp = crate::test_temp::tempdir().unwrap(); + let blocked = temp.path().join("not-a-directory"); + std::fs::write(&blocked, b"file").unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let request = queue_image(&mut input, &blocked, vec![7, 8, 9], false); + runtime + .submit_fallback_save(request.clone(), save_image) + .unwrap(); + assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); + apply_completion(&mut input, runtime.poll_fallback_save()); + + assert_eq!( + &*input + .clipboard_fallback_save_request(request.id) + .unwrap() + .image_data, + &[7, 8, 9] + ); + assert!( + input + .active_toast() + .unwrap() + .message + .contains("Failed to save canvas export") + ); + assert!( + !input + .active_toast() + .unwrap() + .message + .to_lowercase() + .contains("screenshot") + ); + std::fs::remove_file(&blocked).unwrap(); + input.save_pending_clipboard_to_file(); + runtime + .submit_fallback_save( + input.clipboard_fallback_save_request(request.id).unwrap(), + save_image, + ) + .unwrap(); + assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); + apply_completion(&mut input, runtime.poll_fallback_save()); + assert!(input.clipboard_fallback_save_request(request.id).is_none()); + assert_eq!(std::fs::read_dir(&blocked).unwrap().count(), 1); + } + + #[test] + fn slow_save_is_single_flight_and_stale_completion_preserves_newer_image() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let first = queue_image(&mut input, temp.path(), vec![1], true); + let (release, wait) = std::sync::mpsc::channel(); + runtime + .submit_fallback_save(first, move |request| { + wait.recv().unwrap(); + save_image(request) + }) + .unwrap(); + input.save_pending_clipboard_to_file(); + assert!( + !input + .drain_input_effects(InputEffectDrain::Runtime) + .iter() + .any(|effect| matches!( + effect, + InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { .. }) + )) + ); + assert!(matches!( + runtime.poll_fallback_save(), + RuntimeOperationPoll::Pending { .. } + )); + assert!(!input.should_exit); + let second = queue_image(&mut input, temp.path(), vec![2], false); + assert!( + runtime + .submit_fallback_save(second.clone(), save_image) + .is_err() + ); + release.send(()).unwrap(); + assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); + apply_completion(&mut input, runtime.poll_fallback_save()); + + assert!( + !input.should_exit, + "stale completion cannot exit for the old image" + ); + assert_eq!( + &*input + .clipboard_fallback_save_request(second.id) + .unwrap() + .image_data, + &[2] + ); + input.complete_clipboard_fallback_save(second.id, Err("busy; retry".into())); + input.save_pending_clipboard_to_file(); + assert!(input.drain_input_effects(InputEffectDrain::Runtime).iter().any(|effect| matches!(effect, InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { request_id }) if *request_id == second.id))); + } +} diff --git a/src/backend/wayland/state/clipboard_runtime.rs b/src/backend/wayland/state/clipboard_runtime.rs index f08f9bab..d85c8653 100644 --- a/src/backend/wayland/state/clipboard_runtime.rs +++ b/src/backend/wayland/state/clipboard_runtime.rs @@ -76,6 +76,7 @@ pub(in crate::backend::wayland) struct ClipboardRuntime { paste: RuntimeOperationController, hex_copy: RuntimeOperationController, pending_hex_copy: Option, + fallback_save: RuntimeOperationController>, text_copy: RuntimeOperationController, pending_text_copy: VecDeque, text_paste: RuntimeOperationController, @@ -89,6 +90,7 @@ impl ClipboardRuntime { paste: RuntimeOperationController::new(ids.clone(), wake.clone()), hex_copy: RuntimeOperationController::new(ids.clone(), wake.clone()), pending_hex_copy: None, + fallback_save: RuntimeOperationController::new(ids.clone(), wake.clone()), text_copy: RuntimeOperationController::new(ids.clone(), wake.clone()), pending_text_copy: VecDeque::new(), text_paste: RuntimeOperationController::new(ids, wake), @@ -96,6 +98,28 @@ impl ClipboardRuntime { } } + pub(in crate::backend::wayland) fn submit_fallback_save( + &mut self, + request: std::sync::Arc, + operation: impl FnOnce( + std::sync::Arc, + ) -> Result + + Send + + 'static, + ) -> Result<(), RuntimeOperationSubmitFailure> { + self.fallback_save + .try_submit(request.id, "clipboard-fallback-save", move || { + operation(request) + }) + .map(drop) + } + + pub(in crate::backend::wayland) fn poll_fallback_save( + &mut self, + ) -> RuntimeOperationPoll> { + self.fallback_save.poll() + } + pub(in crate::backend::wayland) fn publish_active(&self) -> bool { self.publish.is_active() } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 9824a86a..db0091ea 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -518,6 +518,10 @@ pub enum HelperLaunchRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PendingBackendAction { Screenshot(Action), + /// Save a matching clipboard-failure image on a bounded backend worker. + SaveClipboardFallback { + request_id: u64, + }, MeasureMode, CanvasExport(Action), BoardPdfExport(Action), diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 0db6df42..8c1835ae 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -142,7 +142,7 @@ pub use region_select::{ SelectionPolicy, }; pub(crate) use search::fuzzy_score; -pub(crate) use selection::LocalSelectionContext; +pub(crate) use selection::{ClipboardFallbackSaveRequest, LocalSelectionContext}; pub(crate) use style::DrawingStyle; pub(crate) use text_editing::TextEditing; pub use tool_controls::PrecisionEntryState; diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index 0418589d..8aa107f1 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -1,7 +1,7 @@ mod clipboard; -pub(crate) use clipboard::LocalSelectionContext; pub(in crate::input::state::core) use clipboard::SelectionClipboard; +pub(crate) use clipboard::{ClipboardFallbackSaveRequest, LocalSelectionContext}; use super::base::{InputState, SelectionAxis}; use crate::draw::{ShapeId, TextMeasurer}; diff --git a/src/input/state/core/selection/clipboard.rs b/src/input/state/core/selection/clipboard.rs index d5ce9449..19f671e1 100644 --- a/src/input/state/core/selection/clipboard.rs +++ b/src/input/state/core/selection/clipboard.rs @@ -7,6 +7,7 @@ use crate::input::state::core::base::{ WayscriberClipboardSelection, }; use crate::util::Rect; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; const PRIVATE_CLIPBOARD_SCHEMA_VERSION: u32 = 1; @@ -29,11 +30,12 @@ enum SelectionPublishState { /// Pending image data retained when publishing a capture to the clipboard fails. #[derive(Debug, Clone)] -pub(in crate::input::state::core) struct PendingClipboardFallback { - pub(in crate::input::state::core) image_data: Vec, - pub(in crate::input::state::core) save_config: FileSaveConfig, - pub(in crate::input::state::core) operation: ImageOperationKind, - pub(in crate::input::state::core) exit_after_save: bool, +pub(crate) struct ClipboardFallbackSaveRequest { + pub(crate) id: u64, + pub(crate) image_data: Arc<[u8]>, + pub(crate) save_config: FileSaveConfig, + pub(crate) operation: ImageOperationKind, + pub(crate) exit_after_save: bool, } /// Local selection clipboard identity, publication, paste requests, and capture fallback. @@ -45,7 +47,9 @@ pub(in crate::input::state::core) struct SelectionClipboard { app_instance_id: String, paste_request_counter: u64, active_paste_request_id: Option, - pending_image_fallback: Option, + pending_image_fallback: Option>, + image_fallback_generation: u64, + image_save_requested: bool, /// A capture copied an image to the system clipboard during this run and /// nothing copied here since. The system clipboard cannot be probed /// without blocking, so this and the local shapes are all Paste knows. @@ -72,6 +76,8 @@ impl Default for SelectionClipboard { paste_request_counter: 0, active_paste_request_id: None, pending_image_fallback: None, + image_fallback_generation: 0, + image_save_requested: false, capture_image_published: false, } } @@ -242,28 +248,49 @@ impl SelectionClipboard { operation: ImageOperationKind, exit_after_save: bool, ) { - self.pending_image_fallback = Some(PendingClipboardFallback { - image_data, + self.image_fallback_generation = self.image_fallback_generation.wrapping_add(1); + self.image_save_requested = false; + self.pending_image_fallback = Some(Arc::new(ClipboardFallbackSaveRequest { + id: self.image_fallback_generation, + image_data: image_data.into(), save_config, operation, exit_after_save, - }); + })); } - pub(in crate::input::state::core) fn take_pending_image_fallback( - &mut self, - ) -> Option { - self.pending_image_fallback.take() + pub(in crate::input::state::core) fn request_image_save(&mut self) -> Option { + let fallback = self.pending_image_fallback.as_ref()?; + if self.image_save_requested { + return None; + } + self.image_save_requested = true; + Some(fallback.id) } - pub(in crate::input::state::core) fn restore_pending_image_fallback( + pub(in crate::input::state::core) fn image_save_request( + &self, + id: u64, + ) -> Option> { + self.pending_image_fallback + .as_ref() + .filter(|fallback| fallback.id == id) + .cloned() + } + + pub(in crate::input::state::core) fn complete_image_save( &mut self, - fallback: PendingClipboardFallback, - ) { - self.pending_image_fallback = Some(fallback); + id: u64, + succeeded: bool, + ) -> Option> { + let fallback = self.image_save_request(id)?; + self.image_save_requested = false; + if succeeded { + self.pending_image_fallback = None; + } + Some(fallback) } - #[cfg(test)] pub(in crate::input::state::core) fn has_pending_image_fallback(&self) -> bool { self.pending_image_fallback.is_some() } diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index 313c4b79..d79d26c6 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -3,10 +3,7 @@ use super::super::base::{ ToastPushOutcome, UiToastState, }; use super::super::feedback::ToastBounds; -use crate::capture::{ - ImageOperationKind, - file::{FileSaveConfig, save_screenshot}, -}; +use crate::capture::{ImageOperationKind, file::FileSaveConfig}; use crate::domain::Action; use std::time::Instant; @@ -170,21 +167,44 @@ impl InputState { ); } - /// Save pending clipboard fallback image to file. - /// On success, clears the fallback and exits if exit-after-capture was enabled. - /// On error, retains it for retry. + /// Queue the retained image for backend file work without holding input dispatch. pub(crate) fn save_pending_clipboard_to_file(&mut self) { - let Some(fallback) = self.selection_clipboard.take_pending_image_fallback() else { - self.push_toast( - ToastPriority::Info, - "capture.save", - Toast::warning("No pending image to save"), - ); - self.trigger_blocked_feedback(); + let Some(request_id) = self.selection_clipboard.request_image_save() else { + if !self.selection_clipboard.has_pending_image_fallback() { + self.push_toast( + ToastPriority::Info, + "capture.save", + Toast::warning("No pending image to save"), + ); + self.trigger_blocked_feedback(); + } return; }; - match save_screenshot(&fallback.image_data, &fallback.save_config) { + self.set_pending_backend_action( + super::super::base::PendingBackendAction::SaveClipboardFallback { request_id }, + ); + } + + pub(crate) fn clipboard_fallback_save_request( + &self, + id: u64, + ) -> Option> { + self.selection_clipboard.image_save_request(id) + } + + pub(crate) fn complete_clipboard_fallback_save( + &mut self, + id: u64, + result: Result, + ) { + let Some(fallback) = self + .selection_clipboard + .complete_image_save(id, result.is_ok()) + else { + return; + }; + match result { Ok(path) => { log::info!( "Saved pending {} to: {}", @@ -192,21 +212,15 @@ impl InputState { path.display() ); self.set_capture_feedback(Some(&path), false); - // Exit if exit-after-capture was originally enabled if fallback.exit_after_save { self.request_explicit_exit(); } } - Err(err) => { - let message = fallback.operation.format_error(&err); + Err(message) => { log::error!( - "Failed to save pending {}: {}", - fallback.operation.saved_log_label(), - message + "Failed to save pending {}: {message}", + fallback.operation.saved_log_label() ); - // Restore fallback so user can retry - self.selection_clipboard - .restore_pending_image_fallback(fallback); self.push_toast( ToastPriority::Critical, "capture.save", @@ -217,8 +231,6 @@ impl InputState { } } } - - /// Advance the text edit entry feedback animation. Returns true if still active. pub fn advance_text_edit_entry_feedback(&mut self, now: Instant) -> bool { self.text_editing.expire_edit_entry_feedback(now) } @@ -686,64 +698,6 @@ mod tests { assert!(state.test_blocked_feedback_active()); } - #[test] - fn clipboard_fallback_exit_after_save_requests_explicit_overlay_exit() { - let mut state = make_state(); - let temp = crate::test_temp::tempdir().expect("tempdir"); - state.set_clipboard_fallback( - b"not-a-real-png-but-save-writes-bytes".to_vec(), - FileSaveConfig { - save_directory: temp.path().to_path_buf(), - filename_template: "fallback".to_string(), - format: "png".to_string(), - }, - ImageOperationKind::Screenshot, - true, - ); - - state.save_pending_clipboard_to_file(); - - assert!(state.should_exit); - assert!(state.take_explicit_exit_requested()); - assert!(!state.take_explicit_exit_requested()); - } - - #[test] - fn canvas_clipboard_fallback_retry_failure_uses_canvas_wording() { - let mut state = make_state(); - let temp = crate::test_temp::tempdir().expect("tempdir"); - let not_a_directory = temp.path().join("not-a-directory"); - std::fs::write(¬_a_directory, b"file").expect("test fixture file"); - - state.set_clipboard_fallback( - vec![1, 2, 3], - FileSaveConfig { - save_directory: not_a_directory, - filename_template: "canvas_fallback".to_string(), - format: "png".to_string(), - }, - ImageOperationKind::CanvasExport, - false, - ); - - state.save_pending_clipboard_to_file(); - - let toast = state.active_toast().expect("error toast"); - assert_eq!(toast.kind, UiToastKind::Error); - assert!( - toast.message.contains("Failed to save canvas export"), - "unexpected toast: {}", - toast.message - ); - assert!( - !toast.message.to_lowercase().contains("screenshot"), - "canvas fallback failure should not mention screenshot: {}", - toast.message - ); - assert!(state.selection_clipboard.has_pending_image_fallback()); - assert!(state.test_blocked_feedback_active()); - } - #[test] fn advance_text_edit_entry_feedback_clears_expired_feedback() { let mut state = make_state(); diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 16370497..4a323243 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -9,10 +9,10 @@ mod mouse; mod render; mod spotlight; mod text_resources; +pub(crate) use core::{ClipboardFallbackSaveRequest, InputEffect, InputEffectDrain}; pub(crate) use core::{ DrawingStyle, HistoryLimits, IdleHandle, SpotlightMagnificationTrack, TopMenuState, }; -pub(crate) use core::{InputEffect, InputEffectDrain}; pub(crate) use core::{ LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, From d40238429652c313448b0e4275e6d6f2720e24f8 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:58:37 +0200 Subject: [PATCH 2/2] fix(clipboard): finish accepted image saves before overlay exit --- .../wayland/backend/event_loop/capture.rs | 7 +- src/backend/wayland/backend/event_loop/mod.rs | 1 + src/backend/wayland/config_edits.rs | 1 + src/backend/wayland/runtime_operation.rs | 38 +- .../wayland/state/clipboard/fallback_save.rs | 405 +++++++++++++++--- .../wayland/state/clipboard_runtime.rs | 7 + src/backend/wayland/state/input_actions.rs | 1 + src/backend/wayland/state/toolbar/events.rs | 1 + .../state/core/base/input_effect_outbox.rs | 11 + src/input/state/core/base/types.rs | 4 - src/input/state/core/selection/clipboard.rs | 6 +- src/input/state/core/utility/pending.rs | 3 + src/input/state/core/utility/toasts.rs | 15 +- 13 files changed, 406 insertions(+), 94 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 3a8c2916..dea8a256 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -140,6 +140,7 @@ pub(super) fn handle_pending_actions( && matches!( effect, InputEffect::Backend(_) + | InputEffect::ClipboardFallbackSave(_) | InputEffect::FrozenPass { .. } | InputEffect::BoardRuntimeUi(_) | InputEffect::SpotlightMagnifierFeedback @@ -172,6 +173,9 @@ pub(super) fn handle_pending_actions( InputEffect::QuickColor(edit) => state.handle_quick_color_edit(edit), InputEffect::KeybindingEdit(request) => state.handle_keybinding_edit(request), InputEffect::Backend(action) => apply_backend_effect(state, action), + InputEffect::ClipboardFallbackSave(request) => { + state.start_clipboard_fallback_save(request) + } InputEffect::FrozenPass { user_requested } => { handle_frozen_toggle(state, user_requested); } @@ -227,9 +231,6 @@ struct FrozenTogglePassDecision { fn apply_backend_effect(state: &mut WaylandState, action: PendingBackendAction) { match action { PendingBackendAction::Screenshot(action) => state.handle_capture_action(action), - PendingBackendAction::SaveClipboardFallback { request_id } => { - state.start_clipboard_fallback_save(request_id) - } PendingBackendAction::MeasureMode => state.handle_measure_mode_action(), PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action), PendingBackendAction::BoardPdfExport(action) => { diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 0a345b18..554101d5 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -159,6 +159,7 @@ pub(super) fn run_event_loop( &mut loop_error, || { state.cancel_region_capture_for_teardown(); + state.finish_clipboard_fallback_saves(); if let Err(err) = session_save::persist_session(state) { warn!("Failed to save session state: {}", err); session_save::notify_session_failure(state, &err); diff --git a/src/backend/wayland/config_edits.rs b/src/backend/wayland/config_edits.rs index db9063aa..0b7637ed 100644 --- a/src/backend/wayland/config_edits.rs +++ b/src/backend/wayland/config_edits.rs @@ -745,6 +745,7 @@ pub(in crate::backend::wayland) fn finish_config_edits( queue_keybinding_edit(&config.keybindings, input_state, worker, request); } effect @ (InputEffect::Backend(_) + | InputEffect::ClipboardFallbackSave(_) | InputEffect::SpotlightMagnifierFeedback | InputEffect::ToolbarPersistence(_) | InputEffect::OutputFocus(_) diff --git a/src/backend/wayland/runtime_operation.rs b/src/backend/wayland/runtime_operation.rs index 06051eac..1869affd 100644 --- a/src/backend/wayland/runtime_operation.rs +++ b/src/backend/wayland/runtime_operation.rs @@ -236,31 +236,55 @@ where let Some(active) = self.active.take() else { return RuntimeOperationPoll::Idle; }; - let active_id = active.id; - match active.receiver.try_recv() { + let message = match active.receiver.try_recv() { Err(TryRecvError::Empty) => { + let id = active.id; self.active = Some(active); - RuntimeOperationPoll::Pending { id: active_id } + return RuntimeOperationPoll::Pending { id }; } - Err(TryRecvError::Disconnected) => RuntimeOperationPoll::Disconnected { + Err(TryRecvError::Disconnected) => None, + Ok(message) => Some(message), + }; + + self.consume_completion(active, message) + } + + /// Wait for accepted work during teardown only; dispatch must continue to use `poll`. + pub(in crate::backend::wayland) fn wait(&mut self) -> RuntimeOperationPoll { + let Some(active) = self.active.take() else { + return RuntimeOperationPoll::Idle; + }; + let message = active.receiver.recv().ok(); + + self.consume_completion(active, message) + } + + fn consume_completion( + &mut self, + active: ActiveOperation, + message: Option>, + ) -> RuntimeOperationPoll { + let active_id = active.id; + match message { + None => RuntimeOperationPoll::Disconnected { id: active.id, context: active.context, }, - Ok(ProducerMessage::Ready { id, outcome }) if id == active_id => { + Some(ProducerMessage::Ready { id, outcome }) if id == active_id => { RuntimeOperationPoll::Ready { id, context: active.context, outcome, } } - Ok(ProducerMessage::Failed { id, reason }) if id == active_id => { + Some(ProducerMessage::Failed { id, reason }) if id == active_id => { RuntimeOperationPoll::ProducerFailed { id, context: active.context, reason, } } - Ok(ProducerMessage::Ready { id, .. } | ProducerMessage::Failed { id, .. }) => { + Some(ProducerMessage::Ready { id, .. } | ProducerMessage::Failed { id, .. }) => { self.healthy = false; RuntimeOperationPoll::ProducerFailed { id: active.id, diff --git a/src/backend/wayland/state/clipboard/fallback_save.rs b/src/backend/wayland/state/clipboard/fallback_save.rs index 5ba29cc5..4c7dec01 100644 --- a/src/backend/wayland/state/clipboard/fallback_save.rs +++ b/src/backend/wayland/state/clipboard/fallback_save.rs @@ -1,33 +1,86 @@ use super::WaylandState; use crate::backend::wayland::RuntimeOperationPoll; -use crate::input::state::InputState; +use crate::backend::wayland::state::clipboard_runtime::ClipboardRuntime; +use crate::input::state::{ + ClipboardFallbackSaveRequest, InputEffect, InputEffectDrain, InputState, +}; +use std::sync::Arc; impl WaylandState { - pub(in crate::backend::wayland) fn start_clipboard_fallback_save(&mut self, id: u64) { - let Some(request) = self.input_state.clipboard_fallback_save_request(id) else { - return; - }; - if let Err(failure) = self.clipboard.submit_fallback_save(request, save_image) { - let (error, id) = failure.into_parts(); + pub(in crate::backend::wayland) fn start_clipboard_fallback_save( + &mut self, + request: Arc, + ) { + if let Err(error) = submit_save(&mut self.input_state, &mut self.clipboard, request) { log::error!("Failed to submit clipboard fallback save: {error}"); - let message = if matches!( - error, - crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } - ) { - "Another image is being saved. Try again in a moment." - } else { - "Could not start the image save. Try again." - }; - self.input_state - .complete_clipboard_fallback_save(id, Err(message.into())); } } pub(in crate::backend::wayland) fn poll_clipboard_fallback_save(&mut self) { - apply_completion(&mut self.input_state, self.clipboard.poll_fallback_save()); + if let Err(error) = + apply_completion(&mut self.input_state, self.clipboard.poll_fallback_save()) + { + log::error!("Clipboard fallback save failed: {error}"); + } + } + + pub(in crate::backend::wayland) fn finish_clipboard_fallback_saves(&mut self) { + if let Err(error) = finish_fallback_saves(&mut self.input_state, &mut self.clipboard) { + log::error!("Failed to finish accepted image save before exit: {error}"); + crate::notification::send_notification_async( + &self.tokio_handle, + "Failed to Save Image".into(), + error, + Some("dialog-error".into()), + ); + } } } +fn submit_save( + input: &mut InputState, + clipboard: &mut ClipboardRuntime, + request: Arc, +) -> Result<(), String> { + if let Err(failure) = clipboard.submit_fallback_save(request, save_image) { + let (error, id) = failure.into_parts(); + let message = if matches!( + error, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } + ) { + "Another image is being saved. Try again in a moment." + } else { + "Could not start the image save. Try again." + }; + input.complete_clipboard_fallback_save(id, Err(message.into())); + + return Err(error.to_string()); + } + + Ok(()) +} + +/// Dispatch has stopped: settle the current writer, then drain accepted saves FIFO. +/// Other effects stay queued for their own teardown owners. +fn finish_fallback_saves( + input: &mut InputState, + clipboard: &mut ClipboardRuntime, +) -> Result<(), String> { + let mut result = apply_completion(input, clipboard.wait_fallback_save()); + for effect in input.drain_input_effects(InputEffectDrain::ClipboardFallbackSaves) { + let InputEffect::ClipboardFallbackSave(request) = effect else { + unreachable!("fallback save drain returned {effect:?}"); + }; + let outcome = submit_save(input, clipboard, request) + .and_then(|()| apply_completion(input, clipboard.wait_fallback_save())); + if result.is_ok() { + result = outcome; + } + } + + result +} + fn save_image( request: std::sync::Arc, ) -> Result { @@ -38,25 +91,28 @@ fn save_image( fn apply_completion( input: &mut InputState, completion: RuntimeOperationPoll>, -) { - match completion { - RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} +) -> Result<(), String> { + let (id, outcome) = match completion { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => return Ok(()), RuntimeOperationPoll::Ready { context: id, outcome, .. - } => input.complete_clipboard_fallback_save(id, outcome), + } => (id, outcome), RuntimeOperationPoll::ProducerFailed { context: id, reason, .. - } => input.complete_clipboard_fallback_save(id, Err(reason)), - RuntimeOperationPoll::Disconnected { context: id, .. } => input - .complete_clipboard_fallback_save( - id, - Err("Image save worker exited without a completion".into()), - ), - } + } => (id, Err(reason)), + RuntimeOperationPoll::Disconnected { context: id, .. } => ( + id, + Err("Image save worker exited without a completion".into()), + ), + }; + let result = outcome.as_ref().map(|_| ()).map_err(Clone::clone); + input.complete_clipboard_fallback_save(id, outcome); + + result } #[cfg(test)] @@ -85,18 +141,234 @@ mod tests { exit, ); input.save_pending_clipboard_to_file(); - let requests: Vec<_> = input + take_save_request(input) + } + + fn take_save_request(input: &mut InputState) -> Arc { + let mut requests: Vec<_> = input .drain_input_effects(InputEffectDrain::Runtime) .into_iter() .filter_map(|effect| match effect { - InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { - request_id, - }) => Some(request_id), + InputEffect::ClipboardFallbackSave(request) => Some(request), _ => None, }) .collect(); assert_eq!(requests.len(), 1); - input.clipboard_fallback_save_request(requests[0]).unwrap() + + requests.pop().unwrap() + } + + #[test] + fn teardown_starts_a_save_accepted_in_the_exit_dispatch_batch() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + input.set_clipboard_fallback( + vec![4, 5, 6], + FileSaveConfig { + save_directory: temp.path().to_path_buf(), + filename_template: "fallback".into(), + format: "png".into(), + }, + ImageOperationKind::CanvasExport, + false, + ); + input.save_pending_clipboard_to_file(); + assert!(input.has_pending_backend_actions()); + let unrelated = + PendingBackendAction::HelperLaunch(crate::input::state::HelperLaunchRequest::About); + input.set_pending_backend_action(unrelated.clone()); + input.request_explicit_exit(); + + finish_fallback_saves(&mut input, &mut runtime).unwrap(); + + let paths: Vec<_> = std::fs::read_dir(temp.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!( + paths.len(), + 1, + "accepted save must run even when Exit skips the runtime drain" + ); + assert_eq!(std::fs::read(&paths[0]).unwrap(), [4, 5, 6]); + assert_eq!(input.last_capture_path(), Some(paths[0].as_path())); + assert!(matches!( + runtime.poll_fallback_save(), + RuntimeOperationPoll::Idle + )); + assert!(input.should_exit); + assert_eq!(input.take_pending_backend_action(), Some(unrelated)); + assert!(!input.has_pending_backend_actions()); + finish_fallback_saves(&mut input, &mut runtime).unwrap(); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); + } + + #[test] + fn teardown_preserves_queued_image_ownership_when_the_fallback_changes() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + for (name, bytes) in [("first", vec![1, 2]), ("second", vec![3, 4])] { + input.set_clipboard_fallback( + bytes, + FileSaveConfig { + save_directory: temp.path().join(name), + filename_template: "fallback".into(), + format: "png".into(), + }, + ImageOperationKind::CanvasExport, + name == "first", + ); + input.save_pending_clipboard_to_file(); + } + + finish_fallback_saves(&mut input, &mut runtime).unwrap(); + + for (name, bytes) in [("first", vec![1, 2]), ("second", vec![3, 4])] { + let paths: Vec<_> = std::fs::read_dir(temp.path().join(name)) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(paths.len(), 1); + assert_eq!(std::fs::read(&paths[0]).unwrap(), bytes); + } + assert_eq!( + input.last_capture_path().unwrap().parent(), + Some(temp.path().join("second").as_path()) + ); + assert!( + !input.should_exit, + "stale save cannot apply its exit-after-save flag" + ); + assert!(matches!( + runtime.poll_fallback_save(), + RuntimeOperationPoll::Idle + )); + } + + #[test] + fn teardown_reports_io_and_producer_failures_and_keeps_retry_bytes() { + for (panics, expected) in [ + (false, "Failed to save canvas export"), + (true, "held writer failed"), + ] { + let temp = crate::test_temp::tempdir().unwrap(); + let path = if panics { + temp.path().to_path_buf() + } else { + temp.path().join("not-a-directory") + }; + if !panics { + std::fs::write(&path, b"obstruction").unwrap(); + } + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let request = queue_image(&mut input, &path, vec![7, 8], true); + runtime + .submit_fallback_save(request, move |request| { + assert!(!panics, "held writer failed"); + save_image(request) + }) + .unwrap(); + + let error = finish_fallback_saves(&mut input, &mut runtime).unwrap_err(); + + assert!(error.contains(expected)); + if !panics { + assert_eq!(std::fs::read(&path).unwrap(), b"obstruction"); + } + assert!(!input.should_exit); + assert!(input.last_capture_path().is_none()); + assert!(input.active_toast().unwrap().message.contains(expected)); + input.save_pending_clipboard_to_file(); + assert_eq!(&*take_save_request(&mut input).image_data, &[7, 8]); + } + } + + #[test] + fn teardown_failure_does_not_discard_a_newer_accepted_save() { + let temp = crate::test_temp::tempdir().unwrap(); + let blocked = temp.path().join("not-a-directory"); + std::fs::write(&blocked, b"obstruction").unwrap(); + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let first = queue_image(&mut input, &blocked, vec![1], true); + runtime.submit_fallback_save(first, save_image).unwrap(); + input.set_clipboard_fallback( + vec![2], + FileSaveConfig { + save_directory: temp.path().join("newer"), + filename_template: "fallback".into(), + format: "png".into(), + }, + ImageOperationKind::CanvasExport, + false, + ); + input.save_pending_clipboard_to_file(); + + let error = finish_fallback_saves(&mut input, &mut runtime).unwrap_err(); + + assert!(error.contains("Failed to save canvas export")); + assert_eq!(std::fs::read(&blocked).unwrap(), b"obstruction"); + let saved = input.last_capture_path().unwrap(); + assert_eq!(saved.parent(), Some(temp.path().join("newer").as_path())); + assert_eq!(std::fs::read(saved).unwrap(), [2]); + assert!(!input.should_exit); + assert!(matches!( + runtime.poll_fallback_save(), + RuntimeOperationPoll::Idle + )); + } + + #[test] + fn teardown_waits_for_an_already_started_image_writer() { + let temp = crate::test_temp::tempdir().unwrap(); + let directory = temp.path().to_path_buf(); + let (release, wait) = std::sync::mpsc::channel(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let teardown = std::thread::spawn(move || { + let mut input = crate::input::state::test_support::make_test_input_state(); + let wake = RuntimeWakeSource::new().unwrap(); + let mut runtime = ClipboardRuntime::new(RuntimeOperationIdSource::new(), wake.handle()); + let request = queue_image(&mut input, &directory, vec![8, 9, 10], true); + runtime + .submit_fallback_save(request, move |request| { + started_tx.send(()).unwrap(); + wait.recv().unwrap(); + save_image(request) + }) + .unwrap(); + input.request_explicit_exit(); + + let result = finish_fallback_saves(&mut input, &mut runtime); + finished_tx.send(result).unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let premature = finished_rx.recv_timeout(Duration::from_millis(100)); + release.send(()).unwrap(); + let result = match premature { + Ok(ref result) => result.clone(), + Err(_) => finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(), + }; + teardown.join().unwrap(); + + assert!( + matches!(premature, Err(std::sync::mpsc::RecvTimeoutError::Timeout)), + "teardown returned while its accepted writer was held" + ); + result.unwrap(); + let paths: Vec<_> = std::fs::read_dir(temp.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(paths.len(), 1); + assert_eq!(std::fs::read(&paths[0]).unwrap(), [8, 9, 10]); } #[test] @@ -112,7 +384,7 @@ mod tests { assert!(!input.should_exit); assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); - apply_completion(&mut input, runtime.poll_fallback_save()); + apply_completion(&mut input, runtime.poll_fallback_save()).unwrap(); assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); let path = std::fs::read_dir(temp.path()) .unwrap() @@ -124,7 +396,13 @@ mod tests { assert!(input.should_exit); assert!(input.take_explicit_exit_requested()); assert!(!input.take_explicit_exit_requested()); - assert!(input.clipboard_fallback_save_request(request.id).is_none()); + input.save_pending_clipboard_to_file(); + assert!( + !input + .drain_input_effects(InputEffectDrain::Runtime) + .iter() + .any(|effect| matches!(effect, InputEffect::ClipboardFallbackSave(_))) + ); } #[test] @@ -140,15 +418,7 @@ mod tests { .submit_fallback_save(request.clone(), save_image) .unwrap(); assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); - apply_completion(&mut input, runtime.poll_fallback_save()); - - assert_eq!( - &*input - .clipboard_fallback_save_request(request.id) - .unwrap() - .image_data, - &[7, 8, 9] - ); + assert!(apply_completion(&mut input, runtime.poll_fallback_save()).is_err()); assert!( input .active_toast() @@ -166,16 +436,25 @@ mod tests { ); std::fs::remove_file(&blocked).unwrap(); input.save_pending_clipboard_to_file(); - runtime - .submit_fallback_save( - input.clipboard_fallback_save_request(request.id).unwrap(), - save_image, - ) - .unwrap(); + let retry = take_save_request(&mut input); + assert_eq!(&*retry.image_data, &[7, 8, 9]); + runtime.submit_fallback_save(retry, save_image).unwrap(); assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); - apply_completion(&mut input, runtime.poll_fallback_save()); - assert!(input.clipboard_fallback_save_request(request.id).is_none()); - assert_eq!(std::fs::read_dir(&blocked).unwrap().count(), 1); + apply_completion(&mut input, runtime.poll_fallback_save()).unwrap(); + let saved = std::fs::read_dir(&blocked) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!(std::fs::read(saved).unwrap(), [7, 8, 9]); + input.save_pending_clipboard_to_file(); + assert!( + !input + .drain_input_effects(InputEffectDrain::Runtime) + .iter() + .any(|effect| matches!(effect, InputEffect::ClipboardFallbackSave(_))) + ); } #[test] @@ -197,10 +476,7 @@ mod tests { !input .drain_input_effects(InputEffectDrain::Runtime) .iter() - .any(|effect| matches!( - effect, - InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { .. }) - )) + .any(|effect| matches!(effect, InputEffect::ClipboardFallbackSave(_))) ); assert!(matches!( runtime.poll_fallback_save(), @@ -215,21 +491,16 @@ mod tests { ); release.send(()).unwrap(); assert!(wake.wait_readable(Some(Duration::from_secs(2))).unwrap()); - apply_completion(&mut input, runtime.poll_fallback_save()); + apply_completion(&mut input, runtime.poll_fallback_save()).unwrap(); assert!( !input.should_exit, "stale completion cannot exit for the old image" ); - assert_eq!( - &*input - .clipboard_fallback_save_request(second.id) - .unwrap() - .image_data, - &[2] - ); input.complete_clipboard_fallback_save(second.id, Err("busy; retry".into())); input.save_pending_clipboard_to_file(); - assert!(input.drain_input_effects(InputEffectDrain::Runtime).iter().any(|effect| matches!(effect, InputEffect::Backend(PendingBackendAction::SaveClipboardFallback { request_id }) if *request_id == second.id))); + let retry = take_save_request(&mut input); + assert_eq!(retry.id, second.id); + assert_eq!(&*retry.image_data, &[2]); } } diff --git a/src/backend/wayland/state/clipboard_runtime.rs b/src/backend/wayland/state/clipboard_runtime.rs index d85c8653..19042dfc 100644 --- a/src/backend/wayland/state/clipboard_runtime.rs +++ b/src/backend/wayland/state/clipboard_runtime.rs @@ -120,6 +120,13 @@ impl ClipboardRuntime { self.fallback_save.poll() } + /// Finish an accepted file write before overlay teardown; never use in dispatch. + pub(in crate::backend::wayland) fn wait_fallback_save( + &mut self, + ) -> RuntimeOperationPoll> { + self.fallback_save.wait() + } + pub(in crate::backend::wayland) fn publish_active(&self) -> bool { self.publish.is_active() } diff --git a/src/backend/wayland/state/input_actions.rs b/src/backend/wayland/state/input_actions.rs index cffe9a96..65fc49ef 100644 --- a/src/backend/wayland/state/input_actions.rs +++ b/src/backend/wayland/state/input_actions.rs @@ -104,6 +104,7 @@ impl WaylandState { InputEffect::TextCopy(request) => self.handle_copy_text(request), InputEffect::TextPaste(target) => self.handle_paste_text(target), effect @ (InputEffect::Backend(_) + | InputEffect::ClipboardFallbackSave(_) | InputEffect::SpotlightMagnifierFeedback | InputEffect::ToolbarPersistence(_) | InputEffect::KeybindingEdit(_) diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index f40b6b15..db386b47 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -356,6 +356,7 @@ impl WaylandState { InputEffect::CopyHex(color) => self.handle_copy_hex_color(color), InputEffect::PasteHex(target) => self.handle_paste_hex_color(target), effect @ (InputEffect::Backend(_) + | InputEffect::ClipboardFallbackSave(_) | InputEffect::SpotlightMagnifierFeedback | InputEffect::ToolbarPersistence(_) | InputEffect::KeybindingEdit(_) diff --git a/src/input/state/core/base/input_effect_outbox.rs b/src/input/state/core/base/input_effect_outbox.rs index 76cc51c2..e6930fd5 100644 --- a/src/input/state/core/base/input_effect_outbox.rs +++ b/src/input/state/core/base/input_effect_outbox.rs @@ -16,6 +16,8 @@ use std::collections::VecDeque; #[derive(Debug, Clone)] pub(crate) enum InputEffect { Backend(PendingBackendAction), + /// An accepted file save owns its bytes even if the clipboard fallback changes. + ClipboardFallbackSave(std::sync::Arc), SpotlightMagnifierFeedback, ToolbarPersistence(PendingToolbarPersistence), KeybindingEdit(KeybindingEditRequest), @@ -54,11 +56,14 @@ pub(crate) enum InputEffectDrain { Runtime, /// Config edits that must still be submitted during shutdown. DurableConfig, + /// Accepted fallback file saves that must finish before overlay teardown. + ClipboardFallbackSaves, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::input::state::core) enum InputEffectKind { Backend, + ClipboardFallbackSave, SpotlightMagnifierFeedback, ToolbarPersistence, KeybindingEdit, @@ -219,12 +224,16 @@ impl InputEffectOutbox { InputEffectKind::BoardRuntimeUi, InputEffectKind::SpotlightMagnifierFeedback, InputEffectKind::Backend, + InputEffectKind::ClipboardFallbackSave, InputEffectKind::OutputFocus, InputEffectKind::Zoom, ], &mut drained, ); } + InputEffectDrain::ClipboardFallbackSaves => { + self.drain_kind(InputEffectKind::ClipboardFallbackSave, &mut drained); + } InputEffectDrain::DurableConfig => { self.drain_kinds( &[ @@ -303,6 +312,7 @@ impl InputEffect { fn kind(&self) -> InputEffectKind { match self { Self::Backend(_) => InputEffectKind::Backend, + Self::ClipboardFallbackSave(_) => InputEffectKind::ClipboardFallbackSave, Self::SpotlightMagnifierFeedback => InputEffectKind::SpotlightMagnifierFeedback, Self::ToolbarPersistence(_) => InputEffectKind::ToolbarPersistence, Self::KeybindingEdit(_) => InputEffectKind::KeybindingEdit, @@ -340,6 +350,7 @@ fn policy(effect: &InputEffect) -> EffectPolicy { | InputEffect::EyedropperToggle => EffectPolicy::Coalesce, InputEffect::OcrPass { .. } => EffectPolicy::Merge, InputEffect::Backend(_) + | InputEffect::ClipboardFallbackSave(_) | InputEffect::KeybindingEdit(_) | InputEffect::TextCopy(_) | InputEffect::TextPaste(_) diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index db0091ea..9824a86a 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -518,10 +518,6 @@ pub enum HelperLaunchRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PendingBackendAction { Screenshot(Action), - /// Save a matching clipboard-failure image on a bounded backend worker. - SaveClipboardFallback { - request_id: u64, - }, MeasureMode, CanvasExport(Action), BoardPdfExport(Action), diff --git a/src/input/state/core/selection/clipboard.rs b/src/input/state/core/selection/clipboard.rs index 19f671e1..a320132d 100644 --- a/src/input/state/core/selection/clipboard.rs +++ b/src/input/state/core/selection/clipboard.rs @@ -259,13 +259,15 @@ impl SelectionClipboard { })); } - pub(in crate::input::state::core) fn request_image_save(&mut self) -> Option { + pub(in crate::input::state::core) fn request_image_save( + &mut self, + ) -> Option> { let fallback = self.pending_image_fallback.as_ref()?; if self.image_save_requested { return None; } self.image_save_requested = true; - Some(fallback.id) + Some(fallback.clone()) } pub(in crate::input::state::core) fn image_save_request( diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 1da80f87..cb5cb59f 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -52,6 +52,9 @@ impl InputState { /// Whether another backend output action is waiting to be drained. pub(crate) fn has_pending_backend_actions(&self) -> bool { self.input_effects.contains(InputEffectKind::Backend) + || self + .input_effects + .contains(InputEffectKind::ClipboardFallbackSave) } /// Queues backend output work for retrieval by the backend, oldest first. diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index d79d26c6..0d000a57 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -169,7 +169,7 @@ impl InputState { /// Queue the retained image for backend file work without holding input dispatch. pub(crate) fn save_pending_clipboard_to_file(&mut self) { - let Some(request_id) = self.selection_clipboard.request_image_save() else { + let Some(request) = self.selection_clipboard.request_image_save() else { if !self.selection_clipboard.has_pending_image_fallback() { self.push_toast( ToastPriority::Info, @@ -181,16 +181,9 @@ impl InputState { return; }; - self.set_pending_backend_action( - super::super::base::PendingBackendAction::SaveClipboardFallback { request_id }, - ); - } - - pub(crate) fn clipboard_fallback_save_request( - &self, - id: u64, - ) -> Option> { - self.selection_clipboard.image_save_request(id) + self.emit_input_effect(super::super::base::InputEffect::ClipboardFallbackSave( + request, + )); } pub(crate) fn complete_clipboard_fallback_save(