Files
DarkRoom/core/dr-sync-nextcloud/src/desktop_client.rs
T
dtourolle cc1c5c892d Support requesting VFS hydration; fix Android TLS cross-compilation
Correcting the previous commit: I claimed VFS placeholders could not be
downloaded. That was wrong. The desktop client exposes a socket at
$XDG_RUNTIME_DIR/Nextcloud/socket speaking newline-delimited
COMMAND:argument, and MAKE_AVAILABLE_LOCALLY:<path> does fetch the file.
Verified against client 4.0.7: a 1-byte stub became a real 2.8MB file in
2.8 seconds.

Implemented as dr-sync-nextcloud::desktop_client, deliberately optional.
Android has no desktop client, no XDG_RUNTIME_DIR socket and no
placeholders, so detect() returns None there and callers fall back to the
connector. It earns its place only because it is ~30 lines with no
dependencies: where a library already lives in a VFS folder, asking the
client to fetch beats downloading a second copy over WebDAV and leaving
the client's placeholder state inconsistent.

What this does not change: hydration is whole-file, so it suits the
original tier and never browsing. Filling a grid this way downloads the
entire library. Range extraction remains the only mechanism satisfying
FR-NC-3, and ARCH §9.0 now says so precisely.

Also fixes two real Android build failures found by cross-compiling:

  - reqwest's `rustls` feature defaults to aws-lc-rs, whose aws-lc-sys
    crate is C and fails under the NDK — exactly the pain D1 chose Rust
    to avoid. Switched to rustls-no-provider + ring, installing the
    provider in the constructor so no caller can build a client that
    panics on first use.
  - ring itself needs CC/AR per target; cargo-ndk sets only the linker.
    Added them to the container.

87 tests passing. dr-sync-nextcloud cross-compiles for aarch64-linux-android.
2026-08-09 10:10:29 +02:00

166 lines
5.9 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};
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<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();
}
}