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/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", +] 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 06f4ab2..0c9a5b5 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, @@ -164,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, @@ -180,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 @@ -246,13 +250,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(()) } @@ -340,13 +346,34 @@ 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 - async fn refresh_access_token(&self) -> ApiResult { + /// 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, force: bool) -> 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 !force && !store.is_access_token_expired() { + return Ok(store.access_token.clone().unwrap()); + } + store .refresh_token .clone() @@ -357,7 +384,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?; @@ -403,7 +436,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 { @@ -462,12 +498,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)); } @@ -492,9 +522,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), 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/Cargo.toml b/crates/cloudreve-sync/Cargo.toml index e239a11..212fc40 100644 --- a/crates/cloudreve-sync/Cargo.toml +++ b/crates/cloudreve-sync/Cargo.toml @@ -18,11 +18,12 @@ 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" 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 +44,18 @@ 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(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 = [ "implement", @@ -79,9 +89,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..2e14bbb 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. @@ -780,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 { @@ -849,6 +883,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/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/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..62f48ec 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,15 +100,55 @@ impl DriveManager { } } - if count == 0 { - self.event_broadcaster.no_drive(); - } - 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(); @@ -172,7 +211,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(), @@ -190,6 +230,24 @@ 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(); + 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 + .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"); + } + }); + mount_arc.set_initial_sync_handle(initial_sync_handle).await; + + let mut write_guard = self.drives.write().await; let id = mount_arc.id.clone(); write_guard.insert(id.clone(), mount_arc); Ok(id) @@ -272,12 +330,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 +521,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 +559,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 +570,7 @@ impl DriveManager { drives, active_tasks, finished_tasks: recent_tasks.finished, + pending_conflicts, }) } @@ -595,7 +675,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 +728,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/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..33049a8 100644 --- a/crates/cloudreve-sync/src/drive/mounts.rs +++ b/crates/cloudreve-sync/src/drive/mounts.rs @@ -1,13 +1,16 @@ +use crate::cfapi::root::{Connection, SyncRootId}; +#[cfg(windows)] use crate::cfapi::root::{ - Connection, HydrationType, PopulationType, SecurityId, Session, SyncRootId, SyncRootIdBuilder, - SyncRootInfo, + HydrationType, PopulationType, SecurityId, Session, SyncRootIdBuilder, SyncRootInfo, }; +#[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,16 @@ 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,12 +140,13 @@ type FsWatcher = Debouncer; pub struct Mount { pub config: Arc>, - connection: Option>, + connection: Option, pub command_tx: mpsc::UnboundedSender, command_rx: Arc>>>, 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<()>, @@ -246,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, @@ -358,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() } @@ -371,76 +391,90 @@ impl Mount { } pub async fn start(&mut self) -> Result<()> { - if !StorageProviderSyncRootManager::IsSupported() - .context("Cloud Filter API is not supported")? + #[cfg(not(windows))] { - return Err(anyhow::anyhow!("Cloud Filter API is not supported")); + 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?; + return Ok(()); } - let mut write_guard = self.config.write().await; + #[cfg(windows)] + { + 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(()) + } } pub async fn start_fs_watcher(&self) -> Result<()> { @@ -522,7 +556,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 { @@ -603,9 +641,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 { @@ -630,18 +675,22 @@ 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; + // 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(()) } @@ -653,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() { @@ -667,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; } @@ -763,6 +833,7 @@ impl Mount { } } +#[cfg(windows)] fn generate_sync_root_id( instance_url: &str, _account_name: &str, @@ -808,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/drive/placeholder_non_windows.rs b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs new file mode 100644 index 0000000..6c88c85 --- /dev/null +++ b/crates/cloudreve-sync/src/drive/placeholder_non_windows.rs @@ -0,0 +1,256 @@ +use std::{path::PathBuf, sync::Arc}; + +use anyhow::{Context, Result}; +use chrono::DateTime; +use cloudreve_api::models::explorer::{FileResponse, file_type}; +use uuid::Uuid; + +use crate::{ + cfapi::placeholder::LocalFileInfo, + 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, + drive_id: Uuid, + file_meta: Option, + mark_no_children: bool, +} + +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 { + local_file_info: LocalFileInfo::from_path(&local_path) + .unwrap_or_else(|_| LocalFileInfo::missing()), + local_path, + drive_id, + file_meta: None, + mark_no_children: false, + } + } + + /// 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() { + 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(()) + } + + /// 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 + .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(()) + } + + /// 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() + .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 + } + + /// 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(()) + } +} + +#[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/crates/cloudreve-sync/src/drive/sync.rs b/crates/cloudreve-sync/src/drive/sync.rs index ac28921..87ac97a 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)] +pub(crate) 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, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Md5 => "md5", + Self::Sha1 => "sha1", + Self::Sha256 => "sha256", + Self::Sha512 => "sha512", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct FileHashFingerprint { + pub(crate) algorithm: HashAlgorithm, + pub(crate) 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,144 @@ 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() +} + +pub(crate) 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); + + // Exact key match (e.g. "md5", "sha256"). + 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, + }); + } + } + } + + // Generic keys like "hash" or "checksum" where the algorithm is + // inferred from the digest length. + if normalized_key == "hash" || normalized_key == "checksum" { + if let Some(fingerprint) = hash_fingerprint_from_value(value) { + return Some(fingerprint); + } + } + } + } + + // primary_entity is used as an opaque etag/entity identifier elsewhere + // (see cloud_file_to_metadata_entry and CrPlaceholder::with_remote_file). + // It is not a reliable content hash, so do not use it for equality checks; + // falling back to size comparison avoids false conflicts when the remote + // does not publish a real content hash. + None +} + +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()))?; + 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). @@ -502,14 +723,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", @@ -585,6 +808,68 @@ 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 { + // 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())) + .await + { + aggregate_error.push(path.clone(), anyhow::Error::from(err)); + } + } + } + } + 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 { @@ -607,6 +892,20 @@ 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 { + // 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())) + .await + { + aggregate_error.push(path.clone(), anyhow::Error::from(err)); + } + } } } SyncAction::QueueUpload { path, reason } => { @@ -633,7 +932,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, @@ -939,12 +1238,118 @@ 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 { + // 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(), + 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); + }; + + 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/crates/cloudreve-sync/src/drive/utils.rs b/crates/cloudreve-sync/src/drive/utils.rs index 8e4264d..b0cf6fa 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; @@ -86,13 +88,18 @@ 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())?; unsafe { @@ -105,3 +112,15 @@ 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/inventory/db/file_metadata.rs b/crates/cloudreve-sync/src/inventory/db/file_metadata.rs index 3c3c411..eaf6c9e 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,42 @@ 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 + /// 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/crates/cloudreve-sync/src/lib.rs b/crates/cloudreve-sync/src/lib.rs index 0f06452..9870858 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; @@ -22,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/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/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/crates/cloudreve-sync/src/utils/app.rs b/crates/cloudreve-sync/src/utils/app.rs index 49179b4..d906d2b 100644 --- a/crates/cloudreve-sync/src/utils/app.rs +++ b/crates/cloudreve-sync/src/utils/app.rs @@ -1,14 +1,24 @@ use std::sync::{Arc, OnceLock}; +#[cfg(windows)] 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(); APP_ROOT.set(Arc::new(path)).ok(); } @@ -18,18 +28,37 @@ pub fn get_app_root() -> AppRoot { } #[derive(Clone)] +#[allow(dead_code)] pub struct AppRoot(Arc); impl AppRoot { pub fn image_path(&self) -> String { - 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(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))] + { + // Windows shell icons are not used by non-Windows full sync. + String::new() } } pub fn image_path_general(&self) -> String { - format!("{}\\Images", self.0.as_str()) + #[cfg(windows)] + { + 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 ffaae61..9e8db20 100644 --- a/crates/cloudreve-sync/src/utils/toast.rs +++ b/crates/cloudreve-sync/src/utils/toast.rs @@ -1,6 +1,12 @@ 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)] use win32_notif::{ NotificationBuilder, ToastsNotifier, notification::{ @@ -11,8 +17,58 @@ use win32_notif::{ use crate::config::ConfigManager; +#[cfg(windows)] const APP_NAME: &str = "Cloudreve.Sync"; +#[cfg(any(target_os = "linux", target_os = "macos"))] +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(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(); @@ -35,7 +91,23 @@ pub fn send_general_text_toast(title: &str, message: &str) { notif.show().unwrap(); } +#[cfg(target_os = "linux")] +pub fn send_general_text_toast(title: &str, message: &str) { + send_linux_notification(title, message, Urgency::Normal); +} + +#[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"); +} + /// 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(); @@ -54,7 +126,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(); @@ -62,9 +134,25 @@ pub fn send_warning_toast(title: &str, message: &str) { notif.show().unwrap(); } +#[cfg(target_os = "linux")] +pub fn send_warning_toast(title: &str, message: &str) { + send_linux_notification(title, message, Urgency::Critical); +} + +#[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"); +} + /// 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() { @@ -91,17 +179,56 @@ 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(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(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() { + 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() { @@ -121,10 +248,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( @@ -142,14 +275,75 @@ 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(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 message = conflict_notification_message(path); + 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(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() { + 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..49b9be6 --- /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: Linux Full Sync Only + +Status: implemented. + +- 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: implemented. + +- 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/arch/PKGBUILD b/package/arch/PKGBUILD new file mode 100644 index 0000000..523803e --- /dev/null +++ b/package/arch/PKGBUILD @@ -0,0 +1,68 @@ +# 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') +# 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' + '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 + # `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 + # 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 + # 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() { + 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" +} 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/Cargo.toml b/src-tauri/Cargo.toml index e589d8f..dab01a3 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" @@ -38,15 +38,15 @@ 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.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" +tauri-plugin-frame = "1.1.6" -[dependencies.windows] +[target.'cfg(windows)'.dependencies.windows] version = "0.58.0" features = ["Foundation", "ApplicationModel"] @@ -54,4 +54,7 @@ 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"] } + +[dev-dependencies] +serial_test = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e4c95c0..3a247cb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,19 +1,24 @@ 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, }; +#[cfg(windows)] 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 @@ -269,6 +274,90 @@ 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()) +} + +/// 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> { @@ -293,24 +382,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( + #[allow(unused_variables)] 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) @@ -323,19 +443,116 @@ 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 + } + } +} + +/// 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 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(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); return; } // Create new main window - match WebviewWindowBuilder::new( + let builder = WebviewWindowBuilder::new( app, "main_popup", WebviewUrl::App(get_url_with_lang("index.html/#/popup").into()), @@ -346,26 +563,43 @@ 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.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) => { + #[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(); } } }); - let _ = window.move_window(position); + 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"); @@ -402,7 +636,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 @@ -429,10 +667,13 @@ 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; } - // 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, @@ -445,23 +686,55 @@ 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(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.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); + #[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); + 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(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); } Err(e) => { tracing::error!(target: "main", error = %e, "Failed to create window: {}", title); @@ -483,9 +756,20 @@ 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; } + // 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", @@ -499,18 +783,39 @@ 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.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); + #[cfg(target_os = "macos")] + update_dock_on_window_close(&window); + + #[cfg(windows)] + { + let _ = window.set_transparent(true); + 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(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); } Err(e) => { tracing::error!(target: "main", error = %e, "Failed to create settings window"); @@ -519,61 +824,321 @@ 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 = "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 { + 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)), + } + } +} + +#[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 + + LimitLoadToSessionType + Aqua + + +"#, + label, exe + )) +} + +#[cfg(target_os = "macos")] +fn macos_get_auto_start_enabled() -> CommandResult { + let path = macos_launch_agent_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 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")] +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))?; + + // 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), + 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 { - 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))? + #[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) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + 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))? + } } /// Set auto-start configuration using Windows StartupTask API #[tauri::command] pub async fn set_auto_start(enabled: bool) -> CommandResult { - 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))? + #[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)) + .await + .map_err(|e| format!("Task join error: {}", e))?; + } + + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + 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 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\"" + ); + } } /// Set notification settings for credential expiry @@ -664,13 +1229,18 @@ 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); + // 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 + // 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/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..49325fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,16 @@ 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, menu::{Menu, MenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, RunEvent, + tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, + AppHandle, Emitter, Listener, Manager, RunEvent, }; +#[cfg(not(target_os = "macos"))] use tauri_plugin_deep_link::DeepLinkExt; use tokio::sync::OnceCell; @@ -17,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() { @@ -28,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 @@ -103,6 +154,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 +176,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(()) @@ -127,6 +188,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() @@ -182,29 +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) @@ -237,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(()) } @@ -250,7 +351,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 { @@ -260,8 +362,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()) @@ -273,9 +381,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(); @@ -290,6 +410,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![ @@ -301,6 +425,8 @@ pub fn run() { commands::set_ignore_patterns, 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, @@ -321,7 +447,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() { @@ -335,7 +461,108 @@ 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 + | tauri::WindowEvent::Focused(false), + .. + } => { + // 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 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + update_dock_visibility(&app_handle); + }); + } _ => {} } }); } + +#[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"); + } +} 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(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 008790d..f7784ee 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", @@ -45,5 +46,12 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["cloudreve"] + } + } } -} \ 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/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..9f06eac 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,17 @@ "syncingStatus": "正在同步 {{count}} 个文件...", "upToDate": "文件已是最新。", "waiting": "等待中...", - "processing": "处理中..." + "processing": "处理中...", + "conflicts": "冲突", + "conflictStatus": "{{count}} 个冲突需要处理", + "conflictDescription": "本地文件与远程版本冲突", + "resolveConflict": "解决冲突", + "resolving": "正在处理...", + "keepRemote": "保留远程版本", + "overwriteRemote": "覆盖远程版本", + "saveAsNew": "另存为新文件", + "keepAllRemote": "全部保留远程", + "overwriteAllRemote": "全部覆盖远程" }, "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")} diff --git a/ui/src/pages/popup/ConflictItem.tsx b/ui/src/pages/popup/ConflictItem.tsx new file mode 100644 index 0000000..cc68f37 --- /dev/null +++ b/ui/src/pages/popup/ConflictItem.tsx @@ -0,0 +1,200 @@ +import { + Alert, + Box, + Button, + Collapse, + Link, + ListItem, + ListItemIcon, + ListItemText, + Stack, + 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 [actionsOpen, setActionsOpen] = useState(false); + 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..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, @@ -19,6 +21,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 +29,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(); } @@ -100,10 +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; + const hasPendingConflicts = + 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 +280,7 @@ export default function Popup() { sx={{ px: 2, py: 1, - pb:0, + pb: 0, display: "block", fontWeight: 600, textTransform: "uppercase", @@ -223,7 +308,7 @@ export default function Popup() { sx={{ px: 2, py: 1, - pb:0, + pb: 0, display: "block", fontWeight: 600, textTransform: "uppercase", @@ -264,6 +349,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/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()} 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"