use std::fs::File;
use std::os::fd::OwnedFd as StdOwnedFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use ashpd::desktop::Session;
use ashpd::desktop::clipboard::{Clipboard, SetSelectionOptions};
use ashpd::desktop::remote_desktop::RemoteDesktop;
use auv_driver_common::error::DriverResult;
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
use futures_lite::{StreamExt, future};
use crate::error::backend;
use super::request::{run, session_connection};
const TEXT_MIME: &str = "text/plain;charset=utf-8";
const FD_TRANSFER_TIMEOUT: Duration = Duration::from_secs(2);
pub struct PortalClipboard;
impl PortalClipboard {
pub fn open(app_id: Option<&ashpd::AppID>) -> DriverResult<ClipboardSession> {
ClipboardSession::open(app_id)
}
}
pub struct ClipboardSession {
clipboard: Arc<Clipboard>,
session: Arc<Session<RemoteDesktop>>,
text: Arc<Mutex<String>>,
owns_selection: bool,
running: Arc<AtomicBool>,
transfer_thread: Option<JoinHandle<()>>,
}
impl std::fmt::Debug for ClipboardSession {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.debug_struct("ClipboardSession").field("session", &self.session).finish_non_exhaustive()
}
}
impl ClipboardSession {
fn open(app_id: Option<&ashpd::AppID>) -> DriverResult<Self> {
let connection = session_connection(app_id)?;
let remote_desktop = run("open clipboard RemoteDesktop", RemoteDesktop::with_connection(connection.clone()))?;
let clipboard = Arc::new(run("open Clipboard", Clipboard::with_connection(connection))?);
let session = Arc::new(run("create clipboard session", remote_desktop.create_session(Default::default()))?);
let mut owner = Self {
clipboard,
session,
text: Arc::new(Mutex::new(String::new())),
owns_selection: false,
running: Arc::new(AtomicBool::new(true)),
transfer_thread: None,
};
run("request clipboard access", owner.clipboard.request(&owner.session, Default::default()))?;
let selected =
run("start clipboard session", async { remote_desktop.start(&owner.session, None, Default::default()).await?.response() })?;
if !selected.is_clipboard_enabled() {
return Err(backend("remote desktop portal started without clipboard access"));
}
owner.transfer_thread = Some(spawn_transfer_thread(Arc::clone(&owner.clipboard), Arc::clone(&owner.text), Arc::clone(&owner.running))?);
Ok(owner)
}
pub fn snapshot(&mut self) -> DriverResult<String> {
let fd = match run("read portal clipboard", self.clipboard.selection_read(&self.session, TEXT_MIME)) {
Ok(fd) => fd,
Err(error) => {
if self.owns_selection {
let text = self.text.lock().expect("clipboard owner text lock poisoned").clone();
return Ok(text);
}
let message = error.to_string();
if message.contains("NoSelection") || message.contains("No such selection") || message.contains("Failed to selection read") {
return Ok(String::new());
}
return Err(backend(format!("failed to read portal clipboard text: {error}")));
}
};
let std_fd = StdOwnedFd::from(fd);
let file = File::from(std_fd);
let bytes = read_fd_to_end(file)?;
String::from_utf8(bytes).map_err(|error| backend(format!("portal clipboard returned non-UTF-8 text: {error}")))
}
pub fn set_text(&mut self, text: &str) -> DriverResult<()> {
*self.text.lock().expect("clipboard owner text lock poisoned") = text.to_string();
run(
"set clipboard selection",
self.clipboard.set_selection(&self.session, SetSelectionOptions::default().set_mime_types(&[TEXT_MIME])),
)?;
self.owns_selection = true;
Ok(())
}
}
impl Drop for ClipboardSession {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
let _ = run("close clipboard session", self.session.close());
if let Some(thread) = self.transfer_thread.take() {
let _ = thread.join();
}
}
}
fn spawn_transfer_thread(clipboard: Arc<Clipboard>, text: Arc<Mutex<String>>, running: Arc<AtomicBool>) -> DriverResult<JoinHandle<()>> {
let (ready_tx, ready_rx) = mpsc::channel();
let thread_running = Arc::clone(&running);
let handle = thread::spawn(move || {
let transfers = match run("subscribe to clipboard transfers", clipboard.receive_selection_transfer::<RemoteDesktop>()) {
Ok(transfers) => transfers,
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
let mut transfers = std::pin::pin!(transfers);
if ready_tx.send(Ok(())).is_err() {
return;
}
while thread_running.load(Ordering::SeqCst) {
let transfer = future::block_on(future::race(async { Some(transfers.next().await) }, async {
async_io::Timer::after(Duration::from_millis(100)).await;
None
}));
let Some(transfer) = transfer else {
continue;
};
let Some((session, mime_type, serial)) = transfer else {
break;
};
let result = if mime_type == TEXT_MIME {
let payload = text.lock().expect("clipboard owner text lock poisoned").clone();
run("open clipboard write fd", clipboard.selection_write(&session, serial))
.and_then(|fd| write_fd_all(File::from(StdOwnedFd::from(fd)), payload.as_bytes()))
} else {
Err(backend("unsupported clipboard MIME type"))
};
let _ = run("finish clipboard write", clipboard.selection_write_done(&session, serial, result.is_ok()));
}
});
let ready = ready_rx
.recv_timeout(FD_TRANSFER_TIMEOUT)
.map_err(|error| backend(format!("clipboard transfer listener did not start: {error}")))
.and_then(|result| result);
if let Err(error) = ready {
running.store(false, Ordering::SeqCst);
let _ = handle.join();
return Err(error);
}
Ok(handle)
}
fn read_fd_to_end(file: File) -> DriverResult<Vec<u8>> {
let mut file = async_io::Async::new(file).map_err(|error| backend(format!("failed to register portal clipboard fd: {error}")))?;
future::block_on(future::race(
async {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).await.map_err(|error| backend(format!("failed to read portal clipboard fd: {error}")))?;
Ok(bytes)
},
async {
async_io::Timer::after(FD_TRANSFER_TIMEOUT).await;
Err(backend("timed out reading portal clipboard fd"))
},
))
}
fn write_fd_all(file: File, payload: &[u8]) -> DriverResult<()> {
let mut file = async_io::Async::new(file).map_err(|error| backend(format!("failed to register portal clipboard fd: {error}")))?;
future::block_on(future::race(
async { file.write_all(payload).await.map_err(|error| backend(format!("failed to write portal clipboard payload: {error}"))) },
async {
async_io::Timer::after(FD_TRANSFER_TIMEOUT).await;
Err(backend("timed out writing portal clipboard payload"))
},
))
}
#[cfg(test)]
#[path = "clipboard_test.rs"]
mod tests;