fix: avoid unnecessary clipboard reads and stale initial sync (#16293)

* refact: simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(clipboard): don't keep the clipboard cache

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: prevent initial clipboard sync from overwriting newer updates

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: invalidate initial clipboard snapshots on remote and file updates

Signed-off-by: fufesou <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* fix: block file clipboard traffic before login

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: skip Wayland host startup clipboard broadcasts

Signed-off-by: fufesou <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* 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 <linlong1266@gmail.com>

* refact(clipboard): update clipboard-master

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-09-25 11:34:01 +08:00
committed by GitHub
parent 134af545ae
commit bb924d2939
7 changed files with 317 additions and 68 deletions
Generated
+1 -1
View File
@@ -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",
+22 -17
View File
@@ -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<Message>),
SendFiles((i32, JobType, String, String, i32, bool, bool)),
RemoveDirAll((i32, String, bool, bool)),
ConfirmDeleteFiles((i32, i32)),
+169 -28
View File
@@ -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<T: InvokeUiSession> {
remove_jobs: HashMap<i32, RemoveJob>,
timer: crate::RustDeskInterval,
last_update_jobs_status: (Instant, HashMap<i32, u64>),
// 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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
#[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<T: InvokeUiSession> Remote<T> {
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
async fn handle_local_clipboard_msg(
&self,
&mut self,
peer: &mut Stream,
msg: Option<clipboard::ClipboardFile>,
) {
@@ -458,6 +470,10 @@ impl<T: InvokeUiSession> Remote<T> {
} => {
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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
// 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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
#[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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
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<T: InvokeUiSession> Remote<T> {
#[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<T: InvokeUiSession> Remote<T> {
};
#[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<T: InvokeUiSession> Remote<T> {
}
#[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")]
+107 -16
View File
@@ -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<Mutex<()>> = Arc::new(Mutex::new(()));
// cache the clipboard msg
static ref LAST_MULTI_CLIPBOARDS: Arc<Mutex<MultiClipboards>> = 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<Message> {
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<Message> {
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<Message> {
#[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<Mutex<ClipboardListener>> = 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<Mutex<HashMap<String, Sender<CallbackResult>>>>,
subscribers: Arc<Mutex<HashMap<String, Sender<ClipboardEvent>>>>,
#[cfg(target_os = "linux")]
ready: Option<watch::Sender<bool>>,
}
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<Mutex<HashMap<String, Sender<CallbackResult>>>>,
subscribers: Arc<Mutex<HashMap<String, Sender<ClipboardEvent>>>>,
handle: Option<(Shutdown, JoinHandle<()>)>,
#[cfg(target_os = "linux")]
ready: Option<watch::Receiver<bool>>,
}
pub fn subscribe(name: String, tx: Sender<CallbackResult>) -> ResultType<()> {
pub fn subscribe(name: String, tx: Sender<ClipboardEvent>) -> 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()
};
+2
View File
@@ -1417,6 +1417,7 @@ fn try_send_close_event(event_stream: &Option<StreamSink<EventToUI>>) {
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);
}
+7 -6
View File
@@ -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) => {}
+9
View File
@@ -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;