Merge pull request #235 from Dvorinka/feat/linux-fuse

feat(desktop): Linux on-demand hydration via FUSE
pull/3593/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit bc75984b3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -245,10 +245,11 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo.
| Notifications | win32_notif | `mac_notification_sys` | `notify_rust` | | Notifications | win32_notif | `mac_notification_sys` | `notify_rust` |
| Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same | | 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. - 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). - 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] #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/<id>`) 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) ## 7. Phase E — Android app (native, no iOS)

@ -51,6 +51,8 @@ win32_notif = { path = "../win32_notif" }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
notify-rust = "4.11" notify-rust = "4.11"
fuser = "0.18"
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "0.6" mac-notification-sys = "0.6"

@ -221,7 +221,7 @@ impl Mount {
) -> Result<()> { ) -> Result<()> {
let config = self.config.read().await; let config = self.config.read().await;
let remote_base = config.remote_path.clone(); let remote_base = config.remote_path.clone();
let sync_path = config.sync_path.clone(); let sync_path = config.data_root();
drop(config); drop(config);
let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) 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<GetPlacehodlerResult> { pub async fn fetch_placeholders(&self, path: PathBuf) -> Result<GetPlacehodlerResult> {
let config = self.config.read().await; let config = self.config.read().await;
let remote_base = config.remote_path.clone(); let remote_base = config.remote_path.clone();
let sync_path = config.sync_path.clone(); let sync_path = config.data_root();
drop(config); drop(config);
let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) 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<Bytes> { pub async fn generate_thumbnail(&self, path: PathBuf) -> Result<Bytes> {
// `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 let file_meta = self
.inventory .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")? .context("failed to query metadata by path")?
.ok_or_else(|| anyhow::anyhow!("no metadata found for path: {:?}", 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`, /// Create a public share link for the file or folder at `path`,
/// returning the share URL. /// returning the share URL.
pub async fn create_share_link(&self, path: PathBuf) -> Result<String> { pub async fn create_share_link(&self, path: PathBuf) -> Result<String> {
// `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 (sync_path, remote_base) = {
let config = self.config.read().await; let config = self.config.read().await;
(config.sync_path.clone(), config.remote_path.to_string()) (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<()> { pub async fn rename(&self, source: PathBuf, target: PathBuf) -> Result<()> {
let (sync_path, remote_path) = { let (sync_path, remote_path) = {
let config = self.config.read().await; 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 // 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 // Extract configuration once to avoid repeated lock acquisition
let (sync_path, remote_base) = { let (sync_path, remote_base) = {
let config = self.config.read().await; 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 = let path_uri_mappings =
@ -766,7 +771,7 @@ impl Mount {
let (sync_root, drive_id) = { let (sync_root, drive_id) = {
let config = self.config.read().await; let config = self.config.read().await;
( (
config.sync_path.clone(), config.data_root(),
Uuid::parse_str(&config.id).context("invalid drive ID")?, 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 // Also queue when the recorded local snapshot no longer matches
// the on-disk state even though the IN_SYNC flag looks set - the // the on-disk state even though the IN_SYNC flag looks set - the
// flag may be stale after a race with a metadata refresh. // flag may be stale after a race with a metadata refresh.
let snapshot_differs = match path.to_str() { let meta = match path.to_str() {
Some(path_str) => self Some(path_str) => self.inventory.query_by_path(path_str).ok().flatten(),
.inventory None => None,
.query_by_path(path_str)
.map(|entry| {
entry.is_some_and(|meta| local_snapshot_differs(&meta, &placeholder_info))
})
.unwrap_or(false),
None => false,
}; };
let snapshot_differs = meta
if !placeholder_info.in_sync() || snapshot_differs { .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"); tracing::debug!(target: "drive::commands", path = %path.display(), "Queuing upload task for modified file");
let payload = TaskPayload::upload(path.clone()); let payload = TaskPayload::upload(path.clone());
let result = self let result = self
@ -1100,6 +1109,23 @@ impl Mount {
); );
for (_remote_uri, path) in path_uri_mappings { 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()); let payload = TaskPayload::upload(path.clone());
self.task_queue self.task_queue

File diff suppressed because it is too large Load Diff

@ -175,12 +175,15 @@ impl DriveManager {
.await .await
.ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?; .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 let file_meta = self
.inventory .inventory
.query_by_path(path.to_str().unwrap_or("")) .query_by_path(store_path.to_str().unwrap_or(""))
.context("Failed to query file metadata")?; .context("Failed to query file metadata")?;
let config = mount.get_config().await;
let (sync_path, remote_path) = let (sync_path, remote_path) =
{ (config.sync_path.clone(), config.remote_path.to_string()) }; { (config.sync_path.clone(), config.remote_path.to_string()) };
let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_path) let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_path)

@ -234,7 +234,7 @@ impl DriveManager {
// Spawn initial sync in the background so add_drive returns immediately // Spawn initial sync in the background so add_drive returns immediately
let mount_for_sync = mount_arc.clone(); let mount_for_sync = mount_arc.clone();
let initial_sync_handle = tokio::spawn(async move { 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"); tracing::info!(target: "drive", id = %mount_for_sync.id, path = %sync_path.display(), "Starting background initial sync");
if let Err(e) = mount_for_sync if let Err(e) = mount_for_sync
.sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy) .sync_paths(vec![sync_path], crate::drive::sync::SyncMode::FullHierarchy)

@ -2,6 +2,8 @@
pub mod callback; pub mod callback;
pub mod commands; pub mod commands;
pub mod event_blocker; pub mod event_blocker;
#[cfg(target_os = "linux")]
pub mod fuse_fs;
pub mod ignore; pub mod ignore;
pub mod manager; pub mod manager;
pub mod mounts; pub mod mounts;

@ -42,6 +42,20 @@ type MountConnection = Connection<CallbackHandler>;
#[cfg(not(windows))] #[cfg(not(windows))]
type MountConnection = Connection<()>; 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DriveConfig { pub struct DriveConfig {
pub id: String, pub id: String,
@ -56,6 +70,11 @@ pub struct DriveConfig {
pub enabled: bool, pub enabled: bool,
pub user_id: String, 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 // Windows CFAPI
pub sync_root_id: Option<SyncRootId>, pub sync_root_id: Option<SyncRootId>,
@ -75,6 +94,53 @@ pub struct Credentials {
pub access_expires: Option<String>, pub access_expires: Option<String>,
} }
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MountSyncStatus { pub enum MountSyncStatus {
InSync, InSync,
@ -159,6 +225,9 @@ pub struct Mount {
pub ignore_matcher: RwLock<IgnoreMatcher>, pub ignore_matcher: RwLock<IgnoreMatcher>,
/// Status flags for the mount (credential expired, event push subscribed, etc.) /// Status flags for the mount (credential expired, event push subscribed, etc.)
status_flags: Mutex<MountStatusFlags>, status_flags: Mutex<MountStatusFlags>,
/// Live FUSE session for on-demand drives. Dropping it unmounts.
#[cfg(target_os = "linux")]
fuse_session: Mutex<Option<fuser::BackgroundSession>>,
} }
impl Mount { impl Mount {
@ -221,13 +290,13 @@ impl Mount {
cr_client_arc.clone(), cr_client_arc.clone(),
inventory.clone(), inventory.clone(),
queue_config, queue_config,
config.sync_path.clone(), config.data_root(),
config.remote_path.clone(), config.remote_path.clone(),
) )
.await; .await;
// Parse ignore patterns from config and sync-root ignore files // 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) { let ignore_matcher = match build_ignore_matcher(&config.ignore_patterns, &sync_path) {
Ok(matcher) => { Ok(matcher) => {
if !matcher.is_empty() { if !matcher.is_empty() {
@ -270,6 +339,8 @@ impl Mount {
event_blocker: EventBlocker::new(), event_blocker: EventBlocker::new(),
ignore_matcher: RwLock::new(ignore_matcher), ignore_matcher: RwLock::new(ignore_matcher),
status_flags: Mutex::new(MountStatusFlags::new()), status_flags: Mutex::new(MountStatusFlags::new()),
#[cfg(target_os = "linux")]
fuse_session: Mutex::new(None),
} }
} }
@ -326,7 +397,7 @@ impl Mount {
/// # Errors /// # Errors
/// Returns an error if any pattern is invalid /// Returns an error if any pattern is invalid
pub async fn update_ignore_patterns(&self, patterns: Vec<String>) -> Result<()> { pub async fn update_ignore_patterns(&self, patterns: Vec<String>) -> 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)?; let new_matcher = build_ignore_matcher(&patterns, &sync_path)?;
self.config.write().await.ignore_patterns = patterns; self.config.write().await.ignore_patterns = patterns;
*self.ignore_matcher.write().await = new_matcher; *self.ignore_matcher.write().await = new_matcher;
@ -338,7 +409,7 @@ impl Mount {
pub async fn reload_ignore_patterns(&self) -> Result<()> { pub async fn reload_ignore_patterns(&self) -> Result<()> {
let (patterns, sync_path) = { let (patterns, sync_path) = {
let config = self.config.read().await; 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)?; *self.ignore_matcher.write().await = build_ignore_matcher(&patterns, &sync_path)?;
Ok(()) Ok(())
@ -404,8 +475,53 @@ impl Mount {
pub async fn start(&mut self) -> Result<()> { pub async fn start(&mut self) -> Result<()> {
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let sync_path = self.config.read().await.sync_path.clone(); let (sync_path, data_root, ondemand) = {
std::fs::create_dir_all(&sync_path).context("failed to create sync directory")?; 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?; self.start_fs_watcher().await?;
return Ok(()); return Ok(());
} }
@ -511,7 +627,7 @@ impl Mount {
tracing::info!(target: "drive::mounts", id = %self.id, "Watching FS"); tracing::info!(target: "drive::mounts", id = %self.id, "Watching FS");
debouncer.watch( debouncer.watch(
&self.config.read().await.sync_path, self.config.read().await.data_root(),
RecursiveMode::Recursive, RecursiveMode::Recursive,
)?; )?;
*self.fs_watcher.lock().await = Some(debouncer); *self.fs_watcher.lock().await = Some(debouncer);
@ -680,7 +796,7 @@ impl Mount {
let sync_path = { let sync_path = {
let config = s_clone.config.read().await; let config = s_clone.config.read().await;
config.sync_path.clone() config.data_root()
}; };
let _ = s_clone.command_tx.send(MountCommand::Sync { let _ = s_clone.command_tx.send(MountCommand::Sync {
local_paths: vec![sync_path], 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(()) Ok(())
} }
@ -747,6 +882,14 @@ impl Mount {
drop(fs_watcher); 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 // Close the command channel to signal the processor task to stop
drop(self.command_tx.clone()); drop(self.command_tx.clone());
@ -954,6 +1097,7 @@ mod tests {
raw_icon_path: None, raw_icon_path: None,
enabled: true, enabled: true,
user_id: "user".to_string(), user_id: "user".to_string(),
sync_mode: DriveSyncMode::Full,
sync_root_id: None, sync_root_id: None,
ignore_patterns: vec![], ignore_patterns: vec![],
extra: HashMap::new(), extra: HashMap::new(),
@ -997,4 +1141,64 @@ mod tests {
// Command processor handle should be cleared. // Command processor handle should be cleared.
assert!(mount.processor_handle.lock().await.is_none()); 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());
}
} }

@ -62,7 +62,7 @@ impl Mount {
let sync_path = { let sync_path = {
let config = s.config.read().await; let config = s.config.read().await;
config.sync_path.clone() config.data_root()
}; };
loop { loop {
@ -111,7 +111,7 @@ impl Mount {
async fn listen_remote_events(&self) -> ListenResult { async fn listen_remote_events(&self) -> ListenResult {
let (remote_base, sync_path) = { let (remote_base, sync_path) = {
let config = self.config.read().await; 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 { let mut subscription = match self.cr_client.subscribe_file_events(&remote_base).await {

@ -804,7 +804,7 @@ impl Mount {
// For sync root, directly walk to descendants // For sync root, directly walk to descendants
let sync_root = { let sync_root = {
let config = self.config.read().await; let config = self.config.read().await;
config.sync_path.clone() config.data_root()
}; };
if paths.len() == 1 && paths[0] == sync_root { if paths.len() == 1 && paths[0] == sync_root {
tracing::debug!( tracing::debug!(
@ -904,7 +904,7 @@ impl Mount {
) -> Result<()> { ) -> Result<()> {
let (drive_id, sync_root) = { let (drive_id, sync_root) = {
let config = self.config.read().await; 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 { for action in actions {
@ -940,10 +940,13 @@ impl Mount {
aggregate_error.push(path.clone(), err); aggregate_error.push(path.clone(), err);
} else { } else {
#[cfg(not(windows))] #[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. // Non-Windows `CrPlaceholder` only records inventory metadata here.
// There is no CFAPI-style dehydrated placeholder, so queue a normal // 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 if let Err(err) = self
.task_queue .task_queue
.enqueue(TaskPayload::download(path.clone())) .enqueue(TaskPayload::download(path.clone()))
@ -1024,10 +1027,12 @@ impl Mount {
aggregate_error.push(path.clone(), err); aggregate_error.push(path.clone(), err);
} else { } else {
#[cfg(not(windows))] #[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. // Non-Windows `CrPlaceholder` only refreshes inventory metadata here.
// Since Linux has no on-demand placeholder backend, the real file // Full-sync drives fetch contents immediately through the task
// contents must be downloaded immediately through the task queue. // queue; on-demand (FUSE) drives defer bytes to first open.
if let Err(err) = self if let Err(err) = self
.task_queue .task_queue
.enqueue(TaskPayload::download(path.clone())) .enqueue(TaskPayload::download(path.clone()))
@ -1073,6 +1078,46 @@ impl Mount {
// Cancel ongoing tasks // Cancel ongoing tasks
let _ = self.task_queue.cancel_by_path(path.clone()).await; 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 if let Err(err) = self
.task_queue .task_queue
.enqueue(TaskPayload::download(path.clone())) .enqueue(TaskPayload::download(path.clone()))
@ -1210,7 +1255,7 @@ impl Mount {
let (remote_base, sync_root) = { let (remote_base, sync_root) = {
let config = self.config.read().await; 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<String, PathBuf> = HashMap::with_capacity(paths.len()); let mut target_remote_paths: HashMap<String, PathBuf> = HashMap::with_capacity(paths.len());
@ -1963,7 +2008,7 @@ impl Mount {
) -> Result<(Vec<PathBuf>, HashMap<PathBuf, FileResponse>)> { ) -> Result<(Vec<PathBuf>, HashMap<PathBuf, FileResponse>)> {
let (remote_base, sync_root) = { let (remote_base, sync_root) = {
let config = self.config.read().await; 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 = let remote_dir_uri =

@ -110,6 +110,38 @@ impl InventoryDb {
row.map(FileMetadata::try_from).transpose() 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<Vec<FileMetadata>> {
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::<FileMetadataRow>(&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 /// Query file metadata by id
pub fn query_by_id(&self, id: i64) -> Result<Option<FileMetadata>> { pub fn query_by_id(&self, id: i64) -> Result<Option<FileMetadata>> {
let mut conn = self.connection()?; let mut conn = self.connection()?;
@ -491,4 +523,54 @@ mod tests {
); );
assert!(db.has_drive_props(&drive_b).unwrap()); 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<String> = 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");
}
} }

@ -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<OsString> {
let mut names: Vec<OsString> = 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::<MountCommand>();
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);
}

@ -85,6 +85,9 @@ pub struct AddDriveArgs {
pub local_path: String, pub local_path: String,
pub user_id: String, pub user_id: String,
pub drive_id: Option<String>, pub drive_id: Option<String>,
/// "full" (default) mirrors everything; "ondemand" mounts a FUSE
/// filesystem with hydrate-on-open. Only honored on Linux.
pub sync_mode: Option<String>,
} }
/// Add a new drive configuration /// Add a new drive configuration
@ -146,6 +149,11 @@ pub async fn add_drive(
// Generate a new UUID for a new drive // Generate a new UUID for a new drive
let drive_id = Uuid::new_v4().to_string(); 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 { let drive_config = DriveConfig {
id: drive_id, id: drive_id,
name: config.drive_name, name: config.drive_name,
@ -157,6 +165,7 @@ pub async fn add_drive(
raw_icon_path: None, raw_icon_path: None,
enabled: true, enabled: true,
user_id: config.user_id, user_id: config.user_id,
sync_mode,
sync_root_id: None, sync_root_id: None,
ignore_patterns: Vec::new(), ignore_patterns: Vec::new(),
extra: Default::default(), extra: Default::default(),

@ -18,6 +18,10 @@
"selectFolder": "Ordner auswählen", "selectFolder": "Ordner auswählen",
"folderNotEmpty": "Der ausgewählte Ordner ist nicht leer. Bitte wählen Sie einen leeren Ordner.", "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.", "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", "finish": "Fertig",
"settingUp": "Einrichtung läuft...", "settingUp": "Einrichtung läuft...",
"successTitle": "Fertig!", "successTitle": "Fertig!",

@ -18,6 +18,10 @@
"selectFolder": "Select Folder", "selectFolder": "Select Folder",
"folderNotEmpty": "The selected folder is not empty. Please choose an empty 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.", "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", "finish": "Finish",
"settingUp": "We're setting things up...", "settingUp": "We're setting things up...",
"successTitle": "You're all set!", "successTitle": "You're all set!",

@ -18,6 +18,10 @@
"selectFolder": "Seleccionar carpeta", "selectFolder": "Seleccionar carpeta",
"folderNotEmpty": "La carpeta seleccionada no está vacía. Por favor, elija una carpeta vacía.", "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.", "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", "finish": "Finalizar",
"settingUp": "Configurando...", "settingUp": "Configurando...",
"successTitle": "¡Listo!", "successTitle": "¡Listo!",

@ -18,6 +18,10 @@
"selectFolder": "Sélectionner un dossier", "selectFolder": "Sélectionner un dossier",
"folderNotEmpty": "Le dossier sélectionné n'est pas vide. Veuillez choisir un dossier vide.", "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.", "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", "finish": "Terminer",
"settingUp": "Configuration en cours...", "settingUp": "Configuration en cours...",
"successTitle": "Terminé !", "successTitle": "Terminé !",

@ -18,6 +18,10 @@
"selectFolder": "Seleziona cartella", "selectFolder": "Seleziona cartella",
"folderNotEmpty": "La cartella selezionata non è vuota. Seleziona una cartella vuota.", "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.", "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", "finish": "Fine",
"settingUp": "Configurazione in corso...", "settingUp": "Configurazione in corso...",
"successTitle": "Fatto!", "successTitle": "Fatto!",

@ -18,6 +18,10 @@
"selectFolder": "フォルダを選択", "selectFolder": "フォルダを選択",
"folderNotEmpty": "選択したフォルダは空ではありません。空のフォルダを選択してください。", "folderNotEmpty": "選択したフォルダは空ではありません。空のフォルダを選択してください。",
"folderNotEmptyWillSync": "このフォルダには既にファイルがあります。既存のローカル内容はリモートドライブに統合され、同名でハッシュが一致するファイルは同期済みとして扱われます。", "folderNotEmptyWillSync": "このフォルダには既にファイルがあります。既存のローカル内容はリモートドライブに統合され、同名でハッシュが一致するファイルは同期済みとして扱われます。",
"syncModeFull": "完全同期",
"syncModeOndemand": "オンデマンド",
"syncModeFullHint": "すべてのファイルがこのフォルダーにミラーリングされ、オフラインで利用できます。",
"syncModeOndemandHint": "ファイルはすぐに表示され、開いたときにダウンロードされます。ディスク使用量を抑えられますが、新しいファイルにはネットワークが必要です。マウント中、選択したフォルダー内の既存ファイルは非表示になります。",
"finish": "完了", "finish": "完了",
"settingUp": "設定中...", "settingUp": "設定中...",
"successTitle": "設定完了!", "successTitle": "設定完了!",

@ -18,6 +18,10 @@
"selectFolder": "폴더 선택", "selectFolder": "폴더 선택",
"folderNotEmpty": "선택한 폴더가 비어 있지 않습니다. 빈 폴더를 선택해 주세요.", "folderNotEmpty": "선택한 폴더가 비어 있지 않습니다. 빈 폴더를 선택해 주세요.",
"folderNotEmptyWillSync": "이 폴더에는 이미 파일이 있습니다. 기존 로컬 콘텐츠가 원격 드라이브에 병합되며, 이름이 같고 해시가 일치하는 파일은 이미 동기화된 것으로 처리됩니다.", "folderNotEmptyWillSync": "이 폴더에는 이미 파일이 있습니다. 기존 로컬 콘텐츠가 원격 드라이브에 병합되며, 이름이 같고 해시가 일치하는 파일은 이미 동기화된 것으로 처리됩니다.",
"syncModeFull": "전체 동기화",
"syncModeOndemand": "주문형",
"syncModeFullHint": "모든 파일이 이 폴더에 미러링되어 오프라인에서 사용할 수 있습니다.",
"syncModeOndemandHint": "파일이 즉시 표시되고 열 때 다운로드됩니다. 디스크 공간을 적게 사용하지만 새 파일에는 네트워크가 필요합니다. 마운트되는 동안 선택한 폴더의 기존 파일은 숨겨집니다.",
"finish": "완료", "finish": "완료",
"settingUp": "설정 중...", "settingUp": "설정 중...",
"successTitle": "설정 완료!", "successTitle": "설정 완료!",

@ -18,6 +18,10 @@
"selectFolder": "Wybierz folder", "selectFolder": "Wybierz folder",
"folderNotEmpty": "Wybrany folder nie jest pusty. Proszę wybrać pusty 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.", "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", "finish": "Zakończ",
"settingUp": "Konfigurowanie...", "settingUp": "Konfigurowanie...",
"successTitle": "Gotowe!", "successTitle": "Gotowe!",

@ -18,6 +18,10 @@
"selectFolder": "Выбрать папку", "selectFolder": "Выбрать папку",
"folderNotEmpty": "Выбранная папка не пуста. Пожалуйста, выберите пустую папку.", "folderNotEmpty": "Выбранная папка не пуста. Пожалуйста, выберите пустую папку.",
"folderNotEmptyWillSync": "В этой папке уже есть файлы. Существующее локальное содержимое будет объединено с удалённым диском; файлы с одинаковыми именами и совпадающими хешами будут считаться уже синхронизированными.", "folderNotEmptyWillSync": "В этой папке уже есть файлы. Существующее локальное содержимое будет объединено с удалённым диском; файлы с одинаковыми именами и совпадающими хешами будут считаться уже синхронизированными.",
"syncModeFull": "Полная синхронизация",
"syncModeOndemand": "По запросу",
"syncModeFullHint": "Все файлы зеркалируются в эту папку и доступны офлайн.",
"syncModeOndemandHint": "Файлы появляются мгновенно и загружаются при открытии. Занимает меньше места на диске; для новых файлов нужна сеть. Существующие файлы в выбранной папке скрываются, пока диск смонтирован.",
"finish": "Готово", "finish": "Готово",
"settingUp": "Настройка...", "settingUp": "Настройка...",
"successTitle": "Готово!", "successTitle": "Готово!",

@ -18,6 +18,10 @@
"selectFolder": "选择文件夹", "selectFolder": "选择文件夹",
"folderNotEmpty": "所选文件夹不为空,请选择一个空文件夹。", "folderNotEmpty": "所选文件夹不为空,请选择一个空文件夹。",
"folderNotEmptyWillSync": "此文件夹已有文件。本地已有内容将合并到远程网盘;同名且哈希匹配的文件会被视为已同步。", "folderNotEmptyWillSync": "此文件夹已有文件。本地已有内容将合并到远程网盘;同名且哈希匹配的文件会被视为已同步。",
"syncModeFull": "完整同步",
"syncModeOndemand": "按需同步",
"syncModeFullHint": "所有文件都会镜像到此文件夹,可离线使用。",
"syncModeOndemandHint": "文件即刻显示,打开时才下载。占用更少磁盘空间;访问新文件需要网络。挂载期间,所选文件夹中的现有文件将被隐藏。",
"finish": "完成", "finish": "完成",
"settingUp": "正在配置中...", "settingUp": "正在配置中...",
"successTitle": "设置完成!", "successTitle": "设置完成!",

@ -18,6 +18,10 @@
"selectFolder": "選擇資料夾", "selectFolder": "選擇資料夾",
"folderNotEmpty": "所選資料夾不為空,請選擇一個空資料夾。", "folderNotEmpty": "所選資料夾不為空,請選擇一個空資料夾。",
"folderNotEmptyWillSync": "此資料夾已有檔案。本機既有內容將合併到遠端網盤;同名且雜湊相符的檔案會被視為已同步。", "folderNotEmptyWillSync": "此資料夾已有檔案。本機既有內容將合併到遠端網盤;同名且雜湊相符的檔案會被視為已同步。",
"syncModeFull": "完整同步",
"syncModeOndemand": "按需同步",
"syncModeFullHint": "所有檔案都會鏡像到此資料夾,可離線使用。",
"syncModeOndemandHint": "檔案即刻顯示,開啟時才下載。佔用更少磁碟空間;存取新檔案需要網路。掛載期間,所選資料夾中的現有檔案將被隱藏。",
"finish": "完成", "finish": "完成",
"settingUp": "正在設定中...", "settingUp": "正在設定中...",
"successTitle": "設定完成!", "successTitle": "設定完成!",

@ -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 CloseIcon from "@mui/icons-material/Close";
import { openUrl, openPath } from "@tauri-apps/plugin-opener"; import { openUrl, openPath } from "@tauri-apps/plugin-opener";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
@ -20,6 +20,7 @@ import {
} from "../utils/siteValidation"; } from "../utils/siteValidation";
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { platform } from "@tauri-apps/plugin-os";
import { CALLBACK_PATH, CLIENT_ID, SCOPES } from "../utils/constants"; import { CALLBACK_PATH, CLIENT_ID, SCOPES } from "../utils/constants";
type PageState = "url_input" | "waiting" | "final_setup" | "setting_up" | "success"; 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<PageState>(isReauthorize ? "url_input" : "url_input"); const [pageState, setPageState] = useState<PageState>(isReauthorize ? "url_input" : "url_input");
const [localPath, setLocalPath] = useState(""); const [localPath, setLocalPath] = useState("");
const [folderNotEmpty, setFolderNotEmpty] = useState(false); const [folderNotEmpty, setFolderNotEmpty] = useState(false);
const isLinux = platform() === "linux";
const [syncMode, setSyncMode] = useState<"full" | "ondemand">("full");
const [driveName, setDriveName] = useState(driveNameQuery ? decodeURIComponent(driveNameQuery) : ""); const [driveName, setDriveName] = useState(driveNameQuery ? decodeURIComponent(driveNameQuery) : "");
const lastFetchedUrl = useRef<string>(""); const lastFetchedUrl = useRef<string>("");
const currentIconUrl = useRef<string | undefined>(undefined); const currentIconUrl = useRef<string | undefined>(undefined);
@ -349,6 +352,7 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) {
remote_path: pkceSessionRef.current!.callbackData!.path, remote_path: pkceSessionRef.current!.callbackData!.path,
user_id: pkceSessionRef.current!.callbackData!.user_id || "", user_id: pkceSessionRef.current!.callbackData!.user_id || "",
drive_id: isReauthorize ? driveId : undefined, drive_id: isReauthorize ? driveId : undefined,
sync_mode: isLinux ? syncMode : "full",
} }
}); });
// Success - switch to success state // Success - switch to success state
@ -573,6 +577,37 @@ export default function AddDrive({ mode = "add" }: AddDriveProps) {
)} )}
</Alert> </Alert>
)} )}
{isLinux && (
<>
<RadioGroup
row
value={syncMode}
onChange={(e) => setSyncMode(e.target.value as "full" | "ondemand")}
>
<FormControlLabel
value="full"
control={<Radio size="small" />}
label={t("addDrive.syncModeFull", "Full sync")}
/>
<FormControlLabel
value="ondemand"
control={<Radio size="small" />}
label={t("addDrive.syncModeOndemand", "On-demand")}
/>
</RadioGroup>
<Typography variant="caption" color="text.secondary">
{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."
)}
</Typography>
</>
)}
</> </>
)} )}

Loading…
Cancel
Save