From e364b82dbfee2e97fd2144205e66f5f82a8f7bf1 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 18:52:53 +0200 Subject: [PATCH] feat(desktop): Linux on-demand hydration via FUSE Add a Linux-only "On-demand" sync mode alongside full sync. The remote tree projects virtually into a FUSE mount at the configured path, backed by inventory; files hydrate into a private store on open and the watcher + sync engine operate on the store, so edits sync normally without eagerly mirroring the whole drive. - fuse_fs.rs: fuser-based FS with inode table, inventory-backed virtual entries, per-path serialized hydration, sibling tmp dir + atomic rename, zero kernel TTLs, full callback set, 4 worker threads - mounts.rs: data_root()/mount_to_store() path-space split, FUSE session lifecycle, watcher rooted at the store, tmp-dir cleanup - sync.rs: skip eager downloads in on-demand mode; QueueDownload evicts the hydrated copy with event blockers so no remote delete fires - commands.rs: snapshot-gated create/modify events so engine IO (downloads, hydration) never boomerangs into uploads - add_drive accepts sync_mode; Linux-only radio picker in the UI, all 11 locales Verified: cargo test --workspace green incl. a real kernel-mount integration test (tests/fuse_mount.rs), vite build, tsc, eslint. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 3 +- desktop/crates/cloudreve-sync/Cargo.toml | 2 + .../cloudreve-sync/src/drive/commands.rs | 60 +- .../cloudreve-sync/src/drive/fuse_fs.rs | 1020 +++++++++++++++++ .../src/drive/manager/command_handlers.rs | 7 +- .../cloudreve-sync/src/drive/manager/mod.rs | 2 +- .../crates/cloudreve-sync/src/drive/mod.rs | 2 + .../crates/cloudreve-sync/src/drive/mounts.rs | 220 +++- .../cloudreve-sync/src/drive/remote_events.rs | 4 +- .../crates/cloudreve-sync/src/drive/sync.rs | 63 +- .../src/inventory/db/file_metadata.rs | 82 ++ .../crates/cloudreve-sync/tests/fuse_mount.rs | 153 +++ desktop/src-tauri/src/commands.rs | 9 + desktop/ui/public/locales/de/common.json | 4 + desktop/ui/public/locales/en-US/common.json | 4 + desktop/ui/public/locales/es/common.json | 4 + desktop/ui/public/locales/fr/common.json | 4 + desktop/ui/public/locales/it/common.json | 4 + desktop/ui/public/locales/ja/common.json | 4 + desktop/ui/public/locales/ko/common.json | 4 + desktop/ui/public/locales/pl/common.json | 4 + desktop/ui/public/locales/ru/common.json | 4 + desktop/ui/public/locales/zh-CN/common.json | 4 + desktop/ui/public/locales/zh-TW/common.json | 4 + desktop/ui/src/pages/AddDrive.tsx | 37 +- 25 files changed, 1667 insertions(+), 41 deletions(-) create mode 100644 desktop/crates/cloudreve-sync/src/drive/fuse_fs.rs create mode 100644 desktop/crates/cloudreve-sync/tests/fuse_mount.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1beced0a..413391bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,10 +245,11 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo. | Notifications | win32_notif | `mac_notification_sys` | `notify_rust` | | Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same | -- Status: (1) notifications already per-OS (`win32_notif` / `notify_rust` / `mac_notification_sys`) — no abstraction needed; (2) hydration abstracted via `drive/placeholder` cfg swap — `cfapi` on Windows, `placeholder_non_windows` full-sync adapter elsewhere (FUSE / File Provider still open); (3) CI matrix builds + tests all 3 OSes; (4) packaging: `desktop-release.yml` on `desktop-v*` tags ships .msi/.exe (Windows), .dmg (macOS), .deb/.AppImage (Linux) — MSIX deferred (needs store signing). +- Status: (1) notifications already per-OS (`win32_notif` / `notify_rust` / `mac_notification_sys`) — no abstraction needed; (2) hydration abstracted via `drive/placeholder` cfg swap — `cfapi` on Windows, `placeholder_non_windows` full-sync adapter elsewhere (FUSE landed on Linux; File Provider still open); (3) CI matrix builds + tests all 3 OSes; (4) packaging: `desktop-release.yml` on `desktop-v*` tags ships .msi/.exe (Windows), .dmg (macOS), .deb/.AppImage (Linux) — MSIX deferred (needs store signing). - Verified on Linux: `cargo test --workspace` green (49 tests), `cargo tauri build` produces working .deb + .AppImage. - Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine). - [x] #167 (upstream desktop#49) — online-only thumbnails missing in Explorer: root cause was a client/server contract mismatch — the CE `/file/thumb` response carries only `url`/`expires` while the `cloudreve-api` model required `obfuscated`, failing deserialization on every thumbnail request (`E_FAIL` to Explorer; hydrated files were unaffected since Windows thumbs them locally). `obfuscated` is now `#[serde(default)]`; the decode path still runs when a server emits the flag +- [x] Linux on-demand hydration via FUSE (`fuser`) — new "On-demand" sync mode in Add Drive (Linux-only picker): remote tree projects virtually into a FUSE mount at the configured path backed by inventory; files hydrate into a private store (`~/.cloudreve/fuse-store/`) on open with per-path serialization, sibling tmp dir + atomic rename, zero kernel cache TTLs; the watcher/sync engine operate on the store so hydrated edits sync normally; remote refreshes evict stale hydrated copies (event-blocked so no remote delete); engine writes gated by `local_updated_at`/`local_size` snapshots so downloads/hydrations never boomerang into uploads; `unlink`/`rename` on virtual files route through synthesized events + `MountCommand::Rename`; full-sync mode unchanged on Linux/macOS; covered by a real kernel-mount integration test (`tests/fuse_mount.rs`) ## 7. Phase E — Android app (native, no iOS) diff --git a/desktop/crates/cloudreve-sync/Cargo.toml b/desktop/crates/cloudreve-sync/Cargo.toml index 2442809e..d5bfee47 100644 --- a/desktop/crates/cloudreve-sync/Cargo.toml +++ b/desktop/crates/cloudreve-sync/Cargo.toml @@ -51,6 +51,8 @@ win32_notif = { path = "../win32_notif" } [target.'cfg(target_os = "linux")'.dependencies] notify-rust = "4.11" +fuser = "0.18" +libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "0.6" diff --git a/desktop/crates/cloudreve-sync/src/drive/commands.rs b/desktop/crates/cloudreve-sync/src/drive/commands.rs index cf9601ac..1f40541b 100644 --- a/desktop/crates/cloudreve-sync/src/drive/commands.rs +++ b/desktop/crates/cloudreve-sync/src/drive/commands.rs @@ -221,7 +221,7 @@ impl Mount { ) -> Result<()> { let config = self.config.read().await; let remote_base = config.remote_path.clone(); - let sync_path = config.sync_path.clone(); + let sync_path = config.data_root(); drop(config); let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) @@ -354,7 +354,7 @@ impl Mount { pub async fn fetch_placeholders(&self, path: PathBuf) -> Result { let config = self.config.read().await; let remote_base = config.remote_path.clone(); - let sync_path = config.sync_path.clone(); + let sync_path = config.data_root(); drop(config); let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) @@ -409,9 +409,12 @@ impl Mount { } pub async fn generate_thumbnail(&self, path: PathBuf) -> Result { + // `path` arrives in mount space (user-facing); inventory rows are keyed + // by store path in on-demand mode. + let store_path = self.config.read().await.mount_to_store(&path); let file_meta = self .inventory - .query_by_path(path.to_str().unwrap_or("")) + .query_by_path(store_path.to_str().unwrap_or("")) .context("failed to query metadata by path")? .ok_or_else(|| anyhow::anyhow!("no metadata found for path: {:?}", path))?; @@ -450,6 +453,8 @@ impl Mount { /// Create a public share link for the file or folder at `path`, /// returning the share URL. pub async fn create_share_link(&self, path: PathBuf) -> Result { + // `path` arrives in mount space from the file manager, so the URI root + // is `sync_path` — mount and store share the same relative layout. let (sync_path, remote_base) = { let config = self.config.read().await; (config.sync_path.clone(), config.remote_path.to_string()) @@ -532,7 +537,7 @@ impl Mount { pub async fn rename(&self, source: PathBuf, target: PathBuf) -> Result<()> { let (sync_path, remote_path) = { let config = self.config.read().await; - (config.sync_path.clone(), config.remote_path.to_string()) + (config.data_root(), config.remote_path.to_string()) }; // if target or source is not under sync root, do nothing @@ -708,7 +713,7 @@ impl Mount { // Extract configuration once to avoid repeated lock acquisition let (sync_path, remote_base) = { let config = self.config.read().await; - (config.sync_path.clone(), config.remote_path.to_string()) + (config.data_root(), config.remote_path.to_string()) }; let path_uri_mappings = @@ -766,7 +771,7 @@ impl Mount { let (sync_root, drive_id) = { let config = self.config.read().await; ( - config.sync_path.clone(), + config.data_root(), Uuid::parse_str(&config.id).context("invalid drive ID")?, ) }; @@ -1056,18 +1061,22 @@ impl Mount { // Also queue when the recorded local snapshot no longer matches // the on-disk state even though the IN_SYNC flag looks set - the // flag may be stale after a race with a metadata refresh. - let snapshot_differs = match path.to_str() { - Some(path_str) => self - .inventory - .query_by_path(path_str) - .map(|entry| { - entry.is_some_and(|meta| local_snapshot_differs(&meta, &placeholder_info)) - }) - .unwrap_or(false), - None => false, + let meta = match path.to_str() { + Some(path_str) => self.inventory.query_by_path(path_str).ok().flatten(), + None => None, }; - - if !placeholder_info.in_sync() || snapshot_differs { + let snapshot_differs = meta + .as_ref() + .is_some_and(|m| local_snapshot_differs(m, &placeholder_info)); + // in_sync() is always false off Windows, so a matching recorded + // snapshot is the authoritative "no local change" signal there — + // it keeps engine writes (downloads, FUSE hydration) from + // boomeranging back as uploads. + let snapshot_matches = meta.as_ref().is_some_and(|m| { + m.local_updated_at.is_some() && m.local_size.is_some() + }) && !snapshot_differs; + + if snapshot_differs || (!placeholder_info.in_sync() && !snapshot_matches) { tracing::debug!(target: "drive::commands", path = %path.display(), "Queuing upload task for modified file"); let payload = TaskPayload::upload(path.clone()); let result = self @@ -1100,6 +1109,23 @@ impl Mount { ); for (_remote_uri, path) in path_uri_mappings { + // Skip creates produced by engine IO (downloads, FUSE hydration + // moves a file in): an inventory snapshot matching the on-disk + // state means the content is already the synced version. + let already_synced = path + .to_str() + .and_then(|p| self.inventory.query_by_path(p).ok().flatten()) + .filter(|meta| meta.local_updated_at.is_some() && meta.local_size.is_some()) + .and_then(|meta| { + LocalFileInfo::from_path(path.as_path()) + .ok() + .map(|info| !local_snapshot_differs(&meta, &info)) + }) + .unwrap_or(false); + if already_synced { + continue; + } + let payload = TaskPayload::upload(path.clone()); self.task_queue diff --git a/desktop/crates/cloudreve-sync/src/drive/fuse_fs.rs b/desktop/crates/cloudreve-sync/src/drive/fuse_fs.rs new file mode 100644 index 00000000..3c098de3 --- /dev/null +++ b/desktop/crates/cloudreve-sync/src/drive/fuse_fs.rs @@ -0,0 +1,1020 @@ +//! Linux on-demand filesystem backed by FUSE. +//! +//! `CloudreveFs` projects the remote tree onto a mountpoint: entries known to +//! the inventory but absent from the local store directory appear as +//! regular files/directories ("virtual" entries), and opening one hydrates it +//! into the store on demand. Mutations land in the store, where the normal +//! filesystem watcher picks them up and feeds the existing sync machinery — +//! the FUSE layer itself never talks to the remote API except for hydration +//! fetches and explicit rename/delete of never-materialized entries. +//! +//! Layout: +//! +//! - `data_root` (`~/.cloudreve/fuse-store/`) holds real bytes. The sync +//! engine treats it as the local root: watcher, task queue, and URI mapping +//! all operate on store paths. +//! - `mount_path` (`config.sync_path`) is the user-visible FUSE mountpoint. +//! It is presentation only — no engine code touches it for IO. +//! +//! Kernel caching is disabled via zero TTLs on every reply so remote changes +//! (applied to inventory + store by the sync engine) are visible immediately +//! without a `notify_inval` plumbing layer. + +use std::{ + collections::HashSet, + ffi::{OsStr, OsString}, + io, + os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result}; +use cloudreve_api::{Client, api::ExplorerApi}; +use dashmap::DashMap; +use fuser::{ + AccessFlags, Errno, FopenFlags, FileAttr, FileHandle, FileType, Filesystem, Generation, + INodeNo, LockOwner, OpenAccMode, OpenFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, + ReplyEmpty, ReplyEntry, ReplyOpen, ReplyStatfs, ReplyWrite, Request, TimeOrNow, WriteFlags, +}; +use notify_debouncer_full::{DebouncedEvent, notify::event::RemoveKind, notify::Event}; +use notify_debouncer_full::notify::EventKind; +use tokio::sync::mpsc; + +use crate::{ + drive::commands::MountCommand, + drive::sync::group_fs_events, + inventory::{FileMetadata, InventoryDb}, +}; + +/// Zero TTL: the kernel re-asks for every lookup/attr so remote-side changes +/// surface without explicit invalidation. +const TTL: Duration = Duration::ZERO; +/// Synthetic generation number; the fs does not track generations. +const GENERATION: u64 = 0; +/// First inode handed out past the root (root is always inode 1). +const FIRST_INO: u64 = 2; + +/// Shared state the filesystem needs from the owning `Mount`. +/// Kept separate so the FUSE session never holds the whole mount. +pub struct FuseContext { + /// Engine-visible local root — the backing store directory. + pub data_root: PathBuf, + /// `cloudreve://` base URI of the mounted remote folder. + pub remote_base: String, + /// Owning drive id (inventory rows are per drive). + pub drive_id: String, + pub inventory: Arc, + pub cr_client: Arc, + /// Channel into the mount's command loop for remote rename/delete of + /// entries that have no store file to generate watcher events. + pub command_tx: mpsc::UnboundedSender, + /// Runtime handle used to `block_on` async work from FUSE threads. + /// FUSE worker threads are not tokio threads, so `block_on` is legal. + pub runtime: tokio::runtime::Handle, +} + +/// Bidirectional inode <-> relative-path table. `1` is the mount root and maps +/// to the empty relative path. +// ponytail: inodes are never evicted — forget() only drops lookup counts. +// A session that touches every remote file leaks ~100B per entry; bounded by +// tree size and mount lifetime, acceptable for a desktop sync client. +struct InodeTable { + by_ino: DashMap, + by_path: DashMap, + lookups: DashMap, + next: AtomicU64, +} + +impl InodeTable { + fn new() -> Self { + let table = Self { + by_ino: DashMap::new(), + by_path: DashMap::new(), + lookups: DashMap::new(), + next: AtomicU64::new(FIRST_INO), + }; + table.by_ino.insert(1, PathBuf::new()); + table.by_path.insert(PathBuf::new(), 1); + table.lookups.insert(1, AtomicU64::new(1)); + table + } + + fn intern(&self, rel: PathBuf) -> u64 { + if let Some(ino) = self.by_path.get(&rel) { + let ino = *ino; + self.lookups + .entry(ino) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(1, Ordering::Relaxed); + return ino; + } + let ino = self.next.fetch_add(1, Ordering::Relaxed); + self.by_ino.insert(ino, rel.clone()); + self.by_path.insert(rel, ino); + self.lookups.insert(ino, AtomicU64::new(1)); + ino + } + + fn path(&self, ino: u64) -> Option { + self.by_ino.get(&ino).map(|p| p.clone()) + } + + fn forget(&self, ino: u64, nlookup: u64) { + if let Some(count) = self.lookups.get(&ino) { + count.fetch_sub(nlookup, Ordering::Relaxed); + } + } +} + +/// Handle-side bookkeeping for an open file. +struct OpenFile { + file: std::fs::File, + /// Store-relative path; kept so `release` can flush dirty marks later. + #[allow(dead_code)] + rel: PathBuf, +} + +pub struct CloudreveFs { + ctx: Arc, + inodes: InodeTable, + open_files: DashMap, + next_fh: AtomicU64, + /// Serializes hydration per path so concurrent openers don't fetch twice. + hydrate_locks: DashMap>>, + /// Unique names for hydration temp files. + tmp_seq: AtomicU64, +} + +impl CloudreveFs { + pub fn new(ctx: Arc) -> Self { + Self { + ctx, + inodes: InodeTable::new(), + open_files: DashMap::new(), + next_fh: AtomicU64::new(1), + hydrate_locks: DashMap::new(), + tmp_seq: AtomicU64::new(0), + } + } + + /// Temp directory for in-flight hydration. It sits next to `data_root` + /// rather than inside it so the filesystem watcher never observes + /// `.part` traffic; being a sibling keeps the final rename atomic on the + /// same filesystem. + pub fn tmp_dir(data_root: &Path, drive_id: &str) -> PathBuf { + data_root + .parent() + .unwrap_or(data_root) + .join(format!(".tmp-{drive_id}")) + } + + fn tmp_path(&self, rel: &Path) -> PathBuf { + let seq = self.tmp_seq.fetch_add(1, Ordering::Relaxed); + let name = rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()); + Self::tmp_dir(&self.ctx.data_root, &self.ctx.drive_id) + .join(format!("{seq}-{name}.part")) + } + + /// Absolute store path for a mount-relative path. + fn store_path(&self, rel: &Path) -> PathBuf { + self.ctx.data_root.join(rel) + } + + fn store_path_str(&self, rel: &Path) -> Option { + self.store_path(rel).to_str().map(str::to_string) + } + + fn inventory_meta(&self, rel: &Path) -> Option { + let store = self.store_path_str(rel)?; + self.ctx.inventory.query_by_path(&store).ok().flatten() + } + + fn file_type_for(meta: &FileMetadata) -> FileType { + if meta.is_folder { + FileType::Directory + } else { + FileType::RegularFile + } + } + + fn ts(unix: i64) -> SystemTime { + if unix <= 0 { + UNIX_EPOCH + } else { + UNIX_EPOCH + Duration::from_secs(unix as u64) + } + } + + /// Attributes for a relative path: the store entry wins when it exists so + /// local writes and hydration state are always presented accurately. + /// Otherwise fall back to the inventory's remote metadata (virtual entry). + fn attr_for(&self, ino: u64, rel: &Path, req: &Request) -> Option { + let store = self.store_path(rel); + if let Ok(md) = std::fs::symlink_metadata(&store) { + return Some(Self::attr_from_std(ino, &md, req)); + } + let meta = self.inventory_meta(rel)?; + let kind = Self::file_type_for(&meta); + Some(FileAttr { + ino: INodeNo(ino), + size: if kind == FileType::Directory { + 0 + } else { + meta.size.max(0) as u64 + }, + blocks: 0, + atime: Self::ts(meta.updated_at), + mtime: Self::ts(meta.updated_at), + ctime: Self::ts(meta.updated_at), + crtime: Self::ts(meta.created_at), + kind, + perm: if kind == FileType::Directory { 0o755 } else { 0o644 }, + nlink: if kind == FileType::Directory { 2 } else { 1 }, + uid: req.uid(), + gid: req.gid(), + rdev: 0, + blksize: 4096, + flags: 0, + }) + } + + fn attr_from_std(ino: u64, md: &std::fs::Metadata, req: &Request) -> FileAttr { + FileAttr { + ino: INodeNo(ino), + size: md.len(), + blocks: md.blocks(), + atime: md.accessed().unwrap_or(UNIX_EPOCH), + mtime: md.modified().unwrap_or(UNIX_EPOCH), + ctime: UNIX_EPOCH + Duration::from_secs(md.ctime() as u64), + crtime: md.created().unwrap_or(UNIX_EPOCH), + kind: FileType::from_std(md.file_type()).unwrap_or(FileType::RegularFile), + perm: (md.mode() & 0o7777) as u16, + nlink: md.nlink() as u32, + uid: req.uid(), + gid: req.gid(), + rdev: md.rdev() as u32, + blksize: 4096, + flags: 0, + } + } + + /// Ensure a file's bytes exist in the store, downloading them on first + /// access. Concurrent opens on the same path share one hydration. + fn ensure_materialized(&self, rel: &Path) -> io::Result<()> { + let store = self.store_path(rel); + if store.exists() { + return Ok(()); + } + let meta = self + .inventory_meta(rel) + .ok_or_else(|| io::Error::from_raw_os_error(libc::ENOENT))?; + if meta.is_folder { + return Err(io::Error::from_raw_os_error(libc::EISDIR)); + } + + let lock = self + .hydrate_locks + .entry(rel.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + + // Another opener may have hydrated while we waited on the lock. + if store.exists() { + self.hydrate_locks.remove(rel); + return Ok(()); + } + + if let Some(parent) = store.parent() { + std::fs::create_dir_all(parent)?; + } + + let tmp = self.tmp_path(rel); + let result = self + .ctx + .runtime + .block_on(self.fetch_remote_file(&store, &tmp)); + // The store.exists() check above is the real guard against duplicate + // fetches, so releasing the path lock here is safe and keeps the map + // bounded over the mount's lifetime. + self.hydrate_locks.remove(rel); + result.map_err(|e| { + tracing::error!( + target: "drive::fuse", + path = %store.display(), + error = %e, + "Hydration failed" + ); + let _ = std::fs::remove_file(&tmp); + io::Error::from_raw_os_error(libc::EIO) + })?; + + // Refresh the local snapshot so the next sync diff does not treat the + // freshly hydrated bytes as a local modification. + if let Ok(Some(mut meta)) = self.ctx.inventory.query_by_path(&self.store_path_str(rel).unwrap_or_default()) + && let Ok(fresh) = std::fs::metadata(&store) + && let Ok(mtime) = fresh.modified() + && let Ok(dur) = mtime.duration_since(UNIX_EPOCH) + { + meta.local_updated_at = Some(dur.as_millis() as i64); + meta.local_size = Some(fresh.len() as i64); + let entry = crate::inventory::MetadataEntry::from(&meta); + let _ = self.ctx.inventory.upsert(&entry); + } + Ok(()) + } + + /// Download the remote entity backing `store` to `tmp` and rename it into + /// place. The rename keeps concurrent readers from seeing partial content. + async fn fetch_remote_file(&self, store: &Path, tmp: &Path) -> Result<()> { + let uri = crate::drive::utils::local_path_to_cr_uri( + store.to_path_buf(), + self.ctx.data_root.clone(), + self.ctx.remote_base.clone(), + ) + .context("failed to map store path to remote uri")?; + + let mut request = cloudreve_api::models::explorer::FileURLService::default(); + request.uris.push(uri.to_string()); + if let Some(meta) = self + .ctx + .inventory + .query_by_path(&store.to_string_lossy())? + && !meta.etag.is_empty() + { + request.entity = Some(meta.etag); + } + let url_res = match self.ctx.cr_client.get_file_url(&request).await { + Err(e) if e.is_entity_not_exist() && request.entity.is_some() => { + let mut retry = request.clone(); + retry.entity = None; + self.ctx.cr_client.get_file_url(&retry).await? + } + res => res?, + }; + let download_url = url_res + .urls + .first() + .context("no download URL in response")? + .url + .clone(); + + if let Some(parent) = tmp.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create temp dir {}", parent.display()))?; + } + let mut out = std::fs::File::create(tmp) + .with_context(|| format!("failed to create temp file {}", tmp.display()))?; + let client = reqwest::Client::new(); + let response = client + .get(&download_url) + .send() + .await + .context("failed to send download request")?; + if !response.status().is_success() { + anyhow::bail!("download failed with status {}", response.status()); + } + use futures::StreamExt; + let mut stream = response.bytes_stream(); + use std::io::Write; + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("failed to read download stream")?; + out.write_all(&chunk)?; + } + out.sync_all()?; + drop(out); + std::fs::rename(tmp, store).context("failed to move hydrated file into place")?; + Ok(()) + } + + /// Ensure the entry exists in the store as the right kind: directories + /// materialize as empty dirs, files hydrate. Used by setattr so `touch` + /// and friends work on virtual directories too. + fn ensure_materialized_any(&self, rel: &Path) -> io::Result<()> { + let store = self.store_path(rel); + if store.exists() { + return Ok(()); + } + match self.inventory_meta(rel) { + Some(meta) if meta.is_folder => std::fs::create_dir_all(&store), + _ => self.ensure_materialized(rel), + } + } + + /// Route a mutation that produced no store-side watcher event (the entry + /// was virtual) through the same path real filesystem events take. + fn emit_fs_event(&self, store_path: PathBuf, kind: EventKind) { + let event = DebouncedEvent::new(Event::new(kind).add_path(store_path), Instant::now()); + if let Err(e) = self.ctx.command_tx.send(MountCommand::ProcessFsEvents { + events: group_fs_events(vec![event]), + }) { + tracing::error!( + target: "drive::fuse", + error = %e, + "Failed to emit synthesized fs event" + ); + } + } + + fn register_file(&self, rel: PathBuf, file: std::fs::File) -> FileHandle { + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + self.open_files.insert(fh, OpenFile { file, rel }); + FileHandle(fh) + } +} + +impl Filesystem for CloudreveFs { + fn lookup(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + let Some(parent_rel) = self.inodes.path(parent.0) else { + reply.error(Errno::ENOENT); + return; + }; + let rel = parent_rel.join(name); + let ino = self.inodes.intern(rel.clone()); + match self.attr_for(ino, &rel, req) { + Some(attr) => reply.entry(&TTL, &attr, Generation(GENERATION)), + None => reply.error(Errno::ENOENT), + } + } + + fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) { + self.inodes.forget(ino.0, nlookup); + } + + fn getattr(&self, req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let Some(rel) = self.inodes.path(ino.0) else { + reply.error(Errno::ENOENT); + return; + }; + match self.attr_for(ino.0, &rel, req) { + Some(attr) => reply.attr(&TTL, &attr), + None => reply.error(Errno::ENOENT), + } + } + + fn setattr( + &self, + req: &Request, + ino: INodeNo, + _mode: Option, + _uid: Option, + _gid: Option, + size: Option, + _atime: Option, + mtime: Option, + _ctime: Option, + fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + let Some(rel) = self.inodes.path(ino.0) else { + reply.error(Errno::ENOENT); + return; + }; + let store = self.store_path(&rel); + + if let Some(new_size) = size { + if let Err(e) = self.ensure_materialized_any(&rel) { + reply.error(Errno::from(e)); + return; + } + let res = match fh.and_then(|fh| self.open_files.get(&fh.0).map(|f| f.file.try_clone())) { + Some(Ok(file)) => file.set_len(new_size), + _ => std::fs::File::options() + .write(true) + .open(&store) + .and_then(|file| file.set_len(new_size)), + }; + if let Err(e) = res { + reply.error(Errno::from(e)); + return; + } + } + + if let Some(mtime) = mtime { + if let Err(e) = self.ensure_materialized_any(&rel) { + reply.error(Errno::from(e)); + return; + } + let t = match mtime { + TimeOrNow::SpecificTime(t) => t, + TimeOrNow::Now => SystemTime::now(), + }; + let _ = std::fs::File::options() + .write(true) + .open(&store) + .map(|file| file.set_modified(t)); + } + + match self.attr_for(ino.0, &rel, req) { + Some(attr) => reply.attr(&TTL, &attr), + None => reply.error(Errno::ENOENT), + } + } + + fn mkdir( + &self, + req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + let Some(parent_rel) = self.inodes.path(parent.0) else { + reply.error(Errno::ENOENT); + return; + }; + let rel = parent_rel.join(name); + let store = self.store_path(&rel); + if !store.exists() && self.inventory_meta(&rel).is_some() { + reply.error(Errno::EEXIST); + return; + } + // The parent may be virtual — materialize the path chain first. + if let Some(p) = store.parent() + && let Err(e) = std::fs::create_dir_all(p) + { + reply.error(Errno::from(e)); + return; + } + if let Err(e) = std::fs::create_dir(&store) { + reply.error(Errno::from(e)); + return; + } + if mode != 0 { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&store, std::fs::Permissions::from_mode(mode)); + } + let ino = self.inodes.intern(rel.clone()); + match self.attr_for(ino, &rel, req) { + Some(attr) => reply.entry(&TTL, &attr, Generation(GENERATION)), + None => reply.error(Errno::EIO), + } + } + + fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let Some(parent_rel) = self.inodes.path(parent.0) else { + reply.error(Errno::ENOENT); + return; + }; + let rel = parent_rel.join(name); + let store = self.store_path(&rel); + if store.exists() { + match std::fs::remove_file(&store) { + Ok(()) => reply.ok(), + Err(e) => reply.error(Errno::from(e)), + } + return; + } + if self.inventory_meta(&rel).is_none() { + reply.error(Errno::ENOENT); + return; + } + // Virtual entry: no store file exists, so no watcher event will fire. + // Feed the remove through the synthesized-event path so the remote + // delete and inventory cleanup still happen. + self.emit_fs_event(store, EventKind::Remove(RemoveKind::File)); + reply.ok(); + } + + fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + let Some(parent_rel) = self.inodes.path(parent.0) else { + reply.error(Errno::ENOENT); + return; + }; + let rel = parent_rel.join(name); + let store = self.store_path(&rel); + if store.exists() { + match std::fs::remove_dir(&store) { + Ok(()) => reply.ok(), + Err(e) => reply.error(Errno::from(e)), + } + return; + } + if self.inventory_meta(&rel).is_none() { + reply.error(Errno::ENOENT); + return; + } + self.emit_fs_event(store, EventKind::Remove(RemoveKind::Folder)); + reply.ok(); + } + + fn rename( + &self, + _req: &Request, + parent: INodeNo, + name: &OsStr, + newparent: INodeNo, + newname: &OsStr, + _flags: fuser::RenameFlags, + reply: ReplyEmpty, + ) { + let (Some(from_rel), Some(to_parent_rel)) = + (self.inodes.path(parent.0), self.inodes.path(newparent.0)) + else { + reply.error(Errno::ENOENT); + return; + }; + let from_rel = from_rel.join(name); + let to_rel = to_parent_rel.join(newname); + let src_store = self.store_path(&from_rel); + let dst_store = self.store_path(&to_rel); + + // Remote first: the Rename command performs the server-side rename/move + // and registers event blockers so resulting watcher events are + // suppressed. Bail out without touching local state on failure. + let (tx, rx) = tokio::sync::oneshot::channel(); + if self + .ctx + .command_tx + .send(MountCommand::Rename { + source: src_store.clone(), + target: dst_store.clone(), + response: tx, + }) + .is_err() + { + reply.error(Errno::EIO); + return; + } + match rx.blocking_recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!( + target: "drive::fuse", + from = %src_store.display(), + to = %dst_store.display(), + error = %e, + "Remote rename failed" + ); + reply.error(Errno::EIO); + return; + } + Err(_) => { + reply.error(Errno::EIO); + return; + } + } + + // Local second: real rename when the entry exists in the store. For a + // virtual entry there is nothing to move — inventory gets updated by + // the Renamed command below. + if src_store.exists() { + if let Some(parent) = dst_store.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::rename(&src_store, &dst_store) { + reply.error(Errno::from(e)); + return; + } + } + + let _ = self.ctx.command_tx.send(MountCommand::Renamed { + source: src_store, + destination: dst_store, + }); + reply.ok(); + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let Some(rel) = self.inodes.path(ino.0) else { + reply.error(Errno::ENOENT); + return; + }; + let store = self.store_path(&rel); + let write_intent = flags.acc_mode() != OpenAccMode::O_RDONLY; + + if (write_intent || !store.exists()) + && let Err(e) = self.ensure_materialized(&rel) + { + reply.error(Errno::from(e)); + return; + } + + let mut options = std::fs::OpenOptions::new(); + options.read(true); + if write_intent { + options.write(true); + } + if flags.0 & libc::O_TRUNC != 0 { + options.truncate(true); + } + match options.open(&store) { + Ok(file) => { + let fh = self.register_file(rel, file); + reply.opened(fh, FopenFlags::empty()); + } + Err(e) => reply.error(Errno::from(e)), + } + } + + fn read( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyData, + ) { + let Some(entry) = self.open_files.get(&fh.0) else { + reply.error(Errno::EBADF); + return; + }; + let mut buf = vec![0u8; size as usize]; + match entry.file.read_at(&mut buf, offset) { + Ok(n) => { + buf.truncate(n); + reply.data(&buf); + } + Err(e) => reply.error(Errno::from(e)), + } + } + + fn write( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + offset: u64, + data: &[u8], + _write_flags: WriteFlags, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyWrite, + ) { + let Some(entry) = self.open_files.get(&fh.0) else { + reply.error(Errno::EBADF); + return; + }; + match entry.file.write_at(data, offset) { + Ok(n) => reply.written(n as u32), + Err(e) => reply.error(Errno::from(e)), + } + } + + fn flush( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + _lock_owner: LockOwner, + reply: ReplyEmpty, + ) { + if let Some(entry) = self.open_files.get(&fh.0) { + let _ = entry.file.sync_data(); + } + reply.ok(); + } + + fn release( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + _flags: OpenFlags, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + self.open_files.remove(&fh.0); + reply.ok(); + } + + fn fsync( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + _datasync: bool, + reply: ReplyEmpty, + ) { + if let Some(entry) = self.open_files.get(&fh.0) + && let Err(e) = entry.file.sync_data() + { + reply.error(Errno::from(e)); + return; + } + reply.ok(); + } + + fn opendir(&self, _req: &Request, _ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) { + reply.opened(FileHandle(0), FopenFlags::empty()); + } + + fn readdir(&self, _req: &Request, ino: INodeNo, _fh: FileHandle, offset: u64, mut reply: ReplyDirectory) { + let Some(rel) = self.inodes.path(ino.0) else { + reply.error(Errno::ENOENT); + return; + }; + let store = self.store_path(&rel); + + // Union store children with inventory (remote) children, deduplicated + // by name. The store is authoritative for entries it holds. + let mut seen: HashSet = HashSet::new(); + let mut entries: Vec<(OsString, FileType, u64)> = Vec::new(); + + if let Ok(read_dir) = std::fs::read_dir(&store) { + for entry in read_dir.flatten() { + let name = entry.file_name(); + let kind = entry + .file_type() + .ok() + .and_then(FileType::from_std) + .unwrap_or(FileType::RegularFile); + let child_rel = rel.join(&name); + let child_ino = self.inodes.intern(child_rel); + if seen.insert(name.clone()) { + entries.push((name, kind, child_ino)); + } + } + } + + if let Some(store_str) = store.to_str() + && let Ok(children) = self.ctx.inventory.query_children(&self.ctx.drive_id, store_str) + { + for child in children { + let child_path = PathBuf::from(&child.local_path); + let Some(name) = child_path.file_name().map(|n| n.to_os_string()) else { + continue; + }; + if !seen.insert(name.clone()) { + continue; + } + let child_rel = rel.join(&name); + let child_ino = self.inodes.intern(child_rel); + entries.push((name, Self::file_type_for(&child), child_ino)); + } + } + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let parent_rel = rel.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + let all: Vec<(u64, FileType, OsString)> = std::iter::once((ino.0, FileType::Directory, OsString::from("."))) + .chain(std::iter::once(( + self.inodes.intern(parent_rel), + FileType::Directory, + OsString::from(".."), + ))) + .chain(entries.into_iter().map(|(name, kind, child_ino)| (child_ino, kind, name))) + .collect(); + + for (i, (entry_ino, kind, name)) in all.iter().enumerate().skip(offset as usize) { + if reply.add(INodeNo(*entry_ino), (i + 1) as u64, *kind, name) { + break; + } + } + reply.ok(); + } + + fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { + let path = &self.ctx.data_root; + match statvfs(path) { + Some(stat) => reply.statfs( + stat.f_blocks, + stat.f_bfree, + stat.f_bavail, + stat.f_files, + stat.f_ffree, + stat.f_bsize as u32, + stat.f_namemax as u32, + stat.f_frsize as u32, + ), + None => reply.statfs(0, 0, 0, 0, 0, 4096, 255, 4096), + } + } + + fn access(&self, _req: &Request, _ino: INodeNo, _mask: AccessFlags, reply: ReplyEmpty) { + reply.ok(); + } + + fn create( + &self, + req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + flags: i32, + reply: ReplyCreate, + ) { + let Some(parent_rel) = self.inodes.path(parent.0) else { + reply.error(Errno::ENOENT); + return; + }; + let rel = parent_rel.join(name); + let store = self.store_path(&rel); + let virtual_entry = !store.exists() && self.inventory_meta(&rel).is_some(); + + if virtual_entry { + if flags & libc::O_EXCL != 0 && flags & libc::O_CREAT != 0 { + reply.error(Errno::EEXIST); + return; + } + // O_CREAT|O_TRUNC on a remote file must hydrate first: creating an + // empty store file would make the watcher upload the truncation + // over remote content. + if let Err(e) = self.ensure_materialized(&rel) { + reply.error(Errno::from(e)); + return; + } + } else if !store.exists() + && let Some(p) = store.parent() + && let Err(e) = std::fs::create_dir_all(p) + { + // New file under a possibly-virtual parent — materialize the path + // chain so the create below does not fail with ENOENT. + reply.error(Errno::from(e)); + return; + } + + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true); + if flags & libc::O_EXCL != 0 && flags & libc::O_CREAT != 0 { + options.create_new(true); + } else { + options.create(true); + } + if flags & libc::O_TRUNC != 0 { + options.truncate(true); + } + options.mode(mode & 0o7777); + + match options.open(&store) { + Ok(file) => { + let ino = self.inodes.intern(rel.clone()); + let fh = self.register_file(rel.clone(), file); + match self.attr_for(ino, &rel, req) { + Some(attr) => reply.created(&TTL, &attr, Generation(GENERATION), fh, FopenFlags::empty()), + None => reply.error(Errno::EIO), + } + } + Err(e) => reply.error(Errno::from(e)), + } + } +} + +/// statvfs wrapper so `statfs` reports real store capacity without pulling in +/// a full `nix` dependency. +fn statvfs(path: &Path) -> Option { + use std::os::unix::ffi::OsStrExt; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; + if rc == 0 { Some(stat) } else { None } +} + +/// Spawn the FUSE session in the background. The returned session unmounts on +/// drop; callers must keep it alive for the mount's lifetime. +pub fn spawn(fs: CloudreveFs, mountpoint: &Path) -> io::Result { + let mut config = fuser::Config::default(); + config.mount_options = vec![ + fuser::MountOption::FSName("cloudreve".to_string()), + fuser::MountOption::AutoUnmount, + ]; + config.n_threads = Some(4); + fuser::spawn_mount(fs, mountpoint, &config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inode_table_interns_paths_bidirectionally() { + let table = InodeTable::new(); + assert_eq!(table.path(1).as_deref(), Some(Path::new(""))); + + let ino_a = table.intern(PathBuf::from("dir/file.txt")); + let ino_b = table.intern(PathBuf::from("dir/other.txt")); + assert_ne!(ino_a, ino_b); + assert!(ino_a >= FIRST_INO); + + // Same path interns to the same inode. + assert_eq!(table.intern(PathBuf::from("dir/file.txt")), ino_a); + assert_eq!( + table.path(ino_a).as_deref(), + Some(Path::new("dir/file.txt")) + ); + } + + #[test] + fn tmp_dir_sits_next_to_data_root() { + let tmp = CloudreveFs::tmp_dir(Path::new("/x/fuse-store/d1"), "d1"); + assert_eq!(tmp, PathBuf::from("/x/fuse-store/.tmp-d1")); + } +} diff --git a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs index 179155cd..53efc2c2 100644 --- a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs +++ b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs @@ -175,12 +175,15 @@ impl DriveManager { .await .ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?; + let config = mount.get_config().await; + // Inventory rows are keyed by store path in on-demand mode; `path` + // arrives in mount space. + let store_path = config.mount_to_store(&path); let file_meta = self .inventory - .query_by_path(path.to_str().unwrap_or("")) + .query_by_path(store_path.to_str().unwrap_or("")) .context("Failed to query file metadata")?; - let config = mount.get_config().await; let (sync_path, remote_path) = { (config.sync_path.clone(), config.remote_path.to_string()) }; let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_path) diff --git a/desktop/crates/cloudreve-sync/src/drive/manager/mod.rs b/desktop/crates/cloudreve-sync/src/drive/manager/mod.rs index 62f48ec2..00fbdc85 100644 --- a/desktop/crates/cloudreve-sync/src/drive/manager/mod.rs +++ b/desktop/crates/cloudreve-sync/src/drive/manager/mod.rs @@ -234,7 +234,7 @@ impl DriveManager { // 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(); + let sync_path = mount_for_sync.config.read().await.data_root(); 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) diff --git a/desktop/crates/cloudreve-sync/src/drive/mod.rs b/desktop/crates/cloudreve-sync/src/drive/mod.rs index 5db080bf..45e5b09c 100644 --- a/desktop/crates/cloudreve-sync/src/drive/mod.rs +++ b/desktop/crates/cloudreve-sync/src/drive/mod.rs @@ -2,6 +2,8 @@ pub mod callback; pub mod commands; pub mod event_blocker; +#[cfg(target_os = "linux")] +pub mod fuse_fs; pub mod ignore; pub mod manager; pub mod mounts; diff --git a/desktop/crates/cloudreve-sync/src/drive/mounts.rs b/desktop/crates/cloudreve-sync/src/drive/mounts.rs index d9d60d25..550cf565 100644 --- a/desktop/crates/cloudreve-sync/src/drive/mounts.rs +++ b/desktop/crates/cloudreve-sync/src/drive/mounts.rs @@ -42,6 +42,20 @@ type MountConnection = Connection; #[cfg(not(windows))] type MountConnection = Connection<()>; +/// How a drive presents remote files locally. +/// +/// `Full` mirrors everything into the sync directory (the only mode on +/// Windows and the default everywhere). `OnDemand` mounts a FUSE filesystem at +/// `sync_path` backed by a sparse store directory: remote entries appear +/// immediately and their bytes are fetched on first open. Linux only. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DriveSyncMode { + #[default] + Full, + OnDemand, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DriveConfig { pub id: String, @@ -56,6 +70,11 @@ pub struct DriveConfig { pub enabled: bool, pub user_id: String, + /// Sync presentation mode. `ondemand` (FUSE) is only honored on Linux; + /// other platforms always behave as `full`. + #[serde(default)] + pub sync_mode: DriveSyncMode, + // Windows CFAPI pub sync_root_id: Option, @@ -75,6 +94,53 @@ pub struct Credentials { pub access_expires: Option, } +impl DriveConfig { + /// Directory the sync engine treats as the local root. + /// + /// In `full` mode this is `sync_path` itself. In `ondemand` mode the + /// FUSE mount occupies `sync_path` for presentation while real bytes live + /// in a private store — the watcher, task queue, inventory paths, and URI + /// mapping all operate here so engine IO never traverses the mount. + pub fn data_root(&self) -> PathBuf { + #[cfg(target_os = "linux")] + if self.sync_mode == DriveSyncMode::OnDemand { + return dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cloudreve") + .join("fuse-store") + .join(&self.id); + } + self.sync_path.clone() + } + + /// True when this drive serves its tree through the FUSE layer. + pub fn is_ondemand(&self) -> bool { + cfg!(target_os = "linux") && self.sync_mode == DriveSyncMode::OnDemand + } + + /// Translate a user-visible mount path into the engine's store-space path. + /// Identity outside on-demand mode or for paths already in store space. + pub fn mount_to_store(&self, path: &Path) -> PathBuf { + if self.is_ondemand() + && let Ok(rel) = path.strip_prefix(&self.sync_path) + { + return self.data_root().join(rel); + } + path.to_path_buf() + } + + /// Translate an engine store path back to the user-visible mount path. + /// Identity outside on-demand mode. + pub fn store_to_mount(&self, path: &Path) -> PathBuf { + if self.is_ondemand() + && let Ok(rel) = path.strip_prefix(self.data_root()) + { + return self.sync_path.join(rel); + } + path.to_path_buf() + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MountSyncStatus { InSync, @@ -159,6 +225,9 @@ pub struct Mount { pub ignore_matcher: RwLock, /// Status flags for the mount (credential expired, event push subscribed, etc.) status_flags: Mutex, + /// Live FUSE session for on-demand drives. Dropping it unmounts. + #[cfg(target_os = "linux")] + fuse_session: Mutex>, } impl Mount { @@ -221,13 +290,13 @@ impl Mount { cr_client_arc.clone(), inventory.clone(), queue_config, - config.sync_path.clone(), + config.data_root(), config.remote_path.clone(), ) .await; // Parse ignore patterns from config and sync-root ignore files - let sync_path = config.sync_path.clone(); + let sync_path = config.data_root(); let ignore_matcher = match build_ignore_matcher(&config.ignore_patterns, &sync_path) { Ok(matcher) => { if !matcher.is_empty() { @@ -270,6 +339,8 @@ impl Mount { event_blocker: EventBlocker::new(), ignore_matcher: RwLock::new(ignore_matcher), status_flags: Mutex::new(MountStatusFlags::new()), + #[cfg(target_os = "linux")] + fuse_session: Mutex::new(None), } } @@ -326,7 +397,7 @@ impl Mount { /// # Errors /// Returns an error if any pattern is invalid pub async fn update_ignore_patterns(&self, patterns: Vec) -> Result<()> { - let sync_path = self.config.read().await.sync_path.clone(); + let sync_path = self.config.read().await.data_root(); let new_matcher = build_ignore_matcher(&patterns, &sync_path)?; self.config.write().await.ignore_patterns = patterns; *self.ignore_matcher.write().await = new_matcher; @@ -338,7 +409,7 @@ impl Mount { pub async fn reload_ignore_patterns(&self) -> Result<()> { let (patterns, sync_path) = { let config = self.config.read().await; - (config.ignore_patterns.clone(), config.sync_path.clone()) + (config.ignore_patterns.clone(), config.data_root()) }; *self.ignore_matcher.write().await = build_ignore_matcher(&patterns, &sync_path)?; Ok(()) @@ -404,8 +475,53 @@ impl Mount { pub async fn start(&mut self) -> Result<()> { #[cfg(not(windows))] { - let sync_path = self.config.read().await.sync_path.clone(); - std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; + let (sync_path, data_root, ondemand) = { + let config = self.config.read().await; + ( + config.sync_path.clone(), + config.data_root(), + config.is_ondemand(), + ) + }; + std::fs::create_dir_all(&data_root).context("failed to create sync store directory")?; + + #[cfg(target_os = "linux")] + if ondemand { + // Sweep interrupted hydration leftovers from a previous run. + let tmp_dir = crate::drive::fuse_fs::CloudreveFs::tmp_dir( + &data_root, + &self.id, + ); + let _ = std::fs::remove_dir_all(&tmp_dir); + std::fs::create_dir_all(&sync_path) + .context("failed to create FUSE mountpoint")?; + let (remote_base, drive_id) = { + let config = self.config.read().await; + (config.remote_path.clone(), config.id.clone()) + }; + let fs = crate::drive::fuse_fs::CloudreveFs::new(Arc::new( + crate::drive::fuse_fs::FuseContext { + data_root: data_root.clone(), + remote_base, + drive_id, + inventory: self.inventory.clone(), + cr_client: self.cr_client.clone(), + command_tx: self.command_tx.clone(), + runtime: tokio::runtime::Handle::current(), + }, + )); + let session = crate::drive::fuse_fs::spawn(fs, &sync_path) + .context("failed to mount FUSE filesystem")?; + *self.fuse_session.lock().await = Some(session); + tracing::info!( + target: "drive::mounts", + id = %self.id, + mount = %sync_path.display(), + store = %data_root.display(), + "FUSE on-demand mount started" + ); + } + self.start_fs_watcher().await?; return Ok(()); } @@ -511,7 +627,7 @@ impl Mount { tracing::info!(target: "drive::mounts", id = %self.id, "Watching FS"); debouncer.watch( - &self.config.read().await.sync_path, + self.config.read().await.data_root(), RecursiveMode::Recursive, )?; *self.fs_watcher.lock().await = Some(debouncer); @@ -680,7 +796,7 @@ impl Mount { let sync_path = { let config = s_clone.config.read().await; - config.sync_path.clone() + config.data_root() }; let _ = s_clone.command_tx.send(MountCommand::Sync { local_paths: vec![sync_path], @@ -729,6 +845,25 @@ impl Mount { } } + // On-demand drives keep hydrated bytes in a private store directory; + // remove it once the FUSE session is gone so no cache lingers. + #[cfg(target_os = "linux")] + { + let config = self.config.read().await; + if config.is_ondemand() { + let store = config.data_root(); + let tmp = crate::drive::fuse_fs::CloudreveFs::tmp_dir(&store, &config.id); + drop(config); + for dir in [store, tmp] { + if let Err(e) = std::fs::remove_dir_all(&dir) + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(target: "drive::mounts", id=%self.id, path=%dir.display(), error=%e, "Failed to remove FUSE store directory"); + } + } + } + } + Ok(()) } @@ -747,6 +882,14 @@ impl Mount { drop(fs_watcher); } + // Unmount the FUSE session before the command channel closes so queued + // kernel callbacks don't block on a dead command processor. + #[cfg(target_os = "linux")] + if let Some(session) = self.fuse_session.lock().await.take() { + tracing::debug!(target: "drive::mounts", id=%self.id, "Unmounting FUSE session"); + drop(session); + } + // Close the command channel to signal the processor task to stop drop(self.command_tx.clone()); @@ -954,6 +1097,7 @@ mod tests { raw_icon_path: None, enabled: true, user_id: "user".to_string(), + sync_mode: DriveSyncMode::Full, sync_root_id: None, ignore_patterns: vec![], extra: HashMap::new(), @@ -997,4 +1141,64 @@ mod tests { // Command processor handle should be cleared. assert!(mount.processor_handle.lock().await.is_none()); } + + fn drive_config(mode: DriveSyncMode) -> DriveConfig { + DriveConfig { + id: "drive-1".to_string(), + name: "d".to_string(), + instance_url: "https://example.com".to_string(), + remote_path: "my:///".to_string(), + credentials: Credentials::default(), + sync_path: PathBuf::from("/mnt/cloudreve"), + icon_path: None, + raw_icon_path: None, + enabled: true, + user_id: "u".to_string(), + sync_mode: mode, + sync_root_id: None, + ignore_patterns: vec![], + extra: HashMap::new(), + } + } + + #[test] + fn full_mode_uses_sync_path_as_data_root() { + let config = drive_config(DriveSyncMode::Full); + assert_eq!(config.data_root(), PathBuf::from("/mnt/cloudreve")); + assert!(!config.is_ondemand()); + let p = PathBuf::from("/mnt/cloudreve/a/b.txt"); + assert_eq!(config.mount_to_store(&p), p); + assert_eq!(config.store_to_mount(&p), p); + } + + #[cfg(target_os = "linux")] + #[test] + fn ondemand_separates_mount_and_store() { + let config = drive_config(DriveSyncMode::OnDemand); + assert!(config.is_ondemand()); + let store = config.data_root(); + assert_ne!(store, config.sync_path); + assert!(store.ends_with("fuse-store/drive-1")); + + // Mount-space paths map into the store; store paths map back. + let mounted = PathBuf::from("/mnt/cloudreve/dir/file.txt"); + let stored = config.mount_to_store(&mounted); + assert_eq!(stored, store.join("dir/file.txt")); + assert_eq!(config.store_to_mount(&stored), mounted); + + // Paths outside the mount pass through unchanged. + let foreign = PathBuf::from("/elsewhere/file.txt"); + assert_eq!(config.mount_to_store(&foreign), foreign); + assert_eq!(config.store_to_mount(&foreign), foreign); + } + + #[cfg(target_os = "linux")] + #[test] + fn tmp_dir_is_store_sibling() { + let store = PathBuf::from("/home/u/.cloudreve/fuse-store/drive-1"); + let tmp = crate::drive::fuse_fs::CloudreveFs::tmp_dir(&store, "drive-1"); + assert_eq!(tmp, PathBuf::from("/home/u/.cloudreve/fuse-store/.tmp-drive-1")); + // Same parent directory => same filesystem => atomic rename works. + assert_eq!(tmp.parent(), store.parent()); + } } diff --git a/desktop/crates/cloudreve-sync/src/drive/remote_events.rs b/desktop/crates/cloudreve-sync/src/drive/remote_events.rs index e56b65ee..94813a88 100644 --- a/desktop/crates/cloudreve-sync/src/drive/remote_events.rs +++ b/desktop/crates/cloudreve-sync/src/drive/remote_events.rs @@ -62,7 +62,7 @@ impl Mount { let sync_path = { let config = s.config.read().await; - config.sync_path.clone() + config.data_root() }; loop { @@ -111,7 +111,7 @@ impl Mount { async fn listen_remote_events(&self) -> ListenResult { let (remote_base, sync_path) = { let config = self.config.read().await; - (config.remote_path.clone(), config.sync_path.clone()) + (config.remote_path.clone(), config.data_root()) }; let mut subscription = match self.cr_client.subscribe_file_events(&remote_base).await { diff --git a/desktop/crates/cloudreve-sync/src/drive/sync.rs b/desktop/crates/cloudreve-sync/src/drive/sync.rs index 2de7988c..c85d0324 100644 --- a/desktop/crates/cloudreve-sync/src/drive/sync.rs +++ b/desktop/crates/cloudreve-sync/src/drive/sync.rs @@ -804,7 +804,7 @@ impl Mount { // For sync root, directly walk to descendants let sync_root = { let config = self.config.read().await; - config.sync_path.clone() + config.data_root() }; if paths.len() == 1 && paths[0] == sync_root { tracing::debug!( @@ -904,7 +904,7 @@ impl Mount { ) -> Result<()> { let (drive_id, sync_root) = { let config = self.config.read().await; - (Uuid::parse_str(&config.id)?, config.sync_path.clone()) + (Uuid::parse_str(&config.id)?, config.data_root()) }; for action in actions { @@ -940,10 +940,13 @@ impl Mount { aggregate_error.push(path.clone(), err); } else { #[cfg(not(windows))] - if remote.file_type != file_type::FOLDER { + if remote.file_type != file_type::FOLDER + && !self.config.read().await.is_ondemand() + { // 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. + // download to create the real full-sync file on disk. On-demand + // (FUSE) drives skip this — bytes arrive on first open. if let Err(err) = self .task_queue .enqueue(TaskPayload::download(path.clone())) @@ -1024,10 +1027,12 @@ impl Mount { aggregate_error.push(path.clone(), err); } else { #[cfg(not(windows))] - if remote.file_type != file_type::FOLDER { + if remote.file_type != file_type::FOLDER + && !self.config.read().await.is_ondemand() + { // 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. + // Full-sync drives fetch contents immediately through the task + // queue; on-demand (FUSE) drives defer bytes to first open. if let Err(err) = self .task_queue .enqueue(TaskPayload::download(path.clone())) @@ -1073,6 +1078,46 @@ impl Mount { // Cancel ongoing tasks let _ = self.task_queue.cancel_by_path(path.clone()).await; + // On-demand drives never fetch eagerly: dropping the hydrated + // copy forces re-hydration on next open, which is the FUSE + // equivalent of re-downloading a changed remote file. + if self.config.read().await.is_ondemand() { + if let Ok(md) = std::fs::symlink_metadata(path) { + // Suppress the watcher Remove the eviction is about to + // emit — otherwise it propagates as a remote delete of + // the very file we want to re-fetch. + if md.is_dir() { + // A stale directory at a file's path must go too — + // otherwise it permanently shadows re-hydration. + self.event_blocker.register_prefix( + &EventKind::Remove(RemoveKind::Any), + path.clone(), + std::time::Duration::from_secs(15), + ); + } else { + self.event_blocker.register_once( + &EventKind::Remove(RemoveKind::Any), + path.clone(), + ); + } + let res = if md.is_dir() { + std::fs::remove_dir_all(path) + } else { + std::fs::remove_file(path) + }; + if let Err(err) = res { + tracing::warn!( + target: "drive::sync", + id = %self.id, + path = %path.display(), + error = %err, + "Failed to evict hydrated copy for re-fetch" + ); + } + } + return; + } + if let Err(err) = self .task_queue .enqueue(TaskPayload::download(path.clone())) @@ -1210,7 +1255,7 @@ impl Mount { let (remote_base, sync_root) = { let config = self.config.read().await; - (config.remote_path.clone(), config.sync_path.clone()) + (config.remote_path.clone(), config.data_root()) }; let mut target_remote_paths: HashMap = HashMap::with_capacity(paths.len()); @@ -1963,7 +2008,7 @@ impl Mount { ) -> Result<(Vec, HashMap)> { let (remote_base, sync_root) = { let config = self.config.read().await; - (config.remote_path.clone(), config.sync_path.clone()) + (config.remote_path.clone(), config.data_root()) }; let remote_dir_uri = diff --git a/desktop/crates/cloudreve-sync/src/inventory/db/file_metadata.rs b/desktop/crates/cloudreve-sync/src/inventory/db/file_metadata.rs index f8e81a3e..0c72f0a4 100644 --- a/desktop/crates/cloudreve-sync/src/inventory/db/file_metadata.rs +++ b/desktop/crates/cloudreve-sync/src/inventory/db/file_metadata.rs @@ -110,6 +110,38 @@ impl InventoryDb { row.map(FileMetadata::try_from).transpose() } + /// Query direct children of a directory path for one drive. + /// + /// Rows are keyed by absolute `local_path`, so children of `parent` are the + /// `parent/%` rows whose remaining path segment contains no further + /// separator. Depth filtering happens in Rust because SQLite LIKE cannot + /// express "exactly one path level". + pub fn query_children(&self, drive_id: &str, parent: &str) -> Result> { + let mut conn = self.connection()?; + let prefix = format!("{}/%", parent.trim_end_matches('/')); + let rows = file_metadata_dsl::file_metadata + .filter(file_metadata_dsl::drive_id.eq(drive_id)) + .filter(file_metadata_dsl::local_path.like(&prefix)) + .load::(&mut conn) + .context("Failed to query inventory children")?; + + let parent_len = parent.trim_end_matches('/').len() + 1; + let real_prefix = format!("{}/", parent.trim_end_matches('/')); + rows.into_iter() + .map(FileMetadata::try_from) + .filter(|meta| { + meta.as_ref() + .map(|m| { + // LIKE treats `_`/`%` in the parent path as wildcards, + // so verify the textual prefix before the depth check. + m.local_path.starts_with(&real_prefix) + && !m.local_path[parent_len..].contains('/') + }) + .unwrap_or(false) + }) + .collect() + } + /// Query file metadata by id pub fn query_by_id(&self, id: i64) -> Result> { let mut conn = self.connection()?; @@ -491,4 +523,54 @@ mod tests { ); assert!(db.has_drive_props(&drive_b).unwrap()); } + + #[test] + fn query_children_returns_direct_children_only() { + let (_dir, db) = test_db(); + let drive = Uuid::new_v4().to_string(); + let root = "/store/dir"; + + for (path, folder) in [ + ("/store/dir/a.txt", false), + ("/store/dir/sub", true), + ("/store/dir/sub/deep.txt", false), + ("/store/other.txt", false), + ] { + db.insert(&MetadataEntry::new( + Uuid::parse_str(&drive).unwrap(), + path.to_string(), + folder, + )) + .unwrap(); + } + + let children = db.query_children(&drive, root).unwrap(); + let mut names: Vec = children + .iter() + .map(|m| m.local_path.clone()) + .collect(); + names.sort(); + assert_eq!(names, vec!["/store/dir/a.txt", "/store/dir/sub"]); + } + + #[test] + fn query_children_does_not_confuse_like_wildcards() { + let (_dir, db) = test_db(); + let drive = Uuid::new_v4().to_string(); + + // "a_b" contains a LIKE single-char wildcard; "aXb" must not be + // absorbed as a child of "a_b". + for path in ["/store/a_b/real.txt", "/store/aXb/phantom.txt"] { + db.insert(&MetadataEntry::new( + Uuid::parse_str(&drive).unwrap(), + path.to_string(), + false, + )) + .unwrap(); + } + + let children = db.query_children(&drive, "/store/a_b").unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0].local_path, "/store/a_b/real.txt"); + } } diff --git a/desktop/crates/cloudreve-sync/tests/fuse_mount.rs b/desktop/crates/cloudreve-sync/tests/fuse_mount.rs new file mode 100644 index 00000000..3623fa13 --- /dev/null +++ b/desktop/crates/cloudreve-sync/tests/fuse_mount.rs @@ -0,0 +1,153 @@ +//! End-to-end smoke test for the Linux on-demand filesystem: mounts +//! `CloudreveFs` on a real FUSE mountpoint backed by a stub Cloudreve server +//! and exercises listing, attribute reads, and hydrate-on-open through the +//! kernel. Skips gracefully when FUSE is unavailable (CI containers). +#![cfg(target_os = "linux")] + +use std::{ffi::OsString, path::Path, sync::Arc}; + +use axum::{ + Json, Router, + routing::{get, post}, +}; +use cloudreve_api::{Client, ClientConfig}; +use cloudreve_sync::{ + drive::{commands::MountCommand, fuse_fs}, + inventory::{InventoryDb, MetadataEntry}, +}; +use tokio::sync::mpsc; +use uuid::Uuid; + +const FILE_BYTES: &[u8] = b"hello fuse world"; + +/// Minimal Cloudreve stub: `/file/url` points at `/dl/hello`, which serves the +/// file bytes. Authentication is irrelevant — the client just omits it. +async fn stub_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let download = format!("{base}/dl/hello"); + + let app = Router::new() + .route( + "/api/v3/file/url", + post(move || { + let download = download.clone(); + async move { + Json(serde_json::json!({ + "code": 0, + "msg": "", + "data": { + "urls": [{ "url": download }], + "expires": "2999-01-01T00:00:00Z", + }, + })) + } + }), + ) + .route("/dl/hello", get(|| async { FILE_BYTES })); + tokio::spawn(axum::serve(listener, app).into_future()); + base +} + +fn dir_names(path: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(path) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + names.sort(); + names +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fuse_mount_lists_and_hydrates_virtual_files() { + let base = stub_server().await; + + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("store"); + let mountpoint = tmp.path().join("mnt"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::create_dir_all(&mountpoint).unwrap(); + + let drive_id = Uuid::new_v4().to_string(); + let drive_uuid = Uuid::parse_str(&drive_id).unwrap(); + let inventory = Arc::new(InventoryDb::with_path(tmp.path().join("meta.db")).unwrap()); + + // Remote tree: docs/ with a single virtual file inside. + let remote_dir = store.join("docs"); + let remote_file = remote_dir.join("hello.txt"); + inventory + .insert(&MetadataEntry::new( + drive_uuid, + remote_dir.to_string_lossy().to_string(), + true, + )) + .unwrap(); + inventory + .insert( + &MetadataEntry::new(drive_uuid, remote_file.to_string_lossy().to_string(), false) + .with_etag("entity-1") + .with_size(FILE_BYTES.len() as i64), + ) + .unwrap(); + + // The fs sends synthesized watcher events for virtual-entry mutations; + // drain them so sends never fail. + let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { while command_rx.recv().await.is_some() {} }); + + let ctx = Arc::new(fuse_fs::FuseContext { + data_root: store.clone(), + remote_base: "cloudreve://my/".to_string(), + drive_id, + inventory: inventory.clone(), + cr_client: Arc::new(Client::new(ClientConfig::new(&base))), + command_tx, + runtime: tokio::runtime::Handle::current(), + }); + + let session = match fuse_fs::spawn(fuse_fs::CloudreveFs::new(ctx), &mountpoint) { + Ok(session) => session, + Err(e) => { + eprintln!("FUSE unavailable in this environment ({e}); skipping smoke test"); + return; + } + }; + + // Virtual remote tree is listed without any store content. + assert_eq!(dir_names(&mountpoint), vec![OsString::from("docs")]); + assert_eq!( + dir_names(&mountpoint.join("docs")), + vec![OsString::from("hello.txt")] + ); + + // Attributes come from remote metadata before hydration. + let md = std::fs::symlink_metadata(mountpoint.join("docs/hello.txt")).unwrap(); + assert!(md.is_file()); + assert_eq!(md.len(), FILE_BYTES.len() as u64); + assert!(!remote_file.exists(), "file must not be hydrated yet"); + + // First read hydrates through the stub server. + let content = std::fs::read(mountpoint.join("docs/hello.txt")).unwrap(); + assert_eq!(content, FILE_BYTES); + assert!( + remote_file.exists(), + "hydrated bytes must land in the store" + ); + + // The hydration snapshot keeps the engine from seeing the store write as + // a user modification. + let meta = inventory + .query_by_path(&remote_file.to_string_lossy()) + .unwrap() + .unwrap(); + assert_eq!(meta.local_size, Some(FILE_BYTES.len() as i64)); + assert!(meta.local_updated_at.is_some()); + + // Second read is served from the store. + assert_eq!(std::fs::read(mountpoint.join("docs/hello.txt")).unwrap(), FILE_BYTES); + + // A nonexistent path errors instead of hydrating. + assert!(std::fs::read(mountpoint.join("docs/missing.txt")).is_err()); + + drop(session); +} diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 238a2075..5eb1a542 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -85,6 +85,9 @@ pub struct AddDriveArgs { pub local_path: String, pub user_id: String, pub drive_id: Option, + /// "full" (default) mirrors everything; "ondemand" mounts a FUSE + /// filesystem with hydrate-on-open. Only honored on Linux. + pub sync_mode: Option, } /// Add a new drive configuration @@ -146,6 +149,11 @@ pub async fn add_drive( // Generate a new UUID for a new drive let drive_id = Uuid::new_v4().to_string(); + let sync_mode = match config.sync_mode.as_deref() { + Some("ondemand") => cloudreve_sync::drive::mounts::DriveSyncMode::OnDemand, + _ => cloudreve_sync::drive::mounts::DriveSyncMode::Full, + }; + let drive_config = DriveConfig { id: drive_id, name: config.drive_name, @@ -157,6 +165,7 @@ pub async fn add_drive( raw_icon_path: None, enabled: true, user_id: config.user_id, + sync_mode, sync_root_id: None, ignore_patterns: Vec::new(), extra: Default::default(), diff --git a/desktop/ui/public/locales/de/common.json b/desktop/ui/public/locales/de/common.json index 02d46dad..2d231a3d 100644 --- a/desktop/ui/public/locales/de/common.json +++ b/desktop/ui/public/locales/de/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Vollständige Synchronisierung", + "syncModeOndemand": "Bedarfsgesteuert", + "syncModeFullHint": "Alle Dateien werden in diesen Ordner gespiegelt und sind offline verfügbar.", + "syncModeOndemandHint": "Dateien erscheinen sofort und werden beim Öffnen heruntergeladen. Benötigt weniger Speicherplatz; für neue Dateien ist das Netzwerk erforderlich. Vorhandene Dateien im ausgewählten Ordner werden während der Einbindung ausgeblendet.", "finish": "Fertig", "settingUp": "Einrichtung läuft...", "successTitle": "Fertig!", diff --git a/desktop/ui/public/locales/en-US/common.json b/desktop/ui/public/locales/en-US/common.json index 2431712c..001adb21 100644 --- a/desktop/ui/public/locales/en-US/common.json +++ b/desktop/ui/public/locales/en-US/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Full sync", + "syncModeOndemand": "On-demand", + "syncModeFullHint": "All files are mirrored to this folder and available offline.", + "syncModeOndemandHint": "Files appear instantly and download when opened. Uses less disk space; needs the network for new files. Existing files in the selected folder are hidden while mounted.", "finish": "Finish", "settingUp": "We're setting things up...", "successTitle": "You're all set!", diff --git a/desktop/ui/public/locales/es/common.json b/desktop/ui/public/locales/es/common.json index 2f0ae630..75efb86a 100644 --- a/desktop/ui/public/locales/es/common.json +++ b/desktop/ui/public/locales/es/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Sincronización completa", + "syncModeOndemand": "Bajo demanda", + "syncModeFullHint": "Todos los archivos se reflejan en esta carpeta y están disponibles sin conexión.", + "syncModeOndemandHint": "Los archivos aparecen al instante y se descargan al abrirlos. Usa menos espacio en disco; los archivos nuevos necesitan la red. Los archivos existentes de la carpeta seleccionada quedan ocultos mientras está montada.", "finish": "Finalizar", "settingUp": "Configurando...", "successTitle": "¡Listo!", diff --git a/desktop/ui/public/locales/fr/common.json b/desktop/ui/public/locales/fr/common.json index 37161908..69aa463d 100644 --- a/desktop/ui/public/locales/fr/common.json +++ b/desktop/ui/public/locales/fr/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Synchronisation complète", + "syncModeOndemand": "À la demande", + "syncModeFullHint": "Tous les fichiers sont répliqués dans ce dossier et disponibles hors ligne.", + "syncModeOndemandHint": "Les fichiers apparaissent instantanément et se téléchargent à l'ouverture. Utilise moins d'espace disque ; les nouveaux fichiers nécessitent le réseau. Les fichiers existants du dossier sélectionné sont masqués pendant le montage.", "finish": "Terminer", "settingUp": "Configuration en cours...", "successTitle": "Terminé !", diff --git a/desktop/ui/public/locales/it/common.json b/desktop/ui/public/locales/it/common.json index 35c1bc05..f9e05707 100644 --- a/desktop/ui/public/locales/it/common.json +++ b/desktop/ui/public/locales/it/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Sincronizzazione completa", + "syncModeOndemand": "Su richiesta", + "syncModeFullHint": "Tutti i file vengono copiati in questa cartella e sono disponibili offline.", + "syncModeOndemandHint": "I file compaiono subito e vengono scaricati all'apertura. Usa meno spazio su disco; i nuovi file richiedono la rete. I file esistenti nella cartella selezionata restano nascosti mentre è montata.", "finish": "Fine", "settingUp": "Configurazione in corso...", "successTitle": "Fatto!", diff --git a/desktop/ui/public/locales/ja/common.json b/desktop/ui/public/locales/ja/common.json index 8866ba0a..233b190c 100644 --- a/desktop/ui/public/locales/ja/common.json +++ b/desktop/ui/public/locales/ja/common.json @@ -18,6 +18,10 @@ "selectFolder": "フォルダを選択", "folderNotEmpty": "選択したフォルダは空ではありません。空のフォルダを選択してください。", "folderNotEmptyWillSync": "このフォルダには既にファイルがあります。既存のローカル内容はリモートドライブに統合され、同名でハッシュが一致するファイルは同期済みとして扱われます。", + "syncModeFull": "完全同期", + "syncModeOndemand": "オンデマンド", + "syncModeFullHint": "すべてのファイルがこのフォルダーにミラーリングされ、オフラインで利用できます。", + "syncModeOndemandHint": "ファイルはすぐに表示され、開いたときにダウンロードされます。ディスク使用量を抑えられますが、新しいファイルにはネットワークが必要です。マウント中、選択したフォルダー内の既存ファイルは非表示になります。", "finish": "完了", "settingUp": "設定中...", "successTitle": "設定完了!", diff --git a/desktop/ui/public/locales/ko/common.json b/desktop/ui/public/locales/ko/common.json index c00969b3..e13e89ff 100644 --- a/desktop/ui/public/locales/ko/common.json +++ b/desktop/ui/public/locales/ko/common.json @@ -18,6 +18,10 @@ "selectFolder": "폴더 선택", "folderNotEmpty": "선택한 폴더가 비어 있지 않습니다. 빈 폴더를 선택해 주세요.", "folderNotEmptyWillSync": "이 폴더에는 이미 파일이 있습니다. 기존 로컬 콘텐츠가 원격 드라이브에 병합되며, 이름이 같고 해시가 일치하는 파일은 이미 동기화된 것으로 처리됩니다.", + "syncModeFull": "전체 동기화", + "syncModeOndemand": "주문형", + "syncModeFullHint": "모든 파일이 이 폴더에 미러링되어 오프라인에서 사용할 수 있습니다.", + "syncModeOndemandHint": "파일이 즉시 표시되고 열 때 다운로드됩니다. 디스크 공간을 적게 사용하지만 새 파일에는 네트워크가 필요합니다. 마운트되는 동안 선택한 폴더의 기존 파일은 숨겨집니다.", "finish": "완료", "settingUp": "설정 중...", "successTitle": "설정 완료!", diff --git a/desktop/ui/public/locales/pl/common.json b/desktop/ui/public/locales/pl/common.json index 80b970d9..e6edf386 100644 --- a/desktop/ui/public/locales/pl/common.json +++ b/desktop/ui/public/locales/pl/common.json @@ -18,6 +18,10 @@ "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.", + "syncModeFull": "Pełna synchronizacja", + "syncModeOndemand": "Na żądanie", + "syncModeFullHint": "Wszystkie pliki są kopiowane do tego folderu i dostępne offline.", + "syncModeOndemandHint": "Pliki pojawiają się od razu i są pobierane przy otwarciu. Zajmuje mniej miejsca na dysku; nowe pliki wymagają sieci. Istniejące pliki w wybranym folderze są ukryte podczas montowania.", "finish": "Zakończ", "settingUp": "Konfigurowanie...", "successTitle": "Gotowe!", diff --git a/desktop/ui/public/locales/ru/common.json b/desktop/ui/public/locales/ru/common.json index 855c0e92..6eb913fc 100644 --- a/desktop/ui/public/locales/ru/common.json +++ b/desktop/ui/public/locales/ru/common.json @@ -18,6 +18,10 @@ "selectFolder": "Выбрать папку", "folderNotEmpty": "Выбранная папка не пуста. Пожалуйста, выберите пустую папку.", "folderNotEmptyWillSync": "В этой папке уже есть файлы. Существующее локальное содержимое будет объединено с удалённым диском; файлы с одинаковыми именами и совпадающими хешами будут считаться уже синхронизированными.", + "syncModeFull": "Полная синхронизация", + "syncModeOndemand": "По запросу", + "syncModeFullHint": "Все файлы зеркалируются в эту папку и доступны офлайн.", + "syncModeOndemandHint": "Файлы появляются мгновенно и загружаются при открытии. Занимает меньше места на диске; для новых файлов нужна сеть. Существующие файлы в выбранной папке скрываются, пока диск смонтирован.", "finish": "Готово", "settingUp": "Настройка...", "successTitle": "Готово!", diff --git a/desktop/ui/public/locales/zh-CN/common.json b/desktop/ui/public/locales/zh-CN/common.json index adc2c150..c4bbe0be 100644 --- a/desktop/ui/public/locales/zh-CN/common.json +++ b/desktop/ui/public/locales/zh-CN/common.json @@ -18,6 +18,10 @@ "selectFolder": "选择文件夹", "folderNotEmpty": "所选文件夹不为空,请选择一个空文件夹。", "folderNotEmptyWillSync": "此文件夹已有文件。本地已有内容将合并到远程网盘;同名且哈希匹配的文件会被视为已同步。", + "syncModeFull": "完整同步", + "syncModeOndemand": "按需同步", + "syncModeFullHint": "所有文件都会镜像到此文件夹,可离线使用。", + "syncModeOndemandHint": "文件即刻显示,打开时才下载。占用更少磁盘空间;访问新文件需要网络。挂载期间,所选文件夹中的现有文件将被隐藏。", "finish": "完成", "settingUp": "正在配置中...", "successTitle": "设置完成!", diff --git a/desktop/ui/public/locales/zh-TW/common.json b/desktop/ui/public/locales/zh-TW/common.json index 091cbfe7..12a60dcb 100644 --- a/desktop/ui/public/locales/zh-TW/common.json +++ b/desktop/ui/public/locales/zh-TW/common.json @@ -18,6 +18,10 @@ "selectFolder": "選擇資料夾", "folderNotEmpty": "所選資料夾不為空,請選擇一個空資料夾。", "folderNotEmptyWillSync": "此資料夾已有檔案。本機既有內容將合併到遠端網盤;同名且雜湊相符的檔案會被視為已同步。", + "syncModeFull": "完整同步", + "syncModeOndemand": "按需同步", + "syncModeFullHint": "所有檔案都會鏡像到此資料夾,可離線使用。", + "syncModeOndemandHint": "檔案即刻顯示,開啟時才下載。佔用更少磁碟空間;存取新檔案需要網路。掛載期間,所選資料夾中的現有檔案將被隱藏。", "finish": "完成", "settingUp": "正在設定中...", "successTitle": "設定完成!", diff --git a/desktop/ui/src/pages/AddDrive.tsx b/desktop/ui/src/pages/AddDrive.tsx index be8aa57f..03445525 100644 --- a/desktop/ui/src/pages/AddDrive.tsx +++ b/desktop/ui/src/pages/AddDrive.tsx @@ -1,4 +1,4 @@ -import { Alert, Box, Button, CircularProgress, Container, IconButton, InputAdornment, Snackbar, Typography } from "@mui/material"; +import { Alert, Box, Button, CircularProgress, Container, FormControlLabel, IconButton, InputAdornment, Radio, RadioGroup, Snackbar, Typography } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { openUrl, openPath } from "@tauri-apps/plugin-opener"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; @@ -20,6 +20,7 @@ import { } from "../utils/siteValidation"; import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { platform } from "@tauri-apps/plugin-os"; import { CALLBACK_PATH, CLIENT_ID, SCOPES } from "../utils/constants"; type PageState = "url_input" | "waiting" | "final_setup" | "setting_up" | "success"; @@ -99,6 +100,8 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) { const [pageState, setPageState] = useState(isReauthorize ? "url_input" : "url_input"); const [localPath, setLocalPath] = useState(""); const [folderNotEmpty, setFolderNotEmpty] = useState(false); + const isLinux = platform() === "linux"; + const [syncMode, setSyncMode] = useState<"full" | "ondemand">("full"); const [driveName, setDriveName] = useState(driveNameQuery ? decodeURIComponent(driveNameQuery) : ""); const lastFetchedUrl = useRef(""); const currentIconUrl = useRef(undefined); @@ -349,6 +352,7 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) { remote_path: pkceSessionRef.current!.callbackData!.path, user_id: pkceSessionRef.current!.callbackData!.user_id || "", drive_id: isReauthorize ? driveId : undefined, + sync_mode: isLinux ? syncMode : "full", } }); // Success - switch to success state @@ -573,6 +577,37 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) { )} )} + {isLinux && ( + <> + setSyncMode(e.target.value as "full" | "ondemand")} + > + } + label={t("addDrive.syncModeFull", "Full sync")} + /> + } + label={t("addDrive.syncModeOndemand", "On-demand")} + /> + + + {syncMode === "ondemand" + ? t( + "addDrive.syncModeOndemandHint", + "Files appear instantly and download when opened. Uses less disk space; needs the network for new files. Existing files in the selected folder are hidden while mounted." + ) + : t( + "addDrive.syncModeFullHint", + "All files are mirrored to this folder and available offline." + )} + + + )} )}