//! Optional integration with a locally running Nextcloud desktop client. //! //! **Linux desktop only, and strictly optional.** Android has no desktop //! client, no `XDG_RUNTIME_DIR` socket, and no VFS placeholders, so nothing //! here exists on the platform that needs it most. Every capability offered by //! this module is also reachable through [`crate::NextcloudBackend`], which is //! why it is a convenience rather than a dependency (ARCH §9.0). //! //! What it buys where it *is* available: a user whose library already lives in //! a VFS-synced folder can have DarkRoom ask the client to fetch a file, //! rather than DarkRoom downloading a second copy over WebDAV and leaving the //! client's own placeholder state inconsistent. //! //! The protocol is newline-delimited `COMMAND:argument` over a Unix socket. //! Verified against client 4.0.7. #[cfg(unix)] use std::io::{BufRead, BufReader, Write}; #[cfg(unix)] use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; use dr_sync::RemoteError; /// How long to wait for the client to acknowledge a command. const TIMEOUT: Duration = Duration::from_secs(5); /// A connection to a running desktop client. /// TRACES: FR-NC-6c | FR-CAT-9 pub struct DesktopClient { #[cfg(unix)] socket: PathBuf, } impl DesktopClient { /// Locate a running client, if there is one. /// /// Returns `None` on Android, where no such client exists, and on any /// desktop where the client is not running. Callers treat `None` as /// "use the connector", never as an error. pub fn detect() -> Option { #[cfg(all(unix, not(target_os = "android")))] { let runtime = std::env::var_os("XDG_RUNTIME_DIR")?; let socket = PathBuf::from(runtime).join("Nextcloud/socket"); socket.exists().then_some(Self { socket }) } #[cfg(not(all(unix, not(target_os = "android"))))] { None } } /// Ask the client to download a placeholder. /// /// Hydration is **whole-file**, so this is appropriate for the original /// tier — opening an image in develop, or exporting it — and never for /// browsing. Filling a grid this way would download the entire library, /// which is exactly what range extraction exists to avoid (FR-NC-3). /// /// Returns once the command is accepted, not once the download completes; /// callers poll for the materialised path. pub fn make_available_locally(&self, path: &Path) -> Result<(), RemoteError> { self.send("MAKE_AVAILABLE_LOCALLY", path) } /// Ask the client to dehydrate a file back to a placeholder, freeing disk. pub fn make_online_only(&self, path: &Path) -> Result<(), RemoteError> { self.send("MAKE_ONLINE_ONLY", path) } #[cfg(unix)] fn send(&self, command: &str, path: &Path) -> Result<(), RemoteError> { let mut stream = UnixStream::connect(&self.socket) .map_err(|e| RemoteError::Network(format!("desktop client socket: {e}")))?; stream .set_read_timeout(Some(TIMEOUT)) .and_then(|_| stream.set_write_timeout(Some(TIMEOUT))) .map_err(|e| RemoteError::Network(e.to_string()))?; writeln!(stream, "{command}:{}", path.display()) .map_err(|e| RemoteError::Network(e.to_string()))?; stream .flush() .map_err(|e| RemoteError::Network(e.to_string()))?; // The client greets with REGISTER_PATH lines; reading one confirms it // is speaking the protocol rather than silently discarding input. let mut line = String::new(); BufReader::new(&stream) .read_line(&mut line) .map_err(|e| RemoteError::Network(e.to_string()))?; log::debug!("desktop client: {command} -> {}", line.trim()); Ok(()) } #[cfg(not(unix))] fn send(&self, _command: &str, _path: &Path) -> Result<(), RemoteError> { Err(RemoteError::Unsupported( "desktop client integration is Linux-only", )) } } /// Whether a path names a VFS placeholder — a file the user has remotely but /// not locally. pub fn is_placeholder(path: &Path) -> bool { path.file_name() .map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX)) .unwrap_or(false) } /// The path a placeholder will occupy once hydrated. /// /// Suffix-mode VFS renames on hydration, so the materialised file appears /// under a *different* path than the stub. Callers polling for completion must /// watch this one, not the original. pub fn hydrated_path(placeholder: &Path) -> PathBuf { let s = placeholder.to_string_lossy(); PathBuf::from( s.strip_suffix(dr_types::PLACEHOLDER_SUFFIX) .unwrap_or(&s) .to_string(), ) } #[cfg(test)] mod tests { use super::*; #[test] fn placeholders_are_recognised_by_suffix() { assert!(is_placeholder(Path::new("/x/IMG_4130.CR2.nextcloud"))); assert!(!is_placeholder(Path::new("/x/IMG_4130.CR2"))); assert!(!is_placeholder(Path::new("/x"))); } #[test] fn hydration_changes_the_path() { // Suffix mode renames rather than filling in place, so polling the // original path would wait forever. assert_eq!( hydrated_path(Path::new("/x/IMG_4130.CR2.nextcloud")), PathBuf::from("/x/IMG_4130.CR2") ); } #[test] fn hydrated_path_is_idempotent() { // Calling it on an already-materialised path must not truncate it. assert_eq!( hydrated_path(Path::new("/x/IMG_4130.CR2")), PathBuf::from("/x/IMG_4130.CR2") ); } #[test] fn detection_returns_none_rather_than_failing() { // Absence is the normal case — Android always, desktop whenever the // client is not running — so it must never be an error. let _ = DesktopClient::detect(); } } /// TRACES: FR-NC-6c /// The desktop client's placeholder convention, as a /// [`Vfs`](dr_sync_folder::Vfs). /// /// This is what turns a folder the client syncs into a library DarkRoom can /// open: the folder connector handles every filesystem operation, and this /// answers the three questions it cannot — what is a stub, what is the /// photograph called, and can the content be fetched and given back. /// /// **Linux suffix mode only**, which is the only mode Linux supports /// (ARCH §9.0). A dehydrated `IMG.CR2` exists solely as `IMG.CR2.nextcloud` /// holding one byte. pub struct NextcloudVfs { /// `None` where no client is running. The folder still lists and reads /// correctly; it simply cannot fetch what is not there, which the backend /// reports as `Materialisation::Placeholders`. client: Option, } impl NextcloudVfs { /// Attach to a running client, if there is one. /// /// Absence is the ordinary state — Android always, desktop whenever the /// client is not running — and never an error. pub fn detect() -> Self { Self { client: DesktopClient::detect(), } } /// Whether a directory looks like one this client syncs. /// /// Used to decide whether to apply this convention at all. Deliberately /// cheap and deliberately not authoritative: the client's own database /// would answer properly, but it is a private schema, and being wrong here /// costs one extra `stat` per read rather than anything correctness /// depends on. pub fn looks_synced(root: &Path) -> bool { std::fs::read_dir(root) .map(|entries| { entries.flatten().any(|e| { let name = e.file_name(); let name = name.to_string_lossy(); // The client's per-folder journal sits at the sync root, // and a stub anywhere beneath it is equally conclusive. name.starts_with("._sync_") && name.ends_with(".db") || name.ends_with(dr_types::PLACEHOLDER_SUFFIX) }) }) .unwrap_or(false) } } impl dr_sync_folder::Vfs for NextcloudVfs { fn name(&self) -> &'static str { "Nextcloud" } fn is_placeholder(&self, on_disk: &str) -> bool { on_disk.ends_with(dr_types::PLACEHOLDER_SUFFIX) } fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { on_disk .strip_suffix(dr_types::PLACEHOLDER_SUFFIX) .unwrap_or(on_disk) } fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { std::borrow::Cow::Owned(format!("{name}{}", dr_types::PLACEHOLDER_SUFFIX)) } fn can_materialise(&self) -> bool { self.client.is_some() } fn materialise(&self, local: &Path) -> Result<(), RemoteError> { self.client .as_ref() .ok_or(RemoteError::Unsupported( "no Nextcloud desktop client is running to fetch this", ))? .make_available_locally(local) } fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { self.client .as_ref() .ok_or(RemoteError::Unsupported( "no Nextcloud desktop client is running to release this", ))? .make_online_only(local) } } #[cfg(test)] mod vfs_tests { use super::*; use dr_sync_folder::Vfs as _; #[test] fn a_stub_is_recognised_and_reports_the_photographs_name() { let v = NextcloudVfs { client: None }; assert!(v.is_placeholder("IMG_4130.CR2.nextcloud")); assert!(!v.is_placeholder("IMG_4130.CR2")); // The name the catalog records, so identity survives a download. assert_eq!(v.real_name("IMG_4130.CR2.nextcloud"), "IMG_4130.CR2"); assert_eq!(v.real_name("IMG_4130.CR2"), "IMG_4130.CR2"); assert_eq!(v.placeholder_name("IMG_4130.CR2"), "IMG_4130.CR2.nextcloud"); } #[test] fn without_a_client_it_refuses_rather_than_pretending() { // The folder still works; it just cannot fetch. Silently doing nothing // would make a borrow think it had the content. let v = NextcloudVfs { client: None }; assert!(!v.can_materialise()); assert!(v.materialise(Path::new("/x/a.CR2.nextcloud")).is_err()); assert!(v.dematerialise(Path::new("/x/a.CR2")).is_err()); } #[test] fn an_ordinary_folder_is_not_mistaken_for_a_synced_one() { let d = std::env::temp_dir().join("dr-vfs-detect"); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("a.CR2"), b"raw").unwrap(); assert!(!NextcloudVfs::looks_synced(&d)); std::fs::write(d.join("b.CR2.nextcloud"), [0u8]).unwrap(); assert!(NextcloudVfs::looks_synced(&d)); let _ = std::fs::remove_dir_all(&d); } }