From bb924d2939aa7316fc5612e9d067720484289740 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 25 Sep 2026 11:34:01 +0800 Subject: [PATCH] fix: avoid unnecessary clipboard reads and stale initial sync (#16293) * refact: simple refactor Signed-off-by: fufesou * refact(clipboard): don't keep the clipboard cache Signed-off-by: fufesou * fix: prevent initial clipboard sync from overwriting newer updates Signed-off-by: fufesou * fix: invalidate initial clipboard snapshots on remote and file updates Signed-off-by: fufesou * fix: handle clipboard changes during initial sync Track clipboard changes even when they produce no sync message. Refresh invalidated snapshots to preserve sync after Wayland startup. Prevent repeated PeerInfo responses from restarting initial sync. Signed-off-by: fufesou * fix: skip file clipboard reads when file sync is not required Check is_file_required() before reading clipboard file URLs to avoid unnecessary clipboard access when no session needs file synchronization. Signed-off-by: fufesou * fix: refresh clipboard requirements before listener startup Update Flutter's text and file clipboard requirements before starting the shared clipboard loop so startup notifications use the current settings. Signed-off-by: fufesou * fix: exclude disconnected sessions from clipboard requirements Only count connected Flutter sessions when checking clipboard requirements. Recompute them on disconnect so a remaining session with sync disabled does not keep unnecessary clipboard reads enabled. Signed-off-by: fufesou * fix: filter disconnected sessions from clipboard requirements Exclude disconnected Flutter sessions from text and file clipboard requirement calculations, even when their tabs remain open. Signed-off-by: fufesou * fix: block file clipboard traffic before login Signed-off-by: fufesou * fix: skip Wayland host startup clipboard broadcasts Signed-off-by: fufesou * fix: respect initial clipboard sync setting on Wayland Stop broadcasting startup selections through the normal clipboard sync path. Keep generation invalidation for snapshots captured before the listener becomes active. Signed-off-by: fufesou * fix: wait for clipboard listener readiness before initial sync Wait for Linux clipboard backends to finish subscribing before the initial read. Update clipboard-master to use its readiness callback. Report completion for empty or failed reads so initial sync does not remain pending. Document snapshot invalidation across listener restarts. Signed-off-by: fufesou * fix: wait for Linux clipboard readiness asynchronously Use watch notifications to keep the connection loop responsive during clipboard startup. Cancel readiness waits when the session channel closes. Advance generation on readiness and capture it with each initial read so listener restarts invalidate older snapshots. Signed-off-by: fufesou * fix: preserve Wayland startup file clipboard sync Distinguish startup selections from normal clipboard changes. Process startup files through the existing client clipboard worker, while keeping text initial sync and host startup suppression unchanged. Signed-off-by: fufesou * fix: bound clipboard readiness waits on Linux Add a five-second timeout to the initial-sync readiness wait and log failures. Update clipboard-master to support timeout and cancellation while waiting for the initial Wayland selection. Signed-off-by: fufesou * refact(clipboard): update clipboard-master Signed-off-by: fufesou --------- Signed-off-by: fufesou --- Cargo.lock | 2 +- src/client.rs | 39 ++++--- src/client/io_loop.rs | 197 +++++++++++++++++++++++++++----- src/clipboard.rs | 123 +++++++++++++++++--- src/flutter.rs | 2 + src/server/clipboard_service.rs | 13 ++- src/server/connection.rs | 9 ++ 7 files changed, 317 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcac8e5ad..c469eb81d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1305,7 +1305,7 @@ dependencies = [ [[package]] name = "clipboard-master" version = "4.0.0-beta.6" -source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db" +source = "git+https://github.com/rustdesk-org/clipboard-master#522fafb65edfe7939cf63fa797f916569ecab595" dependencies = [ "objc", "objc-foundation", diff --git a/src/client.rs b/src/client.rs index ef468b60c..d3e66e73c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,9 +1,7 @@ #[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::clipboard::clipboard_listener; +use crate::clipboard::clipboard_listener::{self, ClipboardEvent}; use async_trait::async_trait; use bytes::Bytes; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use clipboard_master::CallbackResult; #[cfg(not(target_os = "linux"))] use cpal::{ traits::{DeviceTrait, HostTrait, StreamTrait}, @@ -1942,14 +1940,19 @@ impl Client { break; } match rx_cb_result.recv_timeout(Duration::from_millis(CLIPBOARD_INTERVAL)) { - Ok(CallbackResult::Next) => { - handler.check_clipboard(); + Ok(ClipboardEvent::Changed) => { + handler.check_clipboard(true); } - Ok(CallbackResult::Stop) => { + #[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] + Ok(ClipboardEvent::InitialSelection) => { + // Preserve file startup sync without bypassing text initial-sync settings. + handler.check_clipboard(false); + } + Ok(ClipboardEvent::Stop) => { log::debug!("Clipboard listener stopped"); break; } - Ok(CallbackResult::StopWithError(err)) => { + Ok(ClipboardEvent::StopWithError(err)) => { log::error!("Clipboard listener stopped with error: {}", err); break; } @@ -2051,16 +2054,16 @@ impl ClientClipboardHandler { } } - fn check_clipboard(&mut self) { + fn check_clipboard(&mut self, check_text: bool) { if CLIPBOARD_STATE.lock().unwrap().running { #[cfg(feature = "unix-file-copy-paste")] - if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Client, false) { - if !urls.is_empty() { - #[cfg(target_os = "macos")] - if crate::clipboard::is_file_url_set_by_rustdesk(&urls) { - return; - } - if self.is_file_required() { + if self.is_file_required() { + if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Client, false) { + if !urls.is_empty() { + #[cfg(target_os = "macos")] + if crate::clipboard::is_file_url_set_by_rustdesk(&urls) { + return; + } match clipboard::platform::unix::serv_files::sync_files(&urls) { Ok(()) => { let msg = crate::clipboard_file::clip_2_msg( @@ -2077,8 +2080,8 @@ impl ClientClipboardHandler { } } - if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) { - if self.is_text_required() { + if check_text && self.is_text_required() { + if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) { self.send_msg(msg, false); } } @@ -4973,6 +4976,8 @@ pub enum Data { RejectInsecureConnection, Login((String, String, String, bool)), Message(Message), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + InitialClipboard(usize, Option), SendFiles((i32, JobType, String, String, i32, bool, bool)), RemoveDirAll((i32, String, bool, bool)), ConfirmDeleteFiles((i32, i32)), diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index b4d0a6c64..4ee6fa1bb 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1,5 +1,5 @@ #[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::clipboard::{update_clipboard, ClipboardSide}; +use crate::clipboard::{clipboard_listener, update_clipboard, ClipboardSide}; #[cfg(not(any(target_os = "ios")))] use crate::{audio_service, clipboard::CLIPBOARD_INTERVAL, ConnInner, CLIENT_SERVER}; use crate::{ @@ -84,7 +84,12 @@ pub struct Remote { remove_jobs: HashMap, timer: crate::RustDeskInterval, last_update_jobs_status: (Instant, HashMap), + // Set after PeerInfo for this round, not when the transport connects. is_connected: bool, + // Whether the scheduled initial snapshot may still be sent. + // The connection loop clears this when handling its result or a live clipboard update. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + initial_clipboard_pending: bool, first_frame: bool, #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: i32, // used for file clipboard @@ -132,6 +137,8 @@ impl Remote { timer: crate::rustdesk_interval(time::interval(SEC30)), last_update_jobs_status: (Instant::now(), Default::default()), is_connected: false, + #[cfg(not(any(target_os = "android", target_os = "ios")))] + initial_clipboard_pending: false, first_frame: false, #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: 0, @@ -432,6 +439,11 @@ impl Remote { #[cfg(not(target_os = "ios"))] if self.handler.is_default() && _set_disconnected_ok { + // Other sessions may keep the listener running after this one disconnects. + #[cfg(feature = "flutter")] + crate::flutter::update_text_clipboard_required(); + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + crate::flutter::update_file_clipboard_required(); Client::try_stop_clipboard(); } @@ -445,7 +457,7 @@ impl Remote { #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] async fn handle_local_clipboard_msg( - &self, + &mut self, peer: &mut Stream, msg: Option, ) { @@ -458,6 +470,10 @@ impl Remote { } => { self.handler.msgbox(&r#type, &title, &text, ""); } + // File-data responses bypass is_stopping_allowed, but still require login. + _ if !self.is_connected => { + log::debug!("Discarding local file clipboard message before login"); + } _ => { let is_stopping_allowed = clip.is_stopping_allowed(); let server_file_transfer_enabled = @@ -466,9 +482,7 @@ impl Remote { self.handler.lc.read().unwrap().enable_file_copy_paste.v; let view_only = self.handler.lc.read().unwrap().view_only.v; let stop = is_stopping_allowed - && (view_only - || !self.is_connected - || !(server_file_transfer_enabled && file_transfer_enabled)); + && (view_only || !(server_file_transfer_enabled && file_transfer_enabled)); log::debug!( "Process clipboard message from system, view_only: {}, stop: {}, is_stopping_allowed: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}", view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled @@ -485,6 +499,10 @@ impl Remote { // to-do: Show msgbox with "Don't show again" option }; log::debug!("Send system clipboard message to remote"); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if matches!(&clip, clipboard::ClipboardFile::FormatList { .. }) { + self.initial_clipboard_pending = false; + } let msg = crate::clipboard_file::clip_2_msg(clip); allow_err!(peer.send(&msg).await); } @@ -625,6 +643,82 @@ impl Remote { self.sent_close_reason = true; } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + fn start_initial_clipboard_sync(&mut self) { + let peer_info = { + let lc = self.handler.lc.read().unwrap(); + lc.peer_info + .as_ref() + .map(|pi| (pi.version.clone(), pi.platform.clone())) + }; + let Some((peer_version, peer_platform)) = peer_info else { + log::error!("Cannot start initial clipboard sync without peer information"); + return; + }; + + self.initial_clipboard_pending = true; + let sender = self.sender.clone(); + let permission_config = self.handler.get_permission_config(); + // Clipboard access and encoding must not block the connection loop. + let read_clipboard = move || { + // Capture after readiness, before reading; later changes invalidate this snapshot. + let generation = clipboard_listener::current_generation(); + let msg_out = if permission_config.is_text_clipboard_required() { + crate::clipboard::get_current_clipboard_msg( + &peer_version, + &peer_platform, + crate::clipboard::ClipboardSide::Client, + ) + } else { + None + }; + let msg_out = msg_out.filter(|_| permission_config.is_text_clipboard_required()); + // Empty or failed reads must also finish the pending initial-sync attempt. + if let Err(err) = sender.send(Data::InitialClipboard(generation, msg_out)) { + log::debug!("Failed to send initial clipboard result: {}", err); + } + }; + #[cfg(target_os = "linux")] + self.spawn_initial_clipboard_read_after_ready(read_clipboard); + #[cfg(not(target_os = "linux"))] + tokio::task::spawn_blocking(read_clipboard); + } + + // Linux listener creation returns before X11/Wayland has subscribed to changes. + // Capture after readiness so changes during the read can invalidate the snapshot. + // A separate task keeps a stalled backend from blocking the connection loop. + #[cfg(target_os = "linux")] + fn spawn_initial_clipboard_read_after_ready(&self, read_clipboard: impl FnOnce() + Send + 'static) { + // Initial-sync wait budget, not a protocol-defined startup deadline. + const CLIPBOARD_READY_TIMEOUT: Duration = Duration::from_secs(5); + + let sender = self.sender.clone(); + tokio::spawn(async move { + let ready = tokio::select! { + result = time::timeout(CLIPBOARD_READY_TIMEOUT, clipboard_listener::wait_for_ready()) => { + match result { + Ok(ready) => ready, + Err(err) => Err(err.into()), + } + } + _ = sender.closed() => return, + }; + if let Err(err) = ready { + log::error!( + "Failed to wait for clipboard listener readiness (limit {:?}): {}", + CLIPBOARD_READY_TIMEOUT, + err, + ); + let generation = clipboard_listener::current_generation(); + if let Err(err) = sender.send(Data::InitialClipboard(generation, None)) { + log::debug!("Failed to send initial clipboard result: {}", err); + } + return; + } + tokio::task::spawn_blocking(read_clipboard); + }); + } + async fn handle_msg_from_ui(&mut self, data: Data, peer: &mut Stream) -> bool { match data { Data::Close => { @@ -640,6 +734,27 @@ impl Remote { Data::ToggleClipboardFile => { self.check_clipboard_file_context(); } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Data::InitialClipboard(generation, msg) => { + // A live update supersedes any initial snapshot still being prepared. + if !self.initial_clipboard_pending { + return true; + } + self.initial_clipboard_pending = false; + if !self.handler.is_text_clipboard_required() { + return true; + } + if generation != clipboard_listener::current_generation() { + // A clipboard change or listener restart can invalidate the snapshot + // without sending a live update that supersedes it. + drop(msg); + self.start_initial_clipboard_sync(); + return true; + } + if let Some(msg) = msg { + allow_err!(peer.send(&msg).await); + } + } Data::Message(msg) => { // The Flutter clipboard broadcast is process-wide, so a clipboard can reach this // round's queue before the round has logged in; it is dropped here, on the round @@ -655,6 +770,20 @@ impl Remote { return true; } match &msg.union { + Some(message::Union::Cliprdr(_)) if !self.is_connected => { + log::debug!("Discarding outgoing file clipboard message before login"); + return true; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Some(message::Union::Clipboard(_)) | Some(message::Union::MultiClipboards(_)) => { + self.initial_clipboard_pending = false; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Some(message::Union::Cliprdr(clip)) + if matches!(clip.union.as_ref(), Some(cliprdr::Union::FormatList(_))) => + { + self.initial_clipboard_pending = false; + } Some(message::Union::Misc(misc)) => match misc.union { Some(misc::Union::RefreshVideo(_)) => { self.video_threads.iter().for_each(|(_, v)| { @@ -1467,6 +1596,14 @@ impl Remote { #[cfg(all(target_os = "windows", not(feature = "flutter")))] self.check_clipboard_file_context(); if self.handler.is_default() { + // Startup notifications must see the current sessions' clipboard requirements. + #[cfg(feature = "flutter")] + #[cfg(not(target_os = "ios"))] + crate::flutter::update_text_clipboard_required(); + + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + crate::flutter::update_file_clipboard_required(); + #[cfg(feature = "flutter")] #[cfg(not(target_os = "ios"))] let rx = Client::try_start_clipboard(None); @@ -1488,31 +1625,18 @@ impl Remote { timeout(CLIPBOARD_INTERVAL, rx.recv()).await.ok(); } + // `is_connected`` becomes true after the first PeerInfo. + // Reconnects create a new Remote. Refreshes after a generation change + // use a separate entry point and are not blocked by this guard. #[cfg(not(any(target_os = "android", target_os = "ios")))] - if self.handler.lc.read().unwrap().sync_init_clipboard.v { - if let Some(msg_out) = crate::clipboard::get_current_clipboard_msg( - &peer_version, - &peer_platform, - crate::clipboard::ClipboardSide::Client, - ) { - let sender = self.sender.clone(); - let permission_config = self.handler.get_permission_config(); - tokio::spawn(async move { - if permission_config.is_text_clipboard_required() { - sender.send(Data::Message(msg_out)).ok(); - } - }); - } + if !self.is_connected + && self.handler.is_text_clipboard_required() + && self.handler.lc.read().unwrap().sync_init_clipboard.v + { + self.start_initial_clipboard_sync(); } // to-do: Android, is `sync_init_clipboard` really needed? // https://github.com/rustdesk/rustdesk/discussions/9010 - - #[cfg(feature = "flutter")] - #[cfg(not(target_os = "ios"))] - crate::flutter::update_text_clipboard_required(); - - #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] - crate::flutter::update_file_clipboard_required(); } if self.handler.is_file_transfer() { @@ -1555,7 +1679,10 @@ impl Remote { crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); } #[cfg(not(any(target_os = "android", target_os = "ios")))] - update_clipboard(vec![cb], ClipboardSide::Client); + { + self.initial_clipboard_pending = false; + update_clipboard(vec![cb], ClipboardSide::Client); + } #[cfg(target_os = "ios")] { let content = if cb.compress { @@ -1590,7 +1717,10 @@ impl Remote { crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); } #[cfg(not(any(target_os = "android", target_os = "ios")))] - update_clipboard(_mcb.clipboards, ClipboardSide::Client); + { + self.initial_clipboard_pending = false; + update_clipboard(_mcb.clipboards, ClipboardSide::Client); + } #[cfg(target_os = "ios")] { if let Some(cb) = _mcb @@ -2417,6 +2547,10 @@ impl Remote { #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] async fn handle_cliprdr_msg(&mut self, clip: base::message_proto::Cliprdr, _peer: &mut Stream) { + if !self.is_connected { + log::debug!("Discarding incoming file clipboard message before login"); + return; + } log::debug!("handling cliprdr msg from server peer"); #[cfg(feature = "flutter")] if let Some(base::message_proto::cliprdr::Union::FormatList(_)) = &clip.union { @@ -2448,6 +2582,9 @@ impl Remote { }; #[cfg(target_os = "windows")] { + if matches!(&clip, clipboard::ClipboardFile::FormatList { .. }) { + self.initial_clipboard_pending = false; + } let _ = ContextSend::proc(|context| -> ResultType<()> { context .server_clip_file(self.client_conn_id, clip) @@ -2456,6 +2593,10 @@ impl Remote { } #[cfg(feature = "unix-file-copy-paste")] if crate::is_support_file_copy_paste_num(self.handler.lc.read().unwrap().version) { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if matches!(&clip, clipboard::ClipboardFile::FormatList { .. }) { + self.initial_clipboard_pending = false; + } let mut out_msgs = vec![]; #[cfg(target_os = "macos")] diff --git a/src/clipboard.rs b/src/clipboard.rs index d6d4c4994..9a1bd6058 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -34,8 +34,6 @@ const CLIPBOARD_FORMAT_EXCEL_XML_SPREADSHEET: &'static str = "XML Spreadsheet"; #[cfg(not(target_os = "android"))] lazy_static::lazy_static! { static ref ARBOARD_MTX: Arc> = Arc::new(Mutex::new(())); - // cache the clipboard msg - static ref LAST_MULTI_CLIPBOARDS: Arc> = Arc::new(Mutex::new(MultiClipboards::new())); // For updating in server and getting content in cm. // Clipboard on Linux is "server--clients" mode. // The clipboard content is owned by the server and passed to the clients when requested. @@ -79,8 +77,7 @@ pub fn check_clipboard( side: ClipboardSide, force: bool, ) -> Option { - let (msg, clipboards) = read_clipboard_message(ctx, side, force)?; - *LAST_MULTI_CLIPBOARDS.lock().unwrap() = clipboards; + let (msg, _) = read_clipboard_message(ctx, side, force)?; Some(msg) } @@ -564,18 +561,25 @@ pub fn get_current_clipboard_msg( peer_platform: &str, side: ClipboardSide, ) -> Option { - let mut multi_clipboards = LAST_MULTI_CLIPBOARDS.lock().unwrap(); - if multi_clipboards.clipboards.is_empty() { - let mut ctx = ClipboardContext::new().ok()?; - *multi_clipboards = proto::create_multi_clipboards(ctx.get(side, true).ok()?); + // Clipboard changes can be skipped while synchronization is disabled. + #[cfg(target_os = "linux")] + if !clipboard_listener::is_ready() { + log::error!("Cannot read initial clipboard before the listener is ready"); + return None; } + let ctx = match ClipboardContext::new() { + Ok(ctx) => ctx, + Err(err) => { + log::error!("Failed to create clipboard context for initial sync: {}", err); + return None; + } + }; + let (msg, multi_clipboards) = read_clipboard_message(&mut Some(ctx), side, true)?; if multi_clipboards.clipboards.is_empty() { return None; } if is_support_multi_clipboard(peer_version, peer_platform) { - let mut msg = Message::new(); - msg.set_multi_clipboards(multi_clipboards.clone()); Some(msg) } else { // Find the first text clipboard and send it. @@ -888,10 +892,13 @@ pub fn get_clipboards_msg(client: bool) -> Option { #[cfg(not(target_os = "android"))] pub mod clipboard_listener { use clipboard_master::{CallbackResult, ClipboardHandler, Master, Shutdown}; + #[cfg(target_os = "linux")] + use hbb_common::tokio::sync::watch; use hbb_common::{bail, log, ResultType}; use std::{ collections::HashMap, io, + sync::atomic::{AtomicUsize, Ordering}, sync::mpsc::{channel, Sender}, sync::{Arc, Mutex}, thread::JoinHandle, @@ -901,15 +908,83 @@ pub mod clipboard_listener { pub static ref CLIPBOARD_LISTENER: Arc> = Default::default(); } + static CLIPBOARD_GENERATION: AtomicUsize = AtomicUsize::new(0); + + pub enum ClipboardEvent { + Changed, + #[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] + InitialSelection, + Stop, + StopWithError(io::Error), + } + + pub fn current_generation() -> usize { + CLIPBOARD_GENERATION.load(Ordering::SeqCst) + } + + #[cfg(target_os = "linux")] + pub fn is_ready() -> bool { + let listener = CLIPBOARD_LISTENER.lock().unwrap(); + let ready = listener + .ready + .as_ref() + .map(|ready| *ready.borrow()) + .unwrap_or(false); + ready + && listener + .handle + .as_ref() + .map(|(_, h)| !h.is_finished()) + .unwrap_or(false) + } + + #[cfg(target_os = "linux")] + pub async fn wait_for_ready() -> ResultType<()> { + let Some(mut ready) = CLIPBOARD_LISTENER.lock().unwrap().ready.clone() else { + bail!("Clipboard listener has not started"); + }; + let is_ready = *ready.borrow_and_update(); + if !is_ready { + ready.changed().await?; + } + Ok(()) + } + struct Handler { - subscribers: Arc>>>, + subscribers: Arc>>>, + #[cfg(target_os = "linux")] + ready: Option>, } impl ClipboardHandler for Handler { + #[cfg(target_os = "linux")] + fn on_clipboard_ready(&mut self) { + // A restart must invalidate older snapshots, including on X11 or an empty selection. + CLIPBOARD_GENERATION.fetch_add(1, Ordering::SeqCst); + if let Some(tx) = self.ready.take() { + if let Err(err) = tx.send(true) { + log::debug!("Failed to report clipboard listener readiness: {}", err); + } + } + } + fn on_clipboard_change(&mut self) -> CallbackResult { + // Empty or unsupported contents still invalidate an initial snapshot. + CLIPBOARD_GENERATION.fetch_add(1, Ordering::SeqCst); let sub_lock = self.subscribers.lock().unwrap(); for tx in sub_lock.values() { - tx.send(CallbackResult::Next).ok(); + tx.send(ClipboardEvent::Changed).ok(); + } + CallbackResult::Next + } + + fn on_clipboard_initial_selection(&mut self) -> CallbackResult { + // Only client file sync consumes this event; text initial sync is per session. + #[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] + for tx in self.subscribers.lock().unwrap().values() { + if let Err(err) = tx.send(ClipboardEvent::InitialSelection) { + log::debug!("Failed to notify initial clipboard selection: {}", err); + } } CallbackResult::Next } @@ -918,23 +993,30 @@ pub mod clipboard_listener { let msg = format!("Clipboard listener error: {}", error); let sub_lock = self.subscribers.lock().unwrap(); for tx in sub_lock.values() { - tx.send(CallbackResult::StopWithError(io::Error::new( + tx.send(ClipboardEvent::StopWithError(io::Error::new( io::ErrorKind::Other, msg.clone(), ))) .ok(); } + #[cfg(target_os = "linux")] + if self.ready.is_some() { + // Subscribers must receive the error before a failed startup stops. + return CallbackResult::StopWithError(error); + } CallbackResult::Next } } #[derive(Default)] pub struct ClipboardListener { - subscribers: Arc>>>, + subscribers: Arc>>>, handle: Option<(Shutdown, JoinHandle<()>)>, + #[cfg(target_os = "linux")] + ready: Option>, } - pub fn subscribe(name: String, tx: Sender) -> ResultType<()> { + pub fn subscribe(name: String, tx: Sender) -> ResultType<()> { log::info!("Subscribe clipboard listener: {}", &name); let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap(); listener_lock @@ -946,8 +1028,12 @@ pub mod clipboard_listener { cleanup_stale_listener(&mut listener_lock); if listener_lock.handle.is_none() { log::info!("Start clipboard listener thread"); + #[cfg(target_os = "linux")] + let (tx_ready, rx_ready) = watch::channel(false); let handler = Handler { subscribers: listener_lock.subscribers.clone(), + #[cfg(target_os = "linux")] + ready: Some(tx_ready), }; let (tx_start_res, rx_start_res) = channel(); let h = start_clipboard_master_thread(handler, tx_start_res); @@ -962,6 +1048,11 @@ pub mod clipboard_listener { } }; listener_lock.handle = Some((shutdown, h)); + #[cfg(target_os = "linux")] + { + // Initial-sync tasks await backend readiness without blocking the connection loop. + listener_lock.ready = Some(rx_ready); + } log::info!("Clipboard listener thread started"); } @@ -993,7 +1084,7 @@ pub mod clipboard_listener { let is_empty = { let mut sub_lock = listener_lock.subscribers.lock().unwrap(); if let Some(tx) = sub_lock.remove(name) { - tx.send(CallbackResult::Stop).ok(); + tx.send(ClipboardEvent::Stop).ok(); } sub_lock.is_empty() }; diff --git a/src/flutter.rs b/src/flutter.rs index cd1f1567d..345ae3929 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1417,6 +1417,7 @@ fn try_send_close_event(event_stream: &Option>) { pub fn update_text_clipboard_required() { let is_required = sessions::get_sessions() .iter() + .filter(|s| s.connection_round_state.lock().unwrap().is_connected()) .any(|s| s.is_default() && s.is_text_clipboard_required()); #[cfg(target_os = "android")] let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required); @@ -1427,6 +1428,7 @@ pub fn update_text_clipboard_required() { pub fn update_file_clipboard_required() { let is_required = sessions::get_sessions() .iter() + .filter(|s| s.connection_round_state.lock().unwrap().is_connected()) .any(|s| s.is_default() && s.is_file_clipboard_required()); Client::set_is_file_clipboard_required(is_required); } diff --git a/src/server/clipboard_service.rs b/src/server/clipboard_service.rs index 8fd757c22..54ae9e640 100644 --- a/src/server/clipboard_service.rs +++ b/src/server/clipboard_service.rs @@ -1,6 +1,6 @@ use super::*; #[cfg(not(target_os = "android"))] -use crate::clipboard::clipboard_listener; +use crate::clipboard::clipboard_listener::{self, ClipboardEvent}; #[cfg(not(target_os = "android"))] pub use crate::clipboard::{ClipboardContext, ClipboardSide}; pub use crate::clipboard::{CLIPBOARD_INTERVAL as INTERVAL, CLIPBOARD_NAME as NAME}; @@ -15,8 +15,6 @@ pub use crate::{ use base::config::keys; #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] use clipboard::platform::unix::fuse::{init_fuse_context, uninit_fuse_context}; -#[cfg(not(target_os = "android"))] -use clipboard_master::CallbackResult; #[cfg(target_os = "android")] use hbb_common::config::option2bool; #[cfg(target_os = "android")] @@ -81,7 +79,7 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { while sp.ok() { match rx_cb_result.recv_timeout(Duration::from_millis(INTERVAL)) { - Ok(CallbackResult::Next) => { + Ok(ClipboardEvent::Changed) => { #[cfg(feature = "unix-file-copy-paste")] if sp.name() == FILE_NAME { handler.check_clipboard_file(); @@ -91,11 +89,14 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { sp.send(msg); } } - Ok(CallbackResult::Stop) => { + // Host startup state must not supersede a client's explicit initial snapshot. + #[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] + Ok(ClipboardEvent::InitialSelection) => {} + Ok(ClipboardEvent::Stop) => { log::debug!("Clipboard listener stopped"); break; } - Ok(CallbackResult::StopWithError(err)) => { + Ok(ClipboardEvent::StopWithError(err)) => { bail!("Clipboard listener stopped with error: {}", err); } Err(RecvTimeoutError::Timeout) => {} diff --git a/src/server/connection.rs b/src/server/connection.rs index ed4a1112c..03a7665a3 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -914,6 +914,11 @@ impl Connection { if !conn.is_remote() { continue; } + // The CM can send MonitorReady before this connection is authorized. + if !conn.authorized { + log::debug!("Discarding file clipboard message before authorization"); + continue; + } match clip { clipboard::ClipboardFile::Files { files } => { let files = files.into_iter().map(|(f, s)| { @@ -6114,6 +6119,10 @@ impl Connection { #[cfg(feature = "unix-file-copy-paste")] async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) { + if !self.authorized { + log::debug!("Discarding file clipboard message before authorization"); + return; + } let is_stopping_allowed = clip.is_stopping_allowed(); let file_transfer_enabled = self.file_transfer_enabled(); let stop = is_stopping_allowed && !file_transfer_enabled;