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 <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
root 2 weeks ago
parent 16e6d7330c
commit 6d1c25f60f

@ -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;

@ -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<String>;
}
#[async_trait]
impl ShareApi for Client {
async fn create_share(&self, request: &ShareCreateService) -> ApiResult<String> {
self.put("/share", request, RequestOptions::new()).await
}
}

@ -20,3 +20,10 @@ pub struct ListShareResponse {
pub shares: Vec<Share>,
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,
}

@ -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",

@ -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<String> {
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;

@ -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");

@ -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;

@ -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<DriveManager>,
app_root: AppRoot,
site: Mutex<Option<IUnknown>>,
}
impl ShareLinkCommandHandler {
pub fn new(drive_manager: Arc<DriveManager>, 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<PWSTR> {
let title = t!("copyShareLink");
let hstring = HSTRING::from(title.as_ref());
unsafe { SHStrDupW(&hstring) }
}
fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
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<PWSTR> {
Err(Error::from(E_NOTIMPL))
}
fn GetCanonicalName(&self) -> Result<GUID> {
Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b))
}
fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result<u32> {
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<u32> {
Ok(ECF_DEFAULT.0 as u32)
}
fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> {
Err(Error::from(E_NOTIMPL))
}
}

@ -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,
];

@ -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<u16> = 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(())
}

@ -1,2 +1,3 @@
pub mod app;
pub mod clipboard;
pub mod toast;

@ -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: "已共享"

Loading…
Cancel
Save