secrets.rs names the keyring service and desktop_client.rs the socket timeout, and every use of both sits under a cfg that a Windows build does not satisfy — the placeholder secret store has nothing to file under, and the Nextcloud client's named pipe is not opened yet. The first cross-compile reported both as dead code, which is a failed clippy job the moment the Windows leg runs with -D warnings. Guarded by the same cfgs as their users, with the reason beside each.
306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
//! 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};
|
|
#[cfg(unix)]
|
|
use std::time::Duration;
|
|
|
|
use dr_sync::RemoteError;
|
|
|
|
/// How long to wait for the client to acknowledge a command.
|
|
///
|
|
/// Only the socket path waits; on Windows the client speaks over a named pipe
|
|
/// this module does not yet open, so there is nothing to time.
|
|
#[cfg(unix)]
|
|
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<Self> {
|
|
#[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<DesktopClient>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|