From 6d1c25f60f08659fc95a98b856542cdbfdf6447e Mon Sep 17 00:00:00 2001 From: root Date: Sat, 19 Sep 2026 15:55:03 +0000 Subject: [PATCH] feat(desktop): copy share link from Explorer context menu (#160) Adds a "Copy share link" command to the Cloudreve Explorer context menu. It creates a public share via PUT /share for the selected file or folder and copies the returned link to the clipboard, with a toast confirming success or failure. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/crates/cloudreve-api/src/api/mod.rs | 2 + desktop/crates/cloudreve-api/src/api/share.rs | 18 +++ .../crates/cloudreve-api/src/models/share.rs | 7 + desktop/crates/cloudreve-sync/Cargo.toml | 1 + .../cloudreve-sync/src/drive/commands.rs | 23 ++- .../src/drive/manager/command_handlers.rs | 39 +++++ .../src/shellext/context_menu/mod.rs | 2 + .../src/shellext/context_menu/share_link.rs | 142 ++++++++++++++++++ .../src/shellext/context_menu/sub_commands.rs | 9 +- .../cloudreve-sync/src/utils/clipboard.rs | 39 +++++ .../crates/cloudreve-sync/src/utils/mod.rs | 1 + desktop/locales/app.yaml | 36 +++++ 12 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 desktop/crates/cloudreve-api/src/api/share.rs create mode 100644 desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs create mode 100644 desktop/crates/cloudreve-sync/src/utils/clipboard.rs diff --git a/desktop/crates/cloudreve-api/src/api/mod.rs b/desktop/crates/cloudreve-api/src/api/mod.rs index 18f4167c..1c1c51be 100644 --- a/desktop/crates/cloudreve-api/src/api/mod.rs +++ b/desktop/crates/cloudreve-api/src/api/mod.rs @@ -1,10 +1,12 @@ pub mod explorer; +pub mod share; pub mod site; pub mod user; pub mod workflow; // Re-export for convenience pub use explorer::ExplorerApi; +pub use share::ShareApi; pub use site::SiteApi; pub use user::UserApi; pub use workflow::WorkflowApi; diff --git a/desktop/crates/cloudreve-api/src/api/share.rs b/desktop/crates/cloudreve-api/src/api/share.rs new file mode 100644 index 00000000..860d4224 --- /dev/null +++ b/desktop/crates/cloudreve-api/src/api/share.rs @@ -0,0 +1,18 @@ +use crate::client::{Client, RequestOptions}; +use crate::error::ApiResult; +use crate::models::share::*; +use async_trait::async_trait; + +/// Share API methods +#[async_trait] +pub trait ShareApi { + /// Create a share link for a file or folder, returns the share URL. + async fn create_share(&self, request: &ShareCreateService) -> ApiResult; +} + +#[async_trait] +impl ShareApi for Client { + async fn create_share(&self, request: &ShareCreateService) -> ApiResult { + self.put("/share", request, RequestOptions::new()).await + } +} diff --git a/desktop/crates/cloudreve-api/src/models/share.rs b/desktop/crates/cloudreve-api/src/models/share.rs index c241959a..f2520d58 100644 --- a/desktop/crates/cloudreve-api/src/models/share.rs +++ b/desktop/crates/cloudreve-api/src/models/share.rs @@ -20,3 +20,10 @@ pub struct ListShareResponse { pub shares: Vec, pub pagination: PaginationResults, } + +/// Share create service. Only the target URI is required; the server applies +/// public-link defaults for all other fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShareCreateService { + pub uri: String, +} diff --git a/desktop/crates/cloudreve-sync/Cargo.toml b/desktop/crates/cloudreve-sync/Cargo.toml index 72839b79..2442809e 100644 --- a/desktop/crates/cloudreve-sync/Cargo.toml +++ b/desktop/crates/cloudreve-sync/Cargo.toml @@ -85,6 +85,7 @@ features = [ "Win32_Graphics_Gdi", "Graphics_Imaging", "Win32_System_Com_StructuredStorage", + "Win32_System_DataExchange", "Win32_System_Variant", "Win32_Storage_EnhancedStorage", "Win32_UI_Notifications", diff --git a/desktop/crates/cloudreve-sync/src/drive/commands.rs b/desktop/crates/cloudreve-sync/src/drive/commands.rs index 9e89d804..4bf1db03 100644 --- a/desktop/crates/cloudreve-sync/src/drive/commands.rs +++ b/desktop/crates/cloudreve-sync/src/drive/commands.rs @@ -20,12 +20,13 @@ use anyhow::{Context, Result}; use bytes::Bytes; use cloudreve_api::{ ApiError, - api::{ExplorerApi, explorer::ExplorerApiExt}, + api::{ExplorerApi, ShareApi, explorer::ExplorerApiExt}, models::{ explorer::{ DeleteFileService, FileResponse, FileURLService, MoveFileService, RenameFileService, metadata, }, + share::ShareCreateService, uri::CrUri, user::Token, }, @@ -151,6 +152,10 @@ pub enum ManagerCommand { ViewOnline { path: PathBuf, }, + /// Create a public share link for a file or folder and copy it to the clipboard + CopyShareLink { + path: PathBuf, + }, PersistConfig, GenerateThumbnail { path: PathBuf, @@ -432,6 +437,22 @@ impl Mount { Ok(thumb_response.bytes().await?) } + /// Create a public share link for the file or folder at `path`, + /// returning the share URL. + pub async fn create_share_link(&self, path: PathBuf) -> Result { + let (sync_path, remote_base) = { + let config = self.config.read().await; + (config.sync_path.clone(), config.remote_path.to_string()) + }; + let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) + .context("failed to convert local path to cloudreve uri")? + .to_string(); + Ok(self + .cr_client + .create_share(&ShareCreateService { uri }) + .await?) + } + pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> { // If source or destination is ignored, do nothing let matcher = self.ignore_matcher.read().await; diff --git a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs index ab8de826..179155cd 100644 --- a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs +++ b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs @@ -41,6 +41,15 @@ impl DriveManager { tracing::debug!(target: "drive::manager", path = %path.display(), result = ?result, "ViewOnline command result"); }); } + ManagerCommand::CopyShareLink { path } => { + let path = path.clone(); + spawn(async move { + let result = manager.handle_copy_share_link(path.clone()).await; + if let Err(e) = result { + tracing::error!(target: "drive::manager", path = %path.display(), error = %e, "CopyShareLink command failed"); + } + }); + } ManagerCommand::PersistConfig => { let result = manager.persist().await; if let Err(e) = result { @@ -194,6 +203,36 @@ impl DriveManager { Ok(()) } + /// Handle CopyShareLink command - creates a share link for the file or + /// folder, copies it to the clipboard and notifies the user. + pub(super) async fn handle_copy_share_link(&self, path: PathBuf) -> Result<()> { + tracing::debug!(target: "drive::manager", path = %path.display(), "CopyShareLink command"); + + let mount = self + .search_drive_by_child_path(path.to_str().unwrap_or("")) + .await + .ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?; + + match mount.create_share_link(path.clone()).await { + Ok(url) => { + crate::utils::clipboard::set_text(&url)?; + crate::utils::toast::send_general_text_toast( + &rust_i18n::t!("shareLinkCopied"), + &url, + ); + } + Err(e) => { + crate::utils::toast::send_general_text_toast( + &rust_i18n::t!("shareLinkFailed"), + &e.to_string(), + ); + return Err(e); + } + } + + Ok(()) + } + /// Handle ShowConflictToast command pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> { tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command"); diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs index e38b5231..2e263e3e 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs @@ -4,6 +4,7 @@ mod explorer_command; mod factory; mod resolve_conflict; +mod share_link; mod sub_commands; mod sync_now; mod view_online; @@ -11,6 +12,7 @@ mod view_online; pub use explorer_command::CrExplorerCommandHandler; pub use factory::CrExplorerCommandFactory; pub use resolve_conflict::ResolveConflictCommandHandler; +pub use share_link::ShareLinkCommandHandler; pub use sub_commands::SubCommands; pub use sync_now::SyncNowCommandHandler; pub use view_online::ViewOnlineCommandHandler; diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs new file mode 100644 index 00000000..13a280b8 --- /dev/null +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs @@ -0,0 +1,142 @@ +use crate::drive::manager::DriveManager; +use crate::{drive::commands::ManagerCommand, utils::app::AppRoot}; +use rust_i18n::t; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use windows::{ + Win32::{Foundation::*, System::Com::*, System::Ole::*, UI::Shell::*}, + core::*, +}; + +/// shlguid.h: SID_SFolderView — resolves IFolderView from the command site. +const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce); + +#[implement(IExplorerCommand, IObjectWithSite)] +pub struct ShareLinkCommandHandler { + drive_manager: Arc, + app_root: AppRoot, + site: Mutex>, +} + +impl ShareLinkCommandHandler { + pub fn new(drive_manager: Arc, app_root: AppRoot) -> Self { + Self { + drive_manager, + app_root, + site: Mutex::new(None), + } + } + + fn send_copy_share_link(&self, path: PathBuf) { + tracing::debug!(target: "shellext::context_menu", path = %path.display(), "Copy share link requested"); + let command_tx = self.drive_manager.get_command_sender(); + if let Err(e) = command_tx.send(ManagerCommand::CopyShareLink { path }) { + tracing::error!(target: "shellext::context_menu", error = %e, "Failed to send CopyShareLink command"); + } + } +} + +impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl { + fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> { + *self.site.lock().unwrap() = punksite.cloned(); + Ok(()) + } + + fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> { + let site = self.site.lock().unwrap(); + if let Some(site) = site.as_ref() { + unsafe { site.query(riid, ppvsite) }.ok() + } else { + Err(Error::from(E_FAIL)) + } + } +} + +impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl { + fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result { + let title = t!("copyShareLink"); + let hstring = HSTRING::from(title.as_ref()); + unsafe { SHStrDupW(&hstring) } + } + + fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result { + let icon_path = format!("{}\\people.ico", self.app_root.image_path()); + let hstring = HSTRING::from(icon_path); + unsafe { SHStrDupW(&hstring) } + } + + fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result { + Err(Error::from(E_NOTIMPL)) + } + + fn GetCanonicalName(&self) -> Result { + Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b)) + } + + fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result { + let Some(items) = items else { + // Not select anthing, but still triggerd from a folder + return Ok(ECS_ENABLED.0 as u32); + }; + + unsafe { + let count = items.GetCount()?; + if count <= 1 { + Ok(ECS_ENABLED.0 as u32) + } else { + Ok(ECS_HIDDEN.0 as u32) + } + } + } + + fn Invoke( + &self, + selection: Option<&IShellItemArray>, + _bindctx: Option<&IBindCtx>, + ) -> Result<()> { + tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked"); + + if let Some(items) = selection { + unsafe { + let count = items.GetCount()?; + if count != 1 { + return Ok(()); + } + + // Get the first item + let item = items.GetItemAt(0)?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + let path = PathBuf::from(display_name.to_string()?); + + self.send_copy_share_link(path); + } + } else { + // Folder-background invoke carries no selection; resolve the + // current folder through the site Explorer gave us in SetSite. + let site = self.site.lock().unwrap().clone(); + let Some(site) = site else { + return Ok(()); + }; + unsafe { + let service_provider: IServiceProvider = site.cast()?; + let folder_view: IFolderView = + service_provider.QueryService(&SID_S_FOLDER_VIEW)?; + let item: IShellItem = folder_view.GetFolder()?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + let path = PathBuf::from(display_name.to_string()?); + + self.send_copy_share_link(path); + } + } + + Ok(()) + } + + fn GetFlags(&self) -> Result { + Ok(ECF_DEFAULT.0 as u32) + } + + fn EnumSubCommands(&self) -> Result { + Err(Error::from(E_NOTIMPL)) + } +} diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs index 8319ce4c..74ffde54 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs @@ -1,4 +1,7 @@ -use super::{ResolveConflictCommandHandler, SyncNowCommandHandler, ViewOnlineCommandHandler}; +use super::{ + ResolveConflictCommandHandler, ShareLinkCommandHandler, SyncNowCommandHandler, + ViewOnlineCommandHandler, +}; use crate::{drive::manager::DriveManager, utils::app::AppRoot}; use std::sync::{Arc, Mutex}; use windows::{ @@ -109,14 +112,16 @@ macro_rules! sub_command_factory { } sub_command_factory!(create_view_online_command, ViewOnlineCommandHandler); +sub_command_factory!(create_share_link_command, ShareLinkCommandHandler); sub_command_factory!(create_sync_now_command, SyncNowCommandHandler); sub_command_factory!( create_resolve_conflict_command, ResolveConflictCommandHandler ); -const SUB_COMMAND_FACTORIES: [SubCommandFactory; 3] = [ +const SUB_COMMAND_FACTORIES: [SubCommandFactory; 4] = [ create_view_online_command, + create_share_link_command, create_sync_now_command, create_resolve_conflict_command, ]; diff --git a/desktop/crates/cloudreve-sync/src/utils/clipboard.rs b/desktop/crates/cloudreve-sync/src/utils/clipboard.rs new file mode 100644 index 00000000..65006426 --- /dev/null +++ b/desktop/crates/cloudreve-sync/src/utils/clipboard.rs @@ -0,0 +1,39 @@ +//! Minimal clipboard helpers. Only Windows sets real clipboard text; other +//! platforms no-op so callers can stay unconditional. + +#[cfg(windows)] +pub fn set_text(text: &str) -> anyhow::Result<()> { + use windows::Win32::{ + Foundation::HANDLE, + System::{ + DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}, + Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock}, + Ole::CF_UNICODETEXT, + }, + }; + + unsafe { + OpenClipboard(None)?; + let result = (|| -> anyhow::Result<()> { + EmptyClipboard()?; + let wide: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let hglobal = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2)?; + let dst = GlobalLock(hglobal); + if dst.is_null() { + return Err(anyhow::anyhow!("GlobalLock failed")); + } + std::ptr::copy_nonoverlapping(wide.as_ptr(), dst as *mut u16, wide.len()); + let _ = GlobalUnlock(hglobal); + SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hglobal.0))?; + Ok(()) + })(); + let _ = CloseClipboard(); + result + } +} + +#[cfg(not(windows))] +pub fn set_text(_text: &str) -> anyhow::Result<()> { + tracing::warn!(target: "utils::clipboard", "Clipboard set_text is not supported on this platform"); + Ok(()) +} diff --git a/desktop/crates/cloudreve-sync/src/utils/mod.rs b/desktop/crates/cloudreve-sync/src/utils/mod.rs index 68495be4..13874d13 100644 --- a/desktop/crates/cloudreve-sync/src/utils/mod.rs +++ b/desktop/crates/cloudreve-sync/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod app; +pub mod clipboard; pub mod toast; diff --git a/desktop/locales/app.yaml b/desktop/locales/app.yaml index 5a375db1..003ffcc4 100644 --- a/desktop/locales/app.yaml +++ b/desktop/locales/app.yaml @@ -11,6 +11,42 @@ viewOnline: ru: "Просмотреть онлайн" pl: "Zobacz online" it: "Visualizza online" +copyShareLink: + en-US: "Copy share link" + zh-CN: "复制分享链接" + zh-TW: "複製分享連結" + ja: "共有リンクをコピー" + de: "Freigabelink kopieren" + fr: "Copier le lien de partage" + es: "Copiar enlace para compartir" + ko: "공유 링크 복사" + ru: "Копировать ссылку" + pl: "Kopiuj link udostępniania" + it: "Copia link di condivisione" +shareLinkCopied: + en-US: "Share link copied to clipboard" + zh-CN: "分享链接已复制到剪贴板" + zh-TW: "分享連結已複製到剪貼簿" + ja: "共有リンクをクリップボードにコピーしました" + de: "Freigabelink in die Zwischenablage kopiert" + fr: "Lien de partage copié dans le presse-papiers" + es: "Enlace para compartir copiado al portapapeles" + ko: "공유 링크가 클립보드에 복사되었습니다" + ru: "Ссылка скопирована в буфер обмена" + pl: "Link udostępniania skopiowany do schowka" + it: "Link di condivisione copiato negli appunti" +shareLinkFailed: + en-US: "Failed to create share link" + zh-CN: "创建分享链接失败" + zh-TW: "建立分享連結失敗" + ja: "共有リンクの作成に失敗しました" + de: "Freigabelink konnte nicht erstellt werden" + fr: "Échec de la création du lien de partage" + es: "Error al crear el enlace para compartir" + ko: "공유 링크 생성 실패" + ru: "Не удалось создать ссылку" + pl: "Nie udało się utworzyć linku udostępniania" + it: "Impossibile creare il link di condivisione" shared: en-US: "Shared" zh-CN: "已共享"