From c53ad6a25881e91f77ca595231a210ea40cd5e3d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 18 Jul 2026 16:33:08 +0200 Subject: [PATCH] Fix clipboard persistence on Linux --- .../wwwroot/changelog/v26.7.3.md | 1 + runtime/src/app_window.rs | 2 + runtime/src/clipboard.rs | 206 ++++++++++++++++-- 3 files changed, 194 insertions(+), 15 deletions(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index fcc57e11..2400717c 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -11,6 +11,7 @@ - Fixed connections to internal HTTPS services and enterprise configuration servers that use organization-provided root certificates on Linux. - Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder. - Fixed voice recording and transcription on Linux. +- Fixed copied content from AI Studio not remaining available on the clipboard on Linux. - Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress. - Fixed file extension handling so files are recognized correctly regardless of uppercase or lowercase letters in their extensions. Thanks, Paul Schweiß, for reporting this issue. - Fixed AI Studio failing to start on Linux systems & showing an outdated version on the Flatpak page. diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index fdac344d..ad8aad9d 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -23,6 +23,7 @@ use tauri_plugin_opener::OpenerExt; use tokio::sync::broadcast; use tokio::time; use crate::api_token::APIToken; +use crate::clipboard::shutdown_clipboard; use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server}; use crate::environment::{ is_prod, is_dev, is_flatpak, CONFIG_DIRECTORY, DATA_DIRECTORY, FLATPAK_LIBRARY_DIRECTORY, @@ -217,6 +218,7 @@ pub fn start_tauri() { RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); + shutdown_clipboard(); stop_qdrant_edge_database(); if is_prod() { warn!("Try to stop the .NET server as well..."); diff --git a/runtime/src/clipboard.rs b/runtime/src/clipboard.rs index bdb612ff..de280cfc 100644 --- a/runtime/src/clipboard.rs +++ b/runtime/src/clipboard.rs @@ -1,10 +1,65 @@ +use std::fmt::Display; +use std::sync::Mutex; use arboard::Clipboard; -use log::{debug, error}; +use log::{debug, error, warn}; +use once_cell::sync::Lazy; use axum::Json; use serde::Serialize; use crate::api_token::APIToken; use crate::encryption::{EncryptedText, ENCRYPTION}; +/// The process-wide clipboard instance. On Linux, retaining this instance keeps the app's +/// ownership of clipboard contents alive until the next write or application shutdown. +static CLIPBOARD: Lazy>> = Lazy::new(|| Mutex::new(None)); + +trait ClipboardBackend { + type Error: Display; + + fn set_text(&mut self, text: String) -> Result<(), Self::Error>; +} + +impl ClipboardBackend for Clipboard { + type Error = arboard::Error; + + fn set_text(&mut self, text: String) -> Result<(), Self::Error> { + Clipboard::set_text(self, text) + } +} + +fn set_text_with_retry( + clipboard: &mut Option, + text: String, + mut create_clipboard: F, +) -> Result<(), B::Error> +where + B: ClipboardBackend, + F: FnMut() -> Result, +{ + if clipboard.is_none() { + *clipboard = Some(create_clipboard()?); + } + + let first_result = clipboard.as_mut().unwrap().set_text(text.clone()); + if let Err(first_error) = first_result { + warn!(Source = "Clipboard"; "Failed to set text using the current clipboard backend; reinitializing it once: {first_error}."); + *clipboard = None; + + let mut retry_clipboard = create_clipboard()?; + if let Err(retry_error) = retry_clipboard.set_text(text) { + error!(Source = "Clipboard"; "Failed to set text after reinitializing the clipboard backend: {retry_error}."); + return Err(retry_error); + } + + *clipboard = Some(retry_clipboard); + } + + Ok(()) +} + +fn release_clipboard(clipboard: &mut Option) -> bool { + clipboard.take().is_some() +} + /// Sets the clipboard text to the provided encrypted text. pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json { let encrypted_text = EncryptedText::new(encrypted_text); @@ -21,20 +76,8 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json clipboard, - Err(e) => { - error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}."); - return Json(SetClipboardResponse { - success: false, - issue: e.to_string(), - }) - }, - }; - - let set_text_result = clipboard.set_text(decrypted_text); - match set_text_result { + let mut clipboard = CLIPBOARD.lock().unwrap(); + match set_text_with_retry(&mut clipboard, decrypted_text, Clipboard::new) { Ok(_) => { debug!(Source = "Clipboard"; "Text was set to the clipboard successfully."); Json(SetClipboardResponse { @@ -53,9 +96,142 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json>>, + drops: Arc, + } + + impl ClipboardBackend for MockClipboard { + type Error = String; + + fn set_text(&mut self, text: String) -> Result<(), Self::Error> { + self.writes.lock().unwrap().push((self.id, text)); + if self.fail_write { + Err(format!("backend {} failed", self.id)) + } else { + Ok(()) + } + } + } + + impl Drop for MockClipboard { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + struct MockFactory { + outcomes: VecDeque, + created: usize, + writes: Arc>>, + drops: Arc, + } + + impl MockFactory { + fn new(outcomes: impl IntoIterator) -> Self { + Self { + outcomes: outcomes.into_iter().collect(), + created: 0, + writes: Arc::new(Mutex::new(Vec::new())), + drops: Arc::new(AtomicUsize::new(0)), + } + } + + fn create(&mut self) -> Result { + let fail_write = self.outcomes.pop_front().expect("missing mock outcome"); + let id = self.created; + self.created += 1; + Ok(MockClipboard { + id, + fail_write, + writes: Arc::clone(&self.writes), + drops: Arc::clone(&self.drops), + }) + } + } + + #[test] + fn initializes_lazily() { + let mut clipboard = None; + let mut factory = MockFactory::new([false]); + + assert_eq!(factory.created, 0); + set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap(); + + assert_eq!(factory.created, 1); + assert!(clipboard.is_some()); + } + + #[test] + fn reuses_the_same_instance_for_multiple_writes() { + let mut clipboard = None; + let mut factory = MockFactory::new([false]); + + set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap(); + set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap(); + + assert_eq!(factory.created, 1); + assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "first".to_string()), (0, "second".to_string())]); + } + + #[test] + fn retries_once_with_a_new_instance_after_a_write_failure() { + let mut clipboard = None; + let mut factory = MockFactory::new([true, false]); + + set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap(); + + assert_eq!(factory.created, 2); + assert_eq!(clipboard.as_ref().unwrap().id, 1); + assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "text".to_string()), (1, "text".to_string())]); + } + + #[test] + fn returns_the_retry_error_and_discards_the_failed_instance() { + let mut clipboard = None; + let mut factory = MockFactory::new([true, true]); + + let error = set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap_err(); + + assert_eq!(error, "backend 1 failed"); + assert_eq!(factory.created, 2); + assert!(clipboard.is_none()); + } + + #[test] + fn releases_the_instance_on_shutdown() { + let mut clipboard = None; + let mut factory = MockFactory::new([false]); + let drops = Arc::clone(&factory.drops); + set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap(); + + assert!(release_clipboard(&mut clipboard)); + + assert!(clipboard.is_none()); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } } \ No newline at end of file