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.
This commit is contained in:
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
dr-types.workspace = true
|
||||
dr-sync.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
quick-xml.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -21,3 +22,4 @@ tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Request hydration of a VFS placeholder via the desktop client.
|
||||
//!
|
||||
//! cargo run -p dr-sync-nextcloud --example hydrate -- <file.ext.nextcloud>
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dr_sync_nextcloud::desktop_client::{hydrated_path, is_placeholder, DesktopClient};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let Some(arg) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: hydrate <placeholder>");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let path = PathBuf::from(arg);
|
||||
|
||||
let Some(client) = DesktopClient::detect() else {
|
||||
eprintln!("no desktop client running (expected on Android)");
|
||||
std::process::exit(1);
|
||||
};
|
||||
println!("desktop client detected");
|
||||
|
||||
if !is_placeholder(&path) {
|
||||
println!("{} is already materialised", path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let target = hydrated_path(&path);
|
||||
let before = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
println!("stub: {} ({before} bytes)", path.display());
|
||||
|
||||
client.make_available_locally(&path).expect("send command");
|
||||
println!("requested; polling for {}", target.display());
|
||||
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(30) {
|
||||
if let Ok(m) = std::fs::metadata(&target) {
|
||||
println!(
|
||||
"hydrated: {} bytes in {:.1}s",
|
||||
m.len(),
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
println!("timed out after 30s");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ use dr_sync::{
|
||||
};
|
||||
|
||||
pub mod auth;
|
||||
pub mod desktop_client;
|
||||
mod propfind;
|
||||
|
||||
pub use auth::{AppCredentials, LoginFlow};
|
||||
pub use desktop_client::DesktopClient;
|
||||
|
||||
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
||||
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
||||
@@ -42,6 +44,8 @@ pub struct NextcloudBackend {
|
||||
impl NextcloudBackend {
|
||||
/// Build a backend from credentials obtained via [`auth`].
|
||||
pub fn new(creds: &AppCredentials, user_id: &str) -> Result<Self, RemoteError> {
|
||||
install_crypto_provider();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("DarkRoom")
|
||||
.build()
|
||||
@@ -302,6 +306,24 @@ impl RemoteBackend for NextcloudBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the rustls crypto provider, once per process.
|
||||
///
|
||||
/// Required because we build reqwest with `rustls-no-provider` rather than
|
||||
/// `rustls`: the default provider is aws-lc-rs, whose `aws-lc-sys` crate is C
|
||||
/// and does not cross-compile for Android. `ring` is pure Rust apart from a
|
||||
/// small assembly core that builds fine under the NDK.
|
||||
///
|
||||
/// Done here rather than left to callers so there is no way to construct a
|
||||
/// client that panics on first use.
|
||||
fn install_crypto_provider() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
// Errs only if a provider is already installed, which is fine.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
/// Translate an HTTP status into a typed error.
|
||||
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
||||
match status.as_u16() {
|
||||
|
||||
Reference in New Issue
Block a user