mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-13 13:42:11 +00:00
296 lines
9.6 KiB
Rust
296 lines
9.6 KiB
Rust
use std::fmt::Display;
|
|
use std::sync::Mutex;
|
|
use arboard::Clipboard;
|
|
use axum::Json;
|
|
use log::{debug, error, warn};
|
|
use once_cell::sync::Lazy;
|
|
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<Mutex<Option<Clipboard>>> = 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)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum ClipboardOperationError<E> {
|
|
Initialization(E),
|
|
Write(E),
|
|
}
|
|
|
|
impl<E: Display> Display for ClipboardOperationError<E> {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Initialization(error) => write!(formatter, "Failed to initialize the clipboard backend: {error}"),
|
|
Self::Write(error) => write!(formatter, "Failed to write to the clipboard: {error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_text_with_retry<B, F>(
|
|
clipboard: &mut Option<B>,
|
|
text: String,
|
|
mut create_clipboard: F,
|
|
) -> Result<(), ClipboardOperationError<B::Error>>
|
|
where
|
|
B: ClipboardBackend,
|
|
F: FnMut() -> Result<B, B::Error>,
|
|
{
|
|
if clipboard.is_none() {
|
|
*clipboard = Some(create_clipboard().map_err(ClipboardOperationError::Initialization)?);
|
|
}
|
|
|
|
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().map_err(ClipboardOperationError::Initialization)?;
|
|
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(ClipboardOperationError::Write(retry_error));
|
|
}
|
|
|
|
*clipboard = Some(retry_clipboard);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn release_clipboard<B>(clipboard: &mut Option<B>) -> 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<SetClipboardResponse> {
|
|
let encrypted_text = EncryptedText::new(encrypted_text);
|
|
|
|
// Decrypt this text first:
|
|
let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) {
|
|
Ok(text) => text,
|
|
Err(e) => {
|
|
error!(Source = "Clipboard"; "Failed to decrypt the text: {e}.");
|
|
return Json(SetClipboardResponse {
|
|
success: false,
|
|
issue: e,
|
|
})
|
|
},
|
|
};
|
|
|
|
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 {
|
|
success: true,
|
|
issue: String::from(""),
|
|
})
|
|
},
|
|
|
|
Err(e) => {
|
|
error!(Source = "Clipboard"; "Clipboard operation failed: {e}.");
|
|
Json(SetClipboardResponse {
|
|
success: false,
|
|
issue: e.to_string(),
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Releases the process-wide clipboard instance during application shutdown.
|
|
pub fn shutdown_clipboard() {
|
|
let mut clipboard = CLIPBOARD.lock().unwrap();
|
|
if release_clipboard(&mut clipboard) {
|
|
debug!(Source = "Clipboard"; "Clipboard instance was released.");
|
|
}
|
|
}
|
|
|
|
/// The response for setting the clipboard text.
|
|
#[derive(Serialize)]
|
|
pub struct SetClipboardResponse {
|
|
success: bool,
|
|
issue: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::VecDeque;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use super::{ClipboardOperationError, release_clipboard, set_text_with_retry, ClipboardBackend};
|
|
|
|
struct MockClipboard {
|
|
id: usize,
|
|
fail_write: bool,
|
|
writes: Arc<Mutex<Vec<(usize, String)>>>,
|
|
drops: Arc<AtomicUsize>,
|
|
}
|
|
|
|
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<bool>,
|
|
created: usize,
|
|
writes: Arc<Mutex<Vec<(usize, String)>>>,
|
|
drops: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl MockFactory {
|
|
fn new(outcomes: impl IntoIterator<Item = bool>) -> 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<MockClipboard, String> {
|
|
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 reports_initialization_failures_and_retries_on_the_next_request() {
|
|
let mut clipboard: Option<MockClipboard> = None;
|
|
let mut factory = MockFactory::new([false]);
|
|
let mut fail_initialization = true;
|
|
|
|
let error = set_text_with_retry(&mut clipboard, "first".to_string(), || {
|
|
if fail_initialization {
|
|
fail_initialization = false;
|
|
Err("initialization failed".to_string())
|
|
} else {
|
|
factory.create()
|
|
}
|
|
}).unwrap_err();
|
|
|
|
assert_eq!(error, ClipboardOperationError::Initialization("initialization failed".to_string()));
|
|
assert!(clipboard.is_none());
|
|
|
|
set_text_with_retry(&mut clipboard, "second".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 reports_reinitialization_failures_and_discards_the_failed_instance() {
|
|
let mut clipboard = None;
|
|
let mut factory = MockFactory::new([true]);
|
|
let mut initialization_attempts = 0;
|
|
|
|
let error = set_text_with_retry(&mut clipboard, "text".to_string(), || {
|
|
initialization_attempts += 1;
|
|
if initialization_attempts == 1 {
|
|
factory.create()
|
|
} else {
|
|
Err("reinitialization failed".to_string())
|
|
}
|
|
}).unwrap_err();
|
|
|
|
assert_eq!(error, ClipboardOperationError::Initialization("reinitialization failed".to_string()));
|
|
assert_eq!(initialization_attempts, 2);
|
|
assert!(clipboard.is_none());
|
|
}
|
|
|
|
#[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, ClipboardOperationError::Write("backend 1 failed".to_string()));
|
|
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);
|
|
}
|
|
} |