From 00293a9902d80f8159fecf46556cfd3db7fc87af Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Fri, 28 Feb 2025 00:46:46 +0800 Subject: [PATCH] Feat/macos clipboard file (#10939) * feat: macos, clipboard file Signed-off-by: fufesou * Can't reuse file transfer Signed-off-by: fufesou * handle paste task Signed-off-by: fufesou --------- Signed-off-by: fufesou --- Cargo.lock | 37 + libs/clipboard/Cargo.toml | 8 + libs/clipboard/src/context_send.rs | 23 +- libs/clipboard/src/lib.rs | 36 +- libs/clipboard/src/platform/mod.rs | 10 + libs/clipboard/src/platform/unix/filetype.rs | 10 +- .../src/platform/unix/macos/README.md | 25 + .../platform/unix/macos/item_data_provider.rs | 77 +++ libs/clipboard/src/platform/unix/macos/mod.rs | 14 + .../platform/unix/macos/paste-files-macos.png | Bin 0 -> 39355 bytes .../src/platform/unix/macos/paste_observer.rs | 179 +++++ .../src/platform/unix/macos/paste_task.rs | 639 ++++++++++++++++++ .../platform/unix/macos/pasteboard_context.rs | 443 ++++++++++++ libs/clipboard/src/platform/unix/mod.rs | 4 + libs/clipboard/src/platform/windows.rs | 17 +- src/client.rs | 4 + src/client/io_loop.rs | 50 +- src/clipboard.rs | 86 ++- src/common.rs | 4 + src/server/clipboard_service.rs | 4 + src/server/connection.rs | 45 +- 21 files changed, 1654 insertions(+), 61 deletions(-) create mode 100644 libs/clipboard/src/platform/unix/macos/README.md create mode 100644 libs/clipboard/src/platform/unix/macos/item_data_provider.rs create mode 100644 libs/clipboard/src/platform/unix/macos/mod.rs create mode 100644 libs/clipboard/src/platform/unix/macos/paste-files-macos.png create mode 100644 libs/clipboard/src/platform/unix/macos/paste_observer.rs create mode 100644 libs/clipboard/src/platform/unix/macos/paste_task.rs create mode 100644 libs/clipboard/src/platform/unix/macos/pasteboard_context.rs diff --git a/Cargo.lock b/Cargo.lock index e67a1b587..2652296ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -978,10 +978,15 @@ dependencies = [ "cacao", "cc", "dashmap", + "dirs 5.0.1", + "fsevent", "fuser", "hbb_common", "lazy_static", "libc", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", "once_cell", "parking_lot", "percent-encoding", @@ -990,8 +995,10 @@ dependencies = [ "serde_derive", "thiserror", "utf16string", + "uuid", "x11-clipboard 0.8.1", "x11rb 0.12.0", + "xattr", ] [[package]] @@ -2218,6 +2225,25 @@ dependencies = [ "time 0.1.45", ] +[[package]] +name = "fsevent" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8836d1f147a0a195bf517a5fd211ea7023d19ced903135faf6c4504f2cf8775f" +dependencies = [ + "bitflags 1.3.2", + "fsevent-sys", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -7999,6 +8025,17 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[package]] +name = "xattr" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" +dependencies = [ + "libc", + "linux-raw-sys 0.4.14", + "rustix 0.38.34", +] + [[package]] name = "xdg-home" version = "1.2.0" diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index 9db2e5a99..afe2f2f31 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -47,3 +47,11 @@ fuser = {version = "0.15", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} +# Use `relax-void-encoding`, as that allows us to pass `c_void` instead of implementing `Encode` correctly for `&CGImageRef` +objc2 = { version = "0.5.1", features = ["relax-void-encoding"] } +objc2-foundation = { version = "0.2.0", features = ["NSArray", "NSString", "NSEnumerator", "NSGeometry", "NSProgress"] } +objc2-app-kit = { version = "0.2.0", features = ["NSPasteboard", "NSPasteboardItem", "NSImage", "NSFilePromiseProvider"] } +uuid = { version = "1.3", features = ["v4"] } +fsevent = "2.1.2" +dirs = "5.0" +xattr = "1.4.0" diff --git a/libs/clipboard/src/context_send.rs b/libs/clipboard/src/context_send.rs index f3606509f..caa9d4a48 100644 --- a/libs/clipboard/src/context_send.rs +++ b/libs/clipboard/src/context_send.rs @@ -1,22 +1,29 @@ use hbb_common::{log, ResultType}; -use std::sync::Mutex; +use std::{ops::Deref, sync::Mutex}; use crate::CliprdrServiceContext; const CLIPBOARD_RESPONSE_WAIT_TIMEOUT_SECS: u32 = 30; lazy_static::lazy_static! { - static ref CONTEXT_SEND: ContextSend = ContextSend{addr: Mutex::new(None)}; + static ref CONTEXT_SEND: ContextSend = ContextSend::default(); } -pub struct ContextSend { - addr: Mutex>>, +#[derive(Default)] +pub struct ContextSend(Mutex>>); + +impl Deref for ContextSend { + type Target = Mutex>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } } impl ContextSend { #[inline] pub fn is_enabled() -> bool { - CONTEXT_SEND.addr.lock().unwrap().is_some() + CONTEXT_SEND.lock().unwrap().is_some() } pub fn set_is_stopped() { @@ -24,7 +31,7 @@ impl ContextSend { } pub fn enable(enabled: bool) { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); if enabled { if lock.is_some() { return; @@ -49,7 +56,7 @@ impl ContextSend { /// make sure the clipboard context is enabled. pub fn make_sure_enabled() -> ResultType<()> { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); if lock.is_some() { return Ok(()); } @@ -63,7 +70,7 @@ impl ContextSend { pub fn proc) -> ResultType<()>>( f: F, ) -> ResultType<()> { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); match lock.as_mut() { Some(context) => f(context), None => Ok(()), diff --git a/libs/clipboard/src/lib.rs b/libs/clipboard/src/lib.rs index 57e6ce617..f28fe083d 100644 --- a/libs/clipboard/src/lib.rs +++ b/libs/clipboard/src/lib.rs @@ -1,6 +1,9 @@ use std::sync::{Arc, Mutex, RwLock}; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] use hbb_common::ResultType; #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] use hbb_common::{allow_err, log}; @@ -14,10 +17,16 @@ use hbb_common::{ use serde_derive::{Deserialize, Serialize}; use thiserror::Error; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub mod context_send; pub mod platform; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub use context_send::*; #[cfg(target_os = "windows")] @@ -27,9 +36,18 @@ const ERR_CODE_INVALID_PARAMETER: u32 = 0x00000002; #[cfg(target_os = "windows")] const ERR_CODE_SEND_MSG: u32 = 0x00000003; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub(crate) use platform::create_cliprdr_context; +pub struct ProgressPercent { + pub percent: f64, + pub is_canceled: bool, + pub is_failed: bool, +} + // to-do: This trait may be removed, because unix file copy paste does not need it. /// Ability to handle Clipboard File from remote rustdesk client /// @@ -44,6 +62,10 @@ pub trait CliprdrServiceContext: Send + Sync { fn empty_clipboard(&mut self, conn_id: i32) -> Result; /// run as a server for clipboard RPC fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>; + /// get the progress of the paste task. + fn get_progress_percent(&self) -> Option; + /// cancel the paste task. + fn cancel(&mut self); } #[derive(Error, Debug)] @@ -62,11 +84,11 @@ pub enum CliprdrError { ConversionFailure, #[error("failure to read clipboard")] OpenClipboard, - #[error("failure to read file metadata or content")] + #[error("failure to read file metadata or content, path: {path}, err: {err}")] FileError { path: String, err: std::io::Error }, - #[error("invalid request")] + #[error("invalid request: {description}")] InvalidRequest { description: String }, - #[error("common request")] + #[error("common request: {description}")] CommonError { description: String }, #[error("unknown cliprdr error")] Unknown(u32), diff --git a/libs/clipboard/src/platform/mod.rs b/libs/clipboard/src/platform/mod.rs index 5bf1279cb..f7d28f322 100644 --- a/libs/clipboard/src/platform/mod.rs +++ b/libs/clipboard/src/platform/mod.rs @@ -14,3 +14,13 @@ pub fn create_cliprdr_context( #[cfg(feature = "unix-file-copy-paste")] pub mod unix; + +#[cfg(target_os = "macos")] +pub fn create_cliprdr_context( + _enable_files: bool, + _enable_others: bool, + _response_wait_timeout_secs: u32, +) -> crate::ResultType> { + let boxed = unix::macos::pasteboard_context::create_pasteboard_context()? as Box<_>; + Ok(boxed) +} diff --git a/libs/clipboard/src/platform/unix/filetype.rs b/libs/clipboard/src/platform/unix/filetype.rs index 6387a3ece..8436ba05e 100644 --- a/libs/clipboard/src/platform/unix/filetype.rs +++ b/libs/clipboard/src/platform/unix/filetype.rs @@ -4,15 +4,17 @@ use hbb_common::{ bytes::{Buf, Bytes}, log, }; +use serde_derive::{Deserialize, Serialize}; use std::{ path::PathBuf, time::{Duration, SystemTime}, }; use utf16string::WStr; +#[cfg(target_os = "linux")] pub type Inode = u64; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum FileType { File, Directory, @@ -28,10 +30,11 @@ pub const PERM_RW: u16 = 0o644; pub const PERM_SELF_RO: u16 = 0o400; /// rwx pub const PERM_RWX: u16 = 0o755; +#[allow(dead_code)] /// max length of file name pub const MAX_NAME_LEN: usize = 255; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FileDescription { pub conn_id: i32, pub name: PathBuf, @@ -40,9 +43,7 @@ pub struct FileDescription { pub last_modified: SystemTime, pub last_metadata_changed: SystemTime, pub creation_time: SystemTime, - pub size: u64, - pub perm: u16, } @@ -144,7 +145,6 @@ impl FileDescription { atime: last_modified, last_modified, last_metadata_changed: last_modified, - creation_time: last_modified, size, perm, diff --git a/libs/clipboard/src/platform/unix/macos/README.md b/libs/clipboard/src/platform/unix/macos/README.md new file mode 100644 index 000000000..5c1cc5c90 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/README.md @@ -0,0 +1,25 @@ +# File pate on macOS + +MacOS cannot use `fuse` because of [macfuse is not supported by default](https://github.com/macfuse/macfuse/wiki/Getting-Started#enabling-support-for-third-party-kernel-extensions-apple-silicon-macs). + +1. Use a temporary file `/tmp/rustdesk_` as a placeholder in the pasteboard. +2. Uses `fsevent` to observe files paste operation. Then perform pasting files. + +## Files + +### `pasteboard_context.rs` + +The context manager of the paste operations. + +### `item_data_provider.rs` + +1. Set pasteboard item. +2. Create temp file in `/tmp/.rustdesk_*`. + +### `paste_observer.rs` + +Use `fsevent` to observe the paste operation with the source file `/tmp/.rustdesk_*`. + +### `paste_task.rs` + +Perform the paste. diff --git a/libs/clipboard/src/platform/unix/macos/item_data_provider.rs b/libs/clipboard/src/platform/unix/macos/item_data_provider.rs new file mode 100644 index 000000000..b12e47d80 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/item_data_provider.rs @@ -0,0 +1,77 @@ +use super::pasteboard_context::{PasteObserverInfo, TEMP_FILE_PREFIX}; +use objc2::{ + declare_class, msg_send_id, mutability, + rc::Id, + runtime::{NSObject, NSObjectProtocol}, + ClassType, DeclaredClass, +}; +use objc2_app_kit::{ + NSPasteboard, NSPasteboardItem, NSPasteboardItemDataProvider, NSPasteboardType, + NSPasteboardTypeFileURL, +}; +use objc2_foundation::NSString; +use std::{io::Result, sync::mpsc::Sender}; + +pub(super) struct Ivars { + task_info: PasteObserverInfo, + tx: Sender>, +} + +declare_class!( + pub(super) struct PasteboardFileUrlProvider; + + unsafe impl ClassType for PasteboardFileUrlProvider { + type Super = NSObject; + type Mutability = mutability::InteriorMutable; + const NAME: &'static str = "PasteboardFileUrlProvider"; + } + + impl DeclaredClass for PasteboardFileUrlProvider { + type Ivars = Ivars; + } + + unsafe impl NSObjectProtocol for PasteboardFileUrlProvider {} + + unsafe impl NSPasteboardItemDataProvider for PasteboardFileUrlProvider { + #[method(pasteboard:item:provideDataForType:)] + #[allow(non_snake_case)] + unsafe fn pasteboard_item_provideDataForType( + &self, + _pasteboard: Option<&NSPasteboard>, + item: &NSPasteboardItem, + r#type: &NSPasteboardType, + ) { + if r#type == NSPasteboardTypeFileURL { + let path = format!("/tmp/{}{}", TEMP_FILE_PREFIX, uuid::Uuid::new_v4().to_string()); + match std::fs::File::create(&path) { + Ok(_) => { + let url = format!("file:///{}", &path); + item.setString_forType(&NSString::from_str(&url), &NSPasteboardTypeFileURL); + let mut task_info = self.ivars().task_info.clone(); + task_info.source_path = path; + self.ivars().tx.send(Ok(task_info)).ok(); + } + Err(e) => { + self.ivars().tx.send(Err(e)).ok(); + } + } + } + } + + // #[method(pasteboardFinishedWithDataProvider:)] + // unsafe fn pasteboardFinishedWithDataProvider(&self, pasteboard: &NSPasteboard) { + // } + } + + unsafe impl PasteboardFileUrlProvider {} +); + +pub(super) fn create_pasteboard_file_url_provider( + task_info: PasteObserverInfo, + tx: Sender>, +) -> Id { + let provider = PasteboardFileUrlProvider::alloc(); + let provider = provider.set_ivars(Ivars { task_info, tx }); + let provider: Id = unsafe { msg_send_id![super(provider), init] }; + provider +} diff --git a/libs/clipboard/src/platform/unix/macos/mod.rs b/libs/clipboard/src/platform/unix/macos/mod.rs new file mode 100644 index 000000000..8b114aa17 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/mod.rs @@ -0,0 +1,14 @@ +mod item_data_provider; +mod paste_observer; +mod paste_task; +pub mod pasteboard_context; + +pub fn should_handle_msg(msg: &crate::ClipboardFile) -> bool { + matches!( + msg, + crate::ClipboardFile::FormatList { .. } + | crate::ClipboardFile::FormatDataResponse { .. } + | crate::ClipboardFile::FileContentsResponse { .. } + | crate::ClipboardFile::TryEmpty + ) +} diff --git a/libs/clipboard/src/platform/unix/macos/paste-files-macos.png b/libs/clipboard/src/platform/unix/macos/paste-files-macos.png new file mode 100644 index 0000000000000000000000000000000000000000..73e4e3f0b696326515b4ad7acabde96e9f66600c GIT binary patch literal 39355 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 WG^SpOPV+eOij1Vf)y&KK_x~Stl|~)_ literal 0 HcmV?d00001 diff --git a/libs/clipboard/src/platform/unix/macos/paste_observer.rs b/libs/clipboard/src/platform/unix/macos/paste_observer.rs new file mode 100644 index 000000000..01e8b6c10 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/paste_observer.rs @@ -0,0 +1,179 @@ +use super::pasteboard_context::PasteObserverInfo; +use fsevent::{self, StreamFlags}; +use hbb_common::{bail, log, ResultType}; +use std::{ + sync::{ + mpsc::{channel, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +enum FseventControl { + Start, + Stop, + Exit, +} + +struct FseventThreadInfo { + tx: Sender, + handle: thread::JoinHandle<()>, +} + +pub struct PasteObserver { + exit: Arc>, + observer_info: Arc>>, + tx_handle_fsevent_thread: Option, + handle_observer_thread: Option>, +} + +impl Drop for PasteObserver { + fn drop(&mut self) { + *self.exit.lock().unwrap() = true; + if let Some(handle_observer_thread) = self.handle_observer_thread.take() { + handle_observer_thread.join().ok(); + } + if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.take() { + tx_handle_fsevent_thread.tx.send(FseventControl::Exit).ok(); + tx_handle_fsevent_thread.handle.join().ok(); + } + } +} + +impl PasteObserver { + const OBSERVE_TIMEOUT: Duration = Duration::from_secs(30); + + pub fn new() -> Self { + Self { + exit: Arc::new(Mutex::new(false)), + observer_info: Default::default(), + tx_handle_fsevent_thread: None, + handle_observer_thread: None, + } + } + + pub fn init(&mut self, cb_pasted: fn(&PasteObserverInfo) -> ()) -> ResultType<()> { + let Some(home_dir) = dirs::home_dir() else { + bail!("No home dir is set, do not observe."); + }; + + let (tx_observer, rx_observer) = channel::(); + let handle_observer = Self::init_thread_observer( + self.exit.clone(), + self.observer_info.clone(), + rx_observer, + cb_pasted, + ); + self.handle_observer_thread = Some(handle_observer); + let (tx_control, rx_control) = channel::(); + let handle_fsevent = Self::init_thread_fsevent( + home_dir.to_string_lossy().to_string(), + tx_observer, + rx_control, + ); + self.tx_handle_fsevent_thread = Some(FseventThreadInfo { + tx: tx_control, + handle: handle_fsevent, + }); + Ok(()) + } + + #[inline] + fn get_file_from_path(path: &String) -> String { + let last_slash = path.rfind('/').or_else(|| path.rfind('\\')); + match last_slash { + Some(index) => path[index + 1..].to_string(), + None => path.clone(), + } + } + + fn init_thread_observer( + exit: Arc>, + observer_info: Arc>>, + rx_observer: Receiver, + cb_pasted: fn(&PasteObserverInfo) -> (), + ) -> thread::JoinHandle<()> { + thread::spawn(move || loop { + match rx_observer.recv_timeout(Duration::from_millis(300)) { + Ok(event) => { + if (event.flag & StreamFlags::ITEM_CREATED) != StreamFlags::NONE + && (event.flag & StreamFlags::ITEM_REMOVED) == StreamFlags::NONE + && (event.flag & StreamFlags::IS_FILE) != StreamFlags::NONE + { + let source_file = observer_info + .lock() + .unwrap() + .as_ref() + .map(|x| Self::get_file_from_path(&x.source_path)); + if let Some(source_file) = source_file { + let file = Self::get_file_from_path(&event.path); + if source_file == file { + if let Some(observer_info) = observer_info.lock().unwrap().as_mut() + { + observer_info.target_path = event.path.clone(); + cb_pasted(observer_info); + } + } + } + } + } + Err(_) => { + if *(exit.lock().unwrap()) { + break; + } + } + } + }) + } + + fn new_fsevent(home_dir: String, tx_observer: Sender) -> fsevent::FsEvent { + let mut evt = fsevent::FsEvent::new(vec![home_dir.to_string()]); + evt.observe_async(tx_observer).ok(); + evt + } + + fn init_thread_fsevent( + home_dir: String, + tx_observer: Sender, + rx_control: Receiver, + ) -> thread::JoinHandle<()> { + log::debug!("fsevent observe dir: {}", &home_dir); + thread::spawn(move || { + let mut fsevent = None; + loop { + match rx_control.recv_timeout(Self::OBSERVE_TIMEOUT) { + Ok(FseventControl::Start) => { + if fsevent.is_none() { + fsevent = + Some(Self::new_fsevent(home_dir.clone(), tx_observer.clone())); + } + } + Ok(FseventControl::Stop) | Err(RecvTimeoutError::Timeout) => { + let _ = fsevent.as_mut().map(|e| e.shutdown_observe()); + fsevent = None; + } + Ok(FseventControl::Exit) | Err(RecvTimeoutError::Disconnected) => { + break; + } + } + } + log::info!("fsevent thread exit"); + let _ = fsevent.as_mut().map(|e| e.shutdown_observe()); + }) + } + + pub fn start(&mut self, observer_info: PasteObserverInfo) { + if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.as_ref() { + self.observer_info.lock().unwrap().replace(observer_info); + tx_handle_fsevent_thread.tx.send(FseventControl::Start).ok(); + } + } + + pub fn stop(&mut self) { + if let Some(tx_handle_fsevent_thread) = &self.tx_handle_fsevent_thread { + self.observer_info = Default::default(); + tx_handle_fsevent_thread.tx.send(FseventControl::Stop).ok(); + } + } +} diff --git a/libs/clipboard/src/platform/unix/macos/paste_task.rs b/libs/clipboard/src/platform/unix/macos/paste_task.rs new file mode 100644 index 000000000..33a11ed6c --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/paste_task.rs @@ -0,0 +1,639 @@ +use crate::{ + platform::unix::{FileDescription, FileType, BLOCK_SIZE}, + send_data, ClipboardFile, CliprdrError, ProgressPercent, +}; +use hbb_common::{allow_err, log, tokio::time::Instant}; +use std::{ + cmp::min, + fs::{File, FileTimes}, + io::{BufWriter, Write}, + os::macos::fs::FileTimesExt, + path::{Path, PathBuf}, + sync::{ + mpsc::{Receiver, RecvTimeoutError}, + Arc, Mutex, + }, + thread, + time::{Duration, SystemTime}, +}; + +const RECV_RETRY_TIMES: usize = 3; + +const DOWNLOAD_EXTENSION: &str = "rddownload"; +const RECEIVE_WAIT_TIMEOUT: Duration = Duration::from_millis(5_000); + +// https://stackoverflow.com/a/15112784/1926020 +// "1984-01-24 08:00:00 +0000" +const TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED: u64 = 443779200; +const ATTR_PROGRESS_FRACTION_COMPLETED: &str = "com.apple.progress.fractionCompleted"; + +pub struct FileContentsResponse { + pub conn_id: i32, + pub msg_flags: i32, + pub stream_id: i32, + pub requested_data: Vec, +} + +#[derive(Debug)] +struct PasteTaskProgress { + // Use list index to identify the file + // `list_index` is also used as the stream id + list_index: i32, + offset: u64, + total_size: u64, + current_size: u64, + last_sent_time: Instant, + download_file_index: i32, + download_file_size: u64, + download_file_path: String, + download_file_current_size: u64, + file_handle: Option>, + error: Option, + is_canceled: bool, +} + +struct PasteTaskHandle { + progress: PasteTaskProgress, + target_dir: PathBuf, + files: Vec, +} + +pub struct PasteTask { + exit: Arc>, + handle: Arc>>, + handle_worker: Option>, +} + +impl Drop for PasteTask { + fn drop(&mut self) { + *self.exit.lock().unwrap() = true; + if let Some(handle_worker) = self.handle_worker.take() { + handle_worker.join().ok(); + } + } +} + +impl PasteTask { + const INVALID_FILE_INDEX: i32 = -1; + + pub fn new(rx_file_contents: Receiver) -> Self { + let exit = Arc::new(Mutex::new(false)); + let handle = Arc::new(Mutex::new(None)); + let handle_worker = + Self::init_worker_thread(exit.clone(), handle.clone(), rx_file_contents); + Self { + handle, + exit, + handle_worker: Some(handle_worker), + } + } + + pub fn start(&mut self, target_dir: PathBuf, files: Vec) { + let mut task_lock = self.handle.lock().unwrap(); + if task_lock + .as_ref() + .map(|x| !x.is_finished()) + .unwrap_or(false) + { + log::error!("Previous paste task is not finished, ignore new request."); + return; + } + let total_size = files.iter().map(|f| f.size).sum(); + let mut task_handle = PasteTaskHandle { + progress: PasteTaskProgress { + list_index: -1, + offset: 0, + total_size, + current_size: 0, + last_sent_time: Instant::now(), + download_file_index: Self::INVALID_FILE_INDEX, + download_file_size: 0, + download_file_path: "".to_owned(), + download_file_current_size: 0, + file_handle: None, + error: None, + is_canceled: false, + }, + target_dir, + files, + }; + task_handle.update_next(0).ok(); + if task_handle.is_finished() { + task_handle.on_finished(); + } else { + if let Err(e) = task_handle.send_file_contents_request() { + log::error!("Failed to send file contents request, error: {}", &e); + task_handle.on_error(e); + } + } + *task_lock = Some(task_handle); + } + + pub fn cancel(&self) { + let mut task_handle = self.handle.lock().unwrap(); + if let Some(task_handle) = task_handle.as_mut() { + task_handle.progress.is_canceled = true; + task_handle.on_cancelled(); + } + } + + fn init_worker_thread( + exit: Arc>, + handle: Arc>>, + rx_file_contents: Receiver, + ) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut retry_count = 0; + loop { + if *exit.lock().unwrap() { + break; + } + + match rx_file_contents.recv_timeout(Duration::from_millis(300)) { + Ok(file_contents) => { + let mut task_lock = handle.lock().unwrap(); + let Some(task_handle) = task_lock.as_mut() else { + continue; + }; + if task_handle.is_finished() { + continue; + } + + if file_contents.stream_id != task_handle.progress.list_index { + // ignore invalid stream id + continue; + } else if file_contents.msg_flags != 0x01 { + retry_count += 1; + if retry_count > RECV_RETRY_TIMES { + task_handle.progress.error = Some(CliprdrError::InvalidRequest { + description: format!( + "Failed to read file contents, stream id: {}, msg_flags: {}", + file_contents.stream_id, + file_contents.msg_flags + ), + }); + } + } else { + let resp_list_index = file_contents.stream_id; + let Some(file) = &task_handle.files.get(resp_list_index as usize) + else { + // unreachable + // Because `task_handle.progress.list_index >= task_handle.files.len()` should always be false + log::warn!( + "Invalid response list index: {}, file length: {}", + resp_list_index, + task_handle.files.len() + ); + continue; + }; + if file.conn_id != file_contents.conn_id { + // unreachable + // We still add log here to make sure we can see the error message when it happens. + log::error!( + "Invalid response conn id: {}, expected: {}", + file_contents.conn_id, + file.conn_id + ); + continue; + } + + if let Err(e) = task_handle.handle_file_contents_response(file_contents) + { + log::error!("Failed to handle file contents response: {}", &e); + task_handle.on_error(e); + } + } + + if !task_handle.is_finished() { + if let Err(e) = task_handle.send_file_contents_request() { + log::error!("Failed to send file contents request: {}", &e); + task_handle.on_error(e); + } + } else { + retry_count = 0; + task_handle.on_finished(); + } + } + Err(RecvTimeoutError::Timeout) => { + let mut task_lock = handle.lock().unwrap(); + if let Some(task_handle) = task_lock.as_mut() { + if task_handle.check_receive_timemout() { + retry_count = 0; + task_handle.on_finished(); + } + } + } + Err(RecvTimeoutError::Disconnected) => { + break; + } + } + } + }) + } + + pub fn is_finished(&self) -> bool { + self.handle + .lock() + .unwrap() + .as_ref() + .map(|handle| handle.is_finished()) + .unwrap_or(true) + } + + pub fn progress_percent(&self) -> Option { + self.handle + .lock() + .unwrap() + .as_ref() + .map(|handle| handle.progress_percent()) + } +} + +impl PasteTaskHandle { + fn update_next(&mut self, size: u64) -> Result<(), CliprdrError> { + if self.is_finished() { + return Ok(()); + } + self.progress.current_size += size; + + let is_start = self.progress.list_index == -1; + if is_start || (self.progress.offset + size) >= self.progress.download_file_size { + if !is_start { + self.on_done(); + } + for i in (self.progress.list_index + 1)..self.files.len() as i32 { + let Some(file_desc) = self.files.get(i as usize) else { + return Err(CliprdrError::InvalidRequest { + description: format!("Invalid file index: {}", i), + }); + }; + match file_desc.kind { + FileType::File => { + if file_desc.size == 0 { + if let Some(new_file_path) = + Self::get_new_filename(&self.target_dir, file_desc) + { + if let Ok(f) = std::fs::File::create(&new_file_path) { + f.set_len(0).ok(); + Self::set_file_metadata(&f, file_desc); + } + }; + } else { + self.progress.list_index = i; + self.progress.offset = 0; + self.open_new_writer()?; + break; + } + } + FileType::Directory => { + let path = self.target_dir.join(&file_desc.name); + if !path.exists() { + std::fs::create_dir_all(path).ok(); + } + } + FileType::Symlink => { + // to-do: handle symlink + } + } + } + } else { + self.progress.offset += size; + self.progress.download_file_current_size += size; + self.update_progress_completed(None); + } + if self.progress.file_handle.is_none() { + self.progress.list_index = self.files.len() as i32; + self.progress.offset = 0; + self.progress.download_file_size = 0; + self.progress.download_file_current_size = 0; + } + Ok(()) + } + + fn start_progress_completed(&self) { + if let Some(file) = self.progress.file_handle.as_ref() { + let creation_time = + SystemTime::UNIX_EPOCH + Duration::from_secs(TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED); + file.get_ref() + .set_times(FileTimes::new().set_created(creation_time)) + .ok(); + xattr::set( + &self.progress.download_file_path, + ATTR_PROGRESS_FRACTION_COMPLETED, + "0.0".as_bytes(), + ) + .ok(); + } + } + + fn update_progress_completed(&mut self, fraction_completed: Option) { + let fraction_completed = fraction_completed.unwrap_or_else(|| { + let current_size = self.progress.download_file_current_size as f64; + let total_size = self.progress.download_file_size as f64; + if total_size > 0.0 { + current_size / total_size + } else { + 1.0 + } + }); + xattr::set( + &self.progress.download_file_path, + ATTR_PROGRESS_FRACTION_COMPLETED, + &fraction_completed.to_string().as_bytes(), + ) + .ok(); + } + + #[inline] + fn remove_progress_completed(path: &str) { + if !path.is_empty() { + xattr::remove(path, ATTR_PROGRESS_FRACTION_COMPLETED).ok(); + } + } + + fn open_new_writer(&mut self) -> Result<(), CliprdrError> { + let Some(file) = &self.files.get(self.progress.list_index as usize) else { + return Err(CliprdrError::InvalidRequest { + description: format!( + "Invalid file index: {}, file count: {}", + self.progress.list_index, + self.files.len() + ), + }); + }; + + let original_file_path = self + .target_dir + .join(&file.name) + .to_string_lossy() + .to_string(); + let Some(download_file_path) = Self::get_first_filename( + format!("{}.{}", original_file_path, DOWNLOAD_EXTENSION), + file.kind, + ) else { + return Err(CliprdrError::CommonError { + description: format!("Failed to get download file path: {}", original_file_path), + }); + }; + let Some(download_path_parent) = Path::new(&download_file_path).parent() else { + return Err(CliprdrError::CommonError { + description: format!( + "Failed to get parent of the download file path: {}", + original_file_path + ), + }); + }; + if !download_path_parent.exists() { + if let Err(e) = std::fs::create_dir_all(download_path_parent) { + return Err(CliprdrError::FileError { + path: download_path_parent.to_string_lossy().to_string(), + err: e, + }); + } + } + match std::fs::File::create(&download_file_path) { + Ok(handle) => { + let writer = BufWriter::with_capacity(BLOCK_SIZE as usize * 2, handle); + self.progress.download_file_index = self.progress.list_index; + self.progress.download_file_size = file.size; + self.progress.download_file_path = download_file_path; + self.progress.download_file_current_size = 0; + self.progress.file_handle = Some(writer); + self.start_progress_completed(); + } + Err(e) => { + self.progress.error = Some(CliprdrError::FileError { + path: download_file_path, + err: e, + }); + } + }; + Ok(()) + } + + fn get_first_filename(path: String, r#type: FileType) -> Option { + let p = Path::new(&path); + if !p.exists() { + return Some(path); + } else { + for i in 1..9999999 { + let new_path = match r#type { + FileType::File => { + if let Some(ext) = p.extension() { + let new_name = format!( + "{}-{}.{}", + p.file_stem().unwrap_or_default().to_string_lossy(), + i, + ext.to_string_lossy() + ); + p.with_file_name(new_name).to_string_lossy().to_string() + } else { + format!("{} ({})", path, i) + } + } + FileType::Directory => format!("{} ({})", path, i), + FileType::Symlink => { + // to-do: handle symlink + return None; + } + }; + if !Path::new(&new_path).exists() { + return Some(new_path); + } + } + } + // unreachable + None + } + + fn progress_percent(&self) -> ProgressPercent { + let percent = self.progress.current_size as f64 / self.progress.total_size as f64; + ProgressPercent { + percent, + is_canceled: self.progress.is_canceled, + is_failed: self.progress.error.is_some(), + } + } + + fn is_finished(&self) -> bool { + self.progress.is_canceled + || self.progress.error.is_some() + || self.progress.list_index >= self.files.len() as i32 + } + + fn check_receive_timemout(&mut self) -> bool { + if !self.is_finished() { + if self.progress.last_sent_time.elapsed() > RECEIVE_WAIT_TIMEOUT { + self.progress.error = Some(CliprdrError::InvalidRequest { + description: "Failed to read file contents".to_string(), + }); + return true; + } + } + false + } + + fn on_finished(&mut self) { + if self.progress.error.is_some() { + self.on_cancelled(); + } else { + self.on_done(); + } + if self.progress.current_size != self.progress.total_size { + self.progress.error = Some(CliprdrError::InvalidRequest { + description: "Failed to download all files".to_string(), + }); + } + } + + fn on_error(&mut self, error: CliprdrError) { + self.progress.error = Some(error); + self.on_cancelled(); + } + + fn on_cancelled(&mut self) { + self.progress.file_handle = None; + std::fs::remove_file(&self.progress.download_file_path).ok(); + } + + fn on_done(&mut self) { + self.update_progress_completed(Some(1.0)); + Self::remove_progress_completed(&self.progress.download_file_path); + + let Some(file) = self.progress.file_handle.as_mut() else { + return; + }; + if self.progress.download_file_index == PasteTask::INVALID_FILE_INDEX { + return; + } + + if let Err(e) = file.flush() { + log::error!("Failed to flush file: {:?}", e); + } + self.progress.file_handle = None; + + let Some(file_desc) = self.files.get(self.progress.download_file_index as usize) else { + // unreachable + log::error!( + "Failed to get file description: {}", + self.progress.download_file_index + ); + return; + }; + let Some(rename_to_path) = Self::get_new_filename(&self.target_dir, file_desc) else { + return; + }; + match std::fs::rename(&self.progress.download_file_path, &rename_to_path) { + Ok(_) => Self::set_file_metadata2(&rename_to_path, file_desc), + Err(e) => { + log::error!("Failed to rename file: {:?}", e); + } + } + self.progress.download_file_path = "".to_owned(); + self.progress.download_file_index = PasteTask::INVALID_FILE_INDEX; + } + + fn get_new_filename(target_dir: &PathBuf, file_desc: &FileDescription) -> Option { + let mut rename_to_path = target_dir + .join(&file_desc.name) + .to_string_lossy() + .to_string(); + if Path::new(&rename_to_path).exists() { + let Some(new_path) = Self::get_first_filename(rename_to_path.clone(), file_desc.kind) + else { + log::error!("Failed to get new file name: {}", &rename_to_path); + return None; + }; + rename_to_path = new_path; + } + Some(rename_to_path) + } + + #[inline] + fn set_file_metadata(f: &File, file_desc: &FileDescription) { + let times = FileTimes::new() + .set_accessed(file_desc.atime) + .set_modified(file_desc.last_modified) + .set_created(file_desc.creation_time); + f.set_times(times).ok(); + } + + #[inline] + fn set_file_metadata2(path: &str, file_desc: &FileDescription) { + let times = FileTimes::new() + .set_accessed(file_desc.atime) + .set_modified(file_desc.last_modified) + .set_created(file_desc.creation_time); + File::options() + .write(true) + .open(path) + .map(|f| f.set_times(times)) + .ok(); + } + + fn send_file_contents_request(&mut self) -> Result<(), CliprdrError> { + if self.is_finished() { + return Ok(()); + } + + let stream_id = self.progress.list_index; + let list_index = self.progress.list_index; + let Some(file) = &self.files.get(list_index as usize) else { + // unreachable + return Err(CliprdrError::InvalidRequest { + description: format!("Invalid file index: {}", list_index), + }); + }; + let cb_requested = min(BLOCK_SIZE as u64, file.size - self.progress.offset); + let conn_id = file.conn_id; + + let (n_position_high, n_position_low) = ( + (self.progress.offset >> 32) as i32, + (self.progress.offset & (u32::MAX as u64)) as i32, + ); + let request = ClipboardFile::FileContentsRequest { + stream_id, + list_index, + dw_flags: 2, + n_position_low, + n_position_high, + cb_requested: cb_requested as _, + have_clip_data_id: false, + clip_data_id: 0, + }; + allow_err!(send_data(conn_id, request)); + self.progress.last_sent_time = Instant::now(); + + Ok(()) + } + + fn handle_file_contents_response( + &mut self, + file_contents: FileContentsResponse, + ) -> Result<(), CliprdrError> { + if let Some(file) = self.progress.file_handle.as_mut() { + let data = file_contents.requested_data.as_slice(); + let mut write_len = 0; + while write_len < data.len() { + match file.write(&data[write_len..]) { + Ok(len) => { + write_len += len; + } + Err(e) => { + return Err(CliprdrError::FileError { + path: self.progress.download_file_path.clone(), + err: e, + }); + } + } + } + self.update_next(write_len as _)?; + } else { + return Err(CliprdrError::FileError { + path: self.progress.download_file_path.clone(), + err: std::io::Error::new(std::io::ErrorKind::NotFound, "file handle is not opened"), + }); + } + Ok(()) + } +} diff --git a/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs new file mode 100644 index 000000000..e55dd46b2 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs @@ -0,0 +1,443 @@ +use super::{ + item_data_provider::create_pasteboard_file_url_provider, + paste_observer::PasteObserver, + paste_task::{FileContentsResponse, PasteTask}, +}; +use crate::{ + platform::unix::{ + filetype::FileDescription, FILECONTENTS_FORMAT_NAME, FILEDESCRIPTORW_FORMAT_NAME, + }, + send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, ProgressPercent, +}; +use hbb_common::{allow_err, bail, log, ResultType}; +use objc2::{msg_send_id, rc::Id, runtime::ProtocolObject, ClassType}; +use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL}; +use objc2_foundation::{NSArray, NSString}; +use std::{ + io, + path::Path, + sync::{ + mpsc::{channel, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +lazy_static::lazy_static! { + static ref PASTE_OBSERVER_INFO: Arc>> = Default::default(); +} + +pub const TEMP_FILE_PREFIX: &str = ".rustdesk_"; + +#[derive(Default, Debug, Clone, PartialEq)] +pub(super) struct PasteObserverInfo { + pub file_descriptor_id: i32, + pub conn_id: i32, + pub source_path: String, + pub target_path: String, +} + +impl PasteObserverInfo { + fn exit_msg() -> Self { + Self::default() + } +} + +struct ContextInfo { + tx: Sender>, + handle: thread::JoinHandle<()>, +} + +pub struct PasteboardContext { + pasteboard: Id, + observer: Arc>, + tx_handle: Option, + tx_remove_file: Option>, + remove_file_handle: Option>, + tx_paste_task: Sender, + paste_task: Arc>, +} + +unsafe impl Send for PasteboardContext {} +unsafe impl Sync for PasteboardContext {} + +impl Drop for PasteboardContext { + fn drop(&mut self) { + self.observer.lock().unwrap().stop(); + if let Some(tx_handle) = self.tx_handle.take() { + if tx_handle.tx.send(Ok(PasteObserverInfo::exit_msg())).is_ok() { + tx_handle.handle.join().ok(); + } + } + } +} + +impl CliprdrServiceContext for PasteboardContext { + fn set_is_stopped(&mut self) -> Result<(), CliprdrError> { + Ok(()) + } + + fn empty_clipboard(&mut self, conn_id: i32) -> Result { + Ok(self.empty_clipboard_(conn_id)) + } + + fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { + self.server_clip_file_(conn_id, msg) + } + + fn get_progress_percent(&self) -> Option { + self.paste_task.lock().unwrap().progress_percent() + } + + fn cancel(&mut self) { + self.paste_task.lock().unwrap().cancel(); + } +} + +impl PasteboardContext { + fn init(&mut self) { + let (tx_remove_file, rx_remove_file) = channel(); + let handle_remove_file = Self::init_thread_remove_file(rx_remove_file); + self.tx_remove_file = Some(tx_remove_file.clone()); + self.remove_file_handle = Some(handle_remove_file); + + let (tx, rx) = channel(); + let observer: Arc> = self.observer.clone(); + let handle = Self::init_thread_observer(tx_remove_file, rx, observer); + self.tx_handle = Some(ContextInfo { tx, handle }); + } + + fn init_thread_observer( + tx_remove_file: Sender, + rx: Receiver>, + observer: Arc>, + ) -> thread::JoinHandle<()> { + let exit_msg = PasteObserverInfo::exit_msg(); + thread::spawn(move || loop { + match rx.recv() { + Ok(Ok(task_info)) => { + if task_info == exit_msg { + log::debug!("pasteboard item data provider: exit"); + break; + } + tx_remove_file.send(task_info.source_path.clone()).ok(); + observer.lock().unwrap().start(task_info); + } + Ok(Err(e)) => { + log::error!("pasteboard item data provider, inner error: {e}"); + } + Err(e) => { + log::error!("pasteboard item data provider, error: {e}"); + break; + } + } + }) + } + + fn init_thread_remove_file(rx: Receiver) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut cur_file: Option = None; + loop { + match rx.recv_timeout(Duration::from_secs(30)) { + Ok(path) => { + if let Some(file) = cur_file.take() { + if !file.is_empty() { + std::fs::remove_file(&file).ok(); + } + } + if !path.is_empty() { + cur_file = Some(path); + } + } + Err(e) => { + if let Some(file) = cur_file.take() { + if !file.is_empty() { + std::fs::remove_file(&file).ok(); + } + } + if e == RecvTimeoutError::Disconnected { + break; + } + } + } + } + }) + } + + // Just removing the file can also make paste option in the context menu disappear. + fn empty_clipboard_(&mut self, _conn_id: i32) -> bool { + self.tx_remove_file + .as_ref() + .map(|tx| tx.send("".to_string()).ok()); + true + } + + fn temp_files_count() -> usize { + let mut count = 0; + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name() { + if let Some(file_name_str) = file_name.to_str() { + if file_name_str.starts_with(TEMP_FILE_PREFIX) { + count += 1; + } + } + } + } + } + } + } + count + } + + fn server_clip_file_(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { + match msg { + ClipboardFile::FormatList { format_list } => { + let temp_files = Self::temp_files_count(); + if temp_files >= 3 { + // The temp files should be 0 or 1 in normal case. + // We should not continue to paste files if there are more than 3 temp files. + return Err(CliprdrError::CommonError { + description: format!( + "too many temp files, current: {}, limit: {}", + temp_files, 3 + ), + }); + } + + let task_lock = self.paste_task.lock().unwrap(); + if !task_lock.is_finished() { + return Err(CliprdrError::CommonError { + description: "previous file paste task is not finished".to_string(), + }); + } + self.handle_format_list(conn_id, format_list)?; + } + ClipboardFile::FormatDataResponse { + msg_flags, + format_data, + } => { + self.handle_format_data_response(conn_id, msg_flags, format_data)?; + } + ClipboardFile::FileContentsResponse { + msg_flags, + stream_id, + requested_data, + } => { + self.handle_file_contents_response(conn_id, msg_flags, stream_id, requested_data)?; + } + ClipboardFile::TryEmpty => self.handle_try_empty(conn_id), + _ => {} + } + Ok(()) + } + + fn handle_format_list( + &self, + conn_id: i32, + format_list: Vec<(i32, String)>, + ) -> Result<(), CliprdrError> { + if let Some(tx_handle) = self.tx_handle.as_ref() { + if !format_list + .iter() + .find(|(_, name)| name == FILECONTENTS_FORMAT_NAME) + .map(|(id, _)| *id) + .is_some() + { + return Err(CliprdrError::CommonError { + description: "no file contents format found".to_string(), + }); + }; + let Some(file_descriptor_id) = format_list + .iter() + .find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME) + .map(|(id, _)| *id) + else { + return Err(CliprdrError::CommonError { + description: "no file descriptor format found".to_string(), + }); + }; + + let tx = tx_handle.tx.clone(); + let provider = create_pasteboard_file_url_provider( + PasteObserverInfo { + file_descriptor_id, + conn_id, + source_path: "".to_string(), + target_path: "".to_string(), + }, + tx, + ); + unsafe { + let types = NSArray::from_vec(vec![NSString::from_str( + &NSPasteboardTypeFileURL.to_string(), + )]); + let item = objc2_app_kit::NSPasteboardItem::new(); + item.setDataProvider_forTypes(&ProtocolObject::from_id(provider), &types); + self.pasteboard.clearContents(); + if !self + .pasteboard + .writeObjects(&Id::cast(NSArray::from_vec(vec![item]))) + { + return Err(CliprdrError::CommonError { + description: "failed to write objects".to_string(), + }); + } + } + } else { + return Err(CliprdrError::CommonError { + description: "pasteboard context is not inited".to_string(), + }); + } + Ok(()) + } + + fn handle_format_data_response( + &self, + conn_id: i32, + msg_flags: i32, + format_data: Vec, + ) -> Result<(), CliprdrError> { + log::debug!("handle format data response, msg_flags: {msg_flags}"); + if msg_flags != 0x1 { + // return failure message? + } + + let mut task_lock = self.paste_task.lock().unwrap(); + let target_dir = PASTE_OBSERVER_INFO + .lock() + .unwrap() + .as_ref() + .map(|task| task.target_path.clone()); + // unreachable in normal case + let Some(target_dir) = target_dir.as_ref().map(|d| Path::new(d).parent()).flatten() else { + return Err(CliprdrError::CommonError { + description: "failed to get parent path".to_string(), + }); + }; + // unreachable in normal case + if !target_dir.exists() { + return Err(CliprdrError::CommonError { + description: "target path does not exist".to_string(), + }); + } + let target_dir = target_dir.to_owned(); + match FileDescription::parse_file_descriptors(format_data, conn_id) { + Ok(files) => { + task_lock.start(target_dir, files); + Ok(()) + } + Err(e) => { + PASTE_OBSERVER_INFO + .lock() + .unwrap() + .replace(PasteObserverInfo::default()); + Err(e) + } + } + } + + fn handle_file_contents_response( + &self, + conn_id: i32, + msg_flags: i32, + stream_id: i32, + requested_data: Vec, + ) -> Result<(), CliprdrError> { + log::debug!("handle file contents response"); + self.tx_paste_task + .send(FileContentsResponse { + conn_id, + msg_flags, + stream_id, + requested_data, + }) + .ok(); + Ok(()) + } + + fn handle_try_empty(&mut self, conn_id: i32) { + log::debug!("empty_clipboard called"); + let ret = self.empty_clipboard_(conn_id); + log::debug!( + "empty_clipboard called, conn_id {}, return {}", + conn_id, + ret + ); + } +} + +fn handle_paste_result(task_info: &PasteObserverInfo) { + log::info!( + "file {} is pasted to {}", + &task_info.source_path, + &task_info.target_path + ); + if Path::new(&task_info.target_path).parent().is_none() { + log::error!( + "failed to get parent path of {}, no need to perform pasting", + &task_info.target_path + ); + return; + } + + PASTE_OBSERVER_INFO + .lock() + .unwrap() + .replace(task_info.clone()); + // to-do: add a timeout to clear data in `PASTE_OBSERVER_INFO`. + std::fs::remove_file(&task_info.source_path).ok(); + std::fs::remove_file(&task_info.target_path).ok(); + let data = ClipboardFile::FormatDataRequest { + requested_format_id: task_info.file_descriptor_id, + }; + allow_err!(send_data(task_info.conn_id as _, data)); +} + +#[inline] +pub fn create_pasteboard_context() -> ResultType> { + let pasteboard: Option> = + unsafe { msg_send_id![NSPasteboard::class(), generalPasteboard] }; + let Some(pasteboard) = pasteboard else { + bail!("failed to get general pasteboard"); + }; + let mut observer = PasteObserver::new(); + observer.init(handle_paste_result)?; + let (tx, rx) = channel(); + let mut context = Box::new(PasteboardContext { + pasteboard, + observer: Arc::new(Mutex::new(observer)), + tx_handle: None, + tx_remove_file: None, + remove_file_handle: None, + tx_paste_task: tx, + paste_task: Arc::new(Mutex::new(PasteTask::new(rx))), + }); + context.init(); + Ok(context) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_temp_files_count() { + let mut c = super::PasteboardContext::temp_files_count(); + + for _ in 0..10 { + let path = format!( + "/tmp/{}{}", + super::TEMP_FILE_PREFIX, + uuid::Uuid::new_v4().to_string() + ); + if std::fs::File::create(&path).is_ok() { + c += 1; + } + } + + assert_eq!(c, super::PasteboardContext::temp_files_count()); + } +} diff --git a/libs/clipboard/src/platform/unix/mod.rs b/libs/clipboard/src/platform/unix/mod.rs index 7e7aeccb1..de5917f49 100644 --- a/libs/clipboard/src/platform/unix/mod.rs +++ b/libs/clipboard/src/platform/unix/mod.rs @@ -2,9 +2,13 @@ use dashmap::DashMap; use lazy_static::lazy_static; mod filetype; +pub use filetype::{FileDescription, FileType}; /// use FUSE for file pasting on these platforms #[cfg(target_os = "linux")] pub mod fuse; +#[cfg(target_os = "macos")] +pub mod macos; + pub mod local_file; pub mod serv_files; diff --git a/libs/clipboard/src/platform/windows.rs b/libs/clipboard/src/platform/windows.rs index 1d8506ead..3734406e0 100644 --- a/libs/clipboard/src/platform/windows.rs +++ b/libs/clipboard/src/platform/windows.rs @@ -6,8 +6,9 @@ #![allow(deref_nullptr)] use crate::{ - send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext, ResultType, - ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL, + send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext, + ProgressPercent, ResultType, ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, + ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL, }; use hbb_common::{allow_err, log}; use std::{ @@ -602,6 +603,12 @@ impl CliprdrServiceContext for CliprdrClientContext { let ret = server_clip_file(self, conn_id, msg); ret_to_result(ret) } + + fn get_progress_percent(&self) -> Option { + None + } + + fn cancel(&mut self) {} } fn ret_to_result(ret: u32) -> Result<(), CliprdrError> { @@ -745,7 +752,11 @@ pub fn server_clip_file( ClipboardFile::TryEmpty => { log::debug!("empty_clipboard called"); let ret = empty_clipboard(context, conn_id); - log::debug!("empty_clipboard called, conn_id {}, return {}", conn_id, ret); + log::debug!( + "empty_clipboard called, conn_id {}, return {}", + conn_id, + ret + ); } } ret diff --git a/src/client.rs b/src/client.rs index 319b3f3da..d2ceffd3c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -848,6 +848,10 @@ impl ClientClipboardHandler { #[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() { match clipboard::platform::unix::serv_files::sync_files(&urls) { Ok(()) => { diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 23d2f4094..448eac7f9 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -12,7 +12,10 @@ use crate::{ }; #[cfg(feature = "unix-file-copy-paste")] use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip}; -#[cfg(target_os = "windows")] +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] use clipboard::ContextSend; use crossbeam_queue::ArrayQueue; #[cfg(not(target_os = "ios"))] @@ -1956,9 +1959,9 @@ impl Remote { #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] async fn handle_cliprdr_msg( - &self, + &mut self, clip: hbb_common::message_proto::Cliprdr, - _peer: &mut Stream, + peer: &mut Stream, ) { log::debug!("handling cliprdr msg from server peer"); #[cfg(feature = "flutter")] @@ -1982,7 +1985,10 @@ impl Remote { "Process clipboard message from server peer, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}", stop, is_stopping_allowed, file_transfer_enabled); if !stop { - #[cfg(target_os = "windows")] + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") + ))] if let Err(e) = ContextSend::make_sure_enabled() { log::error!("failed to restart clipboard context: {}", e); }; @@ -1996,12 +2002,36 @@ impl Remote { } #[cfg(feature = "unix-file-copy-paste")] if crate::is_support_file_copy_paste_num(self.handler.lc.read().unwrap().version) { - if let Some(msg) = unix_file_clip::serve_clip_messages( - ClipboardSide::Client, - clip, - self.client_conn_id, - ) { - allow_err!(_peer.send(&msg).await); + let mut out_msg = None; + + #[cfg(target_os = "macos")] + if clipboard::platform::unix::macos::should_handle_msg(&clip) { + if let Err(e) = ContextSend::proc(|context| -> ResultType<()> { + context + .server_clip_file(self.client_conn_id, clip) + .map_err(|e| e.into()) + }) { + log::error!("failed to handle cliprdr msg: {}", e); + } + } else { + out_msg = unix_file_clip::serve_clip_messages( + ClipboardSide::Client, + clip, + self.client_conn_id, + ); + } + + #[cfg(not(target_os = "macos"))] + { + out_msg = unix_file_clip::serve_clip_messages( + ClipboardSide::Client, + clip, + self.client_conn_id, + ); + } + + if let Some(msg) = out_msg { + allow_err!(peer.send(&msg).await); } } } diff --git a/src/clipboard.rs b/src/clipboard.rs index 4ab4bc666..ee976d68f 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -75,6 +75,24 @@ pub fn check_clipboard( None } +#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))] +pub fn is_file_url_set_by_rustdesk(url: &Vec) -> bool { + if url.len() != 1 { + return false; + } + url.iter() + .next() + .map(|s| { + for prefix in &["file:///tmp/.rustdesk_", "//tmp/.rustdesk_"] { + if s.starts_with(prefix) { + return s[prefix.len()..].parse::().is_ok(); + } + } + false + }) + .unwrap_or(false) +} + #[cfg(feature = "unix-file-copy-paste")] pub fn check_clipboard_files( ctx: &mut Option, @@ -110,7 +128,6 @@ pub fn update_clipboard_files(files: Vec, side: ClipboardSide) { #[cfg(feature = "unix-file-copy-paste")] pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) { - #[cfg(target_os = "linux")] std::thread::spawn(move || { let mut ctx = CLIPBOARD_CTX.lock().unwrap(); if ctx.is_none() { @@ -125,9 +142,22 @@ pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) { } } if let Some(mut ctx) = ctx.as_mut() { - use clipboard::platform::unix; - if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) { + #[cfg(target_os = "linux")] + { + use clipboard::platform::unix; + if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) { + ctx.try_empty_clipboard_files(_side); + } + } + #[cfg(target_os = "macos")] + { ctx.try_empty_clipboard_files(_side); + // No need to make sure the context is enabled. + clipboard::ContextSend::proc(|context| -> ResultType<()> { + context.empty_clipboard(_conn_id).ok(); + Ok(()) + }) + .ok(); } } }); @@ -351,27 +381,43 @@ impl ClipboardContext { Ok(()) } + #[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))] + fn get_file_urls_set_by_rustdesk( + data: Vec, + _side: ClipboardSide, + ) -> Vec { + for item in data.into_iter() { + if let ClipboardData::FileUrl(urls) = item { + if is_file_url_set_by_rustdesk(&urls) { + return urls; + } + } + } + vec![] + } + + #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] + fn get_file_urls_set_by_rustdesk(data: Vec, side: ClipboardSide) -> Vec { + let exclude_path = + clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client); + data.into_iter() + .filter_map(|c| match c { + ClipboardData::FileUrl(urls) => Some( + urls.into_iter() + .filter(|s| s.starts_with(&*exclude_path)) + .collect::>(), + ), + _ => None, + }) + .flatten() + .collect::>() + } + #[cfg(feature = "unix-file-copy-paste")] fn try_empty_clipboard_files(&mut self, side: ClipboardSide) { let _lock = ARBOARD_MTX.lock().unwrap(); if let Ok(data) = self.get_formats(&[ClipboardFormat::FileUrl]) { - #[cfg(target_os = "linux")] - let exclude_path = - clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client); - #[cfg(target_os = "macos")] - let exclude_path: Arc = Default::default(); - let urls = data - .into_iter() - .filter_map(|c| match c { - ClipboardData::FileUrl(urls) => Some( - urls.into_iter() - .filter(|s| s.starts_with(&*exclude_path)) - .collect::>(), - ), - _ => None, - }) - .flatten() - .collect::>(); + let urls = Self::get_file_urls_set_by_rustdesk(data, side); if !urls.is_empty() { // FIXME: // The host-side clear file clipboard `let _ = self.inner.clear();`, diff --git a/src/common.rs b/src/common.rs index dfd3fca44..23f7b4b0f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -139,6 +139,10 @@ pub fn is_support_file_copy_paste_num(ver: i64) -> bool { ver >= hbb_common::get_version_number("1.3.8") } +pub fn is_support_file_paste_if_macos(ver: &str) -> bool { + hbb_common::get_version_number(ver) >= hbb_common::get_version_number("1.3.9") +} + // is server process, with "--server" args #[inline] pub fn is_server() -> bool { diff --git a/src/server/clipboard_service.rs b/src/server/clipboard_service.rs index a3cb65174..1d2f0a3fb 100644 --- a/src/server/clipboard_service.rs +++ b/src/server/clipboard_service.rs @@ -115,6 +115,10 @@ impl Handler { fn check_clipboard_file(&mut self) { if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Host, 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(()) => { // Use `send_data()` here to reuse `handle_file_clip()` in `connection.rs`. diff --git a/src/server/connection.rs b/src/server/connection.rs index 4ac552a42..7b1173fd7 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1267,11 +1267,13 @@ impl Connection { let is_unix_and_peer_supported = crate::is_support_file_copy_paste(&self.lr.version); #[cfg(not(feature = "unix-file-copy-paste"))] let is_unix_and_peer_supported = false; - // to-do: add file clipboard support for macos let is_both_macos = cfg!(target_os = "macos") && self.lr.my_platform == whoami::Platform::MacOS.to_string(); - let has_file_clipboard = - is_both_windows || (is_unix_and_peer_supported && !is_both_macos); + let is_peer_support_paste_if_macos = + crate::is_support_file_paste_if_macos(&self.lr.version); + let has_file_clipboard = is_both_windows + || (is_unix_and_peer_supported + && (!is_both_macos || is_peer_support_paste_if_macos)); platform_additions.insert("has_file_clipboard".into(), json!(has_file_clipboard)); } @@ -2195,11 +2197,38 @@ impl Connection { } #[cfg(feature = "unix-file-copy-paste")] if crate::is_support_file_copy_paste(&self.lr.version) { - if let Some(msg) = unix_file_clip::serve_clip_messages( - ClipboardSide::Host, - clip, - self.inner.id(), - ) { + let mut out_msg = None; + + #[cfg(target_os = "macos")] + if clipboard::platform::unix::macos::should_handle_msg(&clip) { + if let Err(e) = clipboard::ContextSend::make_sure_enabled() { + log::error!("failed to restart clipboard context: {}", e); + } else { + let _ = + clipboard::ContextSend::proc(|context| -> ResultType<()> { + context + .server_clip_file(self.inner.id(), clip) + .map_err(|e| e.into()) + }); + } + } else { + out_msg = unix_file_clip::serve_clip_messages( + ClipboardSide::Host, + clip, + self.inner.id(), + ); + } + + #[cfg(not(target_os = "macos"))] + { + out_msg = unix_file_clip::serve_clip_messages( + ClipboardSide::Host, + clip, + self.inner.id(), + ); + } + + if let Some(msg) = out_msg { self.send(msg).await; } }