mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-21 17:06:27 +00:00
Fixed clipboard persistence on Linux (#866)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
92b316a782
commit
45701189c8
@ -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.
|
||||
|
||||
@ -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...");
|
||||
|
||||
@ -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<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)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_text_with_retry<B, F>(
|
||||
clipboard: &mut Option<B>,
|
||||
text: String,
|
||||
mut create_clipboard: F,
|
||||
) -> Result<(), B::Error>
|
||||
where
|
||||
B: ClipboardBackend,
|
||||
F: FnMut() -> Result<B, B::Error>,
|
||||
{
|
||||
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<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);
|
||||
@ -21,20 +76,8 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
|
||||
},
|
||||
};
|
||||
|
||||
let clipboard_result = Clipboard::new();
|
||||
let mut clipboard = match clipboard_result {
|
||||
Ok(clipboard) => 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<Set
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{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 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user