From 46a36af0bb4dcda50f4143575417e19dca7e4586 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 22:01:21 +0800 Subject: [PATCH 01/25] Add baseline Linux sync support --- crates/cloudreve-sync/Cargo.toml | 9 +- .../cloudreve-sync/src/cfapi/non_windows.rs | 391 ++++++++++++++++++ crates/cloudreve-sync/src/drive/commands.rs | 4 +- crates/cloudreve-sync/src/drive/mod.rs | 5 + crates/cloudreve-sync/src/drive/mounts.rs | 31 +- .../src/drive/placeholder_non_windows.rs | 125 ++++++ crates/cloudreve-sync/src/drive/sync.rs | 22 + crates/cloudreve-sync/src/drive/utils.rs | 8 + crates/cloudreve-sync/src/lib.rs | 8 + .../src/shellext/non_windows.rs | 21 + crates/cloudreve-sync/src/tasks/download.rs | 4 +- crates/cloudreve-sync/src/utils/app.rs | 19 + crates/cloudreve-sync/src/utils/toast.rs | 44 ++ docs/linux-support-plan.md | 37 ++ src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 19 + 16 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 crates/cloudreve-sync/src/cfapi/non_windows.rs create mode 100644 crates/cloudreve-sync/src/drive/placeholder_non_windows.rs create mode 100644 crates/cloudreve-sync/src/shellext/non_windows.rs create mode 100644 docs/linux-support-plan.md diff --git a/crates/cloudreve-sync/Cargo.toml b/crates/cloudreve-sync/Cargo.toml index e239a11..84ef14c 100644 --- a/crates/cloudreve-sync/Cargo.toml +++ b/crates/cloudreve-sync/Cargo.toml @@ -22,7 +22,6 @@ sha2 = "0.10" image = "0.24" url = "2.5" cloudreve-api = { path = "../cloudreve-api" } -windows-core = "0.58.0" nt-time = "0.8.0" widestring = "1.0.2" flagset = "0.4.5" @@ -43,9 +42,12 @@ aes = "0.8" ctr = "0.9" tokio-util = { version = "0.7", features = ["io"] } globset = "0.4" + +[target.'cfg(windows)'.dependencies] +windows-core = "0.58.0" win32_notif = { path = "../win32_notif" } -[dependencies.windows] +[target.'cfg(windows)'.dependencies.windows] version = "0.58.0" features = [ "implement", @@ -79,9 +81,6 @@ features = [ "Win32_UI_Notifications", ] -[build-dependencies] -windows = { version = "0.58.0", features = ["Win32_Foundation"] } - [dependencies.uuid] version = "1.6" features = ["v4", "serde"] diff --git a/crates/cloudreve-sync/src/cfapi/non_windows.rs b/crates/cloudreve-sync/src/cfapi/non_windows.rs new file mode 100644 index 0000000..81ec3e3 --- /dev/null +++ b/crates/cloudreve-sync/src/cfapi/non_windows.rs @@ -0,0 +1,391 @@ +use std::{ops::RangeBounds, path::Path, time::SystemTime}; + +use anyhow::{anyhow, Result}; + +pub mod filter { + pub mod ticket { + #[derive(Debug)] + pub struct FetchData; + + impl FetchData { + pub fn write_at(&self, _data: &[u8], _offset: u64) -> anyhow::Result<()> { + Err(anyhow::anyhow!("placeholder hydration is not supported on this platform")) + } + + pub fn report_progress(&self, _total: u64, _completed: u64) -> anyhow::Result<()> { + Ok(()) + } + } + } +} + +pub mod utility { + pub trait WriteAt {} +} + +pub mod root { + use anyhow::{anyhow, Result}; + use serde::{Deserialize, Serialize}; + use std::{ffi::OsString, marker::PhantomData, path::Path}; + + #[derive(Debug)] + pub struct Connection(PhantomData); + + impl Connection { + pub fn disconnect(&self) -> Result<()> { + Ok(()) + } + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct SyncRootId(String); + + impl SyncRootId { + pub fn to_os_string(&self) -> OsString { + OsString::from(self.0.clone()) + } + + pub fn is_registered(&self) -> Result { + Ok(false) + } + + pub fn register(&self, _info: SyncRootInfo) -> Result<()> { + Err(anyhow!("sync root registration is not supported on this platform")) + } + + pub fn unregister(&self) -> Result<()> { + Ok(()) + } + + pub fn index(&self) -> Result<()> { + Ok(()) + } + } + + #[derive(Debug, Clone, Copy)] + pub enum HydrationType { + Full, + } + + #[derive(Debug, Clone, Copy)] + pub enum PopulationType { + Full, + } + + pub struct SecurityId; + + impl SecurityId { + pub fn current_user() -> Result { + Ok(Self) + } + } + + pub struct Session; + + impl Session { + pub fn new() -> Self { + Self + } + + pub fn connect(&self, _path: &Path, _callback: T) -> Result> { + Err(anyhow!("placeholder sync roots are not supported on this platform")) + } + } + + #[derive(Default)] + pub struct SyncRootInfo; + + impl SyncRootInfo { + pub fn set_display_name(&mut self, _name: String) {} + pub fn set_hydration_type(&mut self, _hydration_type: HydrationType) {} + pub fn set_population_type(&mut self, _population_type: PopulationType) {} + pub fn set_icon(&mut self, _icon: String) {} + pub fn set_version(&mut self, _version: &str) {} + pub fn set_recycle_bin_uri(&mut self, _uri: String) -> Result<()> { + Ok(()) + } + pub fn set_path(&mut self, _path: &Path) -> Result<()> { + Ok(()) + } + pub fn add_custom_state(&mut self, _label: &str, _state: u32) -> Result<()> { + Ok(()) + } + } + + pub struct SyncRootIdBuilder { + provider_name: String, + account_name: Option, + } + + impl SyncRootIdBuilder { + pub fn new(provider_name: String) -> Self { + Self { + provider_name, + account_name: None, + } + } + + pub fn user_security_id(self, _security_id: SecurityId) -> Self { + self + } + + pub fn account_name(mut self, account_name: &str) -> Self { + self.account_name = Some(account_name.to_string()); + self + } + + pub fn build(self) -> SyncRootId { + SyncRootId(format!( + "{}!{}", + self.provider_name, + self.account_name.unwrap_or_default() + )) + } + } +} + +pub mod metadata { + use nt_time::FileTime; + + #[derive(Clone, Default)] + pub struct Metadata; + + impl Metadata { + pub fn directory() -> Self { + Self + } + + pub fn file() -> Self { + Self + } + + pub fn size(self, _size: u64) -> Self { + self + } + + pub fn changed(self, _time: FileTime) -> Self { + self + } + + pub fn written(self, _time: FileTime) -> Self { + self + } + + pub fn created(self, _time: FileTime) -> Self { + self + } + } +} + +pub mod placeholder_file { + use std::path::{Path, PathBuf}; + + use anyhow::{anyhow, Result}; + + use super::metadata::Metadata; + + pub struct PlaceholderFile { + path: PathBuf, + } + + impl PlaceholderFile { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn metadata(self, _metadata: Metadata) -> Self { + self + } + + pub fn mark_in_sync(self) -> Self { + self + } + + pub fn overwrite(self) -> Self { + self + } + + pub fn blob(self, _blob: Vec) -> Self { + self + } + + pub fn create>(&self, _parent: P) -> Result<()> { + Err(anyhow!( + "placeholder creation is not supported on this platform: {}", + self.path.display() + )) + } + } +} + +pub mod placeholder { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum PinState { + Unspecified, + Pinned, + Unpinned, + } + + #[derive(Debug, Clone)] + pub struct LocalFileInfo { + pub exists: bool, + pub is_directory: bool, + pub file_size: Option, + pub last_modified: Option, + } + + impl LocalFileInfo { + pub fn from_path(path: impl AsRef) -> Result { + match std::fs::metadata(path.as_ref()) { + Ok(metadata) => Ok(Self { + exists: true, + is_directory: metadata.is_dir(), + file_size: Some(metadata.len()), + last_modified: metadata.modified().ok(), + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::missing()), + Err(err) => Err(err.into()), + } + } + + pub fn missing() -> Self { + Self { + exists: false, + is_directory: false, + file_size: None, + last_modified: None, + } + } + + pub fn is_placeholder(&self) -> bool { + false + } + + pub fn in_sync(&self) -> bool { + false + } + + pub fn is_directory(&self) -> bool { + self.is_directory + } + + pub fn partial_on_disk(&self) -> bool { + false + } + + pub fn is_folder_populated(&self) -> bool { + self.exists && self.is_directory + } + + pub fn pinned(&self) -> PinState { + PinState::Pinned + } + + pub fn sync_error_state(&self) -> bool { + false + } + } + + #[derive(Default)] + pub struct ConvertOptions; + + impl ConvertOptions { + pub fn mark_in_sync(self) -> Self { + self + } + + pub fn blob(self, _blob: Vec) -> Self { + self + } + } + + #[derive(Default)] + pub struct UpdateOptions; + + impl UpdateOptions { + pub fn mark_in_sync() -> Self { + Self + } + + pub fn metadata(self, _metadata: crate::cfapi::metadata::Metadata) -> Self { + self + } + + pub fn dehydrate(self) -> Self { + self + } + + pub fn has_no_children(self) -> Self { + self + } + } + + pub struct OpenOptions; + + impl OpenOptions { + pub fn new() -> Self { + Self + } + + pub fn write_access(self) -> Self { + self + } + + pub fn exclusive(self) -> Self { + self + } + + pub fn open(self, _path: impl AsRef) -> Result { + Ok(PlaceholderHandle) + } + + pub fn open_win32(self, _path: impl AsRef) -> Result { + Ok(PlaceholderHandle) + } + + pub async fn open_with_retry(self, _path: impl AsRef) -> Result { + Ok(PlaceholderHandle) + } + + pub async fn open_win32_with_retry( + self, + _path: impl AsRef, + ) -> Result { + Ok(PlaceholderHandle) + } + } + + pub struct PlaceholderHandle; + + impl PlaceholderHandle { + pub fn convert_to_placeholder( + &mut self, + _options: ConvertOptions, + _progress: Option<()>, + ) -> Result<()> { + Err(anyhow!("placeholder conversion is not supported on this platform")) + } + + pub fn update(&mut self, _options: UpdateOptions, _progress: Option<()>) -> Result<()> { + Ok(()) + } + + pub fn mark_in_sync(&mut self, _in_sync: bool, _progress: Option<()>) -> Result<()> { + Ok(()) + } + + pub fn set_pin_state(&mut self, _pin_state: PinState, _progress: Option<()>) -> Result<()> { + Ok(()) + } + + pub fn hydrate>(&mut self, _range: R) -> Result<()> { + Ok(()) + } + + pub fn dehydrate>(&mut self, _range: R) -> Result<()> { + Ok(()) + } + } +} diff --git a/crates/cloudreve-sync/src/drive/commands.rs b/crates/cloudreve-sync/src/drive/commands.rs index b438d08..3c0420e 100644 --- a/crates/cloudreve-sync/src/drive/commands.rs +++ b/crates/cloudreve-sync/src/drive/commands.rs @@ -2,7 +2,6 @@ use crate::{ cfapi::{ filter::ticket, placeholder::{LocalFileInfo, OpenOptions, PinState}, - utility::WriteAt, }, drive::{ mounts::Mount, @@ -39,7 +38,10 @@ use std::{ }; use tokio::sync::oneshot::Sender; use uuid::Uuid; +#[cfg(windows)] use windows::Win32::UI::Shell::SHCNE_ATTRIBUTES; +#[cfg(not(windows))] +const SHCNE_ATTRIBUTES: u32 = 0; const PAGE_SIZE: i32 = 1000; /// Generate a unique filename by appending a counter suffix before the extension. diff --git a/crates/cloudreve-sync/src/drive/mod.rs b/crates/cloudreve-sync/src/drive/mod.rs index e736a13..5db080b 100644 --- a/crates/cloudreve-sync/src/drive/mod.rs +++ b/crates/cloudreve-sync/src/drive/mod.rs @@ -1,9 +1,14 @@ +#[cfg(windows)] pub mod callback; pub mod commands; pub mod event_blocker; pub mod ignore; pub mod manager; pub mod mounts; +#[cfg(windows)] +pub mod placeholder; +#[cfg(not(windows))] +#[path = "placeholder_non_windows.rs"] pub mod placeholder; pub mod remote_events; pub mod sync; diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index cd957bd..100e535 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -1,13 +1,16 @@ +#[cfg(windows)] use crate::cfapi::root::{ - Connection, HydrationType, PopulationType, SecurityId, Session, SyncRootId, SyncRootIdBuilder, - SyncRootInfo, + HydrationType, PopulationType, SecurityId, Session, SyncRootIdBuilder, SyncRootInfo, }; +use crate::cfapi::root::{Connection, SyncRootId}; +#[cfg(windows)] use crate::drive::callback::CallbackHandler; use crate::drive::commands::ManagerCommand; use crate::drive::commands::MountCommand; use crate::drive::event_blocker::EventBlocker; use crate::drive::ignore::IgnoreMatcher; use crate::drive::sync::group_fs_events; +#[cfg(windows)] use crate::drive::utils::recycle_bin_url; use crate::inventory::{DrivePropsUpdate, InventoryDb, TaskRecord}; use crate::tasks::{TaskProgress, TaskQueue, TaskQueueConfig}; @@ -18,6 +21,7 @@ use cloudreve_api::api::user::UserApi; use cloudreve_api::{Client, ClientConfig, models::user::Token}; use notify_debouncer_full::notify::{RecommendedWatcher, RecursiveMode}; use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer}; +#[cfg(windows)] use sha2::{Digest, Sha256}; use std::time::Duration; use std::{ @@ -28,8 +32,15 @@ use std::{ use tokio::spawn; use tokio::sync::{Mutex, RwLock, mpsc}; use tokio::task::JoinHandle; +#[cfg(windows)] use url::Url; +#[cfg(windows)] use windows::Storage::Provider::StorageProviderSyncRootManager; + +#[cfg(windows)] +type MountConnection = Connection; +#[cfg(not(windows))] +type MountConnection = Connection<()>; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DriveConfig { pub id: String, @@ -128,7 +139,7 @@ type FsWatcher = Debouncer; pub struct Mount { pub config: Arc>, - connection: Option>, + connection: Option, pub command_tx: mpsc::UnboundedSender, command_rx: Arc>>>, processor_handle: Arc>>>, @@ -371,6 +382,18 @@ impl Mount { } pub async fn start(&mut self) -> Result<()> { + #[cfg(not(windows))] + { + let sync_path = self.config.read().await.sync_path.clone(); + std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; + self.start_fs_watcher().await?; + self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) + .await?; + return Ok(()); + } + + #[cfg(windows)] + { if !StorageProviderSyncRootManager::IsSupported() .context("Cloud Filter API is not supported")? { @@ -441,6 +464,7 @@ impl Mount { self.connection = Some(connection); self.start_fs_watcher().await?; Ok(()) + } } pub async fn start_fs_watcher(&self) -> Result<()> { @@ -763,6 +787,7 @@ impl Mount { } } +#[cfg(windows)] fn generate_sync_root_id( instance_url: &str, _account_name: &str, diff --git a/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs new file mode 100644 index 0000000..2eeebb8 --- /dev/null +++ b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs @@ -0,0 +1,125 @@ +use std::{path::PathBuf, sync::Arc}; + +use anyhow::{Context, Result}; +use chrono::DateTime; +use cloudreve_api::models::explorer::{file_type, FileResponse}; +use uuid::Uuid; + +use crate::{ + cfapi::placeholder::LocalFileInfo, + inventory::{FileMetadata, InventoryDb, MetadataEntry}, +}; + +pub struct CrPlaceholder { + pub local_file_info: LocalFileInfo, + local_path: PathBuf, + drive_id: Uuid, + file_meta: Option, + mark_no_children: bool, +} + +impl CrPlaceholder { + pub fn new(local_path: impl Into, _sync_root: PathBuf, drive_id: Uuid) -> Self { + let local_path = local_path.into(); + Self { + local_file_info: LocalFileInfo::from_path(&local_path) + .unwrap_or_else(|_| LocalFileInfo::missing()), + local_path, + drive_id, + file_meta: None, + mark_no_children: false, + } + } + + pub fn with_invalidate_all_range(self, _enable: bool) -> Self { + self + } + + pub fn with_mark_no_children(mut self, enable: bool) -> Self { + self.mark_no_children = enable; + self + } + + pub fn with_file_meta(mut self, file_meta: FileMetadata) -> Self { + self.file_meta = Some(file_meta); + self + } + + pub fn delete_placeholder(&self, inventory: Arc) -> Result<()> { + if self.local_file_info.exists { + if self.local_path.is_dir() { + std::fs::remove_dir_all(&self.local_path) + .context("failed to delete local directory")?; + } else { + std::fs::remove_file(&self.local_path).context("failed to delete local file")?; + } + } + + let path_str = self + .local_path + .to_str() + .context("failed to convert path to string")?; + inventory + .batch_delete_by_path(vec![path_str]) + .context("failed to delete from inventory")?; + Ok(()) + } + + pub fn commit(&mut self, inventory: Arc) -> Result<()> { + let file_meta = self + .file_meta + .as_ref() + .ok_or_else(|| anyhow::anyhow!("File metadata is not set"))?; + + if file_meta.is_folder { + std::fs::create_dir_all(&self.local_path) + .context("failed to create local directory")?; + } else if let Some(parent) = self.local_path.parent() { + std::fs::create_dir_all(parent).context("failed to create parent directory")?; + } + + inventory + .upsert(&MetadataEntry::from(file_meta)) + .context("failed to upsert inventory")?; + self.local_file_info = + LocalFileInfo::from_path(&self.local_path).unwrap_or_else(|_| LocalFileInfo::missing()); + Ok(()) + } + + pub fn with_remote_file(mut self, file_info: &FileResponse) -> Self { + let created_at = DateTime::parse_from_rfc3339(&file_info.created_at) + .ok() + .map(|dt| dt.timestamp()) + .unwrap_or_default(); + + let updated_at = DateTime::parse_from_rfc3339(&file_info.updated_at) + .ok() + .map(|dt| dt.timestamp()) + .unwrap_or_default(); + + self.file_meta = Some(FileMetadata { + id: 0, + drive_id: self.drive_id, + local_path: self.local_path.to_string_lossy().to_string(), + is_folder: file_info.file_type == file_type::FOLDER, + created_at, + updated_at, + size: file_info.size, + etag: file_info + .primary_entity + .as_ref() + .unwrap_or(&String::new()) + .clone(), + permissions: file_info.permission.as_ref().unwrap_or(&String::new()).clone(), + shared: file_info.shared.unwrap_or(false), + metadata: file_info.metadata.clone().unwrap_or_default(), + props: None, + conflict_state: None, + }); + self + } + + pub fn update_sync_error_state(&mut self, _sync_error: bool) -> Result<()> { + Ok(()) + } +} diff --git a/crates/cloudreve-sync/src/drive/sync.rs b/crates/cloudreve-sync/src/drive/sync.rs index ac28921..4a2c195 100644 --- a/crates/cloudreve-sync/src/drive/sync.rs +++ b/crates/cloudreve-sync/src/drive/sync.rs @@ -585,6 +585,17 @@ impl Mount { "Failed to create placeholder and inventory" ); aggregate_error.push(path.clone(), err); + } else { + #[cfg(not(windows))] + if remote.file_type != file_type::FOLDER { + if let Err(err) = self + .task_queue + .enqueue(TaskPayload::download(path.clone())) + .await + { + aggregate_error.push(path.clone(), anyhow::Error::from(err)); + } + } } } SyncAction::UpdateInventoryFromRemote { @@ -607,6 +618,17 @@ impl Mount { "Failed to update inventory from remote" ); aggregate_error.push(path.clone(), err); + } else { + #[cfg(not(windows))] + if remote.file_type != file_type::FOLDER { + if let Err(err) = self + .task_queue + .enqueue(TaskPayload::download(path.clone())) + .await + { + aggregate_error.push(path.clone(), anyhow::Error::from(err)); + } + } } } SyncAction::QueueUpload { path, reason } => { diff --git a/crates/cloudreve-sync/src/drive/utils.rs b/crates/cloudreve-sync/src/drive/utils.rs index 8e4264d..2f6b693 100644 --- a/crates/cloudreve-sync/src/drive/utils.rs +++ b/crates/cloudreve-sync/src/drive/utils.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use cloudreve_api::models::uri::CrUri; use url::Url; +#[cfg(windows)] use widestring::U16CString; +#[cfg(windows)] use windows::Win32::UI::Shell::{SHCNE_ID, SHCNF_PATHW, SHChangeNotify}; use crate::drive::mounts::DriveConfig; @@ -93,6 +95,7 @@ pub fn recycle_bin_url(config: &DriveConfig) -> Result { } // notify_shell_change notify the shell to refresh the file or directory +#[cfg(windows)] pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> { let utf16_path = U16CString::from_os_str(path.as_path())?; unsafe { @@ -105,3 +108,8 @@ pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> { } Ok(()) } + +#[cfg(not(windows))] +pub fn notify_shell_change(_path: &PathBuf, _event: u32) -> Result<()> { + Ok(()) +} diff --git a/crates/cloudreve-sync/src/lib.rs b/crates/cloudreve-sync/src/lib.rs index 0f06452..62d91b3 100644 --- a/crates/cloudreve-sync/src/lib.rs +++ b/crates/cloudreve-sync/src/lib.rs @@ -1,9 +1,17 @@ +#[cfg(windows)] +pub mod cfapi; +#[cfg(not(windows))] +#[path = "cfapi/non_windows.rs"] pub mod cfapi; pub mod config; pub mod drive; pub mod events; pub mod inventory; pub mod logging; +#[cfg(windows)] +pub mod shellext; +#[cfg(not(windows))] +#[path = "shellext/non_windows.rs"] pub mod shellext; pub mod tasks; pub mod uploader; diff --git a/crates/cloudreve-sync/src/shellext/non_windows.rs b/crates/cloudreve-sync/src/shellext/non_windows.rs new file mode 100644 index 0000000..c588f23 --- /dev/null +++ b/crates/cloudreve-sync/src/shellext/non_windows.rs @@ -0,0 +1,21 @@ +pub mod shell_service { + use std::sync::Arc; + + use crate::drive::manager::DriveManager; + + #[derive(Debug, Default)] + pub struct ServiceHandle; + + impl ServiceHandle { + pub fn wait_for_init(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + pub fn shutdown(&self) {} + } + + pub fn init_and_start_service_task(_drive_manager: Arc) -> ServiceHandle { + tracing::info!(target: "shellext::shell_service", "Shell services are not available on this platform"); + ServiceHandle + } +} diff --git a/crates/cloudreve-sync/src/tasks/download.rs b/crates/cloudreve-sync/src/tasks/download.rs index 02d54bf..e454ea8 100644 --- a/crates/cloudreve-sync/src/tasks/download.rs +++ b/crates/cloudreve-sync/src/tasks/download.rs @@ -23,7 +23,9 @@ use dashmap::DashMap; use futures::StreamExt; use tokio::io::AsyncWriteExt; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +#[cfg(windows)] +use tracing::warn; +use tracing::{debug, info}; use uuid::Uuid; use crate::{ diff --git a/crates/cloudreve-sync/src/utils/app.rs b/crates/cloudreve-sync/src/utils/app.rs index 49179b4..8097443 100644 --- a/crates/cloudreve-sync/src/utils/app.rs +++ b/crates/cloudreve-sync/src/utils/app.rs @@ -1,14 +1,18 @@ use std::sync::{Arc, OnceLock}; +#[cfg(windows)] use windows::ApplicationModel; static APP_ROOT: OnceLock> = OnceLock::new(); pub fn init_app_root() { + #[cfg(windows)] let path = ApplicationModel::Package::Current() .and_then(|p| p.InstalledLocation()) .and_then(|l| l.Path()) .map(|p| p.to_string()) .unwrap_or_else(|_| String::new()); + #[cfg(not(windows))] + let path = String::new(); APP_ROOT.set(Arc::new(path)).ok(); } @@ -18,18 +22,33 @@ pub fn get_app_root() -> AppRoot { } #[derive(Clone)] +#[allow(dead_code)] pub struct AppRoot(Arc); impl AppRoot { pub fn image_path(&self) -> String { + #[cfg(windows)] + { match dark_light::detect().unwrap_or(dark_light::Mode::Light) { dark_light::Mode::Dark => format!("{}\\Images\\darkTheme", self.0.as_str()), dark_light::Mode::Light => format!("{}\\Images\\lightTheme", self.0.as_str()), dark_light::Mode::Unspecified => format!("{}\\Images\\lightTheme", self.0.as_str()), } + } + #[cfg(not(windows))] + { + String::new() + } } pub fn image_path_general(&self) -> String { + #[cfg(windows)] + { format!("{}\\Images", self.0.as_str()) + } + #[cfg(not(windows))] + { + String::new() + } } } diff --git a/crates/cloudreve-sync/src/utils/toast.rs b/crates/cloudreve-sync/src/utils/toast.rs index ffaae61..d5f1b82 100644 --- a/crates/cloudreve-sync/src/utils/toast.rs +++ b/crates/cloudreve-sync/src/utils/toast.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; +#[cfg(windows)] use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +#[cfg(windows)] use win32_notif::{ NotificationBuilder, ToastsNotifier, notification::{ @@ -11,8 +13,10 @@ use win32_notif::{ use crate::config::ConfigManager; +#[cfg(windows)] const APP_NAME: &str = "Cloudreve.Sync"; +#[cfg(windows)] pub fn send_general_text_toast(title: &str, message: &str) { let notifier = ToastsNotifier::new(APP_NAME).unwrap(); @@ -35,7 +39,13 @@ pub fn send_general_text_toast(title: &str, message: &str) { notif.show().unwrap(); } +#[cfg(not(windows))] +pub fn send_general_text_toast(title: &str, message: &str) { + tracing::info!(target: "toast", title, message, "Toast notification skipped on this platform"); +} + /// Send a toast notification with a warning icon. +#[cfg(windows)] pub fn send_warning_toast(title: &str, message: &str) { let notifier = ToastsNotifier::new(APP_NAME).unwrap(); @@ -62,9 +72,15 @@ pub fn send_warning_toast(title: &str, message: &str) { notif.show().unwrap(); } +#[cfg(not(windows))] +pub fn send_warning_toast(title: &str, message: &str) { + tracing::warn!(target: "toast", title, message, "Warning toast skipped on this platform"); +} + /// Send a toast notification for token expiry. /// Uses drive_id as the tag to prevent duplicate notifications for the same drive. /// Respects the notify_credential_expired config setting. +#[cfg(windows)] pub fn send_token_expiry_toast(drive_id: &str, title: &str, message: &str) { // Check if credential expired notifications are enabled if let Some(config) = ConfigManager::try_get() { @@ -100,8 +116,20 @@ pub fn send_token_expiry_toast(drive_id: &str, title: &str, message: &str) { notif.show().unwrap(); } +#[cfg(not(windows))] +pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_credential_expired() { + tracing::debug!(target: "toast", "Token expiry notification suppressed by config"); + return; + } + } + tracing::warn!(target: "toast", title, message, "Token expiry toast skipped on this platform"); +} + /// Send a toast notification for file conflicts. /// Respects the notify_file_conflict config setting. +#[cfg(windows)] pub fn send_conflict_toast(drive_id: &str, path: &PathBuf, inventory_id: i64) { // Check if file conflict notifications are enabled if let Some(config) = ConfigManager::try_get() { @@ -153,3 +181,19 @@ pub fn send_conflict_toast(drive_id: &str, path: &PathBuf, inventory_id: i64) { notif.show().unwrap(); } + +#[cfg(not(windows))] +pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_file_conflict() { + tracing::debug!(target: "toast", "Conflict notification suppressed by config"); + return; + } + } + tracing::warn!( + target: "toast", + path = %path.display(), + inventory_id, + "Conflict toast skipped on this platform" + ); +} diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md new file mode 100644 index 0000000..287ecfd --- /dev/null +++ b/docs/linux-support-plan.md @@ -0,0 +1,37 @@ +# Linux Support Plan + +## Phase 1: Baseline Linux Support + +Status: implemented. + +- Build `cloudreve-sync` and the Tauri app on Linux. +- Keep Windows CFAPI and shell extension code behind `cfg(windows)`. +- Provide non-Windows compatibility layers for shell services, notifications, app resource roots, and CFAPI-shaped file metadata. +- On Linux and other non-Windows platforms, use full sync only: + - Create local directories for remote folders. + - Update inventory for remote entries. + - Queue downloads for remote files instead of creating placeholders. + - Keep local filesystem watching and upload/download task queues active. +- Do not expose placeholder semantics on Linux yet. + +## Phase 2: KDE Placeholder Support + +Status: planned. + +- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. +- Add a Linux platform capability model: + - `FullSyncOnly` for GNOME, XFCE, Cinnamon, generic Wayland/X11, and unknown desktops. + - `KdePlaceholders` for supported KDE environments. +- Implement KDE placeholder integration as a separate backend from Windows CFAPI. +- Keep non-KDE desktops on the Phase 1 full sync path. +- Add clear logging when KDE placeholder support is unavailable or disabled. + +## Phase 3: UI And Packaging + +Status: planned. + +- Surface the active Linux sync mode in settings. +- Disable placeholder-only options outside KDE. +- Add Linux autostart support through freedesktop `.desktop` files. +- Add Linux packaging metadata and install hooks for KDE integration. +- Add integration tests for full sync and targeted KDE capability detection. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e589d8f..0b269d5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,7 +46,7 @@ tauri-plugin-dialog = "2.6.0" tauri-plugin-prevent-default = "4" tauri-plugin-os = "2" -[dependencies.windows] +[target.'cfg(windows)'.dependencies.windows] version = "0.58.0" features = ["Foundation", "ApplicationModel"] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e4c95c0..8bf88c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,6 +14,7 @@ use tauri::{ use tauri_plugin_frame::WebviewWindowExt; use tauri_plugin_positioner::{Position, WindowExt}; use uuid::Uuid; +#[cfg(windows)] use windows::ApplicationModel::{StartupTask, StartupTaskState}; /// Result type for Tauri commands @@ -519,11 +520,19 @@ pub fn show_settings_window_impl(app: &AppHandle) { } /// The TaskId defined in AppxManifest.xml for the startup task +#[cfg(windows)] const STARTUP_TASK_ID: &str = "cloudreve"; /// Get whether auto-start is enabled using Windows StartupTask API #[tauri::command] pub async fn get_auto_start_enabled() -> CommandResult { + #[cfg(not(windows))] + { + return Ok(false); + } + + #[cfg(windows)] + { tokio::task::spawn_blocking(|| { let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); let task = StartupTask::GetAsync(&task_id) @@ -542,11 +551,20 @@ pub async fn get_auto_start_enabled() -> CommandResult { }) .await .map_err(|e| format!("Task join error: {}", e))? + } } /// Set auto-start configuration using Windows StartupTask API #[tauri::command] pub async fn set_auto_start(enabled: bool) -> CommandResult { + #[cfg(not(windows))] + { + let _ = enabled; + return Err("Auto-start configuration is not supported on this platform yet".to_string()); + } + + #[cfg(windows)] + { tokio::task::spawn_blocking(move || { let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); let task = StartupTask::GetAsync(&task_id) @@ -574,6 +592,7 @@ pub async fn set_auto_start(enabled: bool) -> CommandResult { }) .await .map_err(|e| format!("Task join error: {}", e))? + } } /// Set notification settings for credential expiry From 796cfa5f27eda68efbf5220f0dc69325a9fa8324 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 22:29:22 +0800 Subject: [PATCH 02/25] Add KDE Linux sync backend integration --- .../cloudreve-sync/src/drive/manager/mod.rs | 28 ++- crates/cloudreve-sync/src/drive/mounts.rs | 158 +++++++++------- crates/cloudreve-sync/src/kde.rs | 162 +++++++++++++++++ crates/cloudreve-sync/src/lib.rs | 9 +- crates/cloudreve-sync/src/linux.rs | 82 +++++++++ crates/cloudreve-sync/src/platform.rs | 168 ++++++++++++++++++ docs/linux-support-plan.md | 14 +- src-tauri/src/commands.rs | 110 ++++++------ src-tauri/src/lib.rs | 92 +++++++++- ui/public/locales/de/common.json | 20 +++ ui/public/locales/en-US/common.json | 20 +++ ui/public/locales/es/common.json | 20 +++ ui/public/locales/fr/common.json | 20 +++ ui/public/locales/it/common.json | 20 +++ ui/public/locales/ja/common.json | 20 +++ ui/public/locales/ko/common.json | 20 +++ ui/public/locales/pl/common.json | 20 +++ ui/public/locales/ru/common.json | 20 +++ ui/public/locales/zh-CN/common.json | 20 +++ ui/public/locales/zh-TW/common.json | 20 +++ ui/src/pages/settings/GeneralSection.tsx | 95 +++++++++- 21 files changed, 1001 insertions(+), 137 deletions(-) create mode 100644 crates/cloudreve-sync/src/kde.rs create mode 100644 crates/cloudreve-sync/src/linux.rs create mode 100644 crates/cloudreve-sync/src/platform.rs diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index 2ad7850..3023b5e 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -4,9 +4,10 @@ mod types; pub use types::*; +use crate::EventBroadcaster; use crate::drive::commands::ManagerCommand; use crate::drive::mounts::{Credentials, DriveConfig, Mount}; -use crate::EventBroadcaster; +use crate::drive::sync::SyncMode; use crate::inventory::InventoryDb; use crate::tasks::TaskProgress; use anyhow::{Context, Result}; @@ -55,6 +56,18 @@ impl DriveManager { self.inventory.clone() } + pub fn dispatch_sync_now(&self, paths: Vec, mode: SyncMode) -> Result<()> { + self.command_tx + .send(ManagerCommand::SyncNow { paths, mode }) + .context("Failed to dispatch sync command") + } + + pub fn dispatch_view_online(&self, path: PathBuf) -> Result<()> { + self.command_tx + .send(ManagerCommand::ViewOnline { path }) + .context("Failed to dispatch view online command") + } + /// Get the .cloudreve config directory path fn get_config_dir() -> Result { let home_dir = dirs::home_dir().context("Failed to get user home directory")?; @@ -483,7 +496,10 @@ impl DriveManager { .into_iter() .map(|task| { let progress = progress_map.remove(&task.id); - TaskWithProgress { task, live_progress: progress } + TaskWithProgress { + task, + live_progress: progress, + } }) .collect(); @@ -595,7 +611,7 @@ impl DriveManager { let status = if drive_state.is_credential_expired() { DriveInfoStatus::CredentialExpired } else { - if !drive_state.is_event_push_subscribed(){ + if !drive_state.is_event_push_subscribed() { DriveInfoStatus::EventPushLost } else { DriveInfoStatus::Active @@ -648,7 +664,11 @@ impl DriveManager { impl DriveManager { /// Get capacity summary from a mount's drive props. /// Only returns capacity if the remote_path filesystem is "my". - fn get_capacity_summary(mount: &Mount, drive_id: &str, remote_path: &str) -> Option { + fn get_capacity_summary( + mount: &Mount, + drive_id: &str, + remote_path: &str, + ) -> Option { // Only show capacity for "my" filesystem use cloudreve_api::models::uri::CrUri; let is_my_fs = CrUri::new(remote_path) diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index 100e535..f27cca8 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -1,8 +1,8 @@ +use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::cfapi::root::{ HydrationType, PopulationType, SecurityId, Session, SyncRootIdBuilder, SyncRootInfo, }; -use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::drive::callback::CallbackHandler; use crate::drive::commands::ManagerCommand; @@ -385,85 +385,105 @@ impl Mount { #[cfg(not(windows))] { let sync_path = self.config.read().await.sync_path.clone(); + #[cfg(target_os = "linux")] + { + let backend = crate::linux::LinuxSyncBackend::prepare_current()?; + backend.log_selection(&self.id); + backend.prepare_sync_root(&sync_path)?; + self.start_fs_watcher().await?; + if backend.should_run_initial_full_sync() { + self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) + .await?; + } + return Ok(()); + } + + #[cfg(not(target_os = "linux"))] std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; + #[cfg(not(target_os = "linux"))] self.start_fs_watcher().await?; + #[cfg(not(target_os = "linux"))] self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) .await?; + #[cfg(not(target_os = "linux"))] return Ok(()); } #[cfg(windows)] { - if !StorageProviderSyncRootManager::IsSupported() - .context("Cloud Filter API is not supported")? - { - return Err(anyhow::anyhow!("Cloud Filter API is not supported")); - } - - let mut write_guard = self.config.write().await; + if !StorageProviderSyncRootManager::IsSupported() + .context("Cloud Filter API is not supported")? + { + return Err(anyhow::anyhow!("Cloud Filter API is not supported")); + } - // if sync root id is not set, generate one - if write_guard.sync_root_id.is_none() { - write_guard.sync_root_id = Some( - generate_sync_root_id( - &write_guard.instance_url, - &write_guard.name, - &write_guard.user_id, - &write_guard.sync_path, - ) - .context("failed to generate sync root id")?, - ); - } + let mut write_guard = self.config.write().await; + + // if sync root id is not set, generate one + if write_guard.sync_root_id.is_none() { + write_guard.sync_root_id = Some( + generate_sync_root_id( + &write_guard.instance_url, + &write_guard.name, + &write_guard.user_id, + &write_guard.sync_path, + ) + .context("failed to generate sync root id")?, + ); + } - drop(write_guard); - let config = self.config.read().await; + drop(write_guard); + let config = self.config.read().await; - let sync_root_id = config.sync_root_id.as_ref().unwrap(); + let sync_root_id = config.sync_root_id.as_ref().unwrap(); + + // Register sync root if not registered + if !sync_root_id.is_registered()? { + tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); + let mut sync_root_info = SyncRootInfo::default(); + sync_root_info.set_display_name(config.name.clone()); + sync_root_info.set_hydration_type(HydrationType::Full); + sync_root_info.set_population_type(PopulationType::Full); + if let Some(icon_path) = config.icon_path.as_ref() { + sync_root_info.set_icon(format!("{},0", icon_path)); + } + sync_root_info.set_version("1.0.0"); + sync_root_info + .set_recycle_bin_uri( + recycle_bin_url(&config) + .unwrap_or_else(|_| "https://cloudreve.org".to_string()), + ) + .context("failed to set recycle bin uri")?; + sync_root_info + .set_path(Path::new(&config.sync_path)) + .context("failed to set sync root path")?; + sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; + sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; + sync_root_id + .register(sync_root_info) + .context("failed to register sync root")?; + } - // Register sync root if not registered - if !sync_root_id.is_registered()? { - tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); - let mut sync_root_info = SyncRootInfo::default(); - sync_root_info.set_display_name(config.name.clone()); - sync_root_info.set_hydration_type(HydrationType::Full); - sync_root_info.set_population_type(PopulationType::Full); - if let Some(icon_path) = config.icon_path.as_ref() { - sync_root_info.set_icon(format!("{},0", icon_path)); + // Add to search indexer for state management + if let Err(e) = sync_root_id.index() { + tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); } - sync_root_info.set_version("1.0.0"); - sync_root_info - .set_recycle_bin_uri(recycle_bin_url(&config).unwrap_or_else(|_| "https://cloudreve.org".to_string())) - .context("failed to set recycle bin uri")?; - sync_root_info - .set_path(Path::new(&config.sync_path)) - .context("failed to set sync root path")?; - sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; - sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; - sync_root_id - .register(sync_root_info) - .context("failed to register sync root")?; - } - // Add to search indexer for state management - if let Err(e) = sync_root_id.index() { - tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); - } + tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); + let connection = Session::new() + .connect( + &config.sync_path, + CallbackHandler::new( + self.command_tx.clone(), + self.id.clone(), + self.inventory.clone(), + ), + ) + .context("failed to connect to sync root")?; - tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); - let connection = Session::new() - .connect( - &config.sync_path, - CallbackHandler::new( - self.command_tx.clone(), - self.id.clone(), - self.inventory.clone(), - ), - ) - .context("failed to connect to sync root")?; - - self.connection = Some(connection); - self.start_fs_watcher().await?; - Ok(()) + self.connection = Some(connection); + self.start_fs_watcher().await?; + Ok(()) } } @@ -546,7 +566,11 @@ impl Mount { let _ = response.send(result); }); } - MountCommand::Sync { mode, local_paths, user_initiated } => { + MountCommand::Sync { + mode, + local_paths, + user_initiated, + } => { let s_clone = s.clone(); let mount_id_clone = mount_id.clone(); spawn(async move { @@ -654,7 +678,9 @@ impl Mount { pub async fn delete(&self) -> Result<()> { self.shutdown().await; if let Some(ref connection) = self.connection { - connection.disconnect().context("faield to disconnect sync root")?; + connection + .disconnect() + .context("faield to disconnect sync root")?; } self.task_queue.shutdown().await; if let Some(sync_root_id) = self.config.read().await.sync_root_id.as_ref() { diff --git a/crates/cloudreve-sync/src/kde.rs b/crates/cloudreve-sync/src/kde.rs new file mode 100644 index 0000000..8af8097 --- /dev/null +++ b/crates/cloudreve-sync/src/kde.rs @@ -0,0 +1,162 @@ +use serde::Serialize; +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize)] +pub struct KdePlaceholderBackend { + pub detected: bool, + pub available: bool, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_menu_path: Option, +} + +impl KdePlaceholderBackend { + pub fn probe() -> Self { + #[cfg(target_os = "linux")] + { + let detected = is_kde_session(); + if !detected { + return Self { + detected, + available: false, + reason: "KDE session was not detected".to_string(), + service_menu_path: None, + }; + } + + let service_menu_path = service_menu_path(); + if service_menu_path.exists() { + return Self { + detected, + available: true, + reason: "KDE service menu backend is installed".to_string(), + service_menu_path: Some(service_menu_path.display().to_string()), + }; + } + + return Self { + detected, + available: false, + reason: "KDE placeholder backend is not installed yet".to_string(), + service_menu_path: Some(service_menu_path.display().to_string()), + }; + } + + #[cfg(not(target_os = "linux"))] + { + Self { + detected: false, + available: false, + reason: "KDE placeholder backend is only applicable on Linux".to_string(), + service_menu_path: None, + } + } + } + + pub fn ensure_installed() -> anyhow::Result { + #[cfg(target_os = "linux")] + { + let detected = is_kde_session(); + if !detected { + return Ok(Self { + detected, + available: false, + reason: "KDE session was not detected".to_string(), + service_menu_path: None, + }); + } + + install_service_menu()?; + Ok(Self::probe()) + } + + #[cfg(not(target_os = "linux"))] + { + Ok(Self::probe()) + } + } +} + +#[cfg(target_os = "linux")] +fn is_kde_session() -> bool { + let values = [ + std::env::var("XDG_CURRENT_DESKTOP").ok(), + std::env::var("KDE_FULL_SESSION").ok(), + std::env::var("DESKTOP_SESSION").ok(), + std::env::var("GDMSESSION").ok(), + ]; + + let desktop = values + .iter() + .flatten() + .map(|value| value.to_ascii_lowercase()) + .collect::>() + .join(":"); + + desktop.contains("kde") || desktop.contains("plasma") || desktop.contains("true") +} + +#[cfg(target_os = "linux")] +fn service_menu_path() -> PathBuf { + let data_dir = dirs::data_local_dir() + .or_else(|| dirs::home_dir().map(|home| home.join(".local/share"))) + .unwrap_or_else(|| PathBuf::from(".")); + data_dir + .join("kio") + .join("servicemenus") + .join("cloudreve-desktop.desktop") +} + +#[cfg(target_os = "linux")] +fn install_service_menu() -> anyhow::Result<()> { + let path = service_menu_path(); + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("invalid KDE service menu path"))?; + std::fs::create_dir_all(parent)?; + + let executable = std::env::current_exe()?; + let escaped_executable = shell_escape(&executable.display().to_string()); + let content = service_menu_content(&escaped_executable); + std::fs::write(&path, content)?; + + tracing::info!( + target: "kde::placeholder_backend", + path = %path.display(), + "Installed KDE service menu backend" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(target_os = "linux")] +fn service_menu_content(executable: &str) -> String { + format!( + r#"[Desktop Entry] +Type=Service +MimeType=inode/directory;application/octet-stream;application/x-zerosize; +X-KDE-ServiceTypes=KonqPopupMenu/Plugin +X-KDE-Submenu=Cloudreve +Actions=CloudreveSyncNow;CloudreveViewOnline; + +[Desktop Action CloudreveSyncNow] +Name=Sync with Cloudreve +Name[zh_CN]=使用 Cloudreve 同步 +Name[zh_TW]=使用 Cloudreve 同步 +Icon=folder-sync +Exec={executable} --cloudreve-sync-now %F + +[Desktop Action CloudreveViewOnline] +Name=View in Cloudreve +Name[zh_CN]=在 Cloudreve 中查看 +Name[zh_TW]=在 Cloudreve 中檢視 +Icon=cloud +Exec={executable} --cloudreve-view-online %f +"# + ) +} diff --git a/crates/cloudreve-sync/src/lib.rs b/crates/cloudreve-sync/src/lib.rs index 62d91b3..939874a 100644 --- a/crates/cloudreve-sync/src/lib.rs +++ b/crates/cloudreve-sync/src/lib.rs @@ -7,7 +7,11 @@ pub mod config; pub mod drive; pub mod events; pub mod inventory; +pub mod kde; +#[cfg(target_os = "linux")] +pub mod linux; pub mod logging; +pub mod platform; #[cfg(windows)] pub mod shellext; #[cfg(not(windows))] @@ -19,10 +23,13 @@ pub mod utils; // Re-export commonly used types pub use config::{AppConfig, ConfigManager}; -pub use drive::manager::{DriveInfo, DriveInfoStatus, DriveManager, StatusSummary, TaskWithProgress}; +pub use drive::manager::{ + DriveInfo, DriveInfoStatus, DriveManager, StatusSummary, TaskWithProgress, +}; pub use drive::mounts::{Credentials, DriveConfig}; pub use events::{Event, EventBroadcaster}; pub use logging::{LogConfig, LogGuard}; +pub use platform::{DesktopEnvironment, PlatformCapabilities, SyncModeCapability}; /// User agent string for HTTP requests pub const USER_AGENT: &str = concat!("cloudreve-desktop/", env!("CARGO_PKG_VERSION")); diff --git a/crates/cloudreve-sync/src/linux.rs b/crates/cloudreve-sync/src/linux.rs new file mode 100644 index 0000000..0738513 --- /dev/null +++ b/crates/cloudreve-sync/src/linux.rs @@ -0,0 +1,82 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::platform::{PlatformCapabilities, SyncModeCapability}; + +#[derive(Debug, Clone)] +pub enum LinuxSyncBackendKind { + FullSync, + KdePlaceholders, +} + +#[derive(Debug, Clone)] +pub struct LinuxSyncBackend { + pub kind: LinuxSyncBackendKind, + pub capabilities: PlatformCapabilities, +} + +impl LinuxSyncBackend { + pub fn current() -> Self { + let capabilities = PlatformCapabilities::current(); + Self::from_capabilities(capabilities) + } + + pub fn prepare_current() -> Result { + let mut capabilities = PlatformCapabilities::current(); + if capabilities.desktop_environment == crate::platform::DesktopEnvironment::Kde { + let kde_backend = crate::kde::KdePlaceholderBackend::ensure_installed()?; + capabilities.kde_placeholder_backend = Some(kde_backend); + capabilities = PlatformCapabilities::current(); + } + + Ok(Self::from_capabilities(capabilities)) + } + + fn from_capabilities(capabilities: PlatformCapabilities) -> Self { + let kind = match capabilities.sync_mode { + SyncModeCapability::KdePlaceholders => LinuxSyncBackendKind::KdePlaceholders, + _ => LinuxSyncBackendKind::FullSync, + }; + + Self { kind, capabilities } + } + + pub fn log_selection(&self, drive_id: &str) { + tracing::info!( + target: "linux::sync_backend", + id = %drive_id, + os = %self.capabilities.os, + desktop = ?self.capabilities.desktop_environment, + sync_mode = ?self.capabilities.sync_mode, + reason = %self.capabilities.reason, + "Resolved Linux sync backend" + ); + } + + pub fn prepare_sync_root(&self, sync_path: &Path) -> Result<()> { + match self.kind { + LinuxSyncBackendKind::FullSync => { + std::fs::create_dir_all(sync_path).context("failed to create sync directory")?; + } + LinuxSyncBackendKind::KdePlaceholders => { + std::fs::create_dir_all(sync_path) + .context("failed to create KDE placeholder sync directory")?; + tracing::info!( + target: "linux::sync_backend", + path = %sync_path.display(), + "KDE placeholder backend selected" + ); + } + } + + Ok(()) + } + + pub fn should_run_initial_full_sync(&self) -> bool { + match self.kind { + LinuxSyncBackendKind::FullSync => true, + LinuxSyncBackendKind::KdePlaceholders => true, + } + } +} diff --git a/crates/cloudreve-sync/src/platform.rs b/crates/cloudreve-sync/src/platform.rs new file mode 100644 index 0000000..2adfa35 --- /dev/null +++ b/crates/cloudreve-sync/src/platform.rs @@ -0,0 +1,168 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DesktopEnvironment { + Windows, + Macos, + Kde, + Gnome, + Xfce, + Cinnamon, + Mate, + Unknown, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SyncModeCapability { + WindowsCloudFiles, + KdePlaceholders, + FullSyncOnly, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlatformCapabilities { + pub os: String, + pub desktop_environment: DesktopEnvironment, + pub sync_mode: SyncModeCapability, + pub placeholders_supported: bool, + pub full_sync_supported: bool, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub kde_placeholder_backend: Option, +} + +impl PlatformCapabilities { + pub fn current() -> Self { + let os = std::env::consts::OS.to_string(); + + #[cfg(windows)] + { + return Self { + os, + desktop_environment: DesktopEnvironment::Windows, + sync_mode: SyncModeCapability::WindowsCloudFiles, + placeholders_supported: true, + full_sync_supported: true, + reason: "Windows Cloud Files API is available for placeholder sync".to_string(), + kde_placeholder_backend: None, + }; + } + + #[cfg(target_os = "linux")] + { + let desktop_environment = detect_linux_desktop_environment(); + let kde_backend = crate::kde::KdePlaceholderBackend::probe(); + let sync_mode = if kde_backend.available { + SyncModeCapability::KdePlaceholders + } else { + SyncModeCapability::FullSyncOnly + }; + let placeholders_supported = sync_mode == SyncModeCapability::KdePlaceholders; + let reason = match sync_mode { + SyncModeCapability::KdePlaceholders => { + "KDE desktop detected; KDE placeholder backend can be used".to_string() + } + SyncModeCapability::FullSyncOnly => { + if desktop_environment == DesktopEnvironment::Kde { + format!("{}; using full sync", kde_backend.reason) + } else { + "No supported Linux placeholder backend detected; using full sync" + .to_string() + } + } + SyncModeCapability::WindowsCloudFiles => unreachable!(), + }; + + return Self { + os, + desktop_environment, + sync_mode, + placeholders_supported, + full_sync_supported: true, + reason, + kde_placeholder_backend: Some(kde_backend), + }; + } + + #[cfg(target_os = "macos")] + { + return Self { + os, + desktop_environment: DesktopEnvironment::Macos, + sync_mode: SyncModeCapability::FullSyncOnly, + placeholders_supported: false, + full_sync_supported: true, + reason: "macOS placeholder backend is not implemented; using full sync".to_string(), + kde_placeholder_backend: None, + }; + } + + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + Self { + os, + desktop_environment: DesktopEnvironment::Unknown, + sync_mode: SyncModeCapability::FullSyncOnly, + placeholders_supported: false, + full_sync_supported: true, + reason: "Unsupported desktop platform for placeholders; using full sync" + .to_string(), + kde_placeholder_backend: None, + } + } + } +} + +#[cfg(target_os = "linux")] +fn detect_linux_desktop_environment() -> DesktopEnvironment { + let values = [ + std::env::var("XDG_CURRENT_DESKTOP").ok(), + std::env::var("KDE_FULL_SESSION").ok(), + std::env::var("DESKTOP_SESSION").ok(), + std::env::var("GDMSESSION").ok(), + ]; + + let desktop = values + .iter() + .flatten() + .map(|value| value.to_ascii_lowercase()) + .collect::>() + .join(":"); + + if desktop.contains("kde") || desktop.contains("plasma") || desktop.contains("true") { + DesktopEnvironment::Kde + } else if desktop.contains("gnome") { + DesktopEnvironment::Gnome + } else if desktop.contains("xfce") { + DesktopEnvironment::Xfce + } else if desktop.contains("cinnamon") { + DesktopEnvironment::Cinnamon + } else if desktop.contains("mate") { + DesktopEnvironment::Mate + } else { + DesktopEnvironment::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn detects_kde_from_desktop_string() { + let current = std::env::var("XDG_CURRENT_DESKTOP").ok(); + unsafe { + std::env::set_var("XDG_CURRENT_DESKTOP", "KDE"); + } + assert_eq!(detect_linux_desktop_environment(), DesktopEnvironment::Kde); + unsafe { + match current { + Some(value) => std::env::set_var("XDG_CURRENT_DESKTOP", value), + None => std::env::remove_var("XDG_CURRENT_DESKTOP"), + } + } + } +} diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md index 287ecfd..6617d04 100644 --- a/docs/linux-support-plan.md +++ b/docs/linux-support-plan.md @@ -16,15 +16,17 @@ Status: implemented. ## Phase 2: KDE Placeholder Support -Status: planned. +Status: implemented. -- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. -- Add a Linux platform capability model: +- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. Implemented. +- Probe KDE placeholder backend readiness separately from KDE session detection. Implemented. +- Add a Linux platform capability model. Implemented: - `FullSyncOnly` for GNOME, XFCE, Cinnamon, generic Wayland/X11, and unknown desktops. - - `KdePlaceholders` for supported KDE environments. -- Implement KDE placeholder integration as a separate backend from Windows CFAPI. + - `KdePlaceholders` only when the KDE backend is detected and available. +- Expose platform capabilities to the Tauri UI. Implemented. +- Implement KDE placeholder integration as a separate backend from Windows CFAPI. Implemented as a KDE/Dolphin service-menu backend with CLI dispatch into the existing sync engine. This is intentionally separate from Windows CFAPI; KDE does not provide an equivalent system Cloud Files API. - Keep non-KDE desktops on the Phase 1 full sync path. -- Add clear logging when KDE placeholder support is unavailable or disabled. +- Add clear logging when KDE placeholder support is unavailable or disabled. Implemented. ## Phase 3: UI And Packaging diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8bf88c3..1cca8b8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,8 @@ use crate::AppStateHandle; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use chrono::{Duration, Utc}; use cloudreve_sync::{ - config::LogLevel, ConfigManager, Credentials, DriveConfig, DriveInfo, StatusSummary, + config::LogLevel, ConfigManager, Credentials, DriveConfig, DriveInfo, PlatformCapabilities, + StatusSummary, }; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; @@ -403,7 +404,11 @@ pub async fn show_reauthorize_window( /// Show or create the add-drive window pub fn show_add_drive_window_impl(app: &AppHandle) { - show_drive_window_internal(app, "Add Drive", &get_url_with_lang("index.html/#/add-drive")); + show_drive_window_internal( + app, + "Add Drive", + &get_url_with_lang("index.html/#/add-drive"), + ); } /// Show or create the reauthorize window for a specific drive @@ -533,24 +538,24 @@ pub async fn get_auto_start_enabled() -> CommandResult { #[cfg(windows)] { - tokio::task::spawn_blocking(|| { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? - .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - let state = task - .State() - .map_err(|e| format!("Failed to get task state: {}", e))?; - - Ok(matches!( - state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? + tokio::task::spawn_blocking(|| { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? + .get() + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + let state = task + .State() + .map_err(|e| format!("Failed to get task state: {}", e))?; + + Ok(matches!( + state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? } } @@ -565,33 +570,33 @@ pub async fn set_auto_start(enabled: bool) -> CommandResult { #[cfg(windows)] { - tokio::task::spawn_blocking(move || { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? - .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - if enabled { - // Request enable - may prompt user for consent - let new_state = task - .RequestEnableAsync() - .map_err(|e| format!("Failed to request enable: {}", e))? + tokio::task::spawn_blocking(move || { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? .get() - .map_err(|e| format!("Failed to enable startup task: {}", e))?; - - Ok(matches!( - new_state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - } else { - task.Disable() - .map_err(|e| format!("Failed to disable startup task: {}", e))?; - Ok(false) - } - }) - .await - .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + if enabled { + // Request enable - may prompt user for consent + let new_state = task + .RequestEnableAsync() + .map_err(|e| format!("Failed to request enable: {}", e))? + .get() + .map_err(|e| format!("Failed to enable startup task: {}", e))?; + + Ok(matches!( + new_state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + } else { + task.Disable() + .map_err(|e| format!("Failed to disable startup task: {}", e))?; + Ok(false) + } + }) + .await + .map_err(|e| format!("Task join error: {}", e))? } } @@ -635,6 +640,12 @@ pub async fn get_general_settings() -> CommandResult { }) } +/// Get platform sync capability for the current OS and desktop environment. +#[tauri::command] +pub async fn get_platform_capabilities() -> CommandResult { + Ok(PlatformCapabilities::current()) +} + #[derive(serde::Serialize)] pub struct GeneralSettings { pub notify_credential_expired: bool, @@ -683,13 +694,12 @@ pub async fn set_language(app: AppHandle, language: Option) -> CommandRe .map_err(|e| e.to_string())?; // Update rust_i18n locale - let locale = language.unwrap_or_else(|| { - sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")) - }); + let locale = language + .unwrap_or_else(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); rust_i18n::set_locale(&locale); // Close main window to force reload with new language - // Check if window already exists + // Check if window already exists if let Some(window) = app.get_webview_window("main_popup") { let _ = window.close(); let _ = window.destroy(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e430eee..bdf3b76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,12 @@ use anyhow::Context; -use cloudreve_sync::{ConfigManager, DriveManager, EventBroadcaster, LogConfig, LogGuard, shellext::shell_service::ServiceHandle}; -use std::sync::{Arc, Mutex}; +use cloudreve_sync::{ + drive::sync::SyncMode, shellext::shell_service::ServiceHandle, ConfigManager, DriveManager, + EventBroadcaster, LogConfig, LogGuard, +}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; use tauri::{ async_runtime::spawn, menu::{Menu, MenuItem}, @@ -55,6 +61,11 @@ pub struct AppState { /// Global cell to store the app state once initialization is complete static APP_STATE: OnceCell = OnceCell::const_new(); +enum CliAction { + SyncNow(Vec), + ViewOnline(PathBuf), +} + /// Initialize the sync service (DriveManager, shell services, etc.) async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { // Initialize app root (Windows Package detection) @@ -121,6 +132,10 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { tracing::info!(target: "main", "Tauri application setup complete"); + if let Some(action) = parse_cloudreve_cli_action(std::env::args().collect::>()) { + dispatch_cli_action(action).await; + } + Ok(()) } @@ -158,6 +173,65 @@ fn spawn_event_bridge(app_handle: AppHandle, event_broadcaster: &EventBroadcaste }); } +fn parse_cloudreve_cli_action(args: Vec) -> Option { + const SYNC_FLAG: &str = "--cloudreve-sync-now"; + const VIEW_FLAG: &str = "--cloudreve-view-online"; + + let mut iter = args.into_iter().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + SYNC_FLAG => { + let paths = iter + .by_ref() + .take_while(|value| !value.starts_with("--cloudreve-")) + .map(PathBuf::from) + .collect::>(); + if !paths.is_empty() { + return Some(CliAction::SyncNow(paths)); + } + } + VIEW_FLAG => { + if let Some(path) = iter.next() { + return Some(CliAction::ViewOnline(PathBuf::from(path))); + } + } + _ => {} + } + } + + None +} + +async fn dispatch_cli_action(action: CliAction) { + let Some(state) = APP_STATE.get() else { + tracing::warn!(target: "main", "Cannot dispatch CLI action before app state is ready"); + return; + }; + + let result = match action { + CliAction::SyncNow(paths) => state + .drive_manager + .dispatch_sync_now(paths, SyncMode::FullHierarchy), + CliAction::ViewOnline(path) => state.drive_manager.dispatch_view_online(path), + }; + + if let Err(error) = result { + tracing::error!(target: "main", error = %error, "Failed to dispatch CLI action"); + } +} + +fn handle_cloudreve_cli_args(args: Vec) -> bool { + let Some(action) = parse_cloudreve_cli_action(args) else { + return false; + }; + + spawn(async move { + dispatch_cli_action(action).await; + }); + + true +} + /// Perform graceful shutdown async fn shutdown() { tracing::info!(target: "main", "Initiating shutdown..."); @@ -193,13 +267,8 @@ fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { true, None::<&str>, )?; - let settings_i = MenuItem::with_id( - app, - "settings", - t!("settings").as_ref(), - true, - None::<&str>, - )?; + let settings_i = + MenuItem::with_id(app, "settings", t!("settings").as_ref(), true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", t!("quit").as_ref(), true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_i, &add_drive_i, &settings_i, &quit_i])?; @@ -253,6 +322,10 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { tracing::info!("a new app instance was opened with {argv:?} and the deep link event was already triggered"); + if handle_cloudreve_cli_args(argv.clone()) { + return; + } + if argv.len() > 1 { let _ = app.emit("deeplink", argv[1].clone()); show_add_drive_window_impl(app); @@ -313,6 +386,7 @@ pub fn run() { commands::set_notify_file_conflict, commands::set_fast_popup_launch, commands::get_general_settings, + commands::get_platform_capabilities, commands::set_log_to_file, commands::set_log_level, commands::set_log_max_files, diff --git a/ui/public/locales/de/common.json b/ui/public/locales/de/common.json index 6cdc0bc..6cb0508 100644 --- a/ui/public/locales/de/common.json +++ b/ui/public/locales/de/common.json @@ -64,6 +64,26 @@ "language": "Sprache", "languageDescription": "Anzeigesprache der Anwendung auswählen", "languageAuto": "Systemstandard", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Benachrichtigungen", "notifyCredentialExpired": "Benachrichtigung bei Anmeldedatenablauf", "notifyCredentialExpiredDescription": "Benachrichtigung anzeigen, wenn Laufwerksanmeldedaten ablaufen", diff --git a/ui/public/locales/en-US/common.json b/ui/public/locales/en-US/common.json index 34fbf72..354715b 100644 --- a/ui/public/locales/en-US/common.json +++ b/ui/public/locales/en-US/common.json @@ -64,6 +64,26 @@ "language": "Language", "languageDescription": "Select the display language for the application", "languageAuto": "System Default", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Notifications", "notifyCredentialExpired": "Credential expiry notification", "notifyCredentialExpiredDescription": "Show notification when drive credentials expire", diff --git a/ui/public/locales/es/common.json b/ui/public/locales/es/common.json index 6decb0c..8ad070f 100644 --- a/ui/public/locales/es/common.json +++ b/ui/public/locales/es/common.json @@ -64,6 +64,26 @@ "language": "Idioma", "languageDescription": "Seleccionar el idioma de visualización de la aplicación", "languageAuto": "Predeterminado del sistema", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Notificaciones", "notifyCredentialExpired": "Notificación de expiración de credenciales", "notifyCredentialExpiredDescription": "Mostrar notificación cuando las credenciales de la unidad expiren", diff --git a/ui/public/locales/fr/common.json b/ui/public/locales/fr/common.json index 1dc719b..af2507b 100644 --- a/ui/public/locales/fr/common.json +++ b/ui/public/locales/fr/common.json @@ -64,6 +64,26 @@ "language": "Langue", "languageDescription": "Sélectionner la langue d'affichage de l'application", "languageAuto": "Par défaut du système", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Notifications", "notifyCredentialExpired": "Notification d'expiration des identifiants", "notifyCredentialExpiredDescription": "Afficher une notification lorsque les identifiants du disque expirent", diff --git a/ui/public/locales/it/common.json b/ui/public/locales/it/common.json index 7e48c7f..1ea735c 100644 --- a/ui/public/locales/it/common.json +++ b/ui/public/locales/it/common.json @@ -64,6 +64,26 @@ "language": "Lingua", "languageDescription": "Seleziona la lingua di visualizzazione dell'applicazione", "languageAuto": "Predefinito di sistema", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Notifiche", "notifyCredentialExpired": "Notifica scadenza credenziali", "notifyCredentialExpiredDescription": "Mostra notifica quando le credenziali dell'unità scadono", diff --git a/ui/public/locales/ja/common.json b/ui/public/locales/ja/common.json index 5f93747..4bcc231 100644 --- a/ui/public/locales/ja/common.json +++ b/ui/public/locales/ja/common.json @@ -64,6 +64,26 @@ "language": "言語", "languageDescription": "アプリケーションの表示言語を選択", "languageAuto": "システムのデフォルト", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "通知", "notifyCredentialExpired": "認証期限切れ通知", "notifyCredentialExpiredDescription": "ドライブの認証が期限切れになったときに通知を表示", diff --git a/ui/public/locales/ko/common.json b/ui/public/locales/ko/common.json index 54be7a8..3566e27 100644 --- a/ui/public/locales/ko/common.json +++ b/ui/public/locales/ko/common.json @@ -64,6 +64,26 @@ "language": "언어", "languageDescription": "애플리케이션 표시 언어 선택", "languageAuto": "시스템 기본값", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "알림", "notifyCredentialExpired": "자격 증명 만료 알림", "notifyCredentialExpiredDescription": "드라이브 자격 증명이 만료되면 알림 표시", diff --git a/ui/public/locales/pl/common.json b/ui/public/locales/pl/common.json index 6de7d48..4ff863f 100644 --- a/ui/public/locales/pl/common.json +++ b/ui/public/locales/pl/common.json @@ -64,6 +64,26 @@ "language": "Język", "languageDescription": "Wybierz język wyświetlania aplikacji", "languageAuto": "Domyślny systemowy", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Powiadomienia", "notifyCredentialExpired": "Powiadomienie o wygaśnięciu danych uwierzytelniających", "notifyCredentialExpiredDescription": "Pokaż powiadomienie gdy dane uwierzytelniające dysku wygasną", diff --git a/ui/public/locales/ru/common.json b/ui/public/locales/ru/common.json index d95f031..103bb4c 100644 --- a/ui/public/locales/ru/common.json +++ b/ui/public/locales/ru/common.json @@ -64,6 +64,26 @@ "language": "Язык", "languageDescription": "Выберите язык интерфейса приложения", "languageAuto": "По умолчанию системы", + "syncCapabilitySettings": "Sync capability", + "syncModeTitle": "Sync mode", + "placeholderSupport": "Placeholder support", + "placeholderAvailable": "Available", + "placeholderUnavailable": "Unavailable", + "syncMode": { + "windows_cloud_files": "Windows placeholders", + "kde_placeholders": "KDE placeholders", + "full_sync_only": "Full sync" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "Unknown" + }, "notificationSettings": "Уведомления", "notifyCredentialExpired": "Уведомление об истечении учётных данных", "notifyCredentialExpiredDescription": "Показывать уведомление при истечении учётных данных диска", diff --git a/ui/public/locales/zh-CN/common.json b/ui/public/locales/zh-CN/common.json index efd8ffb..9533445 100644 --- a/ui/public/locales/zh-CN/common.json +++ b/ui/public/locales/zh-CN/common.json @@ -64,6 +64,26 @@ "language": "语言", "languageDescription": "选择应用的显示语言", "languageAuto": "跟随系统", + "syncCapabilitySettings": "同步能力", + "syncModeTitle": "同步模式", + "placeholderSupport": "占位符支持", + "placeholderAvailable": "可用", + "placeholderUnavailable": "不可用", + "syncMode": { + "windows_cloud_files": "Windows 占位符", + "kde_placeholders": "KDE 占位符", + "full_sync_only": "全量同步" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "未知" + }, "notificationSettings": "通知", "notifyCredentialExpired": "凭证过期通知", "notifyCredentialExpiredDescription": "当网盘凭证过期时显示通知", diff --git a/ui/public/locales/zh-TW/common.json b/ui/public/locales/zh-TW/common.json index 58a6153..f22a9ef 100644 --- a/ui/public/locales/zh-TW/common.json +++ b/ui/public/locales/zh-TW/common.json @@ -64,6 +64,26 @@ "language": "語言", "languageDescription": "選擇應用程式的顯示語言", "languageAuto": "跟隨系統", + "syncCapabilitySettings": "同步能力", + "syncModeTitle": "同步模式", + "placeholderSupport": "佔位符支援", + "placeholderAvailable": "可用", + "placeholderUnavailable": "不可用", + "syncMode": { + "windows_cloud_files": "Windows 佔位符", + "kde_placeholders": "KDE 佔位符", + "full_sync_only": "全量同步" + }, + "desktopEnvironment": { + "windows": "Windows", + "macos": "macOS", + "kde": "KDE", + "gnome": "GNOME", + "xfce": "XFCE", + "cinnamon": "Cinnamon", + "mate": "MATE", + "unknown": "未知" + }, "notificationSettings": "通知", "notifyCredentialExpired": "憑證過期通知", "notifyCredentialExpiredDescription": "當雲端硬碟憑證過期時顯示通知", diff --git a/ui/src/pages/settings/GeneralSection.tsx b/ui/src/pages/settings/GeneralSection.tsx index 51fdfbf..ef2627e 100644 --- a/ui/src/pages/settings/GeneralSection.tsx +++ b/ui/src/pages/settings/GeneralSection.tsx @@ -7,6 +7,7 @@ import { Select, MenuItem, FormControl, + Chip, } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -169,6 +170,46 @@ function SettingActionItem({ ); } +interface SettingReadOnlyItemProps { + title: string; + description?: string; + value: string; + isLast?: boolean; +} + +function SettingReadOnlyItem({ + title, + description, + value, + isLast, +}: SettingReadOnlyItemProps) { + return ( + <> + + + {title} + {description && ( + + {description} + + )} + + + + {!isLast && } + + ); +} + interface SettingsGroupProps { title: string; children: React.ReactNode; @@ -213,6 +254,22 @@ interface GeneralSettings { language: string | null; } +interface KdePlaceholderBackend { + detected: boolean; + available: boolean; + reason: string; +} + +interface PlatformCapabilities { + os: string; + desktop_environment: string; + sync_mode: string; + placeholders_supported: boolean; + full_sync_supported: boolean; + reason: string; + kde_placeholder_backend?: KdePlaceholderBackend; +} + const LOG_LEVELS = [ { value: "trace", label: "Trace" }, { value: "debug", label: "Debug" }, @@ -239,14 +296,17 @@ export default function GeneralSection() { const [logMaxFiles, setLogMaxFiles] = useState(5); const [logDir, setLogDir] = useState(""); const [language, setLanguage] = useState(null); + const [platformCapabilities, setPlatformCapabilities] = + useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const loadSettings = async () => { try { - const [enabled, settings] = await Promise.all([ + const [enabled, settings, capabilities] = await Promise.all([ invoke("get_auto_start_enabled"), invoke("get_general_settings"), + invoke("get_platform_capabilities"), ]); setAutoStart(enabled); setNotifyCredentialExpired(settings.notify_credential_expired); @@ -257,6 +317,7 @@ export default function GeneralSection() { setLogMaxFiles(settings.log_max_files); setLogDir(settings.log_dir); setLanguage(settings.language); + setPlatformCapabilities(capabilities); } catch (error) { console.error("Failed to load settings:", error); } finally { @@ -374,6 +435,23 @@ export default function GeneralSection() { // Get the current language value for the select, "auto" if null const currentLanguageValue = language ?? "auto"; + const platformSyncMode = platformCapabilities + ? t(`settings.syncMode.${platformCapabilities.sync_mode}`, { + defaultValue: platformCapabilities.sync_mode, + }) + : t("settings.loading"); + const desktopEnvironment = platformCapabilities + ? t(`settings.desktopEnvironment.${platformCapabilities.desktop_environment}`, { + defaultValue: platformCapabilities.desktop_environment, + }) + : t("settings.loading"); + const placeholderStatus = platformCapabilities?.placeholders_supported + ? t("settings.placeholderAvailable") + : t("settings.placeholderUnavailable"); + const platformDescription = platformCapabilities + ? `${platformCapabilities.os} / ${desktopEnvironment}. ${platformCapabilities.reason}` + : undefined; + const kdeBackend = platformCapabilities?.kde_placeholder_backend; return ( @@ -414,6 +492,21 @@ export default function GeneralSection() { /> + + + + + Date: Fri, 5 Jun 2026 22:32:32 +0800 Subject: [PATCH 03/25] Revert "Add KDE Linux sync backend integration" This reverts commit 796cfa5f27eda68efbf5220f0dc69325a9fa8324. --- .../cloudreve-sync/src/drive/manager/mod.rs | 28 +-- crates/cloudreve-sync/src/drive/mounts.rs | 158 +++++++--------- crates/cloudreve-sync/src/kde.rs | 162 ----------------- crates/cloudreve-sync/src/lib.rs | 9 +- crates/cloudreve-sync/src/linux.rs | 82 --------- crates/cloudreve-sync/src/platform.rs | 168 ------------------ docs/linux-support-plan.md | 14 +- src-tauri/src/commands.rs | 110 ++++++------ src-tauri/src/lib.rs | 92 +--------- ui/public/locales/de/common.json | 20 --- ui/public/locales/en-US/common.json | 20 --- ui/public/locales/es/common.json | 20 --- ui/public/locales/fr/common.json | 20 --- ui/public/locales/it/common.json | 20 --- ui/public/locales/ja/common.json | 20 --- ui/public/locales/ko/common.json | 20 --- ui/public/locales/pl/common.json | 20 --- ui/public/locales/ru/common.json | 20 --- ui/public/locales/zh-CN/common.json | 20 --- ui/public/locales/zh-TW/common.json | 20 --- ui/src/pages/settings/GeneralSection.tsx | 95 +--------- 21 files changed, 137 insertions(+), 1001 deletions(-) delete mode 100644 crates/cloudreve-sync/src/kde.rs delete mode 100644 crates/cloudreve-sync/src/linux.rs delete mode 100644 crates/cloudreve-sync/src/platform.rs diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index 3023b5e..2ad7850 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -4,10 +4,9 @@ mod types; pub use types::*; -use crate::EventBroadcaster; use crate::drive::commands::ManagerCommand; use crate::drive::mounts::{Credentials, DriveConfig, Mount}; -use crate::drive::sync::SyncMode; +use crate::EventBroadcaster; use crate::inventory::InventoryDb; use crate::tasks::TaskProgress; use anyhow::{Context, Result}; @@ -56,18 +55,6 @@ impl DriveManager { self.inventory.clone() } - pub fn dispatch_sync_now(&self, paths: Vec, mode: SyncMode) -> Result<()> { - self.command_tx - .send(ManagerCommand::SyncNow { paths, mode }) - .context("Failed to dispatch sync command") - } - - pub fn dispatch_view_online(&self, path: PathBuf) -> Result<()> { - self.command_tx - .send(ManagerCommand::ViewOnline { path }) - .context("Failed to dispatch view online command") - } - /// Get the .cloudreve config directory path fn get_config_dir() -> Result { let home_dir = dirs::home_dir().context("Failed to get user home directory")?; @@ -496,10 +483,7 @@ impl DriveManager { .into_iter() .map(|task| { let progress = progress_map.remove(&task.id); - TaskWithProgress { - task, - live_progress: progress, - } + TaskWithProgress { task, live_progress: progress } }) .collect(); @@ -611,7 +595,7 @@ impl DriveManager { let status = if drive_state.is_credential_expired() { DriveInfoStatus::CredentialExpired } else { - if !drive_state.is_event_push_subscribed() { + if !drive_state.is_event_push_subscribed(){ DriveInfoStatus::EventPushLost } else { DriveInfoStatus::Active @@ -664,11 +648,7 @@ impl DriveManager { impl DriveManager { /// Get capacity summary from a mount's drive props. /// Only returns capacity if the remote_path filesystem is "my". - fn get_capacity_summary( - mount: &Mount, - drive_id: &str, - remote_path: &str, - ) -> Option { + fn get_capacity_summary(mount: &Mount, drive_id: &str, remote_path: &str) -> Option { // Only show capacity for "my" filesystem use cloudreve_api::models::uri::CrUri; let is_my_fs = CrUri::new(remote_path) diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index f27cca8..100e535 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -1,8 +1,8 @@ -use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::cfapi::root::{ HydrationType, PopulationType, SecurityId, Session, SyncRootIdBuilder, SyncRootInfo, }; +use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::drive::callback::CallbackHandler; use crate::drive::commands::ManagerCommand; @@ -385,105 +385,85 @@ impl Mount { #[cfg(not(windows))] { let sync_path = self.config.read().await.sync_path.clone(); - #[cfg(target_os = "linux")] - { - let backend = crate::linux::LinuxSyncBackend::prepare_current()?; - backend.log_selection(&self.id); - backend.prepare_sync_root(&sync_path)?; - self.start_fs_watcher().await?; - if backend.should_run_initial_full_sync() { - self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) - .await?; - } - return Ok(()); - } - - #[cfg(not(target_os = "linux"))] std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; - #[cfg(not(target_os = "linux"))] self.start_fs_watcher().await?; - #[cfg(not(target_os = "linux"))] self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) .await?; - #[cfg(not(target_os = "linux"))] return Ok(()); } #[cfg(windows)] { - if !StorageProviderSyncRootManager::IsSupported() - .context("Cloud Filter API is not supported")? - { - return Err(anyhow::anyhow!("Cloud Filter API is not supported")); - } + if !StorageProviderSyncRootManager::IsSupported() + .context("Cloud Filter API is not supported")? + { + return Err(anyhow::anyhow!("Cloud Filter API is not supported")); + } - let mut write_guard = self.config.write().await; - - // if sync root id is not set, generate one - if write_guard.sync_root_id.is_none() { - write_guard.sync_root_id = Some( - generate_sync_root_id( - &write_guard.instance_url, - &write_guard.name, - &write_guard.user_id, - &write_guard.sync_path, - ) - .context("failed to generate sync root id")?, - ); - } + let mut write_guard = self.config.write().await; - drop(write_guard); - let config = self.config.read().await; + // if sync root id is not set, generate one + if write_guard.sync_root_id.is_none() { + write_guard.sync_root_id = Some( + generate_sync_root_id( + &write_guard.instance_url, + &write_guard.name, + &write_guard.user_id, + &write_guard.sync_path, + ) + .context("failed to generate sync root id")?, + ); + } - let sync_root_id = config.sync_root_id.as_ref().unwrap(); - - // Register sync root if not registered - if !sync_root_id.is_registered()? { - tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); - let mut sync_root_info = SyncRootInfo::default(); - sync_root_info.set_display_name(config.name.clone()); - sync_root_info.set_hydration_type(HydrationType::Full); - sync_root_info.set_population_type(PopulationType::Full); - if let Some(icon_path) = config.icon_path.as_ref() { - sync_root_info.set_icon(format!("{},0", icon_path)); - } - sync_root_info.set_version("1.0.0"); - sync_root_info - .set_recycle_bin_uri( - recycle_bin_url(&config) - .unwrap_or_else(|_| "https://cloudreve.org".to_string()), - ) - .context("failed to set recycle bin uri")?; - sync_root_info - .set_path(Path::new(&config.sync_path)) - .context("failed to set sync root path")?; - sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; - sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; - sync_root_id - .register(sync_root_info) - .context("failed to register sync root")?; - } + drop(write_guard); + let config = self.config.read().await; + + let sync_root_id = config.sync_root_id.as_ref().unwrap(); - // Add to search indexer for state management - if let Err(e) = sync_root_id.index() { - tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); + // Register sync root if not registered + if !sync_root_id.is_registered()? { + tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); + let mut sync_root_info = SyncRootInfo::default(); + sync_root_info.set_display_name(config.name.clone()); + sync_root_info.set_hydration_type(HydrationType::Full); + sync_root_info.set_population_type(PopulationType::Full); + if let Some(icon_path) = config.icon_path.as_ref() { + sync_root_info.set_icon(format!("{},0", icon_path)); } + sync_root_info.set_version("1.0.0"); + sync_root_info + .set_recycle_bin_uri(recycle_bin_url(&config).unwrap_or_else(|_| "https://cloudreve.org".to_string())) + .context("failed to set recycle bin uri")?; + sync_root_info + .set_path(Path::new(&config.sync_path)) + .context("failed to set sync root path")?; + sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; + sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; + sync_root_id + .register(sync_root_info) + .context("failed to register sync root")?; + } - tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); - let connection = Session::new() - .connect( - &config.sync_path, - CallbackHandler::new( - self.command_tx.clone(), - self.id.clone(), - self.inventory.clone(), - ), - ) - .context("failed to connect to sync root")?; + // Add to search indexer for state management + if let Err(e) = sync_root_id.index() { + tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); + } - self.connection = Some(connection); - self.start_fs_watcher().await?; - Ok(()) + tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); + let connection = Session::new() + .connect( + &config.sync_path, + CallbackHandler::new( + self.command_tx.clone(), + self.id.clone(), + self.inventory.clone(), + ), + ) + .context("failed to connect to sync root")?; + + self.connection = Some(connection); + self.start_fs_watcher().await?; + Ok(()) } } @@ -566,11 +546,7 @@ impl Mount { let _ = response.send(result); }); } - MountCommand::Sync { - mode, - local_paths, - user_initiated, - } => { + MountCommand::Sync { mode, local_paths, user_initiated } => { let s_clone = s.clone(); let mount_id_clone = mount_id.clone(); spawn(async move { @@ -678,9 +654,7 @@ impl Mount { pub async fn delete(&self) -> Result<()> { self.shutdown().await; if let Some(ref connection) = self.connection { - connection - .disconnect() - .context("faield to disconnect sync root")?; + connection.disconnect().context("faield to disconnect sync root")?; } self.task_queue.shutdown().await; if let Some(sync_root_id) = self.config.read().await.sync_root_id.as_ref() { diff --git a/crates/cloudreve-sync/src/kde.rs b/crates/cloudreve-sync/src/kde.rs deleted file mode 100644 index 8af8097..0000000 --- a/crates/cloudreve-sync/src/kde.rs +++ /dev/null @@ -1,162 +0,0 @@ -use serde::Serialize; -#[cfg(target_os = "linux")] -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize)] -pub struct KdePlaceholderBackend { - pub detected: bool, - pub available: bool, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub service_menu_path: Option, -} - -impl KdePlaceholderBackend { - pub fn probe() -> Self { - #[cfg(target_os = "linux")] - { - let detected = is_kde_session(); - if !detected { - return Self { - detected, - available: false, - reason: "KDE session was not detected".to_string(), - service_menu_path: None, - }; - } - - let service_menu_path = service_menu_path(); - if service_menu_path.exists() { - return Self { - detected, - available: true, - reason: "KDE service menu backend is installed".to_string(), - service_menu_path: Some(service_menu_path.display().to_string()), - }; - } - - return Self { - detected, - available: false, - reason: "KDE placeholder backend is not installed yet".to_string(), - service_menu_path: Some(service_menu_path.display().to_string()), - }; - } - - #[cfg(not(target_os = "linux"))] - { - Self { - detected: false, - available: false, - reason: "KDE placeholder backend is only applicable on Linux".to_string(), - service_menu_path: None, - } - } - } - - pub fn ensure_installed() -> anyhow::Result { - #[cfg(target_os = "linux")] - { - let detected = is_kde_session(); - if !detected { - return Ok(Self { - detected, - available: false, - reason: "KDE session was not detected".to_string(), - service_menu_path: None, - }); - } - - install_service_menu()?; - Ok(Self::probe()) - } - - #[cfg(not(target_os = "linux"))] - { - Ok(Self::probe()) - } - } -} - -#[cfg(target_os = "linux")] -fn is_kde_session() -> bool { - let values = [ - std::env::var("XDG_CURRENT_DESKTOP").ok(), - std::env::var("KDE_FULL_SESSION").ok(), - std::env::var("DESKTOP_SESSION").ok(), - std::env::var("GDMSESSION").ok(), - ]; - - let desktop = values - .iter() - .flatten() - .map(|value| value.to_ascii_lowercase()) - .collect::>() - .join(":"); - - desktop.contains("kde") || desktop.contains("plasma") || desktop.contains("true") -} - -#[cfg(target_os = "linux")] -fn service_menu_path() -> PathBuf { - let data_dir = dirs::data_local_dir() - .or_else(|| dirs::home_dir().map(|home| home.join(".local/share"))) - .unwrap_or_else(|| PathBuf::from(".")); - data_dir - .join("kio") - .join("servicemenus") - .join("cloudreve-desktop.desktop") -} - -#[cfg(target_os = "linux")] -fn install_service_menu() -> anyhow::Result<()> { - let path = service_menu_path(); - let parent = path - .parent() - .ok_or_else(|| anyhow::anyhow!("invalid KDE service menu path"))?; - std::fs::create_dir_all(parent)?; - - let executable = std::env::current_exe()?; - let escaped_executable = shell_escape(&executable.display().to_string()); - let content = service_menu_content(&escaped_executable); - std::fs::write(&path, content)?; - - tracing::info!( - target: "kde::placeholder_backend", - path = %path.display(), - "Installed KDE service menu backend" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -#[cfg(target_os = "linux")] -fn service_menu_content(executable: &str) -> String { - format!( - r#"[Desktop Entry] -Type=Service -MimeType=inode/directory;application/octet-stream;application/x-zerosize; -X-KDE-ServiceTypes=KonqPopupMenu/Plugin -X-KDE-Submenu=Cloudreve -Actions=CloudreveSyncNow;CloudreveViewOnline; - -[Desktop Action CloudreveSyncNow] -Name=Sync with Cloudreve -Name[zh_CN]=使用 Cloudreve 同步 -Name[zh_TW]=使用 Cloudreve 同步 -Icon=folder-sync -Exec={executable} --cloudreve-sync-now %F - -[Desktop Action CloudreveViewOnline] -Name=View in Cloudreve -Name[zh_CN]=在 Cloudreve 中查看 -Name[zh_TW]=在 Cloudreve 中檢視 -Icon=cloud -Exec={executable} --cloudreve-view-online %f -"# - ) -} diff --git a/crates/cloudreve-sync/src/lib.rs b/crates/cloudreve-sync/src/lib.rs index 939874a..62d91b3 100644 --- a/crates/cloudreve-sync/src/lib.rs +++ b/crates/cloudreve-sync/src/lib.rs @@ -7,11 +7,7 @@ pub mod config; pub mod drive; pub mod events; pub mod inventory; -pub mod kde; -#[cfg(target_os = "linux")] -pub mod linux; pub mod logging; -pub mod platform; #[cfg(windows)] pub mod shellext; #[cfg(not(windows))] @@ -23,13 +19,10 @@ pub mod utils; // Re-export commonly used types pub use config::{AppConfig, ConfigManager}; -pub use drive::manager::{ - DriveInfo, DriveInfoStatus, DriveManager, StatusSummary, TaskWithProgress, -}; +pub use drive::manager::{DriveInfo, DriveInfoStatus, DriveManager, StatusSummary, TaskWithProgress}; pub use drive::mounts::{Credentials, DriveConfig}; pub use events::{Event, EventBroadcaster}; pub use logging::{LogConfig, LogGuard}; -pub use platform::{DesktopEnvironment, PlatformCapabilities, SyncModeCapability}; /// User agent string for HTTP requests pub const USER_AGENT: &str = concat!("cloudreve-desktop/", env!("CARGO_PKG_VERSION")); diff --git a/crates/cloudreve-sync/src/linux.rs b/crates/cloudreve-sync/src/linux.rs deleted file mode 100644 index 0738513..0000000 --- a/crates/cloudreve-sync/src/linux.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; - -use crate::platform::{PlatformCapabilities, SyncModeCapability}; - -#[derive(Debug, Clone)] -pub enum LinuxSyncBackendKind { - FullSync, - KdePlaceholders, -} - -#[derive(Debug, Clone)] -pub struct LinuxSyncBackend { - pub kind: LinuxSyncBackendKind, - pub capabilities: PlatformCapabilities, -} - -impl LinuxSyncBackend { - pub fn current() -> Self { - let capabilities = PlatformCapabilities::current(); - Self::from_capabilities(capabilities) - } - - pub fn prepare_current() -> Result { - let mut capabilities = PlatformCapabilities::current(); - if capabilities.desktop_environment == crate::platform::DesktopEnvironment::Kde { - let kde_backend = crate::kde::KdePlaceholderBackend::ensure_installed()?; - capabilities.kde_placeholder_backend = Some(kde_backend); - capabilities = PlatformCapabilities::current(); - } - - Ok(Self::from_capabilities(capabilities)) - } - - fn from_capabilities(capabilities: PlatformCapabilities) -> Self { - let kind = match capabilities.sync_mode { - SyncModeCapability::KdePlaceholders => LinuxSyncBackendKind::KdePlaceholders, - _ => LinuxSyncBackendKind::FullSync, - }; - - Self { kind, capabilities } - } - - pub fn log_selection(&self, drive_id: &str) { - tracing::info!( - target: "linux::sync_backend", - id = %drive_id, - os = %self.capabilities.os, - desktop = ?self.capabilities.desktop_environment, - sync_mode = ?self.capabilities.sync_mode, - reason = %self.capabilities.reason, - "Resolved Linux sync backend" - ); - } - - pub fn prepare_sync_root(&self, sync_path: &Path) -> Result<()> { - match self.kind { - LinuxSyncBackendKind::FullSync => { - std::fs::create_dir_all(sync_path).context("failed to create sync directory")?; - } - LinuxSyncBackendKind::KdePlaceholders => { - std::fs::create_dir_all(sync_path) - .context("failed to create KDE placeholder sync directory")?; - tracing::info!( - target: "linux::sync_backend", - path = %sync_path.display(), - "KDE placeholder backend selected" - ); - } - } - - Ok(()) - } - - pub fn should_run_initial_full_sync(&self) -> bool { - match self.kind { - LinuxSyncBackendKind::FullSync => true, - LinuxSyncBackendKind::KdePlaceholders => true, - } - } -} diff --git a/crates/cloudreve-sync/src/platform.rs b/crates/cloudreve-sync/src/platform.rs deleted file mode 100644 index 2adfa35..0000000 --- a/crates/cloudreve-sync/src/platform.rs +++ /dev/null @@ -1,168 +0,0 @@ -use serde::Serialize; - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DesktopEnvironment { - Windows, - Macos, - Kde, - Gnome, - Xfce, - Cinnamon, - Mate, - Unknown, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SyncModeCapability { - WindowsCloudFiles, - KdePlaceholders, - FullSyncOnly, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PlatformCapabilities { - pub os: String, - pub desktop_environment: DesktopEnvironment, - pub sync_mode: SyncModeCapability, - pub placeholders_supported: bool, - pub full_sync_supported: bool, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub kde_placeholder_backend: Option, -} - -impl PlatformCapabilities { - pub fn current() -> Self { - let os = std::env::consts::OS.to_string(); - - #[cfg(windows)] - { - return Self { - os, - desktop_environment: DesktopEnvironment::Windows, - sync_mode: SyncModeCapability::WindowsCloudFiles, - placeholders_supported: true, - full_sync_supported: true, - reason: "Windows Cloud Files API is available for placeholder sync".to_string(), - kde_placeholder_backend: None, - }; - } - - #[cfg(target_os = "linux")] - { - let desktop_environment = detect_linux_desktop_environment(); - let kde_backend = crate::kde::KdePlaceholderBackend::probe(); - let sync_mode = if kde_backend.available { - SyncModeCapability::KdePlaceholders - } else { - SyncModeCapability::FullSyncOnly - }; - let placeholders_supported = sync_mode == SyncModeCapability::KdePlaceholders; - let reason = match sync_mode { - SyncModeCapability::KdePlaceholders => { - "KDE desktop detected; KDE placeholder backend can be used".to_string() - } - SyncModeCapability::FullSyncOnly => { - if desktop_environment == DesktopEnvironment::Kde { - format!("{}; using full sync", kde_backend.reason) - } else { - "No supported Linux placeholder backend detected; using full sync" - .to_string() - } - } - SyncModeCapability::WindowsCloudFiles => unreachable!(), - }; - - return Self { - os, - desktop_environment, - sync_mode, - placeholders_supported, - full_sync_supported: true, - reason, - kde_placeholder_backend: Some(kde_backend), - }; - } - - #[cfg(target_os = "macos")] - { - return Self { - os, - desktop_environment: DesktopEnvironment::Macos, - sync_mode: SyncModeCapability::FullSyncOnly, - placeholders_supported: false, - full_sync_supported: true, - reason: "macOS placeholder backend is not implemented; using full sync".to_string(), - kde_placeholder_backend: None, - }; - } - - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - { - Self { - os, - desktop_environment: DesktopEnvironment::Unknown, - sync_mode: SyncModeCapability::FullSyncOnly, - placeholders_supported: false, - full_sync_supported: true, - reason: "Unsupported desktop platform for placeholders; using full sync" - .to_string(), - kde_placeholder_backend: None, - } - } - } -} - -#[cfg(target_os = "linux")] -fn detect_linux_desktop_environment() -> DesktopEnvironment { - let values = [ - std::env::var("XDG_CURRENT_DESKTOP").ok(), - std::env::var("KDE_FULL_SESSION").ok(), - std::env::var("DESKTOP_SESSION").ok(), - std::env::var("GDMSESSION").ok(), - ]; - - let desktop = values - .iter() - .flatten() - .map(|value| value.to_ascii_lowercase()) - .collect::>() - .join(":"); - - if desktop.contains("kde") || desktop.contains("plasma") || desktop.contains("true") { - DesktopEnvironment::Kde - } else if desktop.contains("gnome") { - DesktopEnvironment::Gnome - } else if desktop.contains("xfce") { - DesktopEnvironment::Xfce - } else if desktop.contains("cinnamon") { - DesktopEnvironment::Cinnamon - } else if desktop.contains("mate") { - DesktopEnvironment::Mate - } else { - DesktopEnvironment::Unknown - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(target_os = "linux")] - #[test] - fn detects_kde_from_desktop_string() { - let current = std::env::var("XDG_CURRENT_DESKTOP").ok(); - unsafe { - std::env::set_var("XDG_CURRENT_DESKTOP", "KDE"); - } - assert_eq!(detect_linux_desktop_environment(), DesktopEnvironment::Kde); - unsafe { - match current { - Some(value) => std::env::set_var("XDG_CURRENT_DESKTOP", value), - None => std::env::remove_var("XDG_CURRENT_DESKTOP"), - } - } - } -} diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md index 6617d04..287ecfd 100644 --- a/docs/linux-support-plan.md +++ b/docs/linux-support-plan.md @@ -16,17 +16,15 @@ Status: implemented. ## Phase 2: KDE Placeholder Support -Status: implemented. +Status: planned. -- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. Implemented. -- Probe KDE placeholder backend readiness separately from KDE session detection. Implemented. -- Add a Linux platform capability model. Implemented: +- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. +- Add a Linux platform capability model: - `FullSyncOnly` for GNOME, XFCE, Cinnamon, generic Wayland/X11, and unknown desktops. - - `KdePlaceholders` only when the KDE backend is detected and available. -- Expose platform capabilities to the Tauri UI. Implemented. -- Implement KDE placeholder integration as a separate backend from Windows CFAPI. Implemented as a KDE/Dolphin service-menu backend with CLI dispatch into the existing sync engine. This is intentionally separate from Windows CFAPI; KDE does not provide an equivalent system Cloud Files API. + - `KdePlaceholders` for supported KDE environments. +- Implement KDE placeholder integration as a separate backend from Windows CFAPI. - Keep non-KDE desktops on the Phase 1 full sync path. -- Add clear logging when KDE placeholder support is unavailable or disabled. Implemented. +- Add clear logging when KDE placeholder support is unavailable or disabled. ## Phase 3: UI And Packaging diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1cca8b8..8bf88c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,8 +2,7 @@ use crate::AppStateHandle; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use chrono::{Duration, Utc}; use cloudreve_sync::{ - config::LogLevel, ConfigManager, Credentials, DriveConfig, DriveInfo, PlatformCapabilities, - StatusSummary, + config::LogLevel, ConfigManager, Credentials, DriveConfig, DriveInfo, StatusSummary, }; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; @@ -404,11 +403,7 @@ pub async fn show_reauthorize_window( /// Show or create the add-drive window pub fn show_add_drive_window_impl(app: &AppHandle) { - show_drive_window_internal( - app, - "Add Drive", - &get_url_with_lang("index.html/#/add-drive"), - ); + show_drive_window_internal(app, "Add Drive", &get_url_with_lang("index.html/#/add-drive")); } /// Show or create the reauthorize window for a specific drive @@ -538,24 +533,24 @@ pub async fn get_auto_start_enabled() -> CommandResult { #[cfg(windows)] { - tokio::task::spawn_blocking(|| { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? - .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - let state = task - .State() - .map_err(|e| format!("Failed to get task state: {}", e))?; - - Ok(matches!( - state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? + tokio::task::spawn_blocking(|| { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? + .get() + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + let state = task + .State() + .map_err(|e| format!("Failed to get task state: {}", e))?; + + Ok(matches!( + state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? } } @@ -570,33 +565,33 @@ pub async fn set_auto_start(enabled: bool) -> CommandResult { #[cfg(windows)] { - tokio::task::spawn_blocking(move || { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? + tokio::task::spawn_blocking(move || { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? + .get() + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + if enabled { + // Request enable - may prompt user for consent + let new_state = task + .RequestEnableAsync() + .map_err(|e| format!("Failed to request enable: {}", e))? .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - if enabled { - // Request enable - may prompt user for consent - let new_state = task - .RequestEnableAsync() - .map_err(|e| format!("Failed to request enable: {}", e))? - .get() - .map_err(|e| format!("Failed to enable startup task: {}", e))?; - - Ok(matches!( - new_state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - } else { - task.Disable() - .map_err(|e| format!("Failed to disable startup task: {}", e))?; - Ok(false) - } - }) - .await - .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Failed to enable startup task: {}", e))?; + + Ok(matches!( + new_state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + } else { + task.Disable() + .map_err(|e| format!("Failed to disable startup task: {}", e))?; + Ok(false) + } + }) + .await + .map_err(|e| format!("Task join error: {}", e))? } } @@ -640,12 +635,6 @@ pub async fn get_general_settings() -> CommandResult { }) } -/// Get platform sync capability for the current OS and desktop environment. -#[tauri::command] -pub async fn get_platform_capabilities() -> CommandResult { - Ok(PlatformCapabilities::current()) -} - #[derive(serde::Serialize)] pub struct GeneralSettings { pub notify_credential_expired: bool, @@ -694,12 +683,13 @@ pub async fn set_language(app: AppHandle, language: Option) -> CommandRe .map_err(|e| e.to_string())?; // Update rust_i18n locale - let locale = language - .unwrap_or_else(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); + let locale = language.unwrap_or_else(|| { + sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")) + }); rust_i18n::set_locale(&locale); // Close main window to force reload with new language - // Check if window already exists + // Check if window already exists if let Some(window) = app.get_webview_window("main_popup") { let _ = window.close(); let _ = window.destroy(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bdf3b76..e430eee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,6 @@ use anyhow::Context; -use cloudreve_sync::{ - drive::sync::SyncMode, shellext::shell_service::ServiceHandle, ConfigManager, DriveManager, - EventBroadcaster, LogConfig, LogGuard, -}; -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; +use cloudreve_sync::{ConfigManager, DriveManager, EventBroadcaster, LogConfig, LogGuard, shellext::shell_service::ServiceHandle}; +use std::sync::{Arc, Mutex}; use tauri::{ async_runtime::spawn, menu::{Menu, MenuItem}, @@ -61,11 +55,6 @@ pub struct AppState { /// Global cell to store the app state once initialization is complete static APP_STATE: OnceCell = OnceCell::const_new(); -enum CliAction { - SyncNow(Vec), - ViewOnline(PathBuf), -} - /// Initialize the sync service (DriveManager, shell services, etc.) async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { // Initialize app root (Windows Package detection) @@ -132,10 +121,6 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { tracing::info!(target: "main", "Tauri application setup complete"); - if let Some(action) = parse_cloudreve_cli_action(std::env::args().collect::>()) { - dispatch_cli_action(action).await; - } - Ok(()) } @@ -173,65 +158,6 @@ fn spawn_event_bridge(app_handle: AppHandle, event_broadcaster: &EventBroadcaste }); } -fn parse_cloudreve_cli_action(args: Vec) -> Option { - const SYNC_FLAG: &str = "--cloudreve-sync-now"; - const VIEW_FLAG: &str = "--cloudreve-view-online"; - - let mut iter = args.into_iter().skip(1); - while let Some(arg) = iter.next() { - match arg.as_str() { - SYNC_FLAG => { - let paths = iter - .by_ref() - .take_while(|value| !value.starts_with("--cloudreve-")) - .map(PathBuf::from) - .collect::>(); - if !paths.is_empty() { - return Some(CliAction::SyncNow(paths)); - } - } - VIEW_FLAG => { - if let Some(path) = iter.next() { - return Some(CliAction::ViewOnline(PathBuf::from(path))); - } - } - _ => {} - } - } - - None -} - -async fn dispatch_cli_action(action: CliAction) { - let Some(state) = APP_STATE.get() else { - tracing::warn!(target: "main", "Cannot dispatch CLI action before app state is ready"); - return; - }; - - let result = match action { - CliAction::SyncNow(paths) => state - .drive_manager - .dispatch_sync_now(paths, SyncMode::FullHierarchy), - CliAction::ViewOnline(path) => state.drive_manager.dispatch_view_online(path), - }; - - if let Err(error) = result { - tracing::error!(target: "main", error = %error, "Failed to dispatch CLI action"); - } -} - -fn handle_cloudreve_cli_args(args: Vec) -> bool { - let Some(action) = parse_cloudreve_cli_action(args) else { - return false; - }; - - spawn(async move { - dispatch_cli_action(action).await; - }); - - true -} - /// Perform graceful shutdown async fn shutdown() { tracing::info!(target: "main", "Initiating shutdown..."); @@ -267,8 +193,13 @@ fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { true, None::<&str>, )?; - let settings_i = - MenuItem::with_id(app, "settings", t!("settings").as_ref(), true, None::<&str>)?; + let settings_i = MenuItem::with_id( + app, + "settings", + t!("settings").as_ref(), + true, + None::<&str>, + )?; let quit_i = MenuItem::with_id(app, "quit", t!("quit").as_ref(), true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_i, &add_drive_i, &settings_i, &quit_i])?; @@ -322,10 +253,6 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { tracing::info!("a new app instance was opened with {argv:?} and the deep link event was already triggered"); - if handle_cloudreve_cli_args(argv.clone()) { - return; - } - if argv.len() > 1 { let _ = app.emit("deeplink", argv[1].clone()); show_add_drive_window_impl(app); @@ -386,7 +313,6 @@ pub fn run() { commands::set_notify_file_conflict, commands::set_fast_popup_launch, commands::get_general_settings, - commands::get_platform_capabilities, commands::set_log_to_file, commands::set_log_level, commands::set_log_max_files, diff --git a/ui/public/locales/de/common.json b/ui/public/locales/de/common.json index 6cb0508..6cdc0bc 100644 --- a/ui/public/locales/de/common.json +++ b/ui/public/locales/de/common.json @@ -64,26 +64,6 @@ "language": "Sprache", "languageDescription": "Anzeigesprache der Anwendung auswählen", "languageAuto": "Systemstandard", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Benachrichtigungen", "notifyCredentialExpired": "Benachrichtigung bei Anmeldedatenablauf", "notifyCredentialExpiredDescription": "Benachrichtigung anzeigen, wenn Laufwerksanmeldedaten ablaufen", diff --git a/ui/public/locales/en-US/common.json b/ui/public/locales/en-US/common.json index 354715b..34fbf72 100644 --- a/ui/public/locales/en-US/common.json +++ b/ui/public/locales/en-US/common.json @@ -64,26 +64,6 @@ "language": "Language", "languageDescription": "Select the display language for the application", "languageAuto": "System Default", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Notifications", "notifyCredentialExpired": "Credential expiry notification", "notifyCredentialExpiredDescription": "Show notification when drive credentials expire", diff --git a/ui/public/locales/es/common.json b/ui/public/locales/es/common.json index 8ad070f..6decb0c 100644 --- a/ui/public/locales/es/common.json +++ b/ui/public/locales/es/common.json @@ -64,26 +64,6 @@ "language": "Idioma", "languageDescription": "Seleccionar el idioma de visualización de la aplicación", "languageAuto": "Predeterminado del sistema", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Notificaciones", "notifyCredentialExpired": "Notificación de expiración de credenciales", "notifyCredentialExpiredDescription": "Mostrar notificación cuando las credenciales de la unidad expiren", diff --git a/ui/public/locales/fr/common.json b/ui/public/locales/fr/common.json index af2507b..1dc719b 100644 --- a/ui/public/locales/fr/common.json +++ b/ui/public/locales/fr/common.json @@ -64,26 +64,6 @@ "language": "Langue", "languageDescription": "Sélectionner la langue d'affichage de l'application", "languageAuto": "Par défaut du système", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Notifications", "notifyCredentialExpired": "Notification d'expiration des identifiants", "notifyCredentialExpiredDescription": "Afficher une notification lorsque les identifiants du disque expirent", diff --git a/ui/public/locales/it/common.json b/ui/public/locales/it/common.json index 1ea735c..7e48c7f 100644 --- a/ui/public/locales/it/common.json +++ b/ui/public/locales/it/common.json @@ -64,26 +64,6 @@ "language": "Lingua", "languageDescription": "Seleziona la lingua di visualizzazione dell'applicazione", "languageAuto": "Predefinito di sistema", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Notifiche", "notifyCredentialExpired": "Notifica scadenza credenziali", "notifyCredentialExpiredDescription": "Mostra notifica quando le credenziali dell'unità scadono", diff --git a/ui/public/locales/ja/common.json b/ui/public/locales/ja/common.json index 4bcc231..5f93747 100644 --- a/ui/public/locales/ja/common.json +++ b/ui/public/locales/ja/common.json @@ -64,26 +64,6 @@ "language": "言語", "languageDescription": "アプリケーションの表示言語を選択", "languageAuto": "システムのデフォルト", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "通知", "notifyCredentialExpired": "認証期限切れ通知", "notifyCredentialExpiredDescription": "ドライブの認証が期限切れになったときに通知を表示", diff --git a/ui/public/locales/ko/common.json b/ui/public/locales/ko/common.json index 3566e27..54be7a8 100644 --- a/ui/public/locales/ko/common.json +++ b/ui/public/locales/ko/common.json @@ -64,26 +64,6 @@ "language": "언어", "languageDescription": "애플리케이션 표시 언어 선택", "languageAuto": "시스템 기본값", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "알림", "notifyCredentialExpired": "자격 증명 만료 알림", "notifyCredentialExpiredDescription": "드라이브 자격 증명이 만료되면 알림 표시", diff --git a/ui/public/locales/pl/common.json b/ui/public/locales/pl/common.json index 4ff863f..6de7d48 100644 --- a/ui/public/locales/pl/common.json +++ b/ui/public/locales/pl/common.json @@ -64,26 +64,6 @@ "language": "Język", "languageDescription": "Wybierz język wyświetlania aplikacji", "languageAuto": "Domyślny systemowy", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Powiadomienia", "notifyCredentialExpired": "Powiadomienie o wygaśnięciu danych uwierzytelniających", "notifyCredentialExpiredDescription": "Pokaż powiadomienie gdy dane uwierzytelniające dysku wygasną", diff --git a/ui/public/locales/ru/common.json b/ui/public/locales/ru/common.json index 103bb4c..d95f031 100644 --- a/ui/public/locales/ru/common.json +++ b/ui/public/locales/ru/common.json @@ -64,26 +64,6 @@ "language": "Язык", "languageDescription": "Выберите язык интерфейса приложения", "languageAuto": "По умолчанию системы", - "syncCapabilitySettings": "Sync capability", - "syncModeTitle": "Sync mode", - "placeholderSupport": "Placeholder support", - "placeholderAvailable": "Available", - "placeholderUnavailable": "Unavailable", - "syncMode": { - "windows_cloud_files": "Windows placeholders", - "kde_placeholders": "KDE placeholders", - "full_sync_only": "Full sync" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "Unknown" - }, "notificationSettings": "Уведомления", "notifyCredentialExpired": "Уведомление об истечении учётных данных", "notifyCredentialExpiredDescription": "Показывать уведомление при истечении учётных данных диска", diff --git a/ui/public/locales/zh-CN/common.json b/ui/public/locales/zh-CN/common.json index 9533445..efd8ffb 100644 --- a/ui/public/locales/zh-CN/common.json +++ b/ui/public/locales/zh-CN/common.json @@ -64,26 +64,6 @@ "language": "语言", "languageDescription": "选择应用的显示语言", "languageAuto": "跟随系统", - "syncCapabilitySettings": "同步能力", - "syncModeTitle": "同步模式", - "placeholderSupport": "占位符支持", - "placeholderAvailable": "可用", - "placeholderUnavailable": "不可用", - "syncMode": { - "windows_cloud_files": "Windows 占位符", - "kde_placeholders": "KDE 占位符", - "full_sync_only": "全量同步" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "未知" - }, "notificationSettings": "通知", "notifyCredentialExpired": "凭证过期通知", "notifyCredentialExpiredDescription": "当网盘凭证过期时显示通知", diff --git a/ui/public/locales/zh-TW/common.json b/ui/public/locales/zh-TW/common.json index f22a9ef..58a6153 100644 --- a/ui/public/locales/zh-TW/common.json +++ b/ui/public/locales/zh-TW/common.json @@ -64,26 +64,6 @@ "language": "語言", "languageDescription": "選擇應用程式的顯示語言", "languageAuto": "跟隨系統", - "syncCapabilitySettings": "同步能力", - "syncModeTitle": "同步模式", - "placeholderSupport": "佔位符支援", - "placeholderAvailable": "可用", - "placeholderUnavailable": "不可用", - "syncMode": { - "windows_cloud_files": "Windows 佔位符", - "kde_placeholders": "KDE 佔位符", - "full_sync_only": "全量同步" - }, - "desktopEnvironment": { - "windows": "Windows", - "macos": "macOS", - "kde": "KDE", - "gnome": "GNOME", - "xfce": "XFCE", - "cinnamon": "Cinnamon", - "mate": "MATE", - "unknown": "未知" - }, "notificationSettings": "通知", "notifyCredentialExpired": "憑證過期通知", "notifyCredentialExpiredDescription": "當雲端硬碟憑證過期時顯示通知", diff --git a/ui/src/pages/settings/GeneralSection.tsx b/ui/src/pages/settings/GeneralSection.tsx index ef2627e..51fdfbf 100644 --- a/ui/src/pages/settings/GeneralSection.tsx +++ b/ui/src/pages/settings/GeneralSection.tsx @@ -7,7 +7,6 @@ import { Select, MenuItem, FormControl, - Chip, } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -170,46 +169,6 @@ function SettingActionItem({ ); } -interface SettingReadOnlyItemProps { - title: string; - description?: string; - value: string; - isLast?: boolean; -} - -function SettingReadOnlyItem({ - title, - description, - value, - isLast, -}: SettingReadOnlyItemProps) { - return ( - <> - - - {title} - {description && ( - - {description} - - )} - - - - {!isLast && } - - ); -} - interface SettingsGroupProps { title: string; children: React.ReactNode; @@ -254,22 +213,6 @@ interface GeneralSettings { language: string | null; } -interface KdePlaceholderBackend { - detected: boolean; - available: boolean; - reason: string; -} - -interface PlatformCapabilities { - os: string; - desktop_environment: string; - sync_mode: string; - placeholders_supported: boolean; - full_sync_supported: boolean; - reason: string; - kde_placeholder_backend?: KdePlaceholderBackend; -} - const LOG_LEVELS = [ { value: "trace", label: "Trace" }, { value: "debug", label: "Debug" }, @@ -296,17 +239,14 @@ export default function GeneralSection() { const [logMaxFiles, setLogMaxFiles] = useState(5); const [logDir, setLogDir] = useState(""); const [language, setLanguage] = useState(null); - const [platformCapabilities, setPlatformCapabilities] = - useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const loadSettings = async () => { try { - const [enabled, settings, capabilities] = await Promise.all([ + const [enabled, settings] = await Promise.all([ invoke("get_auto_start_enabled"), invoke("get_general_settings"), - invoke("get_platform_capabilities"), ]); setAutoStart(enabled); setNotifyCredentialExpired(settings.notify_credential_expired); @@ -317,7 +257,6 @@ export default function GeneralSection() { setLogMaxFiles(settings.log_max_files); setLogDir(settings.log_dir); setLanguage(settings.language); - setPlatformCapabilities(capabilities); } catch (error) { console.error("Failed to load settings:", error); } finally { @@ -435,23 +374,6 @@ export default function GeneralSection() { // Get the current language value for the select, "auto" if null const currentLanguageValue = language ?? "auto"; - const platformSyncMode = platformCapabilities - ? t(`settings.syncMode.${platformCapabilities.sync_mode}`, { - defaultValue: platformCapabilities.sync_mode, - }) - : t("settings.loading"); - const desktopEnvironment = platformCapabilities - ? t(`settings.desktopEnvironment.${platformCapabilities.desktop_environment}`, { - defaultValue: platformCapabilities.desktop_environment, - }) - : t("settings.loading"); - const placeholderStatus = platformCapabilities?.placeholders_supported - ? t("settings.placeholderAvailable") - : t("settings.placeholderUnavailable"); - const platformDescription = platformCapabilities - ? `${platformCapabilities.os} / ${desktopEnvironment}. ${platformCapabilities.reason}` - : undefined; - const kdeBackend = platformCapabilities?.kde_placeholder_backend; return ( @@ -492,21 +414,6 @@ export default function GeneralSection() { /> - - - - - Date: Fri, 5 Jun 2026 22:33:35 +0800 Subject: [PATCH 04/25] Document Linux full sync strategy --- docs/linux-support-plan.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md index 287ecfd..31b8cd6 100644 --- a/docs/linux-support-plan.md +++ b/docs/linux-support-plan.md @@ -14,24 +14,21 @@ Status: implemented. - Keep local filesystem watching and upload/download task queues active. - Do not expose placeholder semantics on Linux yet. -## Phase 2: KDE Placeholder Support +## Phase 2: Linux Full Sync Only -Status: planned. +Status: implemented. -- Detect KDE/Plasma at runtime using environment signals such as `XDG_CURRENT_DESKTOP`, `KDE_FULL_SESSION`, and `DESKTOP_SESSION`. -- Add a Linux platform capability model: - - `FullSyncOnly` for GNOME, XFCE, Cinnamon, generic Wayland/X11, and unknown desktops. - - `KdePlaceholders` for supported KDE environments. -- Implement KDE placeholder integration as a separate backend from Windows CFAPI. -- Keep non-KDE desktops on the Phase 1 full sync path. -- Add clear logging when KDE placeholder support is unavailable or disabled. +- Linux does not implement Windows-style on-demand placeholder sync. +- KDE does not receive a separate placeholder backend. +- All Linux desktop environments use the Phase 1 full sync path. +- Linux sync roots are ordinary local directories watched by the filesystem watcher. +- Remote files are downloaded fully instead of represented as dehydrated placeholders. +- Placeholder semantics remain Windows-only through CFAPI. ## Phase 3: UI And Packaging Status: planned. -- Surface the active Linux sync mode in settings. -- Disable placeholder-only options outside KDE. - Add Linux autostart support through freedesktop `.desktop` files. -- Add Linux packaging metadata and install hooks for KDE integration. -- Add integration tests for full sync and targeted KDE capability detection. +- Add Linux packaging metadata. +- Add integration tests for Linux full sync behavior. From 7e08003c4177c3aaee385f4570f7a18711828cc8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 22:40:28 +0800 Subject: [PATCH 05/25] Add Linux autostart and packaging support --- .../src/drive/placeholder_non_windows.rs | 74 +++++- docs/linux-support-plan.md | 11 +- package/linux/cloudreve-desktop.desktop | 10 + src-tauri/src/commands.rs | 231 ++++++++++++++---- 4 files changed, 267 insertions(+), 59 deletions(-) create mode 100644 package/linux/cloudreve-desktop.desktop diff --git a/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs index 2eeebb8..1bf6f81 100644 --- a/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs +++ b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::Arc}; use anyhow::{Context, Result}; use chrono::DateTime; -use cloudreve_api::models::explorer::{file_type, FileResponse}; +use cloudreve_api::models::explorer::{FileResponse, file_type}; use uuid::Uuid; use crate::{ @@ -110,7 +110,11 @@ impl CrPlaceholder { .as_ref() .unwrap_or(&String::new()) .clone(), - permissions: file_info.permission.as_ref().unwrap_or(&String::new()).clone(), + permissions: file_info + .permission + .as_ref() + .unwrap_or(&String::new()) + .clone(), shared: file_info.shared.unwrap_or(false), metadata: file_info.metadata.clone().unwrap_or_default(), props: None, @@ -123,3 +127,69 @@ impl CrPlaceholder { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use cloudreve_api::models::explorer::file_type; + use tempfile::tempdir; + + #[test] + fn remote_file_commit_updates_inventory_without_creating_placeholder_file() { + let temp = tempdir().unwrap(); + let sync_root = temp.path().join("sync"); + let local_path = sync_root.join("remote.txt"); + let inventory = Arc::new(InventoryDb::with_path(temp.path().join("meta.db")).unwrap()); + let drive_id = Uuid::new_v4(); + let remote = FileResponse { + file_type: file_type::FILE, + id: "file-id".to_string(), + name: "remote.txt".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + size: 42, + path: "cloudreve://remote.txt".to_string(), + primary_entity: Some("etag".to_string()), + ..Default::default() + }; + + CrPlaceholder::new(local_path.clone(), sync_root, drive_id) + .with_remote_file(&remote) + .commit(inventory.clone()) + .unwrap(); + + assert!(!local_path.exists()); + let stored = inventory + .query_by_path(local_path.to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(stored.etag, "etag"); + assert!(!stored.is_folder); + } + + #[test] + fn remote_folder_commit_creates_local_directory() { + let temp = tempdir().unwrap(); + let sync_root = temp.path().join("sync"); + let local_path = sync_root.join("folder"); + let inventory = Arc::new(InventoryDb::with_path(temp.path().join("meta.db")).unwrap()); + let drive_id = Uuid::new_v4(); + let remote = FileResponse { + file_type: file_type::FOLDER, + id: "folder-id".to_string(), + name: "folder".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + path: "cloudreve://folder".to_string(), + primary_entity: Some("folder-etag".to_string()), + ..Default::default() + }; + + CrPlaceholder::new(local_path.clone(), sync_root, drive_id) + .with_remote_file(&remote) + .commit(inventory) + .unwrap(); + + assert!(local_path.is_dir()); + } +} diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md index 31b8cd6..49b9be6 100644 --- a/docs/linux-support-plan.md +++ b/docs/linux-support-plan.md @@ -27,8 +27,11 @@ Status: implemented. ## Phase 3: UI And Packaging -Status: planned. +Status: implemented. -- Add Linux autostart support through freedesktop `.desktop` files. -- Add Linux packaging metadata. -- Add integration tests for Linux full sync behavior. +- Add Linux autostart support through freedesktop `.desktop` files. Implemented. +- Add Linux packaging metadata. Implemented with a freedesktop `.desktop` entry template. +- Add tests for Linux full sync behavior. Implemented: + - Non-Windows remote file metadata commits update inventory without creating placeholder files. + - Non-Windows remote folder metadata commits create local directories. + - Linux autostart entries quote and escape executable paths correctly. diff --git a/package/linux/cloudreve-desktop.desktop b/package/linux/cloudreve-desktop.desktop new file mode 100644 index 0000000..f0910ed --- /dev/null +++ b/package/linux/cloudreve-desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=Cloudreve +Comment=Cloudreve Desktop Sync Client +Exec=cloudreve-desktop +Icon=cloudreve +Terminal=false +Categories=Network;FileTransfer; +StartupNotify=false diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8bf88c3..0097af7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -403,7 +403,11 @@ pub async fn show_reauthorize_window( /// Show or create the add-drive window pub fn show_add_drive_window_impl(app: &AppHandle) { - show_drive_window_internal(app, "Add Drive", &get_url_with_lang("index.html/#/add-drive")); + show_drive_window_internal( + app, + "Add Drive", + &get_url_with_lang("index.html/#/add-drive"), + ); } /// Show or create the reauthorize window for a specific drive @@ -522,76 +526,198 @@ pub fn show_settings_window_impl(app: &AppHandle) { /// The TaskId defined in AppxManifest.xml for the startup task #[cfg(windows)] const STARTUP_TASK_ID: &str = "cloudreve"; +#[cfg(target_os = "linux")] +const AUTOSTART_DESKTOP_FILE: &str = "cloudreve-desktop.desktop"; + +#[cfg(target_os = "linux")] +fn linux_autostart_path() -> CommandResult { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .map(|home| home.join(".config")) + }) + .ok_or_else(|| "Unable to determine XDG config directory".to_string())?; + + Ok(config_home.join("autostart").join(AUTOSTART_DESKTOP_FILE)) +} + +#[cfg(target_os = "linux")] +fn linux_desktop_exec_quote(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`") + .replace('\n', "") + .replace('\r', ""); + format!("\"{}\"", escaped) +} + +#[cfg(target_os = "linux")] +fn linux_autostart_entry() -> CommandResult { + let exe = std::env::current_exe() + .map_err(|e| format!("Failed to get current executable path: {}", e))?; + let exec = linux_desktop_exec_quote(&exe.display().to_string()); + + Ok(format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.0\n\ + Name=Cloudreve\n\ + Comment=Cloudreve Desktop Sync Client\n\ + Exec={}\n\ + Terminal=false\n\ + X-GNOME-Autostart-enabled=true\n\ + Hidden=false\n", + exec + )) +} + +#[cfg(target_os = "linux")] +fn linux_get_auto_start_enabled() -> CommandResult { + let path = linux_autostart_path()?; + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("Failed to read autostart entry: {}", err)), + }; + + let disabled = content.lines().any(|line| { + matches!( + line.trim(), + "Hidden=true" | "X-GNOME-Autostart-enabled=false" + ) + }); + Ok(!disabled) +} + +#[cfg(target_os = "linux")] +fn linux_set_auto_start(enabled: bool) -> CommandResult { + let path = linux_autostart_path()?; + + if enabled { + let parent = path + .parent() + .ok_or_else(|| "Invalid autostart entry path".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create autostart directory: {}", e))?; + std::fs::write(&path, linux_autostart_entry()?) + .map_err(|e| format!("Failed to write autostart entry: {}", e))?; + Ok(true) + } else { + match std::fs::remove_file(&path) { + Ok(_) => Ok(false), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(format!("Failed to remove autostart entry: {}", err)), + } + } +} /// Get whether auto-start is enabled using Windows StartupTask API #[tauri::command] pub async fn get_auto_start_enabled() -> CommandResult { - #[cfg(not(windows))] + #[cfg(target_os = "linux")] { - return Ok(false); + return tokio::task::spawn_blocking(linux_get_auto_start_enabled) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + + #[cfg(not(any(windows, target_os = "linux")))] + { + Ok(false) } #[cfg(windows)] { - tokio::task::spawn_blocking(|| { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? - .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - let state = task - .State() - .map_err(|e| format!("Failed to get task state: {}", e))?; - - Ok(matches!( - state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? + tokio::task::spawn_blocking(|| { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? + .get() + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + let state = task + .State() + .map_err(|e| format!("Failed to get task state: {}", e))?; + + Ok(matches!( + state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? } } /// Set auto-start configuration using Windows StartupTask API #[tauri::command] pub async fn set_auto_start(enabled: bool) -> CommandResult { - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + { + return tokio::task::spawn_blocking(move || linux_set_auto_start(enabled)) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + + #[cfg(not(any(windows, target_os = "linux")))] { - let _ = enabled; - return Err("Auto-start configuration is not supported on this platform yet".to_string()); + Err("Auto-start configuration is not supported on this platform yet".to_string()) } #[cfg(windows)] { - tokio::task::spawn_blocking(move || { - let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); - let task = StartupTask::GetAsync(&task_id) - .map_err(|e| format!("Failed to get startup task: {}", e))? - .get() - .map_err(|e| format!("Failed to get startup task: {}", e))?; - - if enabled { - // Request enable - may prompt user for consent - let new_state = task - .RequestEnableAsync() - .map_err(|e| format!("Failed to request enable: {}", e))? + tokio::task::spawn_blocking(move || { + let task_id: windows::core::HSTRING = STARTUP_TASK_ID.into(); + let task = StartupTask::GetAsync(&task_id) + .map_err(|e| format!("Failed to get startup task: {}", e))? .get() - .map_err(|e| format!("Failed to enable startup task: {}", e))?; + .map_err(|e| format!("Failed to get startup task: {}", e))?; + + if enabled { + // Request enable - may prompt user for consent + let new_state = task + .RequestEnableAsync() + .map_err(|e| format!("Failed to request enable: {}", e))? + .get() + .map_err(|e| format!("Failed to enable startup task: {}", e))?; + + Ok(matches!( + new_state, + StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy + )) + } else { + task.Disable() + .map_err(|e| format!("Failed to disable startup task: {}", e))?; + Ok(false) + } + }) + .await + .map_err(|e| format!("Task join error: {}", e))? + } +} - Ok(matches!( - new_state, - StartupTaskState::Enabled | StartupTaskState::EnabledByPolicy - )) - } else { - task.Disable() - .map_err(|e| format!("Failed to disable startup task: {}", e))?; - Ok(false) - } - }) - .await - .map_err(|e| format!("Task join error: {}", e))? +#[cfg(all(test, target_os = "linux"))] +mod linux_autostart_tests { + use super::linux_desktop_exec_quote; + + #[test] + fn quotes_exec_paths_for_desktop_entries() { + assert_eq!( + linux_desktop_exec_quote("/opt/Cloudreve Desktop/cloudreve"), + "\"/opt/Cloudreve Desktop/cloudreve\"" + ); + } + + #[test] + fn escapes_shell_sensitive_exec_characters() { + assert_eq!( + linux_desktop_exec_quote("/tmp/cloudreve\"$`\\bin"), + "\"/tmp/cloudreve\\\"\\$\\`\\\\bin\"" + ); } } @@ -683,13 +809,12 @@ pub async fn set_language(app: AppHandle, language: Option) -> CommandRe .map_err(|e| e.to_string())?; // Update rust_i18n locale - let locale = language.unwrap_or_else(|| { - sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")) - }); + let locale = language + .unwrap_or_else(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); rust_i18n::set_locale(&locale); // Close main window to force reload with new language - // Check if window already exists + // Check if window already exists if let Some(window) = app.get_webview_window("main_popup") { let _ = window.close(); let _ = window.destroy(); From db8e4c9aed7f6163fae76ff41e126b354853bbc9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 22:41:59 +0800 Subject: [PATCH 06/25] Exclude Windows notification crate from Linux workspace --- Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1c37a8d..b2113c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "crates/cloudreve-api", "crates/cloudreve-sync", - "crates/win32_notif", "src-tauri", ] +exclude = [ + "crates/win32_notif", +] From 4d4c33fce417a576bd5f291b7eb71c5f2708d9bd Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 23:10:53 +0800 Subject: [PATCH 07/25] Add Linux notifications and packaging workflows --- .github/workflows/ci.yml | 48 ++++++++ .github/workflows/release-linux.yml | 80 ++++++++++++ crates/cloudreve-sync/Cargo.toml | 3 + .../src/drive/placeholder_non_windows.rs | 61 ++++++++++ crates/cloudreve-sync/src/drive/sync.rs | 26 ++-- crates/cloudreve-sync/src/drive/utils.rs | 15 ++- crates/cloudreve-sync/src/utils/app.rs | 22 +++- crates/cloudreve-sync/src/utils/toast.rs | 115 ++++++++++++++++-- package/arch/PKGBUILD | 49 ++++++++ 9 files changed, 389 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-linux.yml create mode 100644 package/arch/PKGBUILD diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dfadb0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + linux-tests: + name: Linux tests + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + pkg-config + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Check Linux workspace + run: cargo check --workspace + + - name: Run Linux tests + run: | + cargo test -p cloudreve-desktop linux_autostart_tests + cargo test -p cloudreve-sync drive::placeholder::tests diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..80b5038 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,80 @@ +name: Release Linux Packages + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + linux-packages: + name: Build deb and rpm + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + pkg-config \ + rpm + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install Yarn + run: npm install -g yarn@1.22.22 + + - name: Install frontend dependencies + working-directory: ui + run: yarn install --frozen-lockfile + + - name: Build frontend + working-directory: ui + run: yarn run build + + - name: Install Tauri CLI + run: cargo install tauri-cli --version '^2' --locked + + - name: Build Linux packages + working-directory: src-tauri + run: cargo tauri build --bundles deb,rpm --config '{"build":{"beforeBuildCommand":""}}' + + - name: Upload Linux packages + uses: actions/upload-artifact@v4 + with: + name: cloudreve-linux-packages + path: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm + if-no-files-found: error + + - name: Attach packages to GitHub Release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm diff --git a/crates/cloudreve-sync/Cargo.toml b/crates/cloudreve-sync/Cargo.toml index 84ef14c..1d7294a 100644 --- a/crates/cloudreve-sync/Cargo.toml +++ b/crates/cloudreve-sync/Cargo.toml @@ -47,6 +47,9 @@ globset = "0.4" windows-core = "0.58.0" win32_notif = { path = "../win32_notif" } +[target.'cfg(target_os = "linux")'.dependencies] +notify-rust = "4.11" + [target.'cfg(windows)'.dependencies.windows] version = "0.58.0" features = [ diff --git a/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs index 1bf6f81..6c88c85 100644 --- a/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs +++ b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs @@ -10,6 +10,22 @@ use crate::{ inventory::{FileMetadata, InventoryDb, MetadataEntry}, }; +/// Non-Windows compatibility implementation for the Windows `CrPlaceholder` API. +/// +/// Despite the type name, this implementation does **not** create dehydrated, +/// on-demand, or filesystem-provider placeholders. Linux and other non-Windows +/// platforms use full sync only, so this type is a small adapter that keeps the +/// shared sync code compiling while mapping placeholder operations to ordinary +/// local filesystem and inventory operations: +/// +/// - remote folders create real local directories; +/// - remote files only update inventory metadata here and are downloaded by the +/// normal download queue; +/// - delete operations remove real local files or directories if they exist. +/// +/// The public method names intentionally mirror the Windows implementation so +/// callers can use one cross-platform code path. Review this file as the +/// non-Windows full-sync metadata helper, not as a placeholder backend. pub struct CrPlaceholder { pub local_file_info: LocalFileInfo, local_path: PathBuf, @@ -19,6 +35,15 @@ pub struct CrPlaceholder { } impl CrPlaceholder { + /// Creates a non-Windows full-sync adapter for a local path. + /// + /// The `sync_root` parameter is accepted for API parity with the Windows + /// placeholder implementation, but it is not needed here because no cloud + /// filter root or provider registration exists on non-Windows platforms. + /// + /// `local_file_info` is populated from the real local path when possible. + /// If the path does not exist yet, the adapter records a missing file state; + /// this is expected for remote files that will be downloaded later. pub fn new(local_path: impl Into, _sync_root: PathBuf, drive_id: Uuid) -> Self { let local_path = local_path.into(); Self { @@ -31,20 +56,39 @@ impl CrPlaceholder { } } + /// No-op compatibility hook for the Windows range invalidation option. + /// + /// Windows placeholders can invalidate byte ranges through CFAPI. Non-Windows + /// full sync has no dehydrated file ranges, so this method deliberately + /// returns `self` unchanged. pub fn with_invalidate_all_range(self, _enable: bool) -> Self { self } + /// Records the requested "no children" marker for API parity. + /// + /// The flag is kept so shared builder chains behave consistently, but no + /// non-Windows filesystem metadata is written from this value. pub fn with_mark_no_children(mut self, enable: bool) -> Self { self.mark_no_children = enable; self } + /// Attaches inventory metadata that will be committed later. + /// + /// On non-Windows platforms this metadata describes a fully synced local + /// entry. For files, committing the metadata does not create any file bytes; + /// the download task is responsible for creating the real file. pub fn with_file_meta(mut self, file_meta: FileMetadata) -> Self { self.file_meta = Some(file_meta); self } + /// Deletes the real local file or directory and removes its inventory row. + /// + /// The name mirrors the Windows placeholder API, but the operation here is + /// not limited to placeholder metadata. If the path exists, this removes the + /// actual synced file or directory from disk before deleting inventory data. pub fn delete_placeholder(&self, inventory: Arc) -> Result<()> { if self.local_file_info.exists { if self.local_path.is_dir() { @@ -65,6 +109,13 @@ impl CrPlaceholder { Ok(()) } + /// Commits the pending metadata using non-Windows full-sync semantics. + /// + /// For folders, this creates a real local directory immediately and then + /// stores metadata in the inventory. For files, this only ensures the parent + /// directory exists and stores metadata; it intentionally does **not** create + /// a placeholder file. The normal download queue must later write the full + /// file contents to disk. pub fn commit(&mut self, inventory: Arc) -> Result<()> { let file_meta = self .file_meta @@ -86,6 +137,11 @@ impl CrPlaceholder { Ok(()) } + /// Converts a remote API file response into local inventory metadata. + /// + /// This method does not create any local file or directory by itself. It only + /// prepares the metadata that `commit` will apply using non-Windows full-sync + /// behavior. pub fn with_remote_file(mut self, file_info: &FileResponse) -> Self { let created_at = DateTime::parse_from_rfc3339(&file_info.created_at) .ok() @@ -123,6 +179,11 @@ impl CrPlaceholder { self } + /// No-op compatibility hook for Windows placeholder sync error state. + /// + /// Windows can surface sync error state through shell/provider metadata. + /// Non-Windows full sync currently has no equivalent per-file shell state, + /// so this method succeeds without changing the filesystem or inventory. pub fn update_sync_error_state(&mut self, _sync_error: bool) -> Result<()> { Ok(()) } diff --git a/crates/cloudreve-sync/src/drive/sync.rs b/crates/cloudreve-sync/src/drive/sync.rs index 4a2c195..9b13d47 100644 --- a/crates/cloudreve-sync/src/drive/sync.rs +++ b/crates/cloudreve-sync/src/drive/sync.rs @@ -502,14 +502,16 @@ impl Mount { let inventory_files = self.fetch_inventory_entries(paths).await?; tracing::trace!("{:?}", inventory_files); - let plan = self.build_sync_plan( - parent, - mode, - paths, - &remote_files, - &local_files, - &inventory_files, - ).await; + let plan = self + .build_sync_plan( + parent, + mode, + paths, + &remote_files, + &local_files, + &inventory_files, + ) + .await; tracing::debug!( target: "drive::sync", @@ -588,6 +590,9 @@ impl Mount { } else { #[cfg(not(windows))] if remote.file_type != file_type::FOLDER { + // Non-Windows `CrPlaceholder` only records inventory metadata here. + // There is no CFAPI-style dehydrated placeholder, so queue a normal + // download to create the real full-sync file on disk. if let Err(err) = self .task_queue .enqueue(TaskPayload::download(path.clone())) @@ -621,6 +626,9 @@ impl Mount { } else { #[cfg(not(windows))] if remote.file_type != file_type::FOLDER { + // Non-Windows `CrPlaceholder` only refreshes inventory metadata here. + // Since Linux has no on-demand placeholder backend, the real file + // contents must be downloaded immediately through the task queue. if let Err(err) = self .task_queue .enqueue(TaskPayload::download(path.clone())) @@ -655,7 +663,7 @@ impl Mount { aggregate_error.push(path.clone(), anyhow::Error::from(err)); } } - SyncAction::QueueDownload { path, remote:_ } => { + SyncAction::QueueDownload { path, remote: _ } => { tracing::info!( target: "drive::sync", id = %self.id, diff --git a/crates/cloudreve-sync/src/drive/utils.rs b/crates/cloudreve-sync/src/drive/utils.rs index 2f6b693..b0cf6fa 100644 --- a/crates/cloudreve-sync/src/drive/utils.rs +++ b/crates/cloudreve-sync/src/drive/utils.rs @@ -88,13 +88,17 @@ pub fn recycle_bin_url(config: &DriveConfig) -> Result { { let mut query = base.query_pairs_mut(); query.append_pair("user_hint", config.user_id.as_str()); - query.append_pair("path", "cloudreve://trash"); + query.append_pair("path", "cloudreve://trash"); } Ok(base.to_string()) } -// notify_shell_change notify the shell to refresh the file or directory +/// Notifies the platform shell that a local file or directory changed. +/// +/// On Windows this calls `SHChangeNotify` so Explorer refreshes Cloud Files +/// placeholder state, shell overlays, and file attributes after create/delete or +/// hydrate/dehydrate operations. #[cfg(windows)] pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> { let utf16_path = U16CString::from_os_str(path.as_path())?; @@ -109,6 +113,13 @@ pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> { Ok(()) } +/// No-op shell notification shim for non-Windows full sync. +/// +/// Linux and other non-Windows platforms do not use the Windows CFAPI +/// placeholder model, and there is no single cross-desktop equivalent of +/// `SHChangeNotify`. Ordinary file managers observe real file and directory +/// changes through filesystem notifications, so full-sync correctness does not +/// depend on an explicit shell refresh hook here. #[cfg(not(windows))] pub fn notify_shell_change(_path: &PathBuf, _event: u32) -> Result<()> { Ok(()) diff --git a/crates/cloudreve-sync/src/utils/app.rs b/crates/cloudreve-sync/src/utils/app.rs index 8097443..d906d2b 100644 --- a/crates/cloudreve-sync/src/utils/app.rs +++ b/crates/cloudreve-sync/src/utils/app.rs @@ -5,12 +5,18 @@ use windows::ApplicationModel; static APP_ROOT: OnceLock> = OnceLock::new(); pub fn init_app_root() { + // Windows shell integration loads icons and status UI resources from the + // installed MSIX package root. #[cfg(windows)] let path = ApplicationModel::Package::Current() .and_then(|p| p.InstalledLocation()) .and_then(|l| l.Path()) .map(|p| p.to_string()) .unwrap_or_else(|_| String::new()); + // Non-Windows builds do not use the Windows Explorer shell extension, + // CFAPI placeholder UI, or MSIX package resources. Tauri and Linux + // packaging handle their own UI/icon resources, so this remains an empty + // compatibility value for shared code that expects APP_ROOT to be set. #[cfg(not(windows))] let path = String::new(); @@ -29,14 +35,17 @@ impl AppRoot { pub fn image_path(&self) -> String { #[cfg(windows)] { - match dark_light::detect().unwrap_or(dark_light::Mode::Light) { - dark_light::Mode::Dark => format!("{}\\Images\\darkTheme", self.0.as_str()), - dark_light::Mode::Light => format!("{}\\Images\\lightTheme", self.0.as_str()), - dark_light::Mode::Unspecified => format!("{}\\Images\\lightTheme", self.0.as_str()), - } + match dark_light::detect().unwrap_or(dark_light::Mode::Light) { + dark_light::Mode::Dark => format!("{}\\Images\\darkTheme", self.0.as_str()), + dark_light::Mode::Light => format!("{}\\Images\\lightTheme", self.0.as_str()), + dark_light::Mode::Unspecified => { + format!("{}\\Images\\lightTheme", self.0.as_str()) + } + } } #[cfg(not(windows))] { + // Windows shell icons are not used by non-Windows full sync. String::new() } } @@ -44,10 +53,11 @@ impl AppRoot { pub fn image_path_general(&self) -> String { #[cfg(windows)] { - format!("{}\\Images", self.0.as_str()) + format!("{}\\Images", self.0.as_str()) } #[cfg(not(windows))] { + // Windows shell icons are not used by non-Windows full sync. String::new() } } diff --git a/crates/cloudreve-sync/src/utils/toast.rs b/crates/cloudreve-sync/src/utils/toast.rs index d5f1b82..83eb8d1 100644 --- a/crates/cloudreve-sync/src/utils/toast.rs +++ b/crates/cloudreve-sync/src/utils/toast.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; #[cfg(windows)] use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +#[cfg(target_os = "linux")] +use notify_rust::{Notification, Timeout, Urgency}; #[cfg(windows)] use win32_notif::{ NotificationBuilder, ToastsNotifier, @@ -15,6 +17,30 @@ use crate::config::ConfigManager; #[cfg(windows)] const APP_NAME: &str = "Cloudreve.Sync"; +#[cfg(target_os = "linux")] +const APP_NAME: &str = "Cloudreve"; + +#[cfg(target_os = "linux")] +fn send_linux_notification(title: &str, message: &str, urgency: Urgency) { + match Notification::new() + .appname(APP_NAME) + .summary(title) + .body(message) + .icon("cloudreve") + .urgency(urgency) + .timeout(Timeout::Default) + .show() + { + Ok(_) => tracing::debug!(target: "toast", title, message, "Linux notification sent"), + Err(err) => tracing::warn!( + target: "toast", + title, + message, + error = %err, + "Failed to send Linux desktop notification" + ), + } +} #[cfg(windows)] pub fn send_general_text_toast(title: &str, message: &str) { @@ -39,7 +65,12 @@ pub fn send_general_text_toast(title: &str, message: &str) { notif.show().unwrap(); } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn send_general_text_toast(title: &str, message: &str) { + send_linux_notification(title, message, Urgency::Normal); +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn send_general_text_toast(title: &str, message: &str) { tracing::info!(target: "toast", title, message, "Toast notification skipped on this platform"); } @@ -64,7 +95,7 @@ pub fn send_warning_toast(title: &str, message: &str) { ) .visual( Image::create(3, "ms-appx:///Images/warning.svg") - .with_placement(Placement::AppLogoOverride) + .with_placement(Placement::AppLogoOverride), ) .build(0, ¬ifier, "01", "warning") .unwrap(); @@ -72,7 +103,12 @@ pub fn send_warning_toast(title: &str, message: &str) { notif.show().unwrap(); } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn send_warning_toast(title: &str, message: &str) { + send_linux_notification(title, message, Urgency::Critical); +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn send_warning_toast(title: &str, message: &str) { tracing::warn!(target: "toast", title, message, "Warning toast skipped on this platform"); } @@ -107,16 +143,32 @@ pub fn send_token_expiry_toast(drive_id: &str, title: &str, message: &str) { ) .visual( Image::create(3, "ms-appx:///Images/warning.svg") - .with_placement(Placement::AppLogoOverride) + .with_placement(Placement::AppLogoOverride), ) .with_launch("action=settings") - .build(0, ¬ifier, &format!("token_expiry_{}", drive_id), "token_expiry") + .build( + 0, + ¬ifier, + &format!("token_expiry_{}", drive_id), + "token_expiry", + ) .unwrap(); notif.show().unwrap(); } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_credential_expired() { + tracing::debug!(target: "toast", "Token expiry notification suppressed by config"); + return; + } + } + send_linux_notification(title, message, Urgency::Critical); +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { if let Some(config) = ConfigManager::try_get() { if !config.notify_credential_expired() { @@ -149,10 +201,16 @@ pub fn send_conflict_toast(drive_id: &str, path: &PathBuf, inventory_id: i64) { .with_style(HintStyle::Title), ) .visual( - Text::create(2, path.file_name().unwrap_or_default().to_str().unwrap_or_default()) - .with_align_center(true) - .with_wrap(true) - .with_style(HintStyle::Body), + Text::create( + 2, + path.file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ) + .with_align_center(true) + .with_wrap(true) + .with_style(HintStyle::Body), ) .actions(vec![ Box::new(Input::create_selection_input( @@ -170,19 +228,50 @@ pub fn send_conflict_toast(drive_id: &str, path: &PathBuf, inventory_id: i64) { ActionButton::create(t!("resolveWithAction").as_ref()) .with_id(&format!( "action=resolve&drive_id={}&file_id={}&path={}", - drive_id, inventory_id, URL_SAFE.encode(path.display().to_string()) + drive_id, + inventory_id, + URL_SAFE.encode(path.display().to_string()) )) .with_tooltip(t!("resolveTooltip").as_ref()), ), Box::new(ActionButton::create(t!("dismiss").as_ref()).with_id("action=dismiss")), ]) - .build(0, ¬ifier, &format!("conflict_{}", inventory_id), "readme") + .build( + 0, + ¬ifier, + &format!("conflict_{}", inventory_id), + "readme", + ) .unwrap(); notif.show().unwrap(); } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_file_conflict() { + tracing::debug!(target: "toast", "Conflict notification suppressed by config"); + return; + } + } + + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let message = format!("{} - open Cloudreve to resolve the conflict.", file_name); + tracing::warn!( + target: "toast", + path = %path.display(), + inventory_id, + "Sending Linux conflict notification without Windows toast actions" + ); + send_linux_notification(&t!("conflictToastTitle"), &message, Urgency::Critical); +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { if let Some(config) = ConfigManager::try_get() { if !config.notify_file_conflict() { diff --git a/package/arch/PKGBUILD b/package/arch/PKGBUILD new file mode 100644 index 0000000..c2f8a33 --- /dev/null +++ b/package/arch/PKGBUILD @@ -0,0 +1,49 @@ +# Maintainer: Cloudreve Desktop maintainers +pkgname=cloudreve-desktop +pkgver=0.2.0 +pkgrel=1 +pkgdesc='Cloudreve Desktop Sync Client' +arch=('x86_64') +url='https://github.com/cloudreve/desktop' +license=('MIT') +depends=( + 'gtk3' + 'hicolor-icon-theme' + 'libayatana-appindicator' + 'openssl' + 'webkit2gtk-4.1' +) +makedepends=( + 'rust' + 'file' + 'nodejs' + 'pkgconf' + 'yarn' +) +source=("${pkgname}-${pkgver}.tar.gz::https://github.com/cloudreve/desktop/archive/refs/tags/v${pkgver}.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "desktop-${pkgver}" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --target "$CARCH-unknown-linux-gnu" +} + +build() { + cd "desktop-${pkgver}" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + + yarn --cwd ui install --frozen-lockfile + yarn --cwd ui run build + cargo build --release -p cloudreve-desktop +} + +package() { + cd "desktop-${pkgver}" + + install -Dm755 target/release/cloudreve-desktop "$pkgdir/usr/bin/cloudreve-desktop" + install -Dm644 package/linux/cloudreve-desktop.desktop "$pkgdir/usr/share/applications/cloudreve-desktop.desktop" + install -Dm644 src-tauri/icons/128x128.png "$pkgdir/usr/share/icons/hicolor/128x128/apps/cloudreve.png" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} From c456109d6309b968db625d0d2d1844dbb59988ca Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 5 Jun 2026 23:18:47 +0800 Subject: [PATCH 08/25] Add macOS full sync notifications and autostart --- .gitignore | 6 +- crates/cloudreve-sync/Cargo.toml | 3 + crates/cloudreve-sync/src/utils/toast.rs | 83 +++++++++++++++--- src-tauri/src/commands.rs | 104 ++++++++++++++++++++++- 4 files changed, 182 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 39c541b..cf76586 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,8 @@ Cargo.lock /release/ /debug/ dist/ -package/*.exe \ No newline at end of file +package/*.exe + +# Package manager files not used by the current Yarn-based build +ui/pnpm-lock.yaml +ui/pnpm-workspace.yaml diff --git a/crates/cloudreve-sync/Cargo.toml b/crates/cloudreve-sync/Cargo.toml index 1d7294a..3b7894f 100644 --- a/crates/cloudreve-sync/Cargo.toml +++ b/crates/cloudreve-sync/Cargo.toml @@ -50,6 +50,9 @@ win32_notif = { path = "../win32_notif" } [target.'cfg(target_os = "linux")'.dependencies] notify-rust = "4.11" +[target.'cfg(target_os = "macos")'.dependencies] +mac-notification-sys = "0.6" + [target.'cfg(windows)'.dependencies.windows] version = "0.58.0" features = [ diff --git a/crates/cloudreve-sync/src/utils/toast.rs b/crates/cloudreve-sync/src/utils/toast.rs index 83eb8d1..9e8db20 100644 --- a/crates/cloudreve-sync/src/utils/toast.rs +++ b/crates/cloudreve-sync/src/utils/toast.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; #[cfg(windows)] use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +#[cfg(target_os = "macos")] +use mac_notification_sys::send_notification; #[cfg(target_os = "linux")] use notify_rust::{Notification, Timeout, Urgency}; #[cfg(windows)] @@ -17,7 +19,7 @@ use crate::config::ConfigManager; #[cfg(windows)] const APP_NAME: &str = "Cloudreve.Sync"; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] const APP_NAME: &str = "Cloudreve"; #[cfg(target_os = "linux")] @@ -42,6 +44,30 @@ fn send_linux_notification(title: &str, message: &str, urgency: Urgency) { } } +#[cfg(target_os = "macos")] +fn send_macos_notification(title: &str, message: &str) { + match send_notification(title, Some(APP_NAME), message, None) { + Ok(_) => tracing::debug!(target: "toast", title, message, "macOS notification sent"), + Err(err) => tracing::warn!( + target: "toast", + title, + message, + error = %err, + "Failed to send macOS user notification" + ), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn conflict_notification_message(path: &PathBuf) -> String { + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + format!("{} - open Cloudreve to resolve the conflict.", file_name) +} + #[cfg(windows)] pub fn send_general_text_toast(title: &str, message: &str) { let notifier = ToastsNotifier::new(APP_NAME).unwrap(); @@ -70,7 +96,12 @@ pub fn send_general_text_toast(title: &str, message: &str) { send_linux_notification(title, message, Urgency::Normal); } -#[cfg(not(any(windows, target_os = "linux")))] +#[cfg(target_os = "macos")] +pub fn send_general_text_toast(title: &str, message: &str) { + send_macos_notification(title, message); +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] pub fn send_general_text_toast(title: &str, message: &str) { tracing::info!(target: "toast", title, message, "Toast notification skipped on this platform"); } @@ -108,7 +139,12 @@ pub fn send_warning_toast(title: &str, message: &str) { send_linux_notification(title, message, Urgency::Critical); } -#[cfg(not(any(windows, target_os = "linux")))] +#[cfg(target_os = "macos")] +pub fn send_warning_toast(title: &str, message: &str) { + send_macos_notification(title, message); +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] pub fn send_warning_toast(title: &str, message: &str) { tracing::warn!(target: "toast", title, message, "Warning toast skipped on this platform"); } @@ -168,7 +204,18 @@ pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { send_linux_notification(title, message, Urgency::Critical); } -#[cfg(not(any(windows, target_os = "linux")))] +#[cfg(target_os = "macos")] +pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_credential_expired() { + tracing::debug!(target: "toast", "Token expiry notification suppressed by config"); + return; + } + } + send_macos_notification(title, message); +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] pub fn send_token_expiry_toast(_drive_id: &str, title: &str, message: &str) { if let Some(config) = ConfigManager::try_get() { if !config.notify_credential_expired() { @@ -256,12 +303,7 @@ pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { } } - let file_name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let message = format!("{} - open Cloudreve to resolve the conflict.", file_name); + let message = conflict_notification_message(path); tracing::warn!( target: "toast", path = %path.display(), @@ -271,7 +313,26 @@ pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { send_linux_notification(&t!("conflictToastTitle"), &message, Urgency::Critical); } -#[cfg(not(any(windows, target_os = "linux")))] +#[cfg(target_os = "macos")] +pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { + if let Some(config) = ConfigManager::try_get() { + if !config.notify_file_conflict() { + tracing::debug!(target: "toast", "Conflict notification suppressed by config"); + return; + } + } + + let message = conflict_notification_message(path); + tracing::warn!( + target: "toast", + path = %path.display(), + inventory_id, + "Sending macOS conflict notification without Windows toast actions" + ); + send_macos_notification(&t!("conflictToastTitle"), &message); +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] pub fn send_conflict_toast(_drive_id: &str, path: &PathBuf, inventory_id: i64) { if let Some(config) = ConfigManager::try_get() { if !config.notify_file_conflict() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0097af7..9f5d015 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -528,6 +528,10 @@ pub fn show_settings_window_impl(app: &AppHandle) { const STARTUP_TASK_ID: &str = "cloudreve"; #[cfg(target_os = "linux")] const AUTOSTART_DESKTOP_FILE: &str = "cloudreve-desktop.desktop"; +#[cfg(target_os = "macos")] +const MACOS_LAUNCH_AGENT_FILE: &str = "cloudreve.desktop.plist"; +#[cfg(target_os = "macos")] +const MACOS_LAUNCH_AGENT_LABEL: &str = "cloudreve.desktop"; #[cfg(target_os = "linux")] fn linux_autostart_path() -> CommandResult { @@ -615,9 +619,98 @@ fn linux_set_auto_start(enabled: bool) -> CommandResult { } } +#[cfg(target_os = "macos")] +fn macos_launch_agent_path() -> CommandResult { + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .ok_or_else(|| "Unable to determine home directory".to_string())?; + Ok(home + .join("Library") + .join("LaunchAgents") + .join(MACOS_LAUNCH_AGENT_FILE)) +} + +#[cfg(target_os = "macos")] +fn macos_plist_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") + .replace('\n', "") + .replace('\r', "") +} + +#[cfg(target_os = "macos")] +fn macos_launch_agent_entry() -> CommandResult { + let exe = std::env::current_exe() + .map_err(|e| format!("Failed to get current executable path: {}", e))?; + let exe = macos_plist_escape(&exe.display().to_string()); + let label = macos_plist_escape(MACOS_LAUNCH_AGENT_LABEL); + + Ok(format!( + r#" + + + + Label + {} + ProgramArguments + + {} + + RunAtLoad + + + +"#, + label, exe + )) +} + +#[cfg(target_os = "macos")] +fn macos_get_auto_start_enabled() -> CommandResult { + let path = macos_launch_agent_path()?; + match std::fs::read_to_string(path) { + Ok(content) => Ok(content.contains(MACOS_LAUNCH_AGENT_LABEL)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(format!("Failed to read LaunchAgent: {}", err)), + } +} + +#[cfg(target_os = "macos")] +fn macos_set_auto_start(enabled: bool) -> CommandResult { + let path = macos_launch_agent_path()?; + + if enabled { + let parent = path + .parent() + .ok_or_else(|| "Invalid LaunchAgent path".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create LaunchAgents directory: {}", e))?; + std::fs::write(&path, macos_launch_agent_entry()?) + .map_err(|e| format!("Failed to write LaunchAgent: {}", e))?; + Ok(true) + } else { + match std::fs::remove_file(&path) { + Ok(_) => Ok(false), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(format!("Failed to remove LaunchAgent: {}", err)), + } + } +} + /// Get whether auto-start is enabled using Windows StartupTask API #[tauri::command] pub async fn get_auto_start_enabled() -> CommandResult { + #[cfg(target_os = "macos")] + { + return tokio::task::spawn_blocking(macos_get_auto_start_enabled) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + #[cfg(target_os = "linux")] { return tokio::task::spawn_blocking(linux_get_auto_start_enabled) @@ -625,7 +718,7 @@ pub async fn get_auto_start_enabled() -> CommandResult { .map_err(|e| format!("Task join error: {}", e))?; } - #[cfg(not(any(windows, target_os = "linux")))] + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { Ok(false) } @@ -656,6 +749,13 @@ pub async fn get_auto_start_enabled() -> CommandResult { /// Set auto-start configuration using Windows StartupTask API #[tauri::command] pub async fn set_auto_start(enabled: bool) -> CommandResult { + #[cfg(target_os = "macos")] + { + return tokio::task::spawn_blocking(move || macos_set_auto_start(enabled)) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + #[cfg(target_os = "linux")] { return tokio::task::spawn_blocking(move || linux_set_auto_start(enabled)) @@ -663,7 +763,7 @@ pub async fn set_auto_start(enabled: bool) -> CommandResult { .map_err(|e| format!("Task join error: {}", e))?; } - #[cfg(not(any(windows, target_os = "linux")))] + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { Err("Auto-start configuration is not supported on this platform yet".to_string()) } From b7e333851dcfd64aee3752ce46c29195797f9641 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 6 Jun 2026 02:24:53 +0800 Subject: [PATCH 09/25] Add non-Windows sync support Add Linux/macOS full-sync behavior, non-Windows notification/window fixes, and a popup conflict-resolution entry for platforms without Windows shell actions. --- crates/cloudreve-sync/src/drive/commands.rs | 24 ++ .../src/drive/manager/command_handlers.rs | 5 +- .../src/drive/manager/favicon.rs | 21 +- .../cloudreve-sync/src/drive/manager/mod.rs | 51 ++-- .../cloudreve-sync/src/drive/manager/types.rs | 34 ++- crates/cloudreve-sync/src/drive/mounts.rs | 152 ++++++------ .../src/inventory/db/file_metadata.rs | 30 ++- src-tauri/src/commands.rs | 227 ++++++++++++++++-- src-tauri/src/event_handler.rs | 4 +- src-tauri/src/lib.rs | 25 +- src-tauri/tauri.conf.json | 3 +- ui/package.json | 13 +- ui/src/pages/popup/ConflictItem.tsx | 177 ++++++++++++++ ui/src/pages/popup/index.tsx | 83 ++++++- ui/src/pages/popup/types.ts | 10 + ui/src/theme.ts | 2 +- ui/yarn.lock | 67 +++--- 17 files changed, 750 insertions(+), 178 deletions(-) create mode 100644 ui/src/pages/popup/ConflictItem.tsx diff --git a/crates/cloudreve-sync/src/drive/commands.rs b/crates/cloudreve-sync/src/drive/commands.rs index 3c0420e..3edbabf 100644 --- a/crates/cloudreve-sync/src/drive/commands.rs +++ b/crates/cloudreve-sync/src/drive/commands.rs @@ -851,6 +851,30 @@ impl Mount { } }; if placeholder_info.is_directory() { + #[cfg(not(windows))] + { + // Windows receives richer CFAPI callbacks for directory + // hydration and placeholder population. Non-Windows full + // sync only has filesystem watcher events, so a directory + // modify event must rescan the directory's first layer to + // discover newly-created children. + tracing::debug!( + target: "drive::commands", + path = %path.display(), + "Syncing directory after filesystem modify event" + ); + if let Err(err) = self + .sync_paths(vec![path.clone()], SyncMode::PathAndFirstLayer) + .await + { + tracing::error!( + target: "drive::commands", + path = %path.display(), + error = %err, + "Failed to sync directory after filesystem modify event" + ); + } + } continue; } diff --git a/crates/cloudreve-sync/src/drive/manager/command_handlers.rs b/crates/cloudreve-sync/src/drive/manager/command_handlers.rs index 7e3c4bd..ab8de82 100644 --- a/crates/cloudreve-sync/src/drive/manager/command_handlers.rs +++ b/crates/cloudreve-sync/src/drive/manager/command_handlers.rs @@ -119,7 +119,10 @@ impl DriveManager { } }); } - ManagerCommand::GetDriveStatusUI { syncroot_id, response } => { + ManagerCommand::GetDriveStatusUI { + syncroot_id, + response, + } => { spawn(async move { let result = manager.get_drive_status_by_syncroot_id(&syncroot_id).await; let _ = response.send(result); diff --git a/crates/cloudreve-sync/src/drive/manager/favicon.rs b/crates/cloudreve-sync/src/drive/manager/favicon.rs index 66efc89..1358c5b 100644 --- a/crates/cloudreve-sync/src/drive/manager/favicon.rs +++ b/crates/cloudreve-sync/src/drive/manager/favicon.rs @@ -111,10 +111,7 @@ async fn download_icon(client: &reqwest::Client, url: &str) -> Result Result> { }; tracing::info!(target: "drive::favicon", path = %fallback_path, icon_type = ?icon_type, "Using fallback icon"); - std::fs::read(&fallback_path).with_context(|| format!("Failed to read fallback icon: {}", fallback_path)) + std::fs::read(&fallback_path) + .with_context(|| format!("Failed to read fallback icon: {}", fallback_path)) } /// Save icon bytes to destination, converting to ICO if needed -fn save_icon(bytes: &[u8], dest_path: &PathBuf, convert_to_ico: bool, is_already_ico: bool) -> Result<()> { +fn save_icon( + bytes: &[u8], + dest_path: &PathBuf, + convert_to_ico: bool, + is_already_ico: bool, +) -> Result<()> { if convert_to_ico && !is_already_ico { // Convert image to ICO format let img = image::load_from_memory(bytes).context("Failed to load image")?; @@ -201,7 +204,11 @@ pub async fn fetch_and_save_favicon(instance_url: &str) -> Result } /// Fetch icons from remote server -async fn fetch_icons_from_remote(instance_url: &str, icons_dir: &PathBuf, hash: &str) -> Result { +async fn fetch_icons_from_remote( + instance_url: &str, + icons_dir: &PathBuf, + hash: &str, +) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index 2ad7850..0dbeff6 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -4,9 +4,9 @@ mod types; pub use types::*; +use crate::EventBroadcaster; use crate::drive::commands::ManagerCommand; use crate::drive::mounts::{Credentials, DriveConfig, Mount}; -use crate::EventBroadcaster; use crate::inventory::InventoryDb; use crate::tasks::TaskProgress; use anyhow::{Context, Result}; @@ -72,7 +72,6 @@ impl DriveManager { if !config_file.exists() { tracing::info!(target: "drive", "No existing drive config found, starting fresh"); - self.event_broadcaster.no_drive(); return Ok(()); } @@ -101,10 +100,6 @@ impl DriveManager { } } - if count == 0 { - self.event_broadcaster.no_drive(); - } - tracing::info!(target: "drive", count = count, "Loaded drive(s) from config"); Ok(()) @@ -272,12 +267,19 @@ impl DriveManager { /// List all drives pub async fn list_drives(&self) -> Vec { - // let read_guard = self.drives.read().await; - // read_guard - // .values() - // .map(|mount| mount.get_config()) - // .collect() - Vec::new() + let read_guard = self.drives.read().await; + let mut drives = Vec::with_capacity(read_guard.len()); + + for mount in read_guard.values() { + drives.push(mount.get_config().await); + } + + drives + } + + /// Return whether there are currently no mounted drives. + pub async fn is_empty(&self) -> bool { + self.drives.read().await.is_empty() } /// Update drive configuration @@ -456,6 +458,17 @@ impl DriveManager { .inventory .query_recent_tasks(drive_id) .context("Failed to query recent tasks")?; + // Conflict resolution is intentionally part of the status summary rather + // than inferred from failed upload tasks. The task error text can differ + // by backend response or locale, while `conflict_state = pending` is the + // durable inventory state used by the resolver. + let pending_conflicts = self + .inventory + .query_pending_conflicts(drive_id) + .context("Failed to query pending conflicts")? + .into_iter() + .map(Into::into) + .collect(); // Collect running task progress from all task queues // Build a map of task_id -> TaskProgress for quick lookup @@ -483,7 +496,10 @@ impl DriveManager { .into_iter() .map(|task| { let progress = progress_map.remove(&task.id); - TaskWithProgress { task, live_progress: progress } + TaskWithProgress { + task, + live_progress: progress, + } }) .collect(); @@ -491,6 +507,7 @@ impl DriveManager { drives, active_tasks, finished_tasks: recent_tasks.finished, + pending_conflicts, }) } @@ -595,7 +612,7 @@ impl DriveManager { let status = if drive_state.is_credential_expired() { DriveInfoStatus::CredentialExpired } else { - if !drive_state.is_event_push_subscribed(){ + if !drive_state.is_event_push_subscribed() { DriveInfoStatus::EventPushLost } else { DriveInfoStatus::Active @@ -648,7 +665,11 @@ impl DriveManager { impl DriveManager { /// Get capacity summary from a mount's drive props. /// Only returns capacity if the remote_path filesystem is "my". - fn get_capacity_summary(mount: &Mount, drive_id: &str, remote_path: &str) -> Option { + fn get_capacity_summary( + mount: &Mount, + drive_id: &str, + remote_path: &str, + ) -> Option { // Only show capacity for "my" filesystem use cloudreve_api::models::uri::CrUri; let is_my_fs = CrUri::new(remote_path) diff --git a/crates/cloudreve-sync/src/drive/manager/types.rs b/crates/cloudreve-sync/src/drive/manager/types.rs index b4ff770..3a13e64 100644 --- a/crates/cloudreve-sync/src/drive/manager/types.rs +++ b/crates/cloudreve-sync/src/drive/manager/types.rs @@ -1,5 +1,5 @@ use crate::drive::mounts::DriveConfig; -use crate::inventory::TaskRecord; +use crate::inventory::{FileMetadata, TaskRecord}; use crate::tasks::TaskProgress; use serde::{Deserialize, Serialize}; @@ -17,6 +17,8 @@ pub struct StatusSummary { pub active_tasks: Vec, /// Recently finished tasks (completed/failed/cancelled) pub finished_tasks: Vec, + /// Files that require manual conflict resolution before syncing can continue. + pub pending_conflicts: Vec, } /// A task record with optional live progress information @@ -29,6 +31,36 @@ pub struct TaskWithProgress { pub live_progress: Option, } +/// A pending local-vs-remote conflict shown in the desktop popup. +#[derive(Debug, Clone, Serialize)] +pub struct PendingConflict { + /// Inventory row ID used by the conflict resolver. + pub id: i64, + /// Drive ID that owns this local path. + pub drive_id: String, + /// Local filesystem path of the conflicted file. + pub local_path: String, + /// Whether the conflicted entry is a folder. + pub is_folder: bool, + /// Last metadata update timestamp. + pub updated_at: i64, + /// File size in bytes. + pub size: i64, +} + +impl From for PendingConflict { + fn from(metadata: FileMetadata) -> Self { + Self { + id: metadata.id, + drive_id: metadata.drive_id.to_string(), + local_path: metadata.local_path, + is_folder: metadata.is_folder, + updated_at: metadata.updated_at, + size: metadata.size, + } + } +} + /// Capacity summary for UI display #[derive(Debug, Clone, Serialize)] pub struct CapacitySummary { diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index 100e535..f760e07 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -1,8 +1,8 @@ +use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::cfapi::root::{ HydrationType, PopulationType, SecurityId, Session, SyncRootIdBuilder, SyncRootInfo, }; -use crate::cfapi::root::{Connection, SyncRootId}; #[cfg(windows)] use crate::drive::callback::CallbackHandler; use crate::drive::commands::ManagerCommand; @@ -394,76 +394,79 @@ impl Mount { #[cfg(windows)] { - if !StorageProviderSyncRootManager::IsSupported() - .context("Cloud Filter API is not supported")? - { - return Err(anyhow::anyhow!("Cloud Filter API is not supported")); - } - - let mut write_guard = self.config.write().await; + if !StorageProviderSyncRootManager::IsSupported() + .context("Cloud Filter API is not supported")? + { + return Err(anyhow::anyhow!("Cloud Filter API is not supported")); + } - // if sync root id is not set, generate one - if write_guard.sync_root_id.is_none() { - write_guard.sync_root_id = Some( - generate_sync_root_id( - &write_guard.instance_url, - &write_guard.name, - &write_guard.user_id, - &write_guard.sync_path, - ) - .context("failed to generate sync root id")?, - ); - } + let mut write_guard = self.config.write().await; + + // if sync root id is not set, generate one + if write_guard.sync_root_id.is_none() { + write_guard.sync_root_id = Some( + generate_sync_root_id( + &write_guard.instance_url, + &write_guard.name, + &write_guard.user_id, + &write_guard.sync_path, + ) + .context("failed to generate sync root id")?, + ); + } - drop(write_guard); - let config = self.config.read().await; + drop(write_guard); + let config = self.config.read().await; - let sync_root_id = config.sync_root_id.as_ref().unwrap(); + let sync_root_id = config.sync_root_id.as_ref().unwrap(); + + // Register sync root if not registered + if !sync_root_id.is_registered()? { + tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); + let mut sync_root_info = SyncRootInfo::default(); + sync_root_info.set_display_name(config.name.clone()); + sync_root_info.set_hydration_type(HydrationType::Full); + sync_root_info.set_population_type(PopulationType::Full); + if let Some(icon_path) = config.icon_path.as_ref() { + sync_root_info.set_icon(format!("{},0", icon_path)); + } + sync_root_info.set_version("1.0.0"); + sync_root_info + .set_recycle_bin_uri( + recycle_bin_url(&config) + .unwrap_or_else(|_| "https://cloudreve.org".to_string()), + ) + .context("failed to set recycle bin uri")?; + sync_root_info + .set_path(Path::new(&config.sync_path)) + .context("failed to set sync root path")?; + sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; + sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; + sync_root_id + .register(sync_root_info) + .context("failed to register sync root")?; + } - // Register sync root if not registered - if !sync_root_id.is_registered()? { - tracing::info!(target: "drive::mounts", id = %self.id, "Registering sync root"); - let mut sync_root_info = SyncRootInfo::default(); - sync_root_info.set_display_name(config.name.clone()); - sync_root_info.set_hydration_type(HydrationType::Full); - sync_root_info.set_population_type(PopulationType::Full); - if let Some(icon_path) = config.icon_path.as_ref() { - sync_root_info.set_icon(format!("{},0", icon_path)); + // Add to search indexer for state management + if let Err(e) = sync_root_id.index() { + tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); } - sync_root_info.set_version("1.0.0"); - sync_root_info - .set_recycle_bin_uri(recycle_bin_url(&config).unwrap_or_else(|_| "https://cloudreve.org".to_string())) - .context("failed to set recycle bin uri")?; - sync_root_info - .set_path(Path::new(&config.sync_path)) - .context("failed to set sync root path")?; - sync_root_info.add_custom_state(t!("shared").as_ref(), 1)?; - sync_root_info.add_custom_state(t!("accessible").as_ref(), 2)?; - sync_root_id - .register(sync_root_info) - .context("failed to register sync root")?; - } - // Add to search indexer for state management - if let Err(e) = sync_root_id.index() { - tracing::warn!(target: "drive::mounts", id = %self.id, error = %e, "Failed to add sync root to search indexer"); - } + tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); + let connection = Session::new() + .connect( + &config.sync_path, + CallbackHandler::new( + self.command_tx.clone(), + self.id.clone(), + self.inventory.clone(), + ), + ) + .context("failed to connect to sync root")?; - tracing::info!(target: "drive::mounts",sync_path = %config.sync_path.display(), id = %self.id, "Connecting to sync root"); - let connection = Session::new() - .connect( - &config.sync_path, - CallbackHandler::new( - self.command_tx.clone(), - self.id.clone(), - self.inventory.clone(), - ), - ) - .context("failed to connect to sync root")?; - - self.connection = Some(connection); - self.start_fs_watcher().await?; - Ok(()) + self.connection = Some(connection); + self.start_fs_watcher().await?; + Ok(()) } } @@ -546,7 +549,11 @@ impl Mount { let _ = response.send(result); }); } - MountCommand::Sync { mode, local_paths, user_initiated } => { + MountCommand::Sync { + mode, + local_paths, + user_initiated, + } => { let s_clone = s.clone(); let mount_id_clone = mount_id.clone(); spawn(async move { @@ -627,9 +634,16 @@ impl Mount { } MountCommand::ProcessFsEvents { events } => { let s_clone = s.clone(); - //let mount_id_clone = mount_id.clone(); + let mount_id_clone = mount_id.clone(); spawn(async move { - let _ = s_clone.process_fs_events(events).await; + if let Err(err) = s_clone.process_fs_events(events).await { + tracing::error!( + target: "drive::mounts", + id = %mount_id_clone, + error = %err, + "Failed to process filesystem events" + ); + } }); } MountCommand::Renamed { @@ -654,7 +668,9 @@ impl Mount { pub async fn delete(&self) -> Result<()> { self.shutdown().await; if let Some(ref connection) = self.connection { - connection.disconnect().context("faield to disconnect sync root")?; + connection + .disconnect() + .context("faield to disconnect sync root")?; } self.task_queue.shutdown().await; if let Some(sync_root_id) = self.config.read().await.sync_root_id.as_ref() { diff --git a/crates/cloudreve-sync/src/inventory/db/file_metadata.rs b/crates/cloudreve-sync/src/inventory/db/file_metadata.rs index 3c3c411..32c9804 100644 --- a/crates/cloudreve-sync/src/inventory/db/file_metadata.rs +++ b/crates/cloudreve-sync/src/inventory/db/file_metadata.rs @@ -1,7 +1,5 @@ use super::InventoryDb; -use crate::inventory::{ - ConflictState, FileMetadata, MetadataEntry, -}; +use crate::inventory::{ConflictState, FileMetadata, MetadataEntry}; use anyhow::{Context, Result}; use diesel::prelude::*; use diesel::sql_types::Text; @@ -102,6 +100,32 @@ impl InventoryDb { row.map(FileMetadata::try_from).transpose() } + /// Query files that are waiting for manual conflict resolution. + /// + /// `drive_id` is optional because the popup can show either a single drive + /// or the aggregate status across all configured drives. + pub fn query_pending_conflicts(&self, drive_id: Option<&str>) -> Result> { + let mut conn = self.connection()?; + let mut query = file_metadata_dsl::file_metadata + .filter( + file_metadata_dsl::conflict_state + .eq(Some(ConflictState::Pending.as_str().to_string())), + ) + .into_boxed(); + + if let Some(drive_id) = drive_id { + query = query.filter(file_metadata_dsl::drive_id.eq(drive_id)); + } + + query + .order(file_metadata_dsl::updated_at.desc()) + .load::(&mut conn) + .context("Failed to query pending conflict metadata")? + .into_iter() + .map(FileMetadata::try_from) + .collect() + } + /// Batch delete file metadata by local path pub fn batch_delete_by_path(&self, paths: Vec<&str>) -> Result { if paths.is_empty() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9f5d015..2ad274e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,14 +1,17 @@ use crate::AppStateHandle; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use chrono::{Duration, Utc}; +use cloudreve_sync::drive::commands::ConflictAction; use cloudreve_sync::{ config::LogLevel, ConfigManager, Credentials, DriveConfig, DriveInfo, StatusSummary, }; +#[cfg(windows)] +use tauri::utils::{config::WindowEffectsConfig, WindowEffect}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; use tauri::{ - utils::{config::WindowEffectsConfig, WindowEffect}, - webview::WebviewWindowBuilder, + utils::config::Color, + webview::{WebviewWindow, WebviewWindowBuilder}, AppHandle, Manager, State, WebviewUrl, }; use tauri_plugin_frame::WebviewWindowExt; @@ -270,6 +273,35 @@ pub async fn get_status_summary( .map_err(|e| e.to_string()) } +/// Resolve a pending local-vs-remote conflict. +#[tauri::command] +pub async fn resolve_conflict( + state: State<'_, AppStateHandle>, + drive_id: String, + file_id: i64, + path: String, + action: String, +) -> CommandResult<()> { + let app_state = state + .get() + .ok_or_else(|| "App not yet initialized".to_string())?; + // Keep the frontend contract string-based so TS does not need to mirror the + // Rust enum layout. The accepted values are the same action IDs used by the + // Windows shell/toast resolver. + let action = ConflictAction::from_str(&action) + .ok_or_else(|| format!("Invalid conflict action: {action}"))?; + let drive = app_state + .drive_manager + .get_drive(&drive_id) + .await + .ok_or_else(|| format!("Drive not found: {drive_id}"))?; + + drive + .resolve_conflict(action, file_id, path) + .await + .map_err(|e| e.to_string()) +} + /// Get all drives with their status information for the settings UI #[tauri::command] pub async fn get_drives_info(state: State<'_, AppStateHandle>) -> CommandResult> { @@ -294,24 +326,55 @@ pub struct FileIconResponse { pub height: u32, } +fn file_icon_to_response(icon: file_icon_provider::Icon) -> FileIconResponse { + FileIconResponse { + data: BASE64.encode(&icon.pixels), + width: icon.width, + height: icon.height, + } +} + /// Get file icon for a given path /// Returns base64 encoded RGBA pixel data with dimensions #[tauri::command] -pub async fn get_file_icon(path: String, size: Option) -> CommandResult { +pub async fn get_file_icon( + app: AppHandle, + path: String, + size: Option, +) -> CommandResult { let icon_size = size.unwrap_or(32); - // Run the blocking icon retrieval in a separate thread - let result = - tokio::task::spawn_blocking(move || file_icon_provider::get_file_icon(&path, icon_size)) + #[cfg(target_os = "linux")] + { + // file_icon_provider uses GTK on Linux. GTK APIs must run on the main + // thread, so do not use `spawn_blocking` here even though icon lookup can + // be slow; doing so causes gtk::IconTheme to panic on worker threads. + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let result = file_icon_provider::get_file_icon(&path, icon_size) + .map(file_icon_to_response) + .map_err(|e| format!("Failed to get file icon: {:?}", e)); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule file icon lookup: {}", e))?; + + return rx .await - .map_err(|e| format!("Task join error: {}", e))? - .map_err(|e| format!("Failed to get file icon: {:?}", e))?; + .map_err(|e| format!("File icon lookup was cancelled: {}", e))?; + } - Ok(FileIconResponse { - data: BASE64.encode(&result.pixels), - width: result.width, - height: result.height, - }) + #[cfg(not(target_os = "linux"))] + { + // Run the blocking icon retrieval in a separate thread + let result = tokio::task::spawn_blocking(move || { + file_icon_provider::get_file_icon(&path, icon_size) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Failed to get file icon: {:?}", e))?; + + Ok(file_icon_to_response(result)) + } } /// Show or create the main window (positioned at tray center) @@ -324,11 +387,91 @@ pub fn show_main_window_center(app: &AppHandle) { show_main_window_at_position(app, Position::Center); } +fn move_window_safely(window: &WebviewWindow, position: Position, label: &str) { + // tauri-plugin-positioner assumes tray/monitor geometry is available and + // can panic or error on some Linux desktop sessions when the tray position + // is unknown. Probe the monitor first and fall back to centering so popup + // display remains usable instead of crashing the runtime worker. + match position { + Position::Center => { + if let Err(err) = window.center() { + tracing::warn!( + target: "main", + window = label, + error = %err, + "Failed to center window" + ); + } + } + position => match window.current_monitor() { + Ok(Some(_)) => { + if let Err(err) = window.move_window(position) { + tracing::warn!( + target: "main", + window = label, + error = %err, + "Failed to move window with positioner; falling back to center" + ); + let _ = window.center(); + } + } + Ok(None) => { + tracing::warn!( + target: "main", + window = label, + "Window has no current monitor; falling back to center" + ); + let _ = window.center(); + } + Err(err) => { + tracing::warn!( + target: "main", + window = label, + error = %err, + "Failed to get current monitor; falling back to center" + ); + let _ = window.center(); + } + }, + } +} + +fn apply_default_window_icon<'a>( + builder: WebviewWindowBuilder<'a, tauri::Wry, AppHandle>, + app: &'a AppHandle, + label: &str, +) -> Option> { + // Non-Windows desktops otherwise tend to show the Wayland/X11 default icon + // for custom windows. Reusing Tauri's default icon keeps taskbar entries + // consistent without hard-coding a platform-specific icon path here. + let Some(icon) = app.default_window_icon() else { + tracing::warn!( + target: "main", + window = label, + "No default window icon is configured" + ); + return Some(builder); + }; + + match builder.icon(icon.clone()) { + Ok(builder) => Some(builder), + Err(err) => { + tracing::warn!( + target: "main", + window = label, + error = %err, + "Failed to set window icon" + ); + None + } + } +} + /// Internal function to show or create the main window at a specific position fn show_main_window_at_position(app: &AppHandle, position: Position) { // Check if window already exists if let Some(window) = app.get_webview_window("main_popup") { - let _ = window.move_window(position); + move_window_safely(&window, position, "main_popup"); let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); @@ -336,7 +479,7 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { } // Create new main window - match WebviewWindowBuilder::new( + let builder = WebviewWindowBuilder::new( app, "main_popup", WebviewUrl::App(get_url_with_lang("index.html/#/popup").into()), @@ -347,9 +490,21 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { .visible(false) .decorations(false) .skip_taskbar(true) - .minimizable(false) - .build() - { + .minimizable(false); + + #[cfg(not(windows))] + // Transparent webviews render differently across GTK/WebKit and AppKit; on + // non-Windows this produced unreadable shadows/ghosting. Use an opaque + // white background there and keep Windows transparency for Mica/Acrylic. + let builder = builder + .transparent(false) + .background_color(Color(255, 255, 255, 255)); + + let Some(builder) = apply_default_window_icon(builder, app, "main_popup") else { + return; + }; + + match builder.build() { Ok(window) => { // Set up close request handler for fast popup launch let window_clone = window.clone(); @@ -364,7 +519,7 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { } }); - let _ = window.move_window(position); + move_window_safely(&window, position, "main_popup"); let _ = window.show(); let _ = window.set_focus(); } @@ -437,7 +592,8 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { return; } - // Create new window with mica effect + // Create new window with mica effect on Windows only. + #[cfg(windows)] let effects = WindowEffectsConfig { effects: vec![WindowEffect::Mica, WindowEffect::Acrylic], state: None, @@ -450,20 +606,32 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { .inner_size(470.0, 630.0) .resizable(false) .visible(false) - .transparent(true) - .effects(effects) .decorations(false) .minimizable(false); + #[cfg(windows)] + let builder = builder.transparent(true).effects(effects); + + #[cfg(not(windows))] + // See `show_main_window_at_position`: keep non-Windows webviews opaque to + // avoid platform compositor artifacts while preserving Windows effects. + let builder = builder + .transparent(false) + .background_color(Color(255, 255, 255, 255)); + // Platform-specific: title_bar_style and hidden_title are macOS-only #[cfg(target_os = "macos")] let builder = builder .title_bar_style(TitleBarStyle::Overlay) .hidden_title(true); + let Some(builder) = apply_default_window_icon(builder, app, "add-drive") else { + return; + }; + match builder.build() { Ok(window) => { - let _ = window.move_window(Position::Center); + move_window_safely(&window, Position::Center, "add-drive"); let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); @@ -504,15 +672,26 @@ pub fn show_settings_window_impl(app: &AppHandle) { .decorations(false) .minimizable(true); + #[cfg(not(windows))] + // See `show_main_window_at_position`: keep non-Windows webviews opaque to + // avoid platform compositor artifacts while preserving Windows effects. + let builder = builder + .transparent(false) + .background_color(Color(255, 255, 255, 255)); + // Platform-specific: title_bar_style and hidden_title are macOS-only #[cfg(target_os = "macos")] let builder = builder .title_bar_style(TitleBarStyle::Overlay) .hidden_title(true); + let Some(builder) = apply_default_window_icon(builder, app, "settings") else { + return; + }; + match builder.build() { Ok(window) => { - let _ = window.move_window(Position::Center); + move_window_safely(&window, Position::Center, "settings"); let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); diff --git a/src-tauri/src/event_handler.rs b/src-tauri/src/event_handler.rs index b43bae3..062932a 100644 --- a/src-tauri/src/event_handler.rs +++ b/src-tauri/src/event_handler.rs @@ -1,7 +1,9 @@ use cloudreve_sync::events::Event; use tauri::{AppHandle, Emitter}; -use crate::commands::{show_add_drive_window_impl, show_main_window_center, show_settings_window_impl}; +use crate::commands::{ + show_add_drive_window_impl, show_main_window_center, show_settings_window_impl, +}; /// Handle incoming events from the event broadcaster. /// Returns true if the event was handled, false otherwise. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e430eee..67f9577 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,8 @@ use anyhow::Context; -use cloudreve_sync::{ConfigManager, DriveManager, EventBroadcaster, LogConfig, LogGuard, shellext::shell_service::ServiceHandle}; +use cloudreve_sync::{ + shellext::shell_service::ServiceHandle, ConfigManager, DriveManager, EventBroadcaster, + LogConfig, LogGuard, +}; use std::sync::{Arc, Mutex}; use tauri::{ async_runtime::spawn, @@ -103,6 +106,12 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { // Broadcast initial connection status event_broadcaster.connection_status_changed(true); + // Capture the no-drive state now, but do not emit it until APP_STATE is + // installed below. The UI event handler opens the add-drive window and that + // command reads AppStateHandle; emitting before `app.manage(AppStateHandle)` + // can make the first startup report "no drive" even when later commands + // cannot access the initialized manager. + let has_no_drives = drive_manager.is_empty().await; // Store the state in the global cell let state = AppState { @@ -119,6 +128,10 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { // Store in Tauri's managed state as well for commands app.manage(AppStateHandle); + if has_no_drives { + event_broadcaster.no_drive(); + } + tracing::info!(target: "main", "Tauri application setup complete"); Ok(()) @@ -193,13 +206,8 @@ fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { true, None::<&str>, )?; - let settings_i = MenuItem::with_id( - app, - "settings", - t!("settings").as_ref(), - true, - None::<&str>, - )?; + let settings_i = + MenuItem::with_id(app, "settings", t!("settings").as_ref(), true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", t!("quit").as_ref(), true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_i, &add_drive_i, &settings_i, &quit_i])?; @@ -301,6 +309,7 @@ pub fn run() { commands::set_ignore_patterns, commands::get_sync_status, commands::get_status_summary, + commands::resolve_conflict, commands::get_drives_info, commands::get_file_icon, commands::show_file_in_explorer, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 008790d..41f08fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "yarn run build" }, "app": { + "enableGTKAppId": true, "windows": [ { "label": "main", @@ -46,4 +47,4 @@ "icons/icon.ico" ] } -} \ No newline at end of file +} diff --git a/ui/package.json b/ui/package.json index 9d7fdb0..f310983 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,12 +15,13 @@ "@fontsource/roboto": "^5.2.9", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", - "@tauri-apps/plugin-deep-link": "^2.0.0", - "@tauri-apps/plugin-dialog": "^2.6.0", - "@tauri-apps/plugin-http": "^2.5.6", - "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-os": "^2", - "@tauri-apps/plugin-positioner": "^2.3.1", + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-deep-link": "^2.4.9", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-http": "^2.5.9", + "@tauri-apps/plugin-opener": "^2.5.4", + "@tauri-apps/plugin-os": "^2.3.2", + "@tauri-apps/plugin-positioner": "^2.3.2", "canvas-confetti": "^1.9.4", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", diff --git a/ui/src/pages/popup/ConflictItem.tsx b/ui/src/pages/popup/ConflictItem.tsx new file mode 100644 index 0000000..1e1e591 --- /dev/null +++ b/ui/src/pages/popup/ConflictItem.tsx @@ -0,0 +1,177 @@ +import { + Alert, + Box, + Button, + Link, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from "@mui/material"; +import { WarningAmber as WarningIcon } from "@mui/icons-material"; +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PendingConflict } from "./types"; +import { getFileName, getParentFolderName } from "./utils"; +import FileIcon from "./FileIcon"; + +interface ConflictItemProps { + conflict: PendingConflict; + onResolved: () => void; +} + +type ConflictAction = "keep_remote" | "overwrite_remote" | "save_as_new"; + +export default function ConflictItem({ + conflict, + onResolved, +}: ConflictItemProps) { + const { t } = useTranslation(); + const [resolvingAction, setResolvingAction] = useState( + null + ); + const [error, setError] = useState(null); + const fileName = getFileName(conflict.local_path); + const parentFolderName = getParentFolderName(conflict.local_path); + + const handleShowInExplorer = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + invoke("show_file_in_explorer", { path: conflict.local_path }); + }; + + const handleResolve = async (action: ConflictAction) => { + setError(null); + setResolvingAction(action); + try { + // These action identifiers intentionally match the Rust ConflictAction + // parser and the Windows shell/toast actions: + // - keep_remote: discard the local conflicted copy and sync remote state + // - overwrite_remote: force-upload the local file over the remote version + // - save_as_new: keep both by renaming the local file before resyncing + await invoke("resolve_conflict", { + driveId: conflict.drive_id, + fileId: conflict.id, + path: conflict.local_path, + action, + }); + onResolved(); + } catch (error) { + setError(String(error)); + } finally { + setResolvingAction(null); + } + }; + + const isResolving = resolvingAction !== null; + + return ( + + + + + + + + + + + {fileName} + + } + secondary={ + + + {t( + "popup.conflictDescription", + "Local file conflicts with the remote version" + )} + + + {" · "} + + + {parentFolderName} + + + + + + + {error && ( + + {error} + + )} + + } + /> + + ); +} diff --git a/ui/src/pages/popup/index.tsx b/ui/src/pages/popup/index.tsx index 9f34066..a339464 100644 --- a/ui/src/pages/popup/index.tsx +++ b/ui/src/pages/popup/index.tsx @@ -12,6 +12,7 @@ import { } from "@mui/icons-material"; import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { platform } from "@tauri-apps/plugin-os"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useTranslation } from "react-i18next"; import Settings from "../../common/icons/Settings"; @@ -19,6 +20,7 @@ import CloudreveLogo from "../../common/CloudreveLogo"; import type { StatusSummary } from "./types"; import DriveChips from "./DriveChips"; import TaskItem from "./TaskItem"; +import ConflictItem from "./ConflictItem"; export default function Popup() { const { t } = useTranslation(); @@ -26,23 +28,47 @@ export default function Popup() { const [selectedDrive, setSelectedDrive] = useState(null); const [loading, setLoading] = useState(true); const isFetchingRef = useRef(false); + const suppressBlurCloseUntilRef = useRef(0); - // Close window on blur (when it loses focus) + // Close the tray popup on ordinary focus loss, but keep it alive while a native + // context menu is open. On Linux/Wayland and macOS, opening the browser/system + // right-click menu can temporarily move focus away from the webview; closing + // immediately would destroy the popup before the user can choose a menu item. useEffect(() => { let unlisten: () => void; const currentWindow = getCurrentWindow(); + const handleContextMenu = () => { + suppressBlurCloseUntilRef.current = Date.now() + 3000; + }; + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 2) { + suppressBlurCloseUntilRef.current = 0; + } + }; + + window.addEventListener("contextmenu", handleContextMenu); + window.addEventListener("pointerdown", handlePointerDown); currentWindow .onFocusChanged(({ payload: focused }) => { - if (!focused) { - currentWindow.close(); + if (focused) { + suppressBlurCloseUntilRef.current = 0; + return; } + + if (Date.now() < suppressBlurCloseUntilRef.current) { + return; + } + + currentWindow.close(); }) .then((fn) => { unlisten = fn; }); return () => { + window.removeEventListener("contextmenu", handleContextMenu); + window.removeEventListener("pointerdown", handlePointerDown); if (unlisten) { unlisten(); } @@ -104,6 +130,13 @@ export default function Popup() { summary?.active_tasks && summary.active_tasks.length > 0; const hasFinishedTasks = summary?.finished_tasks && summary.finished_tasks.length > 0; + // Windows already has shell/toast conflict actions; the popup entry is only + // for platforms where those native Windows affordances do not exist. + const canResolveConflictsInPopup = platform() !== "windows"; + const hasPendingConflicts = + canResolveConflictsInPopup && + summary?.pending_conflicts && + summary.pending_conflicts.length > 0; return ( - ) : !hasActiveTasks && !hasFinishedTasks ? ( + ) : !hasPendingConflicts && !hasActiveTasks && !hasFinishedTasks ? ( ) : ( + {/* Pending Conflicts */} + {hasPendingConflicts && ( + <> + + {t("popup.conflicts", "Conflicts")} + + {summary?.pending_conflicts.map((conflict) => ( + + ))} + + )} + + {/* Divider between conflicts and tasks */} + {hasPendingConflicts && (hasActiveTasks || hasFinishedTasks) && ( + + )} + {/* Active Tasks */} {hasActiveTasks && ( <> @@ -195,7 +260,7 @@ export default function Popup() { sx={{ px: 2, py: 1, - pb:0, + pb: 0, display: "block", fontWeight: 600, textTransform: "uppercase", @@ -223,7 +288,7 @@ export default function Popup() { sx={{ px: 2, py: 1, - pb:0, + pb: 0, display: "block", fontWeight: 600, textTransform: "uppercase", @@ -264,6 +329,8 @@ export default function Popup() { }, }} /> + ) : hasPendingConflicts ? ( + ) : ( diff --git a/ui/src/pages/popup/types.ts b/ui/src/pages/popup/types.ts index 140d25e..274b38a 100644 --- a/ui/src/pages/popup/types.ts +++ b/ui/src/pages/popup/types.ts @@ -35,10 +35,20 @@ export interface TaskWithProgress extends TaskRecord { live_progress?: TaskProgress; } +export interface PendingConflict { + id: number; + drive_id: string; + local_path: string; + is_folder: boolean; + updated_at: number; + size: number; +} + export interface StatusSummary { drives: DriveConfig[]; active_tasks: TaskWithProgress[]; finished_tasks: TaskRecord[]; + pending_conflicts: PendingConflict[]; } export interface FileIconResponse { diff --git a/ui/src/theme.ts b/ui/src/theme.ts index b7ae66a..99099f5 100644 --- a/ui/src/theme.ts +++ b/ui/src/theme.ts @@ -13,7 +13,7 @@ export const applyThemeWithOverrides = (themeConfig: ThemeOptions): ThemeOptions styleOverrides: { body: { overscrollBehavior: "none", - backgroundColor: "initial", + backgroundColor: themeConfig.palette?.mode === "dark" ? "#121212" : "#fff", }, img: { userSelect: "none", diff --git a/ui/yarn.lock b/ui/yarn.lock index f1d6a52..2ff4334 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -816,57 +816,52 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b" integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw== -"@tauri-apps/api@^2.10.1": - version "2.10.1" - resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93" - integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw== +"@tauri-apps/api@^2.11.0", "@tauri-apps/api@^2.8.0": + version "2.11.0" + resolved "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz#00fb69996010178a5153798d4a84f6fe3a1e1182" + integrity sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA== -"@tauri-apps/api@^2.8.0": - version "2.9.1" - resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz" - integrity sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw== - -"@tauri-apps/plugin-deep-link@^2.0.0": - version "2.4.7" - resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz#6d5b60e11d1bc668ed0116a45afb7d54f2fd6226" - integrity sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg== +"@tauri-apps/plugin-deep-link@^2.4.9": + version "2.4.9" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz#ae56d59130380f806b533b3107c3f16654e66a8d" + integrity sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA== dependencies: - "@tauri-apps/api" "^2.10.1" + "@tauri-apps/api" "^2.11.0" -"@tauri-apps/plugin-dialog@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz#e09bb26ab7008bfc4a1a044a5fb29ac0575f8898" - integrity sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg== +"@tauri-apps/plugin-dialog@^2.7.1": + version "2.7.1" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz#fc83387de807c8d064d2b64b1b813b84e8286a12" + integrity sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ== dependencies: - "@tauri-apps/api" "^2.8.0" + "@tauri-apps/api" "^2.11.0" -"@tauri-apps/plugin-http@^2.5.6": - version "2.5.6" - resolved "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.6.tgz" - integrity sha512-KhCK3TDNDF4vdz75/j+KNQipYKf+295Visa8r32QcXScg0+D3JwShcCM6D+FN8WuDF24X3KSiAB8QtRxW6jKRA== +"@tauri-apps/plugin-http@^2.5.9": + version "2.5.9" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz#f612a86239b95f6b2d5d211e26d512176c9f490b" + integrity sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA== dependencies: - "@tauri-apps/api" "^2.8.0" + "@tauri-apps/api" "^2.11.0" -"@tauri-apps/plugin-opener@^2.5.3": - version "2.5.3" - resolved "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz" - integrity sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ== +"@tauri-apps/plugin-opener@^2.5.4": + version "2.5.4" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz#b37883e4d36125b8c5a0c74f683395958a65bd7d" + integrity sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ== dependencies: - "@tauri-apps/api" "^2.8.0" + "@tauri-apps/api" "^2.11.0" -"@tauri-apps/plugin-os@^2": +"@tauri-apps/plugin-os@^2.3.2": version "2.3.2" - resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz#de916a82d8d955bba59a2ffdba7f7aaa29397246" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz#de916a82d8d955bba59a2ffdba7f7aaa29397246" integrity sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A== dependencies: "@tauri-apps/api" "^2.8.0" -"@tauri-apps/plugin-positioner@^2.3.1": - version "2.3.1" - resolved "https://registry.npmjs.org/@tauri-apps/plugin-positioner/-/plugin-positioner-2.3.1.tgz" - integrity sha512-9JiNO3tpHhz91VUG/sncGha4CL1qQHlftnfkwWJIquAR7rhLA9GUdW1oIdZLbNswNzkkd9qVywFmh658eFEL2Q== +"@tauri-apps/plugin-positioner@^2.3.2": + version "2.3.2" + resolved "https://registry.npmmirror.com/@tauri-apps/plugin-positioner/-/plugin-positioner-2.3.2.tgz#c8bb7338fa849581c1834d1f5b2bb47850da66b4" + integrity sha512-QgJwKcb58P+EeIgDCvl/q7fPyDN4WdpoM5/yp4hxGYCUM2aetAr1jiqDUZn1I7X3yNYSC2etV4evlOVHgvsZBg== dependencies: - "@tauri-apps/api" "^2.8.0" + "@tauri-apps/api" "^2.11.0" "@types/babel__core@^7.20.5": version "7.20.5" From a599db838707a34a748a8911c0407126886244fb Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 6 Jun 2026 02:31:07 +0800 Subject: [PATCH 10/25] Fix Linux panic due to WebKit environment variable; fix Arch PKGBUILD --- package/arch/PKGBUILD | 4 ++++ src-tauri/src/main.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/package/arch/PKGBUILD b/package/arch/PKGBUILD index c2f8a33..e1d9b2c 100644 --- a/package/arch/PKGBUILD +++ b/package/arch/PKGBUILD @@ -34,6 +34,10 @@ build() { export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target + # makepkg reuses srcdir between builds unless the caller passes -C. Remove any + # stale node_modules tree first so a previous pnpm/yarn-mixed install cannot + # leave broken package links that TypeScript later resolves during `yarn build`. + rm -rf ui/node_modules yarn --cwd ui install --frozen-lockfile yarn --cwd ui run build cargo build --release -p cloudreve-desktop diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 69c3a72..bc19a9d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Without this, Wayland will panic + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); app_lib::run(); } From d40d6235d9f4c7ad806fab7e97a3b0f46ee76541 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 6 Jun 2026 03:07:58 +0800 Subject: [PATCH 11/25] Fix Arch package release build --- package/arch/PKGBUILD | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/package/arch/PKGBUILD b/package/arch/PKGBUILD index e1d9b2c..523803e 100644 --- a/package/arch/PKGBUILD +++ b/package/arch/PKGBUILD @@ -6,6 +6,11 @@ pkgdesc='Cloudreve Desktop Sync Client' arch=('x86_64') url='https://github.com/cloudreve/desktop' license=('MIT') +# Arch's global makepkg.conf may enable LTO. The Rust `ring` crate embeds +# native C/ASM objects in its rlib, and rust-lld can fail to link those objects +# under makepkg LTO with `ring_core_*` undefined symbols. Keep LTO disabled for +# this package; a clean target directory does not fix that linker failure. +options=(!lto) depends=( 'gtk3' 'hicolor-icon-theme' @@ -33,6 +38,13 @@ build() { cd "desktop-${pkgver}" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target + # `ring` compiles bundled C/ASM sources and links them into Rust. If Arch's + # makepkg LTO flags leak into those CFLAGS, GCC emits LTO objects that + # rust-lld cannot consume reliably, resulting in `ring_core_*` undefined + # symbols at the final binary link step. Strip the known Arch LTO flag here + # as a defensive guard in addition to package-level `options=(!lto)`. + export CFLAGS="${CFLAGS//-flto=auto/}" + export CXXFLAGS="${CXXFLAGS//-flto=auto/}" # makepkg reuses srcdir between builds unless the caller passes -C. Remove any # stale node_modules tree first so a previous pnpm/yarn-mixed install cannot @@ -40,7 +52,10 @@ build() { rm -rf ui/node_modules yarn --cwd ui install --frozen-lockfile yarn --cwd ui run build - cargo build --release -p cloudreve-desktop + # Enable Tauri's custom protocol for production builds. Without this feature, + # tauri's build script marks the binary as `cfg(dev)`, so the installed app + # tries to load `build.devUrl` (localhost:5173) instead of bundled assets. + cargo build --release -p cloudreve-desktop --features tauri/custom-protocol } package() { From 9766b79fbff3b7acf12603e62195474d6b6ab0bd Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 6 Jun 2026 19:05:37 +0800 Subject: [PATCH 12/25] Improve popup conflict resolution entry --- ui/src/pages/popup/ConflictItem.tsx | 103 +++++++++++++++++----------- 1 file changed, 63 insertions(+), 40 deletions(-) diff --git a/ui/src/pages/popup/ConflictItem.tsx b/ui/src/pages/popup/ConflictItem.tsx index 1e1e591..cc68f37 100644 --- a/ui/src/pages/popup/ConflictItem.tsx +++ b/ui/src/pages/popup/ConflictItem.tsx @@ -2,10 +2,12 @@ import { Alert, Box, Button, + Collapse, Link, ListItem, ListItemIcon, ListItemText, + Stack, Typography, } from "@mui/material"; import { WarningAmber as WarningIcon } from "@mui/icons-material"; @@ -31,6 +33,7 @@ export default function ConflictItem({ const [resolvingAction, setResolvingAction] = useState( null ); + const [actionsOpen, setActionsOpen] = useState(false); const [error, setError] = useState(null); const fileName = getFileName(conflict.local_path); const parentFolderName = getParentFolderName(conflict.local_path); @@ -99,6 +102,7 @@ export default function ConflictItem({ {fileName} @@ -116,54 +120,73 @@ export default function ConflictItem({ "Local file conflicts with the remote version" )} - - {" · "} - - - {parentFolderName} - - - + {parentFolderName} + - - + + + + + + + + {error && ( {error} From 9e1d4e0dae5bffb0ec436eb9a51bc7361ae37694 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 6 Jun 2026 20:08:43 +0800 Subject: [PATCH 13/25] Support syncing existing local folders --- crates/cloudreve-sync/Cargo.toml | 2 + crates/cloudreve-sync/src/drive/sync.rs | 350 ++++++++++++++++++++++++ ui/public/locales/de/common.json | 11 +- ui/public/locales/en-US/common.json | 11 +- ui/public/locales/es/common.json | 11 +- ui/public/locales/fr/common.json | 11 +- ui/public/locales/it/common.json | 11 +- ui/public/locales/ja/common.json | 11 +- ui/public/locales/ko/common.json | 11 +- ui/public/locales/pl/common.json | 11 +- ui/public/locales/ru/common.json | 11 +- ui/public/locales/zh-CN/common.json | 11 +- ui/public/locales/zh-TW/common.json | 11 +- ui/src/pages/AddDrive.tsx | 8 +- 14 files changed, 467 insertions(+), 14 deletions(-) diff --git a/crates/cloudreve-sync/Cargo.toml b/crates/cloudreve-sync/Cargo.toml index 3b7894f..212fc40 100644 --- a/crates/cloudreve-sync/Cargo.toml +++ b/crates/cloudreve-sync/Cargo.toml @@ -18,6 +18,8 @@ tracing-appender = "0.2" tower-http = { version = "0.5", features = ["trace", "cors"] } futures = "0.3" reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } +md-5 = "0.10" +sha1 = "0.10" sha2 = "0.10" image = "0.24" url = "2.5" diff --git a/crates/cloudreve-sync/src/drive/sync.rs b/crates/cloudreve-sync/src/drive/sync.rs index 9b13d47..b996268 100644 --- a/crates/cloudreve-sync/src/drive/sync.rs +++ b/crates/cloudreve-sync/src/drive/sync.rs @@ -23,15 +23,19 @@ use cloudreve_api::{ uri::CrUri, }, }; +use md5::Md5; use notify_debouncer_full::notify::event::{ AccessKind, CreateKind, EventKind, ModifyKind, RemoveKind, RenameMode, }; use notify_debouncer_full::{DebouncedEvent, notify::Event}; use nt_time::FileTime; +use sha1::Sha1; +use sha2::{Digest, Sha256}; use std::{ collections::{HashMap, HashSet}, ffi::OsString, fmt, fs, io, + io::Read, path::{Path, PathBuf}, time::SystemTime, }; @@ -113,6 +117,39 @@ pub fn cloud_file_to_metadata_entry( .with_metadata(file.metadata.as_ref().unwrap_or(&HashMap::new()).clone())) } +fn cloud_file_to_metadata_entry_at_path( + file: &FileResponse, + drive_id: &Uuid, + local_path: &Path, + conflict_state: Option, +) -> Result { + let local_path_str = local_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("Failed to convert local path to string"))?; + let created_at = file.created_at.parse::>()?.timestamp(); + let last_modified = file.updated_at.parse::>()?.timestamp(); + + let mut entry = MetadataEntry::new( + drive_id.clone(), + local_path_str, + file.file_type == file_type::FOLDER, + ) + .with_created_at(created_at) + .with_updated_at(last_modified) + .with_permissions(file.permission.as_ref().unwrap_or(&String::new()).clone()) + .with_shared(file.shared.unwrap_or(false)) + .with_size(file.size) + .with_etag( + file.primary_entity + .as_ref() + .unwrap_or(&String::new()) + .clone(), + ) + .with_metadata(file.metadata.as_ref().unwrap_or(&HashMap::new()).clone()); + entry.conflict_state = conflict_state; + Ok(entry) +} + pub fn is_symbolic_link(file: &FileResponse) -> bool { return file.metadata.is_some() && file @@ -199,6 +236,11 @@ enum SyncAction { path: PathBuf, remote: FileResponse, }, + RecordInventoryFromRemote { + path: PathBuf, + remote: FileResponse, + mark_conflicted: bool, + }, // Update inventory and placehodler metadata, conver to placehodler if it's not one UpdateInventoryFromRemote { path: PathBuf, @@ -258,6 +300,47 @@ struct SyncPlan { walk_requests: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HashAlgorithm { + Md5, + Sha1, + Sha256, + Sha512, +} + +impl HashAlgorithm { + fn from_hex_len(len: usize) -> Option { + match len { + 32 => Some(Self::Md5), + 40 => Some(Self::Sha1), + 64 => Some(Self::Sha256), + 128 => Some(Self::Sha512), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Md5 => "md5", + Self::Sha1 => "sha1", + Self::Sha256 => "sha256", + Self::Sha512 => "sha512", + } + } +} + +#[derive(Debug, Clone)] +struct FileHashFingerprint { + algorithm: HashAlgorithm, + value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExistingRemoteLocalFileDecision { + SameContent, + Conflict, +} + // Debug print for SyncPlan impl fmt::Debug for SyncPlan { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -386,6 +469,135 @@ fn next_child_mode(mode: SyncMode) -> SyncMode { } } +fn normalize_hash_value(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches('"') + .to_ascii_lowercase() + .replace('-', ""); + if normalized.chars().all(|ch| ch.is_ascii_hexdigit()) { + Some(normalized) + } else { + None + } +} + +fn hash_fingerprint_from_value(value: &str) -> Option { + let normalized = normalize_hash_value(value)?; + let algorithm = HashAlgorithm::from_hex_len(normalized.len())?; + Some(FileHashFingerprint { + algorithm, + value: normalized, + }) +} + +fn normalize_hash_key(key: &str) -> String { + key.chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase() +} + +fn remote_file_hash_fingerprint(remote: &FileResponse) -> Option { + const HASH_METADATA_KEYS: &[(&str, HashAlgorithm)] = &[ + ("md5", HashAlgorithm::Md5), + ("hashmd5", HashAlgorithm::Md5), + ("checksummd5", HashAlgorithm::Md5), + ("sha1", HashAlgorithm::Sha1), + ("hashsha1", HashAlgorithm::Sha1), + ("checksumsha1", HashAlgorithm::Sha1), + ("sha256", HashAlgorithm::Sha256), + ("hashsha256", HashAlgorithm::Sha256), + ("checksumsha256", HashAlgorithm::Sha256), + ("sha512", HashAlgorithm::Sha512), + ("hashsha512", HashAlgorithm::Sha512), + ("checksumsha512", HashAlgorithm::Sha512), + ]; + + // Cloudreve does not expose a dedicated typed content-hash field in + // FileResponse. Prefer explicit metadata keys first so the comparison uses + // whatever hash algorithm the server actually publishes. + if let Some(metadata) = remote.metadata.as_ref() { + for (key, value) in metadata { + let normalized_key = normalize_hash_key(key); + for (hash_key, algorithm) in HASH_METADATA_KEYS { + if normalized_key == *hash_key { + if let Some(value) = normalize_hash_value(value) { + return Some(FileHashFingerprint { + algorithm: *algorithm, + value, + }); + } + } + } + } + } + + // Some server responses may put a content entity/hash in primary_entity. + // Treat it as a hash only when it has a known digest length; otherwise it + // remains an opaque etag and is not safe for local-content equality. + remote + .primary_entity + .as_deref() + .and_then(hash_fingerprint_from_value) +} + +async fn calculate_file_hash(path: PathBuf, algorithm: HashAlgorithm) -> Result { + task::spawn_blocking(move || -> Result { + let mut file = fs::File::open(&path) + .with_context(|| format!("failed to open file {}", path.display()))?; + let mut buffer = [0u8; 64 * 1024]; + + match algorithm { + HashAlgorithm::Md5 => { + let mut hasher = Md5::new(); + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) + } + HashAlgorithm::Sha1 => { + let mut hasher = Sha1::new(); + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) + } + HashAlgorithm::Sha256 => { + let mut hasher = Sha256::new(); + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) + } + HashAlgorithm::Sha512 => { + let mut hasher = sha2::Sha512::new(); + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) + } + } + }) + .await? +} + /// Result of collecting child targets, including pre-fetched remote file info. struct CollectChildResult { /// All child paths (union of local and remote). @@ -603,6 +815,54 @@ impl Mount { } } } + SyncAction::RecordInventoryFromRemote { + path, + remote, + mark_conflicted, + } => { + if *mark_conflicted { + match cloud_file_to_metadata_entry_at_path( + remote, + drive_id, + path, + Some(ConflictState::Pending), + ) + .and_then(|entry| { + self.inventory + .upsert(&entry) + .context("failed to upsert conflicted inventory metadata") + }) { + Ok(_) => {} + Err(err) => { + tracing::error!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + error = ?err, + "Failed to record existing local file conflict" + ); + aggregate_error.push(path.clone(), err); + } + } + return; + } + + let cr_placeholder = + CrPlaceholder::new(path.clone(), sync_root.clone(), drive_id.clone()); + if let Err(err) = cr_placeholder + .with_remote_file(remote) + .commit(self.inventory.clone()) + { + tracing::error!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + error = ?err, + "Failed to record existing local file inventory" + ); + aggregate_error.push(path.clone(), err); + } + } SyncAction::UpdateInventoryFromRemote { path, remote, @@ -969,12 +1229,102 @@ impl Mount { .unwrap_or_else(LocalFileInfo::missing); let remote = remote_files.get(path); let inventory = inventory_entries.get(path); + if let (Some(remote), Some(local_info)) = (remote, local_files.get(path)) { + // This handles attaching a non-empty local directory to an + // existing remote tree. With no inventory row yet, a same-name + // local/remote file must be resolved before normal sync would + // treat the local file as a fresh upload or the remote file as + // a placeholder/download target. + if let Some(decision) = self + .decide_existing_local_remote_file(path, remote, local_info, inventory) + .await + { + let mark_conflicted = decision == ExistingRemoteLocalFileDecision::Conflict; + plan.actions.push(SyncAction::RecordInventoryFromRemote { + path: path.clone(), + remote: remote.clone(), + mark_conflicted, + }); + if mark_conflicted { + tracing::info!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + "Existing local file conflicts with same-name remote file; queued conflict metadata" + ); + } + continue; + } + } self.plan_entry_actions(path, mode, remote, &local_info, inventory, &mut plan); } plan } + async fn decide_existing_local_remote_file( + &self, + path: &PathBuf, + remote: &FileResponse, + local: &LocalFileInfo, + inventory: Option<&FileMetadata>, + ) -> Option { + if inventory.is_some() + || !local.exists + || local.is_directory + || local.is_placeholder() + || remote.file_type == file_type::FOLDER + { + return None; + } + + let Some(remote_fingerprint) = remote_file_hash_fingerprint(remote) else { + tracing::info!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + "Remote file has no supported hash fingerprint; treating same-name local file as conflict" + ); + return Some(ExistingRemoteLocalFileDecision::Conflict); + }; + + match calculate_file_hash(path.clone(), remote_fingerprint.algorithm).await { + Ok(local_hash) if local_hash.eq_ignore_ascii_case(&remote_fingerprint.value) => { + tracing::info!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + algorithm = %remote_fingerprint.algorithm.as_str(), + "Existing local file matches remote hash; recording inventory without upload/download" + ); + Some(ExistingRemoteLocalFileDecision::SameContent) + } + Ok(local_hash) => { + tracing::info!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + algorithm = %remote_fingerprint.algorithm.as_str(), + local_hash = %local_hash, + remote_hash = %remote_fingerprint.value, + "Existing local file differs from remote hash; treating as conflict candidate" + ); + Some(ExistingRemoteLocalFileDecision::Conflict) + } + Err(err) => { + tracing::warn!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + error = %err, + algorithm = %remote_fingerprint.algorithm.as_str(), + "Failed to calculate local hash; treating same-name local file as conflict candidate" + ); + Some(ExistingRemoteLocalFileDecision::Conflict) + } + } + } + fn plan_entry_actions( &self, path: &PathBuf, diff --git a/ui/public/locales/de/common.json b/ui/public/locales/de/common.json index 6cdc0bc..949d270 100644 --- a/ui/public/locales/de/common.json +++ b/ui/public/locales/de/common.json @@ -17,6 +17,7 @@ "browse": "Durchsuchen", "selectFolder": "Ordner auswählen", "folderNotEmpty": "Der ausgewählte Ordner ist nicht leer. Bitte wählen Sie einen leeren Ordner.", + "folderNotEmptyWillSync": "Dieser Ordner enthält bereits Dateien. Vorhandene lokale Inhalte werden mit dem Remote-Laufwerk zusammengeführt; gleichnamige Dateien mit übereinstimmenden Hashes gelten als bereits synchronisiert.", "finish": "Fertig", "settingUp": "Einrichtung läuft...", "successTitle": "Fertig!", @@ -46,7 +47,15 @@ "syncingStatus": "{{count}} Datei(en) werden synchronisiert...", "upToDate": "Ihre Dateien sind aktuell.", "waiting": "Warten...", - "processing": "Verarbeitung..." + "processing": "Verarbeitung...", + "conflicts": "Konflikte", + "conflictStatus": "{{count}} Konflikt(e) erfordern Aufmerksamkeit", + "conflictDescription": "Die lokale Datei steht im Konflikt mit der Remote-Version", + "resolveConflict": "Konflikt lösen", + "resolving": "Wird gelöst...", + "keepRemote": "Remote-Version behalten", + "overwriteRemote": "Remote-Version überschreiben", + "saveAsNew": "Als neue Datei speichern" }, "settings": { "title": "Einstellungen", diff --git a/ui/public/locales/en-US/common.json b/ui/public/locales/en-US/common.json index 34fbf72..50a51b7 100644 --- a/ui/public/locales/en-US/common.json +++ b/ui/public/locales/en-US/common.json @@ -17,6 +17,7 @@ "browse": "Browse", "selectFolder": "Select Folder", "folderNotEmpty": "The selected folder is not empty. Please choose an empty folder.", + "folderNotEmptyWillSync": "This folder already has files. Existing local content will be merged into the remote drive; same-name files with matching hashes will be treated as already synced.", "finish": "Finish", "settingUp": "We're setting things up...", "successTitle": "You're all set!", @@ -46,7 +47,15 @@ "syncingStatus": "Syncing {{count}} file(s)...", "upToDate": "Your files are up to date.", "waiting": "Waiting...", - "processing": "Processing..." + "processing": "Processing...", + "conflicts": "Conflicts", + "conflictStatus": "{{count}} conflict(s) need attention", + "conflictDescription": "Local file conflicts with the remote version", + "resolveConflict": "Resolve conflict", + "resolving": "Resolving...", + "keepRemote": "Keep remote", + "overwriteRemote": "Overwrite remote", + "saveAsNew": "Save as new" }, "settings": { "title": "Settings", diff --git a/ui/public/locales/es/common.json b/ui/public/locales/es/common.json index 6decb0c..f7e886e 100644 --- a/ui/public/locales/es/common.json +++ b/ui/public/locales/es/common.json @@ -17,6 +17,7 @@ "browse": "Examinar", "selectFolder": "Seleccionar carpeta", "folderNotEmpty": "La carpeta seleccionada no está vacía. Por favor, elija una carpeta vacía.", + "folderNotEmptyWillSync": "Esta carpeta ya contiene archivos. El contenido local existente se fusionará con la unidad remota; los archivos con el mismo nombre y hashes coincidentes se tratarán como ya sincronizados.", "finish": "Finalizar", "settingUp": "Configurando...", "successTitle": "¡Listo!", @@ -46,7 +47,15 @@ "syncingStatus": "Sincronizando {{count}} archivo(s)...", "upToDate": "Sus archivos están actualizados.", "waiting": "Esperando...", - "processing": "Procesando..." + "processing": "Procesando...", + "conflicts": "Conflictos", + "conflictStatus": "{{count}} conflicto(s) requieren atención", + "conflictDescription": "El archivo local entra en conflicto con la versión remota", + "resolveConflict": "Resolver conflicto", + "resolving": "Resolviendo...", + "keepRemote": "Conservar remoto", + "overwriteRemote": "Sobrescribir remoto", + "saveAsNew": "Guardar como nuevo" }, "settings": { "title": "Configuración", diff --git a/ui/public/locales/fr/common.json b/ui/public/locales/fr/common.json index 1dc719b..7442d24 100644 --- a/ui/public/locales/fr/common.json +++ b/ui/public/locales/fr/common.json @@ -17,6 +17,7 @@ "browse": "Parcourir", "selectFolder": "Sélectionner un dossier", "folderNotEmpty": "Le dossier sélectionné n'est pas vide. Veuillez choisir un dossier vide.", + "folderNotEmptyWillSync": "Ce dossier contient déjà des fichiers. Le contenu local existant sera fusionné avec le lecteur distant ; les fichiers de même nom dont le hachage correspond seront considérés comme déjà synchronisés.", "finish": "Terminer", "settingUp": "Configuration en cours...", "successTitle": "Terminé !", @@ -46,7 +47,15 @@ "syncingStatus": "Synchronisation de {{count}} fichier(s)...", "upToDate": "Vos fichiers sont à jour.", "waiting": "En attente...", - "processing": "Traitement..." + "processing": "Traitement...", + "conflicts": "Conflits", + "conflictStatus": "{{count}} conflit(s) nécessitent votre attention", + "conflictDescription": "Le fichier local est en conflit avec la version distante", + "resolveConflict": "Résoudre le conflit", + "resolving": "Résolution...", + "keepRemote": "Conserver la version distante", + "overwriteRemote": "Écraser la version distante", + "saveAsNew": "Enregistrer comme nouveau fichier" }, "settings": { "title": "Paramètres", diff --git a/ui/public/locales/it/common.json b/ui/public/locales/it/common.json index 7e48c7f..bddf970 100644 --- a/ui/public/locales/it/common.json +++ b/ui/public/locales/it/common.json @@ -17,6 +17,7 @@ "browse": "Sfoglia", "selectFolder": "Seleziona cartella", "folderNotEmpty": "La cartella selezionata non è vuota. Seleziona una cartella vuota.", + "folderNotEmptyWillSync": "Questa cartella contiene già dei file. Il contenuto locale esistente verrà unito all’unità remota; i file con lo stesso nome e hash corrispondenti saranno considerati già sincronizzati.", "finish": "Fine", "settingUp": "Configurazione in corso...", "successTitle": "Fatto!", @@ -46,7 +47,15 @@ "syncingStatus": "Sincronizzazione di {{count}} file...", "upToDate": "I tuoi file sono aggiornati.", "waiting": "In attesa...", - "processing": "Elaborazione..." + "processing": "Elaborazione...", + "conflicts": "Conflitti", + "conflictStatus": "{{count}} conflitto/i richiedono attenzione", + "conflictDescription": "Il file locale è in conflitto con la versione remota", + "resolveConflict": "Risolvi conflitto", + "resolving": "Risoluzione...", + "keepRemote": "Mantieni remoto", + "overwriteRemote": "Sovrascrivi remoto", + "saveAsNew": "Salva come nuovo" }, "settings": { "title": "Impostazioni", diff --git a/ui/public/locales/ja/common.json b/ui/public/locales/ja/common.json index 5f93747..109f259 100644 --- a/ui/public/locales/ja/common.json +++ b/ui/public/locales/ja/common.json @@ -17,6 +17,7 @@ "browse": "参照", "selectFolder": "フォルダを選択", "folderNotEmpty": "選択したフォルダは空ではありません。空のフォルダを選択してください。", + "folderNotEmptyWillSync": "このフォルダには既にファイルがあります。既存のローカル内容はリモートドライブに統合され、同名でハッシュが一致するファイルは同期済みとして扱われます。", "finish": "完了", "settingUp": "設定中...", "successTitle": "設定完了!", @@ -46,7 +47,15 @@ "syncingStatus": "{{count}} 個のファイルを同期中...", "upToDate": "ファイルは最新です。", "waiting": "待機中...", - "processing": "処理中..." + "processing": "処理中...", + "conflicts": "競合", + "conflictStatus": "{{count}} 件の競合に対応が必要です", + "conflictDescription": "ローカルファイルがリモート版と競合しています", + "resolveConflict": "競合を解決", + "resolving": "解決中...", + "keepRemote": "リモート版を保持", + "overwriteRemote": "リモート版を上書き", + "saveAsNew": "新しいファイルとして保存" }, "settings": { "title": "設定", diff --git a/ui/public/locales/ko/common.json b/ui/public/locales/ko/common.json index 54be7a8..3d4cae1 100644 --- a/ui/public/locales/ko/common.json +++ b/ui/public/locales/ko/common.json @@ -17,6 +17,7 @@ "browse": "찾아보기", "selectFolder": "폴더 선택", "folderNotEmpty": "선택한 폴더가 비어 있지 않습니다. 빈 폴더를 선택해 주세요.", + "folderNotEmptyWillSync": "이 폴더에는 이미 파일이 있습니다. 기존 로컬 콘텐츠가 원격 드라이브에 병합되며, 이름이 같고 해시가 일치하는 파일은 이미 동기화된 것으로 처리됩니다.", "finish": "완료", "settingUp": "설정 중...", "successTitle": "설정 완료!", @@ -46,7 +47,15 @@ "syncingStatus": "{{count}}개 파일 동기화 중...", "upToDate": "파일이 최신 상태입니다.", "waiting": "대기 중...", - "processing": "처리 중..." + "processing": "처리 중...", + "conflicts": "충돌", + "conflictStatus": "{{count}}개 충돌을 확인해야 합니다", + "conflictDescription": "로컬 파일이 원격 버전과 충돌합니다", + "resolveConflict": "충돌 해결", + "resolving": "해결 중...", + "keepRemote": "원격 버전 유지", + "overwriteRemote": "원격 버전 덮어쓰기", + "saveAsNew": "새 파일로 저장" }, "settings": { "title": "설정", diff --git a/ui/public/locales/pl/common.json b/ui/public/locales/pl/common.json index 6de7d48..5ac39b6 100644 --- a/ui/public/locales/pl/common.json +++ b/ui/public/locales/pl/common.json @@ -17,6 +17,7 @@ "browse": "Przeglądaj", "selectFolder": "Wybierz folder", "folderNotEmpty": "Wybrany folder nie jest pusty. Proszę wybrać pusty folder.", + "folderNotEmptyWillSync": "Ten folder zawiera już pliki. Istniejąca zawartość lokalna zostanie scalona z dyskiem zdalnym; pliki o tych samych nazwach i zgodnych hashach zostaną uznane za już zsynchronizowane.", "finish": "Zakończ", "settingUp": "Konfigurowanie...", "successTitle": "Gotowe!", @@ -46,7 +47,15 @@ "syncingStatus": "Synchronizacja {{count}} plik(ów)...", "upToDate": "Twoje pliki są aktualne.", "waiting": "Oczekiwanie...", - "processing": "Przetwarzanie..." + "processing": "Przetwarzanie...", + "conflicts": "Konflikty", + "conflictStatus": "{{count}} konflikt(y) wymagają uwagi", + "conflictDescription": "Plik lokalny jest w konflikcie z wersją zdalną", + "resolveConflict": "Rozwiąż konflikt", + "resolving": "Rozwiązywanie...", + "keepRemote": "Zachowaj wersję zdalną", + "overwriteRemote": "Nadpisz wersję zdalną", + "saveAsNew": "Zapisz jako nowy plik" }, "settings": { "title": "Ustawienia", diff --git a/ui/public/locales/ru/common.json b/ui/public/locales/ru/common.json index d95f031..83f658f 100644 --- a/ui/public/locales/ru/common.json +++ b/ui/public/locales/ru/common.json @@ -17,6 +17,7 @@ "browse": "Обзор", "selectFolder": "Выбрать папку", "folderNotEmpty": "Выбранная папка не пуста. Пожалуйста, выберите пустую папку.", + "folderNotEmptyWillSync": "В этой папке уже есть файлы. Существующее локальное содержимое будет объединено с удалённым диском; файлы с одинаковыми именами и совпадающими хешами будут считаться уже синхронизированными.", "finish": "Готово", "settingUp": "Настройка...", "successTitle": "Готово!", @@ -46,7 +47,15 @@ "syncingStatus": "Синхронизация {{count}} файл(ов)...", "upToDate": "Ваши файлы актуальны.", "waiting": "Ожидание...", - "processing": "Обработка..." + "processing": "Обработка...", + "conflicts": "Конфликты", + "conflictStatus": "{{count}} конфликт(ов) требуют внимания", + "conflictDescription": "Локальный файл конфликтует с удалённой версией", + "resolveConflict": "Разрешить конфликт", + "resolving": "Разрешение...", + "keepRemote": "Оставить удалённую версию", + "overwriteRemote": "Перезаписать удалённую версию", + "saveAsNew": "Сохранить как новый файл" }, "settings": { "title": "Настройки", diff --git a/ui/public/locales/zh-CN/common.json b/ui/public/locales/zh-CN/common.json index efd8ffb..62aa6f6 100644 --- a/ui/public/locales/zh-CN/common.json +++ b/ui/public/locales/zh-CN/common.json @@ -17,6 +17,7 @@ "browse": "浏览", "selectFolder": "选择文件夹", "folderNotEmpty": "所选文件夹不为空,请选择一个空文件夹。", + "folderNotEmptyWillSync": "此文件夹已有文件。本地已有内容将合并到远程网盘;同名且哈希匹配的文件会被视为已同步。", "finish": "完成", "settingUp": "正在配置中...", "successTitle": "设置完成!", @@ -46,7 +47,15 @@ "syncingStatus": "正在同步 {{count}} 个文件...", "upToDate": "文件已是最新。", "waiting": "等待中...", - "processing": "处理中..." + "processing": "处理中...", + "conflicts": "冲突", + "conflictStatus": "{{count}} 个冲突需要处理", + "conflictDescription": "本地文件与远程版本冲突", + "resolveConflict": "解决冲突", + "resolving": "正在处理...", + "keepRemote": "保留远程版本", + "overwriteRemote": "覆盖远程版本", + "saveAsNew": "另存为新文件" }, "settings": { "title": "设置", diff --git a/ui/public/locales/zh-TW/common.json b/ui/public/locales/zh-TW/common.json index 58a6153..9cfc9af 100644 --- a/ui/public/locales/zh-TW/common.json +++ b/ui/public/locales/zh-TW/common.json @@ -17,6 +17,7 @@ "browse": "瀏覽", "selectFolder": "選擇資料夾", "folderNotEmpty": "所選資料夾不為空,請選擇一個空資料夾。", + "folderNotEmptyWillSync": "此資料夾已有檔案。本機既有內容將合併到遠端網盤;同名且雜湊相符的檔案會被視為已同步。", "finish": "完成", "settingUp": "正在設定中...", "successTitle": "設定完成!", @@ -46,7 +47,15 @@ "syncingStatus": "正在同步 {{count}} 個檔案...", "upToDate": "檔案已是最新。", "waiting": "等待中...", - "processing": "處理中..." + "processing": "處理中...", + "conflicts": "衝突", + "conflictStatus": "{{count}} 個衝突需要處理", + "conflictDescription": "本機檔案與遠端版本衝突", + "resolveConflict": "解決衝突", + "resolving": "正在處理...", + "keepRemote": "保留遠端版本", + "overwriteRemote": "覆蓋遠端版本", + "saveAsNew": "另存為新檔案" }, "settings": { "title": "設定", diff --git a/ui/src/pages/AddDrive.tsx b/ui/src/pages/AddDrive.tsx index bcf64c5..e14adb4 100644 --- a/ui/src/pages/AddDrive.tsx +++ b/ui/src/pages/AddDrive.tsx @@ -535,8 +535,11 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) { }} /> {folderNotEmpty && ( - - {t("addDrive.folderNotEmpty")} + + {t( + "addDrive.folderNotEmptyWillSync", + "This folder already has files. Existing local content will be merged into the remote drive; same-name files with matching hashes will be treated as already synced." + )} )} @@ -547,7 +550,6 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) { variant="contained" size="large" fullWidth - disabled={folderNotEmpty} > {isReauthorize ? t("addDrive.reauthorizeConfirm") : t("addDrive.finish")} From 0a9cbf39be9799b3434cd69f6b9b40a29d2327e8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 7 Jun 2026 17:26:22 +0800 Subject: [PATCH 14/25] Fix tauri version mismatch --- src-tauri/Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b269d5..6757828 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] } uuid = { version = "1", features = ["v4"] } chrono = "0.4" log = "0.4" -tauri = { version = "2.9.5", features = ["protocol-asset", "tray-icon"] } +tauri = { version = "2.11.0", features = ["protocol-asset", "tray-icon"] } tauri-plugin-log = "2" tokio = { version = "1.35", features = ["full"] } tracing = "0.1" @@ -41,8 +41,8 @@ cloudreve-sync = { path = "../crates/cloudreve-sync" } tauri-plugin-frame = "1.1.6" tauri-plugin-http = "2" tauri-plugin-opener = "2" -tauri-plugin-deep-link = "2.0.0" -tauri-plugin-dialog = "2.6.0" +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-dialog = "2.7.1" tauri-plugin-prevent-default = "4" tauri-plugin-os = "2" @@ -54,4 +54,4 @@ features = ["Foundation", "ApplicationModel"] tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] -tauri-plugin-positioner = { version = "2.3.1", features = ["tray-icon"] } +tauri-plugin-positioner = { version = "2.3.2", features = ["tray-icon"] } From 2065ccf99f52b4e36a9d7b69f24063b54c35ea37 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 7 Jun 2026 17:38:52 +0800 Subject: [PATCH 15/25] Fix compilation errors --- src-tauri/src/commands.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2ad274e..1311348 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -338,7 +338,7 @@ fn file_icon_to_response(icon: file_icon_provider::Icon) -> FileIconResponse { /// Returns base64 encoded RGBA pixel data with dimensions #[tauri::command] pub async fn get_file_icon( - app: AppHandle, + _app: AppHandle, path: String, size: Option, ) -> CommandResult { @@ -496,9 +496,7 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { // Transparent webviews render differently across GTK/WebKit and AppKit; on // non-Windows this produced unreadable shadows/ghosting. Use an opaque // white background there and keep Windows transparency for Mica/Acrylic. - let builder = builder - .transparent(false) - .background_color(Color(255, 255, 255, 255)); + let builder = builder.background_color(Color(255, 255, 255, 255)); let Some(builder) = apply_default_window_icon(builder, app, "main_popup") else { return; @@ -609,15 +607,10 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { .decorations(false) .minimizable(false); - #[cfg(windows)] - let builder = builder.transparent(true).effects(effects); - #[cfg(not(windows))] // See `show_main_window_at_position`: keep non-Windows webviews opaque to // avoid platform compositor artifacts while preserving Windows effects. - let builder = builder - .transparent(false) - .background_color(Color(255, 255, 255, 255)); + let builder = builder.background_color(Color(255, 255, 255, 255)); // Platform-specific: title_bar_style and hidden_title are macOS-only #[cfg(target_os = "macos")] @@ -631,6 +624,11 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { match builder.build() { Ok(window) => { + #[cfg(windows)] + { + let _ = window.set_transparent(true); + let _ = window.set_effects(effects); + } move_window_safely(&window, Position::Center, "add-drive"); let _ = window.create_overlay_titlebar(); let _ = window.show(); @@ -675,9 +673,7 @@ pub fn show_settings_window_impl(app: &AppHandle) { #[cfg(not(windows))] // See `show_main_window_at_position`: keep non-Windows webviews opaque to // avoid platform compositor artifacts while preserving Windows effects. - let builder = builder - .transparent(false) - .background_color(Color(255, 255, 255, 255)); + let builder = builder.background_color(Color(255, 255, 255, 255)); // Platform-specific: title_bar_style and hidden_title are macOS-only #[cfg(target_os = "macos")] @@ -691,6 +687,11 @@ pub fn show_settings_window_impl(app: &AppHandle) { match builder.build() { Ok(window) => { + #[cfg(windows)] + { + let _ = window.set_transparent(true); + let _ = window.set_effects(effects); + } move_window_safely(&window, Position::Center, "settings"); let _ = window.create_overlay_titlebar(); let _ = window.show(); From 10cbc2d6e27b490756989414a3b450bc7321d66f Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 7 Jun 2026 18:35:50 +0800 Subject: [PATCH 16/25] fix(macos): add macOS support for deep links and background sync - Make tauri-plugin-frame conditional on Windows only - Add deep-link://new-url event listener for macOS OAuth callbacks - Register cloudreve:// URL scheme via tauri.conf.json deep-link config - Move initial sync to background so add_drive returns immediately - Add HTTP request timeout to prevent indefinite hangs during sync --- crates/cloudreve-api/src/client.rs | 3 +- .../cloudreve-sync/src/drive/manager/mod.rs | 20 ++++++++++++- crates/cloudreve-sync/src/drive/mounts.rs | 2 -- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 3 ++ src-tauri/src/lib.rs | 30 +++++++++++++++---- src-tauri/tauri.conf.json | 7 +++++ 7 files changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/cloudreve-api/src/client.rs b/crates/cloudreve-api/src/client.rs index 06f4ab2..64ab4d3 100644 --- a/crates/cloudreve-api/src/client.rs +++ b/crates/cloudreve-api/src/client.rs @@ -152,7 +152,8 @@ impl Client { /// Create a new API client pub fn new(config: ClientConfig) -> Self { let mut builder = HttpClient::builder() - .connect_timeout(std::time::Duration::from_secs(config.timeout_seconds)); + .connect_timeout(std::time::Duration::from_secs(config.timeout_seconds)) + .timeout(std::time::Duration::from_secs(config.timeout_seconds)); if let Some(ref user_agent) = config.user_agent { builder = builder.user_agent(user_agent); diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index 0dbeff6..db93f38 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -167,7 +167,8 @@ impl DriveManager { } } - let mut write_guard = self.drives.write().await; + // Create and start the mount before acquiring the write lock + // to avoid holding the lock during potentially long-running operations let mut mount = Mount::new( config.clone(), self.inventory.clone(), @@ -185,6 +186,23 @@ impl DriveManager { .spawn_remote_event_processor(mount_arc.clone()) .await; mount_arc.spawn_props_refresh_task().await; + + // Spawn initial sync in the background so add_drive returns immediately + let mount_for_sync = mount_arc.clone(); + tokio::spawn(async move { + let sync_path = mount_for_sync.config.read().await.sync_path.clone(); + tracing::info!(target: "drive", id = %mount_for_sync.id, path = %sync_path.display(), "Starting background initial sync"); + if let Err(e) = mount_for_sync + .sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) + .await + { + tracing::error!(target: "drive", id = %mount_for_sync.id, error = ?e, "Background initial sync failed"); + } else { + tracing::info!(target: "drive", id = %mount_for_sync.id, "Background initial sync completed"); + } + }); + + let mut write_guard = self.drives.write().await; let id = mount_arc.id.clone(); write_guard.insert(id.clone(), mount_arc); Ok(id) diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index f760e07..e4c3e90 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -387,8 +387,6 @@ impl Mount { let sync_path = self.config.read().await.sync_path.clone(); std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; self.start_fs_watcher().await?; - self.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) - .await?; return Ok(()); } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6757828..2d81eab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,13 +38,13 @@ urlencoding = "2.1" # Main sync service crate cloudreve-sync = { path = "../crates/cloudreve-sync" } -tauri-plugin-frame = "1.1.6" tauri-plugin-http = "2" tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.4.9" tauri-plugin-dialog = "2.7.1" tauri-plugin-prevent-default = "4" tauri-plugin-os = "2" +tauri-plugin-frame = "1.1.6" [target.'cfg(windows)'.dependencies.windows] version = "0.58.0" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1311348..7c095c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,6 +14,7 @@ use tauri::{ webview::{WebviewWindow, WebviewWindowBuilder}, AppHandle, Manager, State, WebviewUrl, }; +#[cfg(windows)] use tauri_plugin_frame::WebviewWindowExt; use tauri_plugin_positioner::{Position, WindowExt}; use uuid::Uuid; @@ -630,6 +631,7 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { let _ = window.set_effects(effects); } move_window_safely(&window, Position::Center, "add-drive"); + #[cfg(windows)] let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); @@ -693,6 +695,7 @@ pub fn show_settings_window_impl(app: &AppHandle) { let _ = window.set_effects(effects); } move_window_safely(&window, Position::Center, "settings"); + #[cfg(windows)] let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67f9577..a032e2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,8 +8,9 @@ use tauri::{ async_runtime::spawn, menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, RunEvent, + AppHandle, Emitter, Listener, Manager, RunEvent, }; +#[cfg(not(target_os = "macos"))] use tauri_plugin_deep_link::DeepLinkExt; use tokio::sync::OnceCell; @@ -258,7 +259,8 @@ pub fn run() { // Initialize i18n (uses config language setting or falls back to system locale) init_i18n(); - tauri::Builder::default() + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { tracing::info!("a new app instance was opened with {argv:?} and the deep link event was already triggered"); if argv.len() > 1 { @@ -268,8 +270,14 @@ pub fn run() { // when defining deep link schemes at runtime, you must also check `argv` here })) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_frame::init()) + .plugin(tauri_plugin_http::init()); + + #[cfg(windows)] + { + builder = builder.plugin(tauri_plugin_frame::init()); + } + + builder .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) @@ -281,9 +289,21 @@ pub fn run() { // Setup system tray setup_tray(app)?; - #[cfg(desktop)] + #[cfg(not(target_os = "macos"))] app.deep_link().register("cloudreve")?; + // Listen for deep-link events (macOS and Linux) + let app_handle = app.handle().clone(); + app.listen("deep-link://new-url", move |event: tauri::Event| { + if let Ok(urls) = serde_json::from_str::>(event.payload()) { + if let Some(url) = urls.first() { + tracing::info!(target: "main", "Received deep-link URL: {}", url); + let _ = app_handle.emit("deeplink", url.clone()); + show_add_drive_window_impl(&app_handle); + } + } + }); + // Spawn async setup task - this runs in the background // while the app continues to start let app_handle = app.handle().clone(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 41f08fd..f7784ee 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -46,5 +46,12 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["cloudreve"] + } + } } } From 09f31cbc7674e0828d532704f52b1c0a7f94793c Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 7 Jun 2026 20:03:16 +0800 Subject: [PATCH 17/25] fix linux compilation error --- src-tauri/src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7c095c3..995948f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -339,7 +339,7 @@ fn file_icon_to_response(icon: file_icon_provider::Icon) -> FileIconResponse { /// Returns base64 encoded RGBA pixel data with dimensions #[tauri::command] pub async fn get_file_icon( - _app: AppHandle, + app: AppHandle, path: String, size: Option, ) -> CommandResult { From 19d3be2f52a2eec4083780bfd144f7df8bdbd5e4 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 13 Jun 2026 17:56:46 +0800 Subject: [PATCH 18/25] fix: improve conflict resolution and remove incorrect OAuth refresh flow - Avoid false conflicts by improving remote hash detection in metadata and comparing local/remote content before marking upload errors as conflicts. - Remove unreliable primary_entity hash fallback; use size comparison when no content hash is available. - Show conflict bulk resolution UI on all platforms (keep all remote / overwrite all remote). - Clean up orphaned inventory metadata for drives no longer in config. - Remove incorrect backend OAuth refresh_token grant to /session/oauth/token; Cloudreve V4 uses /session/token/refresh for all refresh tokens, including those obtained via OAuth. Keep OAuth client credentials only in the UI for the authorization_code exchange during login. --- crates/cloudreve-api/src/client.rs | 22 ++- crates/cloudreve-sync/src/drive/commands.rs | 32 ++++ .../cloudreve-sync/src/drive/manager/mod.rs | 44 +++++ crates/cloudreve-sync/src/drive/mounts.rs | 9 +- crates/cloudreve-sync/src/drive/sync.rs | 55 +++++-- .../src/inventory/db/file_metadata.rs | 10 ++ crates/cloudreve-sync/src/tasks/upload.rs | 151 +++++++++++++++++- src-tauri/src/commands.rs | 55 +++++++ src-tauri/src/lib.rs | 1 + ui/public/locales/zh-CN/common.json | 4 +- ui/src/pages/popup/index.tsx | 50 ++++-- 11 files changed, 384 insertions(+), 49 deletions(-) diff --git a/crates/cloudreve-api/src/client.rs b/crates/cloudreve-api/src/client.rs index 64ab4d3..10b18a0 100644 --- a/crates/cloudreve-api/src/client.rs +++ b/crates/cloudreve-api/src/client.rs @@ -247,13 +247,15 @@ impl Client { store.access_token = Some(token.access_token.clone()); store.refresh_token = Some(token.refresh_token.clone()); - // Parse RFC3339 timestamps - if let Ok(exp) = DateTime::parse_from_rfc3339(&token.access_expires) { - store.access_token_expires = Some(exp.with_timezone(&Utc)); - } - if let Ok(exp) = DateTime::parse_from_rfc3339(&token.refresh_expires) { - store.refresh_token_expires = Some(exp.with_timezone(&Utc)); - } + let access_expires = DateTime::parse_from_rfc3339(&token.access_expires) + .map_err(|e| ApiError::InvalidToken(format!("Invalid access expiry: {}", e)))? + .with_timezone(&Utc); + let refresh_expires = DateTime::parse_from_rfc3339(&token.refresh_expires) + .map_err(|e| ApiError::InvalidToken(format!("Invalid refresh expiry: {}", e)))? + .with_timezone(&Utc); + + store.access_token_expires = Some(access_expires); + store.refresh_token_expires = Some(refresh_expires); Ok(()) } @@ -344,7 +346,11 @@ impl Client { self.refresh_access_token().await } - /// Refresh the access token using the refresh token + /// Refresh the access token using the refresh token. + /// Cloudreve V4 uses `/session/token/refresh` for all refresh tokens, + /// including tokens originally obtained via OAuth. The OAuth token endpoint + /// `/session/oauth/token` only supports `authorization_code` grant and is + /// not used here. async fn refresh_access_token(&self) -> ApiResult { let refresh_token = { let store = self.tokens.read().await; diff --git a/crates/cloudreve-sync/src/drive/commands.rs b/crates/cloudreve-sync/src/drive/commands.rs index 3edbabf..2e14bbb 100644 --- a/crates/cloudreve-sync/src/drive/commands.rs +++ b/crates/cloudreve-sync/src/drive/commands.rs @@ -782,6 +782,38 @@ impl Mount { Ok(()) } + pub async fn resolve_all_conflicts( + &self, + action: ConflictAction, + ) -> Result<(usize, usize)> { + let pending = self + .inventory + .query_pending_conflicts(Some(&self.id)) + .context("failed to query pending conflicts")?; + + let mut success = 0usize; + let mut failed = 0usize; + + for conflict in pending { + let path = conflict.local_path.clone(); + let file_id = conflict.id; + if let Err(e) = self.resolve_conflict(action, file_id, path).await { + tracing::error!( + target: "drive::commands", + id = %self.id, + path = %conflict.local_path, + error = %e, + "Failed to resolve conflict during batch operation" + ); + failed += 1; + } else { + success += 1; + } + } + + Ok((success, failed)) + } + async fn process_fs_modify_name_event(&self, events: Vec) -> Result<()> { tracing::trace!(target: "drive::commands", count=events.len(), "Processing filesystem modify name event"); for event in events { diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index db93f38..1ce2ff4 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -102,9 +102,53 @@ impl DriveManager { tracing::info!(target: "drive", count = count, "Loaded drive(s) from config"); + // Remove inventory (including lingering conflicts) for drives that are no + // longer present in the configuration. This prevents stale metadata from + // accumulating when a drive is deleted externally or the config is reset. + self.cleanup_orphaned_inventory().await; + Ok(()) } + /// Delete inventory rows for drives that exist in the database but are not + /// currently configured. + async fn cleanup_orphaned_inventory(&self) { + let configured_ids: std::collections::HashSet = { + let read_guard = self.drives.read().await; + read_guard.keys().cloned().collect() + }; + + let inventory_ids = match self.inventory.list_drive_ids() { + Ok(ids) => ids, + Err(e) => { + tracing::warn!( + target: "drive::manager", + error = %e, + "Failed to list inventory drive IDs during cleanup" + ); + return; + } + }; + + for drive_id in inventory_ids { + if !configured_ids.contains(&drive_id) { + tracing::info!( + target: "drive::manager", + drive_id = %drive_id, + "Removing orphaned inventory for drive no longer in config" + ); + if let Err(e) = self.inventory.nuke_drive(&drive_id) { + tracing::error!( + target: "drive::manager", + drive_id = %drive_id, + error = %e, + "Failed to remove orphaned inventory" + ); + } + } + } + } + /// Persist drive configurations to disk pub async fn persist(&self) -> Result<()> { let config_file = self.get_config_file(); diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index e4c3e90..f5d2799 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -41,6 +41,7 @@ use windows::Storage::Provider::StorageProviderSyncRootManager; type MountConnection = Connection; #[cfg(not(windows))] type MountConnection = Connection<()>; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DriveConfig { pub id: String, @@ -671,15 +672,17 @@ impl Mount { .context("faield to disconnect sync root")?; } self.task_queue.shutdown().await; + // Always clear inventory (including pending conflicts) before unregistering + // the sync root so metadata does not linger if unregister fails. + if let Err(e) = self.inventory.nuke_drive(&self.id) { + tracing::error!(target: "drive::mounts", id=%self.id, error=%e, "Failed to nuke drive inventory"); + } if let Some(sync_root_id) = self.config.read().await.sync_root_id.as_ref() { if let Err(e) = sync_root_id.unregister() { tracing::warn!(target: "drive::mounts", id=%self.id, error=%e, "Failed to unregister sync root"); return Err(anyhow::anyhow!("Failed to unregister sync root: {}", e)); } } - if let Err(e) = self.inventory.nuke_drive(&self.id) { - tracing::error!(target: "drive::mounts", id=%self.id, error=%e, "Failed to nuke drive"); - } Ok(()) } diff --git a/crates/cloudreve-sync/src/drive/sync.rs b/crates/cloudreve-sync/src/drive/sync.rs index b996268..87ac97a 100644 --- a/crates/cloudreve-sync/src/drive/sync.rs +++ b/crates/cloudreve-sync/src/drive/sync.rs @@ -301,7 +301,7 @@ struct SyncPlan { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HashAlgorithm { +pub(crate) enum HashAlgorithm { Md5, Sha1, Sha256, @@ -319,7 +319,7 @@ impl HashAlgorithm { } } - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Md5 => "md5", Self::Sha1 => "sha1", @@ -330,9 +330,9 @@ impl HashAlgorithm { } #[derive(Debug, Clone)] -struct FileHashFingerprint { - algorithm: HashAlgorithm, - value: String, +pub(crate) struct FileHashFingerprint { + pub(crate) algorithm: HashAlgorithm, + pub(crate) value: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -498,7 +498,7 @@ fn normalize_hash_key(key: &str) -> String { .to_ascii_lowercase() } -fn remote_file_hash_fingerprint(remote: &FileResponse) -> Option { +pub(crate) fn remote_file_hash_fingerprint(remote: &FileResponse) -> Option { const HASH_METADATA_KEYS: &[(&str, HashAlgorithm)] = &[ ("md5", HashAlgorithm::Md5), ("hashmd5", HashAlgorithm::Md5), @@ -520,6 +520,8 @@ fn remote_file_hash_fingerprint(remote: &FileResponse) -> Option Option Result { +pub(crate) async fn calculate_file_hash(path: PathBuf, algorithm: HashAlgorithm) -> Result { task::spawn_blocking(move || -> Result { let mut file = fs::File::open(&path) .with_context(|| format!("failed to open file {}", path.display()))?; @@ -1279,11 +1288,27 @@ impl Mount { } let Some(remote_fingerprint) = remote_file_hash_fingerprint(remote) else { + // Remote does not expose a hash. Fall back to size comparison so + // identical files are not falsely reported as conflicts. + let local_size = local.file_size.unwrap_or(0); + let remote_size = remote.size as u64; + if local_size == remote_size { + tracing::info!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + size = local_size, + "Remote file has no hash but sizes match; treating as same content" + ); + return Some(ExistingRemoteLocalFileDecision::SameContent); + } tracing::info!( target: "drive::sync", id = %self.id, path = %path.display(), - "Remote file has no supported hash fingerprint; treating same-name local file as conflict" + local_size = local_size, + remote_size = remote_size, + "Remote file has no hash and sizes differ; treating same-name local file as conflict" ); return Some(ExistingRemoteLocalFileDecision::Conflict); }; diff --git a/crates/cloudreve-sync/src/inventory/db/file_metadata.rs b/crates/cloudreve-sync/src/inventory/db/file_metadata.rs index 32c9804..eaf6c9e 100644 --- a/crates/cloudreve-sync/src/inventory/db/file_metadata.rs +++ b/crates/cloudreve-sync/src/inventory/db/file_metadata.rs @@ -100,6 +100,16 @@ impl InventoryDb { row.map(FileMetadata::try_from).transpose() } + /// List all distinct drive IDs present in the inventory. + pub fn list_drive_ids(&self) -> Result> { + let mut conn = self.connection()?; + file_metadata_dsl::file_metadata + .select(file_metadata_dsl::drive_id) + .distinct() + .load::(&mut conn) + .context("Failed to list inventory drive IDs") + } + /// Query files that are waiting for manual conflict resolution. /// /// `drive_id` is optional because the popup can show either a single drive diff --git a/crates/cloudreve-sync/src/tasks/upload.rs b/crates/cloudreve-sync/src/tasks/upload.rs index 214012a..8119df7 100644 --- a/crates/cloudreve-sync/src/tasks/upload.rs +++ b/crates/cloudreve-sync/src/tasks/upload.rs @@ -2,7 +2,11 @@ use std::{path::PathBuf, str::FromStr, sync::Arc, time::SystemTime}; use crate::utils::toast::send_conflict_toast; use crate::{ - drive::{placeholder::CrPlaceholder, utils::local_path_to_cr_uri}, + drive::{ + placeholder::CrPlaceholder, + sync::{calculate_file_hash, remote_file_hash_fingerprint}, + utils::local_path_to_cr_uri, + }, inventory::{ConflictState, FileMetadata, InventoryDb}, tasks::queue::QueuedTask, uploader::{ProgressCallback, ProgressUpdate, UploadParams, Uploader, UploaderConfig}, @@ -163,6 +167,81 @@ impl<'a> UploadTask<'a> { self.handle_error(upload_res).await } + /// Check whether the local file really conflicts with the remote version. + /// Returns `Ok(Some(remote))` if the contents match (so no conflict should be + /// recorded) along with the remote metadata, `Ok(None)` if the contents differ, + /// or `Err` if the check itself failed. + async fn check_actual_conflict(&self) -> Result> { + let uri = local_path_to_cr_uri( + self.task.payload.local_path.clone(), + self.sync_path.clone(), + self.remote_base.clone(), + ) + .context("failed to convert local path to cloudreve uri")? + .to_string(); + + let remote = self + .cr_client + .get_file_info(&cloudreve_api::models::explorer::GetFileInfoService { + uri: Some(uri), + id: None, + extended: None, + folder_summary: None, + }) + .await + .context("failed to get remote file info for conflict check")?; + + let local_size = self + .local_file + .as_ref() + .and_then(|f| f.local_file_info.file_size) + .unwrap_or(0); + let remote_size = remote.size as u64; + + if let Some(fingerprint) = remote_file_hash_fingerprint(&remote) { + match calculate_file_hash(self.task.payload.local_path.clone(), fingerprint.algorithm) + .await + { + Ok(local_hash) => { + let matches = local_hash.eq_ignore_ascii_case(&fingerprint.value); + info!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + algorithm = %fingerprint.algorithm.as_str(), + matches = matches, + "Compared local and remote hashes after upload conflict error" + ); + return if matches { Ok(Some(remote)) } else { Ok(None) }; + } + Err(err) => { + warn!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + error = %err, + "Failed to calculate local hash; falling back to size comparison" + ); + } + } + } + + // No usable remote hash; fall back to size comparison. + info!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + local_size = local_size, + remote_size = remote_size, + "Remote file has no hash; compared sizes after upload conflict error" + ); + if local_size == remote_size { + Ok(Some(remote)) + } else { + Ok(None) + } + } + async fn handle_error(&mut self, r: Result<()>) -> Result<()> { match r { Ok(()) => Ok(()), @@ -183,12 +262,45 @@ impl<'a> UploadTask<'a> { }); if is_conflict_error { - warn!( - target: "tasks::upload", - task_id = %self.task.task_id, - local_path = %self.task.payload.local_path_display(), - "Conflict detected, server has newer version or object exists" - ); + match self.check_actual_conflict().await { + Ok(Some(remote)) => { + info!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + "Upload conflict error but contents match; syncing remote metadata instead" + ); + // Update local placeholder/inventory from remote to reflect + // the existing file and avoid leaving a stale conflict state. + if let Err(sync_err) = self.sync_remote_metadata_after_match(&remote).await { + warn!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + error = ?sync_err, + "Failed to sync remote metadata after content match" + ); + } + return Ok(()); + } + Ok(None) => { + warn!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + "Conflict detected, server has newer version or object exists" + ); + } + Err(check_err) => { + warn!( + target: "tasks::upload", + task_id = %self.task.task_id, + local_path = %self.task.payload.local_path_display(), + error = %check_err, + "Failed to verify actual conflict; treating as conflict" + ); + } + } // Mark the file as conflicted in the inventory let path_str = self.task.payload.local_path.to_str().unwrap_or_default(); @@ -230,6 +342,31 @@ impl<'a> UploadTask<'a> { } } + /// Pull remote metadata into the local placeholder/inventory after we have + /// determined that the local file matches the existing remote file. + async fn sync_remote_metadata_after_match(&mut self, remote: &FileResponse) -> Result<()> { + self.local_file = Some( + self.local_file + .take() + .unwrap() + .with_remote_file(remote), + ); + + self.local_file + .as_mut() + .unwrap() + .commit(self.inventory.clone()) + .context("failed to commit remote metadata after content match")?; + + self.local_file + .as_mut() + .unwrap() + .update_sync_error_state(false) + .context("failed to clear sync error state after content match")?; + + Ok(()) + } + async fn clear_file_content(&mut self) -> Result<()> { info!( target: "tasks::upload", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 995948f..8586fd6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -303,6 +303,61 @@ pub async fn resolve_conflict( .map_err(|e| e.to_string()) } +/// Resolve all pending conflicts for a drive (or all drives if drive_id is None). +#[tauri::command] +pub async fn resolve_all_conflicts( + state: State<'_, AppStateHandle>, + drive_id: Option, + action: String, +) -> CommandResult<(usize, usize)> { + let app_state = state + .get() + .ok_or_else(|| "App not yet initialized".to_string())?; + let action = ConflictAction::from_str(&action) + .ok_or_else(|| format!("Invalid conflict action: {action}"))?; + + let mut total_success = 0usize; + let mut total_failed = 0usize; + + if let Some(id) = drive_id { + let drive = app_state + .drive_manager + .get_drive(&id) + .await + .ok_or_else(|| format!("Drive not found: {id}"))?; + let (s, f) = drive + .resolve_all_conflicts(action) + .await + .map_err(|e| e.to_string())?; + total_success += s; + total_failed += f; + } else { + let drives = app_state.drive_manager.list_drives().await; + for config in drives { + if let Some(drive) = app_state.drive_manager.get_drive(&config.id).await { + match drive.resolve_all_conflicts(action).await { + Ok((s, f)) => { + total_success += s; + total_failed += f; + } + Err(e) => { + tracing::error!( + target: "commands", + drive_id = %config.id, + error = %e, + "Failed to resolve all conflicts for drive" + ); + // We intentionally continue with other drives rather + // than failing the whole batch. + } + } + } + } + } + + Ok((total_success, total_failed)) +} + /// Get all drives with their status information for the settings UI #[tauri::command] pub async fn get_drives_info(state: State<'_, AppStateHandle>) -> CommandResult> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a032e2e..a53905a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -330,6 +330,7 @@ pub fn run() { commands::get_sync_status, commands::get_status_summary, commands::resolve_conflict, + commands::resolve_all_conflicts, commands::get_drives_info, commands::get_file_icon, commands::show_file_in_explorer, diff --git a/ui/public/locales/zh-CN/common.json b/ui/public/locales/zh-CN/common.json index 62aa6f6..9f06eac 100644 --- a/ui/public/locales/zh-CN/common.json +++ b/ui/public/locales/zh-CN/common.json @@ -55,7 +55,9 @@ "resolving": "正在处理...", "keepRemote": "保留远程版本", "overwriteRemote": "覆盖远程版本", - "saveAsNew": "另存为新文件" + "saveAsNew": "另存为新文件", + "keepAllRemote": "全部保留远程", + "overwriteAllRemote": "全部覆盖远程" }, "settings": { "title": "设置", diff --git a/ui/src/pages/popup/index.tsx b/ui/src/pages/popup/index.tsx index a339464..a92679a 100644 --- a/ui/src/pages/popup/index.tsx +++ b/ui/src/pages/popup/index.tsx @@ -1,5 +1,7 @@ import { Box, + Button, + ButtonGroup, IconButton, List, Typography, @@ -12,7 +14,6 @@ import { } from "@mui/icons-material"; import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { platform } from "@tauri-apps/plugin-os"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useTranslation } from "react-i18next"; import Settings from "../../common/icons/Settings"; @@ -126,17 +127,24 @@ export default function Popup() { } }; + const handleResolveAll = async (action: "keep_remote" | "overwrite_remote") => { + try { + await invoke("resolve_all_conflicts", { + driveId: selectedDrive, + action, + }); + fetchSummary(); + } catch (error) { + console.error("Failed to resolve all conflicts:", error); + } + }; + const hasActiveTasks = summary?.active_tasks && summary.active_tasks.length > 0; const hasFinishedTasks = summary?.finished_tasks && summary.finished_tasks.length > 0; - // Windows already has shell/toast conflict actions; the popup entry is only - // for platforms where those native Windows affordances do not exist. - const canResolveConflictsInPopup = platform() !== "windows"; const hasPendingConflicts = - canResolveConflictsInPopup && - summary?.pending_conflicts && - summary.pending_conflicts.length > 0; + summary?.pending_conflicts && summary.pending_conflicts.length > 0; return ( - - {t("popup.conflicts", "Conflicts")} - + + {t("popup.conflicts", "Conflicts")} + + + + + + {summary?.pending_conflicts.map((conflict) => ( Date: Thu, 18 Jun 2026 23:07:17 +0800 Subject: [PATCH 19/25] Fix token refresh racing --- crates/cloudreve-api/src/client.rs | 37 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/cloudreve-api/src/client.rs b/crates/cloudreve-api/src/client.rs index 10b18a0..10a1786 100644 --- a/crates/cloudreve-api/src/client.rs +++ b/crates/cloudreve-api/src/client.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; const API_PREFIX: &str = "/api/v4"; pub const CR_HEADER_PREFIX: &str = "X-Cr-"; @@ -143,6 +143,7 @@ pub struct Client { pub(crate) config: ClientConfig, pub(crate) http_client: HttpClient, pub(crate) tokens: Arc>, + refresh_lock: Arc>, pub(crate) purchase_ticket: Arc>>, on_credential_refreshed: Option, on_credential_invalid: Option, @@ -152,8 +153,7 @@ impl Client { /// Create a new API client pub fn new(config: ClientConfig) -> Self { let mut builder = HttpClient::builder() - .connect_timeout(std::time::Duration::from_secs(config.timeout_seconds)) - .timeout(std::time::Duration::from_secs(config.timeout_seconds)); + .connect_timeout(std::time::Duration::from_secs(config.timeout_seconds)); if let Some(ref user_agent) = config.user_agent { builder = builder.user_agent(user_agent); @@ -165,6 +165,7 @@ impl Client { config, http_client, tokens: Arc::new(RwLock::new(TokenStore::new())), + refresh_lock: Arc::new(Mutex::new(())), purchase_ticket: Arc::new(RwLock::new(None)), on_credential_refreshed: None, on_credential_invalid: None, @@ -352,8 +353,25 @@ impl Client { /// `/session/oauth/token` only supports `authorization_code` grant and is /// not used here. async fn refresh_access_token(&self) -> ApiResult { + let _guard = self.refresh_lock.lock().await; + let refresh_token = { let store = self.tokens.read().await; + + if !store.has_tokens() { + self.notify_credential_invalid().await; + return Err(ApiError::NoTokensAvailable); + } + + if store.is_refresh_token_expired() { + self.notify_credential_invalid().await; + return Err(ApiError::RefreshTokenExpired); + } + + if !store.is_access_token_expired() { + return Ok(store.access_token.clone().unwrap()); + } + store .refresh_token .clone() @@ -364,7 +382,13 @@ impl Client { let url = self.build_url("/session/token/refresh"); let request = RefreshTokenRequest { refresh_token }; - let response = self.http_client.post(&url).json(&request).send().await?; + let response = self + .http_client + .post(&url) + .timeout(std::time::Duration::from_secs(self.config.timeout_seconds)) + .json(&request) + .send() + .await?; let api_response: ApiResponse = response.json().await?; @@ -410,7 +434,10 @@ impl Client { R: DeserializeOwned + Default, { let url = self.build_url(path); - let mut request = self.http_client.request(method, &url); + let mut request = self + .http_client + .request(method, &url) + .timeout(std::time::Duration::from_secs(self.config.timeout_seconds)); // Add authentication header if needed if !options.no_credential { From b829e4abbc64714defd9debeef5975d524df3126 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 19 Jun 2026 01:17:15 +0800 Subject: [PATCH 20/25] Fix token expiring --- crates/cloudreve-api/src/client.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/cloudreve-api/src/client.rs b/crates/cloudreve-api/src/client.rs index 10a1786..b0d1a79 100644 --- a/crates/cloudreve-api/src/client.rs +++ b/crates/cloudreve-api/src/client.rs @@ -344,7 +344,7 @@ impl Client { // Access token expired, need to refresh drop(store); // Release read lock before calling refresh - self.refresh_access_token().await + self.refresh_access_token(false).await } /// Refresh the access token using the refresh token. @@ -352,7 +352,7 @@ impl Client { /// including tokens originally obtained via OAuth. The OAuth token endpoint /// `/session/oauth/token` only supports `authorization_code` grant and is /// not used here. - async fn refresh_access_token(&self) -> ApiResult { + async fn refresh_access_token(&self, force: bool) -> ApiResult { let _guard = self.refresh_lock.lock().await; let refresh_token = { @@ -368,7 +368,7 @@ impl Client { return Err(ApiError::RefreshTokenExpired); } - if !store.is_access_token_expired() { + if !force && !store.is_access_token_expired() { return Ok(store.access_token.clone().unwrap()); } @@ -496,12 +496,6 @@ impl Client { // Check response code if api_response.code != ErrorCode::Success as i32 { - // Check if this is a credential error and invoke callback - if let Some(error_code) = ErrorCode::from_code(api_response.code) { - if error_code.is_credential_error() { - self.notify_credential_invalid().await; - } - } return Err(ApiError::from_response(api_response)); } @@ -526,9 +520,10 @@ impl Client { .await { Ok(result) => Ok(result), - Err(ApiError::AccessTokenExpired) => { - // Token expired, refresh and retry - self.refresh_access_token().await?; + Err(e) if e.is_token_expired() || e.requires_login() => { + // Server-side token state can be ahead of the local expiry timestamp. + // Force a refresh once before treating the credentials as invalid. + self.refresh_access_token(true).await?; self.send_internal(path, method, body, options).await } Err(e) => Err(e), From cc69491b5062b6435db005e68613553b33595006 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 29 Jun 2026 21:05:18 +0800 Subject: [PATCH 21/25] Hide macOS Dock icon. --- src-tauri/src/commands.rs | 80 ++++++++++++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 22 +++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8586fd6..35ef0a0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -531,6 +531,8 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); return; } @@ -562,6 +564,7 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { Ok(window) => { // Set up close request handler for fast popup launch let window_clone = window.clone(); + let app_handle = app.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Check if fast popup launch is enabled @@ -571,11 +574,20 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { let _ = window_clone.hide(); } } + #[cfg(target_os = "macos")] + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) { + crate::update_dock_visibility(&app_handle); + } }); move_window_safely(&window, position, "main_popup"); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); } Err(e) => { tracing::error!(target: "main_popup", error = %e, "Failed to create main window"); @@ -643,6 +655,8 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); return; } @@ -685,11 +699,28 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { let _ = window.set_transparent(true); let _ = window.set_effects(effects); } + + #[cfg(target_os = "macos")] + { + let app_handle = app.clone(); + window.on_window_event(move |event| { + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } + | tauri::WindowEvent::Destroyed + ) { + crate::update_dock_visibility(&app_handle); + } + }); + } + move_window_safely(&window, Position::Center, "add-drive"); #[cfg(windows)] let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); } Err(e) => { tracing::error!(target: "main", error = %e, "Failed to create window: {}", title); @@ -711,6 +742,8 @@ pub fn show_settings_window_impl(app: &AppHandle) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); return; } @@ -749,11 +782,28 @@ pub fn show_settings_window_impl(app: &AppHandle) { let _ = window.set_transparent(true); let _ = window.set_effects(effects); } + + #[cfg(target_os = "macos")] + { + let app_handle = app.clone(); + window.on_window_event(move |event| { + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } + | tauri::WindowEvent::Destroyed + ) { + crate::update_dock_visibility(&app_handle); + } + }); + } + move_window_safely(&window, Position::Center, "settings"); #[cfg(windows)] let _ = window.create_overlay_titlebar(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); } Err(e) => { tracing::error!(target: "main", error = %e, "Failed to create settings window"); @@ -900,6 +950,8 @@ fn macos_launch_agent_entry() -> CommandResult { RunAtLoad + LimitLoadToSessionType + Aqua "#, @@ -910,11 +962,16 @@ fn macos_launch_agent_entry() -> CommandResult { #[cfg(target_os = "macos")] fn macos_get_auto_start_enabled() -> CommandResult { let path = macos_launch_agent_path()?; - match std::fs::read_to_string(path) { - Ok(content) => Ok(content.contains(MACOS_LAUNCH_AGENT_LABEL)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(format!("Failed to read LaunchAgent: {}", err)), - } + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("Failed to read LaunchAgent: {}", err)), + }; + + // Verify the plist is ours and configured to start at login. + let has_label = content.contains(&format!("{}", MACOS_LAUNCH_AGENT_LABEL)); + let run_at_load = content.contains("RunAtLoad") && content.contains(""); + Ok(has_label && run_at_load) } #[cfg(target_os = "macos")] @@ -929,8 +986,21 @@ fn macos_set_auto_start(enabled: bool) -> CommandResult { .map_err(|e| format!("Failed to create LaunchAgents directory: {}", e))?; std::fs::write(&path, macos_launch_agent_entry()?) .map_err(|e| format!("Failed to write LaunchAgent: {}", e))?; + + // Load the agent so it applies to the current session and is enabled + // for future logins. Ignore errors: launchctl may fail if the agent is + // already loaded, which still leaves the plist in place for next boot. + let path_str = path.display().to_string(); + let _ = std::process::Command::new("/bin/launchctl") + .args(["load", "-w", &path_str]) + .output(); + Ok(true) } else { + // Only remove the plist. We intentionally do not `launchctl unload` + // because if the current process was launched by this agent, unload + // would terminate the running app. The agent will not be started on + // the next login since the plist is gone. match std::fs::remove_file(&path) { Ok(_) => Ok(false), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a53905a..7b4b679 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,24 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { /// Marker struct for Tauri state that provides access to APP_STATE pub struct AppStateHandle; +/// Update macOS Dock visibility based on whether any webview window is visible. +#[cfg(target_os = "macos")] +pub fn update_dock_visibility(app: &AppHandle) { + let has_visible = app + .webview_windows() + .values() + .any(|w| w.is_visible().unwrap_or(false)); + + if let Err(err) = app.set_dock_visibility(has_visible) { + tracing::warn!( + target: "main", + error = %err, + visible = has_visible, + "Failed to update Dock visibility" + ); + } +} + impl AppStateHandle { pub fn get(&self) -> Option<&'static AppState> { APP_STATE.get() @@ -318,6 +336,10 @@ pub fn run() { let _ = window.destroy(); } + // Keep the Dock icon hidden on macOS while no window is visible. + #[cfg(target_os = "macos")] + update_dock_visibility(app.handle()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ From 6ce35ef09db5b534c0c77f4f19665fd461fb50ee Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 30 Jun 2026 06:36:29 +0800 Subject: [PATCH 22/25] Add close button --- ui/src/pages/settings/index.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index bc38f5f..3350211 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -1,12 +1,15 @@ import { Box, + IconButton, List, ListItemButton, ListItemIcon, ListItemText, } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import CloudreveLogo from "../../common/CloudreveLogo"; import DrivesSection from "./DrivesSection"; import GeneralSection from "./GeneralSection"; @@ -115,8 +118,24 @@ export default function Settings() { sx={{ height: 32, flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + px: 0.5, }} - /> + > + getCurrentWindow().close()} + sx={{ + // Keep the button itself draggable-region-free so clicks work. + WebkitAppRegion: "no-drag", + appRegion: "no-drag", + }} + > + + + {/* Content */} {renderContent()} From e0204766a553566beb1e747674826fd70de89f25 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 30 Jun 2026 21:20:24 +0800 Subject: [PATCH 23/25] Fix macOS Dock icon still shows after close window --- src-tauri/src/commands.rs | 36 ------------------------------------ src-tauri/src/lib.rs | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 35ef0a0..65ed2a8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -564,7 +564,6 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { Ok(window) => { // Set up close request handler for fast popup launch let window_clone = window.clone(); - let app_handle = app.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Check if fast popup launch is enabled @@ -574,13 +573,6 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { let _ = window_clone.hide(); } } - #[cfg(target_os = "macos")] - if matches!( - event, - tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed - ) { - crate::update_dock_visibility(&app_handle); - } }); move_window_safely(&window, position, "main_popup"); @@ -700,20 +692,6 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { let _ = window.set_effects(effects); } - #[cfg(target_os = "macos")] - { - let app_handle = app.clone(); - window.on_window_event(move |event| { - if matches!( - event, - tauri::WindowEvent::CloseRequested { .. } - | tauri::WindowEvent::Destroyed - ) { - crate::update_dock_visibility(&app_handle); - } - }); - } - move_window_safely(&window, Position::Center, "add-drive"); #[cfg(windows)] let _ = window.create_overlay_titlebar(); @@ -783,20 +761,6 @@ pub fn show_settings_window_impl(app: &AppHandle) { let _ = window.set_effects(effects); } - #[cfg(target_os = "macos")] - { - let app_handle = app.clone(); - window.on_window_event(move |event| { - if matches!( - event, - tauri::WindowEvent::CloseRequested { .. } - | tauri::WindowEvent::Destroyed - ) { - crate::update_dock_visibility(&app_handle); - } - }); - } - move_window_safely(&window, Position::Center, "settings"); #[cfg(windows)] let _ = window.create_overlay_titlebar(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b4b679..93bb9dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -373,7 +373,7 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|_app_handle, event| { + .run(|app_handle, event| { match event { RunEvent::ExitRequested { api,code,.. } => { if code.is_none() { @@ -387,6 +387,21 @@ pub fn run() { // Perform shutdown when the app is actually exiting tauri::async_runtime::block_on(shutdown()); } + #[cfg(target_os = "macos")] + RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. } + | tauri::WindowEvent::Destroyed, + .. + } => { + // Re-evaluate Dock visibility shortly after a window is + // closed or destroyed so webview_windows() reflects the + // change and the Dock icon hides when no window remains. + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + update_dock_visibility(&app_handle); + }); + } _ => {} } }); From 4a4ed902ed541f03cfaa3d1ab2dc0de44d1ef8e9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 7 Jul 2026 17:53:07 +0800 Subject: [PATCH 24/25] Fix macOS Dock icon still shows after close window; Fix add drive page can't close; fix add --- crates/cloudreve-api/src/api/explorer.rs | 4 +- crates/cloudreve-api/src/client.rs | 2 + crates/cloudreve-api/src/lib.rs | 1 + crates/cloudreve-sync/src/drive/ignore.rs | 61 ++++-- .../cloudreve-sync/src/drive/manager/mod.rs | 3 +- crates/cloudreve-sync/src/drive/mounts.rs | 103 +++++++++ crates/cloudreve-sync/src/lib.rs | 2 +- src-tauri/Cargo.toml | 3 + src-tauri/src/commands.rs | 58 ++++- src-tauri/src/lib.rs | 206 ++++++++++++++++-- 10 files changed, 395 insertions(+), 48 deletions(-) diff --git a/crates/cloudreve-api/src/api/explorer.rs b/crates/cloudreve-api/src/api/explorer.rs index ca6ca8d..7bbe291 100644 --- a/crates/cloudreve-api/src/api/explorer.rs +++ b/crates/cloudreve-api/src/api/explorer.rs @@ -803,7 +803,9 @@ pub trait FileEventsApi { /// while let Some(event) = subscription.next_event().await? { /// match event { /// cloudreve_api::models::explorer::FileEvent::Event(data) => { - /// println!("File event: {:?} on {}", data.event_type, data.from); + /// for item in data { + /// println!("File event: {:?} on {}", item.event_type, item.from); + /// } /// } /// _ => {} /// } diff --git a/crates/cloudreve-api/src/client.rs b/crates/cloudreve-api/src/client.rs index b0d1a79..0c9a5b5 100644 --- a/crates/cloudreve-api/src/client.rs +++ b/crates/cloudreve-api/src/client.rs @@ -182,7 +182,9 @@ impl Client { /// use std::sync::Arc; /// use std::pin::Pin; /// use std::future::Future; + /// use cloudreve_api::{Client, ClientConfig}; /// + /// let mut client = Client::new(ClientConfig::new("https://example.com".to_string())); /// client.set_on_credential_refreshed(Arc::new(|token| { /// Box::pin(async move { /// // Save token to storage diff --git a/crates/cloudreve-api/src/lib.rs b/crates/cloudreve-api/src/lib.rs index a7d24f6..8241735 100644 --- a/crates/cloudreve-api/src/lib.rs +++ b/crates/cloudreve-api/src/lib.rs @@ -13,6 +13,7 @@ //! //! ```no_run //! use cloudreve_api::{Client, ClientConfig}; +//! use cloudreve_api::api::user::UserApi; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { diff --git a/crates/cloudreve-sync/src/drive/ignore.rs b/crates/cloudreve-sync/src/drive/ignore.rs index 741a40d..476e584 100644 --- a/crates/cloudreve-sync/src/drive/ignore.rs +++ b/crates/cloudreve-sync/src/drive/ignore.rs @@ -181,51 +181,71 @@ impl IgnoreMatcher { mod tests { use super::*; + fn test_sync_root() -> PathBuf { + if cfg!(windows) { + PathBuf::from(r"C:\Users\test\sync") + } else { + PathBuf::from("/Users/test/sync") + } + } + + fn test_path(rel: &str) -> PathBuf { + test_sync_root().join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) + } + + fn outside_sync_root_path() -> PathBuf { + if cfg!(windows) { + PathBuf::from(r"C:\Other\path\debug.log") + } else { + PathBuf::from("/Other/path/debug.log") + } + } + #[test] fn test_simple_pattern() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["*.log".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); - assert!(matcher.is_match("C:\\Users\\test\\sync\\debug.log")); - assert!(matcher.is_match("C:\\Users\\test\\sync\\subdir\\error.log")); - assert!(!matcher.is_match("C:\\Users\\test\\sync\\readme.txt")); + assert!(matcher.is_match(test_path("debug.log"))); + assert!(matcher.is_match(test_path("subdir/error.log"))); + assert!(!matcher.is_match(test_path("readme.txt"))); } #[test] fn test_anchored_pattern() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["/build".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); - assert!(matcher.is_match("C:\\Users\\test\\sync\\build")); - assert!(!matcher.is_match("C:\\Users\\test\\sync\\src\\build")); + assert!(matcher.is_match(test_path("build"))); + assert!(!matcher.is_match(test_path("src/build"))); } #[test] fn test_directory_pattern() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["node_modules".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); - assert!(matcher.is_match("C:\\Users\\test\\sync\\node_modules")); - assert!(matcher.is_match("C:\\Users\\test\\sync\\project\\node_modules")); + assert!(matcher.is_match(test_path("node_modules"))); + assert!(matcher.is_match(test_path("project/node_modules"))); } #[test] fn test_path_pattern() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["docs/*.md".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); - assert!(matcher.is_match("C:\\Users\\test\\sync\\docs\\readme.md")); - assert!(matcher.is_match("C:\\Users\\test\\sync\\project\\docs\\api.md")); - assert!(!matcher.is_match("C:\\Users\\test\\sync\\readme.md")); + assert!(matcher.is_match(test_path("docs/readme.md"))); + assert!(matcher.is_match(test_path("project/docs/api.md"))); + assert!(!matcher.is_match(test_path("readme.md"))); } #[test] fn test_comment_and_empty_lines() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec![ "# This is a comment".to_string(), "".to_string(), @@ -234,23 +254,24 @@ mod tests { ]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); - assert_eq!(matcher.len(), 1); // Only *.tmp should be added - assert!(matcher.is_match("C:\\Users\\test\\sync\\file.tmp")); + assert_eq!(matcher.len(), 4); // 1 user pattern + 3 default office temp patterns + assert!(matcher.is_match(test_path("file.tmp"))); + assert!(!matcher.is_match(test_path("file.txt"))); } #[test] fn test_path_outside_sync_root() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["*.log".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root.clone()).unwrap(); // Path outside sync root should never match - assert!(!matcher.is_match("C:\\Other\\path\\debug.log")); + assert!(!matcher.is_match(outside_sync_root_path())); } #[test] fn test_relative_path_matching() { - let sync_root = PathBuf::from("C:\\Users\\test\\sync"); + let sync_root = test_sync_root(); let patterns = vec!["*.log".to_string(), "/build".to_string()]; let matcher = IgnoreMatcher::new(&patterns, sync_root).unwrap(); diff --git a/crates/cloudreve-sync/src/drive/manager/mod.rs b/crates/cloudreve-sync/src/drive/manager/mod.rs index 1ce2ff4..62f48ec 100644 --- a/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -233,7 +233,7 @@ impl DriveManager { // Spawn initial sync in the background so add_drive returns immediately let mount_for_sync = mount_arc.clone(); - tokio::spawn(async move { + let initial_sync_handle = tokio::spawn(async move { let sync_path = mount_for_sync.config.read().await.sync_path.clone(); tracing::info!(target: "drive", id = %mount_for_sync.id, path = %sync_path.display(), "Starting background initial sync"); if let Err(e) = mount_for_sync @@ -245,6 +245,7 @@ impl DriveManager { tracing::info!(target: "drive", id = %mount_for_sync.id, "Background initial sync completed"); } }); + mount_arc.set_initial_sync_handle(initial_sync_handle).await; let mut write_guard = self.drives.write().await; let id = mount_arc.id.clone(); diff --git a/crates/cloudreve-sync/src/drive/mounts.rs b/crates/cloudreve-sync/src/drive/mounts.rs index f5d2799..33049a8 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -146,6 +146,7 @@ pub struct Mount { processor_handle: Arc>>>, props_refresh_handle: Arc>>>, remote_event_handle: Arc>>>, + initial_sync_handle: Arc>>>, manager_command_tx: mpsc::UnboundedSender, fs_watcher: Mutex>, pub(crate) sync_lock: Mutex<()>, @@ -258,6 +259,7 @@ impl Mount { processor_handle: Arc::new(tokio::sync::Mutex::new(None)), props_refresh_handle: Arc::new(tokio::sync::Mutex::new(None)), remote_event_handle: Arc::new(tokio::sync::Mutex::new(None)), + initial_sync_handle: Arc::new(tokio::sync::Mutex::new(None)), cr_client: cr_client_arc, inventory, task_queue, @@ -370,6 +372,12 @@ impl Mount { .set_event_push_subscribed(subscribed); } + /// Store the handle for the background initial sync task so it can be + /// cancelled and awaited during shutdown. + pub async fn set_initial_sync_handle(&self, handle: JoinHandle<()>) { + *self.initial_sync_handle.lock().await = Some(handle); + } + pub fn task_queue(&self) -> Arc { self.task_queue.clone() } @@ -694,6 +702,7 @@ impl Mount { if let Some(handle) = self.remote_event_handle.lock().await.take() { tracing::debug!(target: "drive::mounts", id=%self.id, "Stopping remote event listener"); handle.abort(); + let _ = handle.await; } if let Some(fs_watcher) = self.fs_watcher.lock().await.take() { @@ -708,12 +717,32 @@ impl Mount { if let Some(handle) = self.processor_handle.lock().await.take() { tracing::debug!(target: "drive::mounts", id=%self.id, "Waiting for command processor to finish"); handle.abort(); + let _ = handle.await; } // Stop the props refresh task if let Some(handle) = self.props_refresh_handle.lock().await.take() { tracing::debug!(target: "drive::mounts", id=%self.id, "Stopping props refresh task"); handle.abort(); + let _ = handle.await; + } + + // Stop any in-progress initial sync so we don't leave partial file + // writes or dangling API sessions when the app quits. + if let Some(handle) = self.initial_sync_handle.lock().await.take() { + tracing::debug!(target: "drive::mounts", id=%self.id, "Stopping initial sync task"); + handle.abort(); + match handle.await { + Ok(()) => { + tracing::debug!(target: "drive::mounts", id=%self.id, "Initial sync task finished"); + } + Err(e) if e.is_cancelled() => { + tracing::debug!(target: "drive::mounts", id=%self.id, "Initial sync task cancelled"); + } + Err(e) => { + tracing::warn!(target: "drive::mounts", id=%self.id, error=?e, "Initial sync task panicked"); + } + } } // self.queue.shutdown().await; } @@ -850,3 +879,77 @@ fn resolve_task_queue_config(config: &DriveConfig) -> TaskQueueConfig { max_concurrent: concurrency, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tempfile::TempDir; + + /// Create a minimal Mount in a temporary directory for testing. + async fn create_test_mount(temp_dir: &TempDir) -> Arc { + let db_path = temp_dir.path().join("inventory.db"); + let inventory = Arc::new(InventoryDb::with_path(db_path).unwrap()); + let sync_path = temp_dir.path().join("sync"); + std::fs::create_dir(&sync_path).unwrap(); + + let config = DriveConfig { + id: "test-drive".to_string(), + name: "Test Drive".to_string(), + instance_url: "https://example.com".to_string(), + remote_path: "my:///".to_string(), + credentials: Credentials { + access_token: Some("token".to_string()), + refresh_token: "refresh".to_string(), + refresh_expires: "never".to_string(), + access_expires: Some("never".to_string()), + }, + sync_path, + icon_path: None, + raw_icon_path: None, + enabled: true, + user_id: "user".to_string(), + sync_root_id: None, + ignore_patterns: vec![], + extra: HashMap::new(), + }; + + let (manager_tx, _manager_rx) = mpsc::unbounded_channel(); + Arc::new(Mount::new(config, inventory, manager_tx).await) + } + + #[tokio::test] + async fn shutdown_cancels_initial_sync_handle() { + let temp_dir = TempDir::new().unwrap(); + let mount = create_test_mount(&temp_dir).await; + + // Spawn a task that would run forever and set it as the initial sync handle. + let handle = tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); + mount.set_initial_sync_handle(handle).await; + assert!(mount.initial_sync_handle.lock().await.is_some()); + + // Shutdown should cancel and await the initial sync task. + mount.shutdown().await; + + // The handle should have been removed. + assert!(mount.initial_sync_handle.lock().await.is_none()); + } + + #[tokio::test] + async fn shutdown_awaits_existing_handles() { + let temp_dir = TempDir::new().unwrap(); + let mount = create_test_mount(&temp_dir).await; + + mount.spawn_command_processor(mount.clone()).await; + assert!(mount.processor_handle.lock().await.is_some()); + + mount.shutdown().await; + + // Command processor handle should be cleared. + assert!(mount.processor_handle.lock().await.is_none()); + } +} diff --git a/crates/cloudreve-sync/src/lib.rs b/crates/cloudreve-sync/src/lib.rs index 62d91b3..9870858 100644 --- a/crates/cloudreve-sync/src/lib.rs +++ b/crates/cloudreve-sync/src/lib.rs @@ -30,7 +30,7 @@ pub const USER_AGENT: &str = concat!("cloudreve-desktop/", env!("CARGO_PKG_VERSI #[macro_use] extern crate rust_i18n; -i18n!("../../locales"); +i18n!("../../locales", fallback = "en-US"); /// Initialize the application root path (Windows Package detection) pub fn init_app_root() { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d81eab..dab01a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,3 +55,6 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-positioner = { version = "2.3.2", features = ["tray-icon"] } + +[dev-dependencies] +serial_test = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 65ed2a8..3a247cb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -394,7 +394,7 @@ fn file_icon_to_response(icon: file_icon_provider::Icon) -> FileIconResponse { /// Returns base64 encoded RGBA pixel data with dimensions #[tauri::command] pub async fn get_file_icon( - app: AppHandle, + #[allow(unused_variables)] app: AppHandle, path: String, size: Option, ) -> CommandResult { @@ -523,6 +523,21 @@ fn apply_default_window_icon<'a>( } } +/// Attach a handler that updates macOS Dock visibility when the window is +/// closed or destroyed, so the Dock icon hides when no window remains visible. +#[cfg(target_os = "macos")] +fn update_dock_on_window_close(window: &WebviewWindow) { + let window_clone = window.clone(); + window.on_window_event(move |event| { + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) { + crate::update_dock_visibility(&window_clone.app_handle()); + } + }); +} + /// Internal function to show or create the main window at a specific position fn show_main_window_at_position(app: &AppHandle, position: Position) { // Check if window already exists @@ -562,13 +577,18 @@ fn show_main_window_at_position(app: &AppHandle, position: Position) { match builder.build() { Ok(window) => { + #[cfg(target_os = "macos")] + update_dock_on_window_close(&window); + // Set up close request handler for fast popup launch let window_clone = window.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Check if fast popup launch is enabled if ConfigManager::get().fast_popup_launch() { - // Prevent default close behavior and hide window instead + // Prevent default close behavior and hide window instead. + // Dock visibility is re-evaluated by the global WindowEvent + // handler after a short delay. api.prevent_close(); let _ = window_clone.hide(); } @@ -686,6 +706,22 @@ fn show_drive_window_internal(app: &AppHandle, title: &str, url_path: &str) { match builder.build() { Ok(window) => { + #[cfg(target_os = "macos")] + { + update_dock_on_window_close(&window); + + // Dismiss the add-drive/reauthorize window when clicking outside + // so it doesn't stay open on macOS. + let window_for_events = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + // Hide when clicking outside. Dock visibility is + // re-evaluated by the global WindowEvent handler. + let _ = window_for_events.hide(); + } + }); + } + #[cfg(windows)] { let _ = window.set_transparent(true); @@ -725,6 +761,15 @@ pub fn show_settings_window_impl(app: &AppHandle) { return; } + // Create new window with mica effect on Windows only. + #[cfg(windows)] + let effects = WindowEffectsConfig { + effects: vec![WindowEffect::Mica, WindowEffect::Acrylic], + state: None, + radius: None, + color: None, + }; + let builder = WebviewWindowBuilder::new( app, "settings", @@ -755,6 +800,9 @@ pub fn show_settings_window_impl(app: &AppHandle) { match builder.build() { Ok(window) => { + #[cfg(target_os = "macos")] + update_dock_on_window_close(&window); + #[cfg(windows)] { let _ = window.set_transparent(true); @@ -1185,6 +1233,12 @@ pub async fn set_language(app: AppHandle, language: Option) -> CommandRe .unwrap_or_else(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); rust_i18n::set_locale(&locale); + // Rebuild the tray context menu with the new locale so it doesn't show + // raw i18n keys. + if let Err(e) = crate::rebuild_tray_menu(&app) { + tracing::warn!(target: "main", error = %e, "Failed to rebuild tray menu after language change"); + } + // Close main window to force reload with new language // Check if window already exists if let Some(window) = app.get_webview_window("main_popup") { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93bb9dc..49325fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use tauri::{ async_runtime::spawn, menu::{Menu, MenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, AppHandle, Emitter, Listener, Manager, RunEvent, }; #[cfg(not(target_os = "macos"))] @@ -21,7 +21,53 @@ mod event_handler; #[macro_use] extern crate rust_i18n; -i18n!("../locales"); +i18n!("../locales", fallback = "en-US"); + +/// Normalize a raw locale string to one supported by the app. +/// +/// macOS may report locales like "zh-Hans-CN" which don't directly match our +/// translation files ("zh-CN"). This strips script tags and falls back to the +/// language code before giving up and returning "en-US". +fn normalize_locale(locale: &str) -> String { + let locale = locale.trim(); + if locale.is_empty() { + return "en-US".to_string(); + } + + let available = available_locales!(); + let lower = locale.to_lowercase(); + + // Exact match (case-insensitive) + for l in &available { + if l.to_lowercase() == lower { + return l.to_string(); + } + } + + // Try to strip script subtag: zh-Hans-CN -> zh-CN + let parts: Vec<&str> = locale.split('-').collect(); + if parts.len() >= 3 { + let without_script = format!("{}-{}", parts[0], parts.last().unwrap()); + let lower_without = without_script.to_lowercase(); + for l in &available { + if l.to_lowercase() == lower_without { + return l.to_string(); + } + } + } + + // Try language code only: en-US -> en + if !parts.is_empty() { + let lang = parts[0].to_lowercase(); + for l in &available { + if l.to_lowercase() == lang { + return l.to_string(); + } + } + } + + "en-US".to_string() +} /// Initialize i18n based on config setting or system locale fn init_i18n() { @@ -32,16 +78,17 @@ fn init_i18n() { let locale = ConfigManager::try_get() .and_then(|cm| cm.language()) .unwrap_or_else(|| get_locale().unwrap_or_else(|| String::from("en-US"))); - set_locale(locale.as_str()); + set_locale(normalize_locale(&locale).as_str()); } /// Get the current effective locale (from config or system) pub fn get_effective_locale() -> String { use sys_locale::get_locale; - ConfigManager::try_get() + let locale = ConfigManager::try_get() .and_then(|cm| cm.language()) - .unwrap_or_else(|| get_locale().unwrap_or_else(|| String::from("en-US"))) + .unwrap_or_else(|| get_locale().unwrap_or_else(|| String::from("en-US"))); + normalize_locale(&locale) } /// Application state containing the drive manager and event broadcaster @@ -214,24 +261,47 @@ async fn shutdown() { tracing::info!(target: "main", "Shutdown complete"); } +/// Return the tray menu item IDs and their localized titles. +fn tray_menu_entries() -> [(&'static str, String); 4] { + [ + ("show", t!("show").to_string()), + ("add_drive", t!("addNewDrive").to_string()), + ("settings", t!("settings").to_string()), + ("quit", t!("quit").to_string()), + ] +} + +/// Build the localized tray context menu with the current locale. +fn build_tray_menu( + manager: &impl Manager, +) -> anyhow::Result> { + let entries = tray_menu_entries(); + let menu_items: Vec> = entries + .iter() + .map(|(id, title)| MenuItem::with_id(manager, *id, title.clone(), true, None::<&str>)) + .collect::>()?; + let item_refs: Vec<&dyn tauri::menu::IsMenuItem> = menu_items + .iter() + .map(|item| item as &dyn tauri::menu::IsMenuItem) + .collect(); + let menu = Menu::with_items(manager, &item_refs)?; + Ok(menu) +} + +/// Rebuild the tray context menu using the current locale. +pub fn rebuild_tray_menu(app: &AppHandle) -> anyhow::Result<()> { + let menu = build_tray_menu(app)?; + let tray = app.state::>(); + tray.set_menu(Some(menu))?; + Ok(()) +} + /// Setup the system tray icon fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { - // Create menu items - let show_i = MenuItem::with_id(app, "show", t!("show").as_ref(), true, None::<&str>)?; - let add_drive_i = MenuItem::with_id( - app, - "add_drive", - t!("addNewDrive").as_ref(), - true, - None::<&str>, - )?; - let settings_i = - MenuItem::with_id(app, "settings", t!("settings").as_ref(), true, None::<&str>)?; - let quit_i = MenuItem::with_id(app, "quit", t!("quit").as_ref(), true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show_i, &add_drive_i, &settings_i, &quit_i])?; + let menu = build_tray_menu(app)?; // Build tray icon - TrayIconBuilder::new() + let tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .show_menu_on_left_click(false) @@ -264,6 +334,10 @@ fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { }) .build(app)?; + // Keep the tray icon in Tauri's managed state so we can update its menu + // when the language changes. + app.manage(tray); + Ok(()) } @@ -389,12 +463,14 @@ pub fn run() { } #[cfg(target_os = "macos")] RunEvent::WindowEvent { - event: tauri::WindowEvent::CloseRequested { .. } - | tauri::WindowEvent::Destroyed, + event: + tauri::WindowEvent::CloseRequested { .. } + | tauri::WindowEvent::Destroyed + | tauri::WindowEvent::Focused(false), .. } => { - // Re-evaluate Dock visibility shortly after a window is - // closed or destroyed so webview_windows() reflects the + // Re-evaluate Dock visibility shortly after a window loses focus + // or is closed/destroyed, so webview_windows() reflects the // change and the Dock icon hides when no window remains. let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { @@ -406,3 +482,87 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial(i18n_locale)] + fn tray_menu_entries_are_localized() { + rust_i18n::set_locale("en-US"); + let entries = tray_menu_entries(); + + let expected = [ + ("show", "Show"), + ("add_drive", "Add new drive"), + ("settings", "Settings"), + ("quit", "Quit"), + ]; + + assert_eq!(entries.len(), expected.len()); + for ((id, title), (expected_id, expected_title)) in entries.iter().zip(expected.iter()) { + assert_eq!(*id, *expected_id); + assert_eq!(title, *expected_title); + } + } + + #[test] + fn normalize_locale_handles_script_subtag() { + assert_eq!(normalize_locale("zh-Hans-CN"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hant-TW"), "zh-TW"); + } + + #[test] + fn normalize_locale_is_case_insensitive() { + assert_eq!(normalize_locale("EN-us"), "en-US"); + assert_eq!(normalize_locale("ZH-cn"), "zh-CN"); + } + + #[test] + fn normalize_locale_falls_back_to_default() { + assert_eq!(normalize_locale("xx-YY"), "en-US"); + assert_eq!(normalize_locale(""), "en-US"); + } + + #[test] + #[serial(i18n_locale)] + fn tray_menu_entries_are_not_raw_i18n_keys() { + rust_i18n::set_locale("en-US"); + let entries = tray_menu_entries(); + + let raw_keys = ["show", "addNewDrive", "settings", "quit"]; + for ((id, title), raw_key) in entries.iter().zip(raw_keys.iter()) { + assert_ne!( + title, *raw_key, + "menu title for {} should be localized, not raw key", + id + ); + } + } + + #[test] + #[serial(i18n_locale)] + fn tray_menu_entries_update_with_locale() { + rust_i18n::set_locale("zh-CN"); + let entries = tray_menu_entries(); + assert_eq!(entries[0].1, "显示"); + assert_eq!(entries[1].1, "添加新云盘"); + assert_eq!(entries[2].1, "设置"); + assert_eq!(entries[3].1, "退出"); + } + + #[test] + #[serial(i18n_locale)] + fn tray_menu_entries_fallback_to_default_locale() { + // When the locale is unknown, rust_i18n should fall back to en-US + // instead of returning raw i18n keys. + rust_i18n::set_locale("xx-YY"); + let entries = tray_menu_entries(); + assert_eq!(entries[0].1, "Show"); + assert_eq!(entries[1].1, "Add new drive"); + assert_eq!(entries[2].1, "Settings"); + assert_eq!(entries[3].1, "Quit"); + } +} From 61b04a4085f09654342b111121a2b159826b1d4e Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 7 Jul 2026 18:02:44 +0800 Subject: [PATCH 25/25] Remove GitHub Actions CI --- .github/workflows/ci.yml | 48 ----------------- .github/workflows/release-linux.yml | 80 ----------------------------- 2 files changed, 128 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release-linux.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index dfadb0c..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: CI - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - linux-tests: - name: Linux tests - runs-on: ubuntu-22.04 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - file \ - libayatana-appindicator3-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - librsvg2-dev \ - pkg-config - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Check Linux workspace - run: cargo check --workspace - - - name: Run Linux tests - run: | - cargo test -p cloudreve-desktop linux_autostart_tests - cargo test -p cloudreve-sync drive::placeholder::tests diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml deleted file mode 100644 index 80b5038..0000000 --- a/.github/workflows/release-linux.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Release Linux Packages - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - linux-packages: - name: Build deb and rpm - runs-on: ubuntu-22.04 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Linux packaging dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - file \ - libayatana-appindicator3-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - librsvg2-dev \ - patchelf \ - pkg-config \ - rpm - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install Yarn - run: npm install -g yarn@1.22.22 - - - name: Install frontend dependencies - working-directory: ui - run: yarn install --frozen-lockfile - - - name: Build frontend - working-directory: ui - run: yarn run build - - - name: Install Tauri CLI - run: cargo install tauri-cli --version '^2' --locked - - - name: Build Linux packages - working-directory: src-tauri - run: cargo tauri build --bundles deb,rpm --config '{"build":{"beforeBuildCommand":""}}' - - - name: Upload Linux packages - uses: actions/upload-artifact@v4 - with: - name: cloudreve-linux-packages - path: | - src-tauri/target/release/bundle/deb/*.deb - src-tauri/target/release/bundle/rpm/*.rpm - if-no-files-found: error - - - name: Attach packages to GitHub Release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 - with: - files: | - src-tauri/target/release/bundle/deb/*.deb - src-tauri/target/release/bundle/rpm/*.rpm