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 000000000..73e4e3f0b Binary files /dev/null and b/libs/clipboard/src/platform/unix/macos/paste-files-macos.png differ 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; } }