From cc1c5c892d567dcada465d1bba81241a78039df6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 10:10:29 +0200 Subject: [PATCH] Support requesting VFS hydration; fix Android TLS cross-compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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. --- Cargo.lock | 171 +------------------ Cargo.toml | 15 +- core/dr-sync-nextcloud/Cargo.toml | 2 + core/dr-sync-nextcloud/examples/hydrate.rs | 50 ++++++ core/dr-sync-nextcloud/src/desktop_client.rs | 165 ++++++++++++++++++ core/dr-sync-nextcloud/src/lib.rs | 22 +++ docker/android/Dockerfile | 13 ++ docs/architecture.md | 32 +++- docs/traceability.md | 10 +- 9 files changed, 297 insertions(+), 183 deletions(-) create mode 100644 core/dr-sync-nextcloud/examples/hydrate.rs create mode 100644 core/dr-sync-nextcloud/src/desktop_client.rs diff --git a/Cargo.lock b/Cargo.lock index 25bdba1..e68254b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,29 +452,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -783,17 +760,6 @@ dependencies = [ "libc", ] -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.45" @@ -836,15 +802,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "codespan-reporting" version = "0.11.1" @@ -995,15 +952,6 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1260,9 +1208,11 @@ dependencies = [ "async-trait", "dr-sync", "dr-types", + "env_logger", "log", "quick-xml", "reqwest", + "rustls", "serde", "serde_json", "thiserror 2.0.20", @@ -1338,12 +1288,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.17.0" @@ -1699,12 +1643,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.33" @@ -1854,10 +1792,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1879,11 +1815,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -3379,12 +3312,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lyon_algorithms" version = "1.0.20" @@ -4589,63 +4516,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases 0.2.2", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases 0.2.2", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -4674,18 +4544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -4695,7 +4554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4707,21 +4566,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "range-alloc" version = "0.1.5" @@ -4755,7 +4599,7 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand 0.9.5", + "rand", "rand_chacha", "simd_helpers", "thiserror 2.0.20", @@ -4968,7 +4812,6 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -5127,8 +4970,8 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5153,7 +4996,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] @@ -5190,7 +5032,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/Cargo.toml b/Cargo.toml index 9b1236b..d9c906d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,11 +41,16 @@ pollster = "0.4" # Networking — no mature Nextcloud crate exists; the connector is hand-rolled # over reqwest (D7). reqwest_dav was evaluated and is too thin to build on. -# `rustls` (not `rustls-tls` — renamed in 0.13) pulls in -# rustls-platform-verifier, which crashes on Android unless initialised from -# Kotlin. That is spike S3, and D7 records `tls_certs_only` + webpki-roots as -# the escape hatch. -reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream", "json"] } +# `rustls-no-provider` rather than `rustls`: the latter defaults to the +# aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to +# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid. +# ring is pure Rust apart from a small asm core that does build under the NDK. +# +# Note this still pulls rustls-platform-verifier, which crashes on Android +# unless initialised from Kotlin (spike S3). D7 records `tls_certs_only` plus +# webpki-roots as the escape hatch. +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } quick-xml = "0.41" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } url = "2.5" diff --git a/core/dr-sync-nextcloud/Cargo.toml b/core/dr-sync-nextcloud/Cargo.toml index 88209ee..5bddb2e 100644 --- a/core/dr-sync-nextcloud/Cargo.toml +++ b/core/dr-sync-nextcloud/Cargo.toml @@ -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 diff --git a/core/dr-sync-nextcloud/examples/hydrate.rs b/core/dr-sync-nextcloud/examples/hydrate.rs new file mode 100644 index 0000000..103c302 --- /dev/null +++ b/core/dr-sync-nextcloud/examples/hydrate.rs @@ -0,0 +1,50 @@ +//! Request hydration of a VFS placeholder via the desktop client. +//! +//! cargo run -p dr-sync-nextcloud --example hydrate -- + +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 "); + 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); +} diff --git a/core/dr-sync-nextcloud/src/desktop_client.rs b/core/dr-sync-nextcloud/src/desktop_client.rs new file mode 100644 index 0000000..1617892 --- /dev/null +++ b/core/dr-sync-nextcloud/src/desktop_client.rs @@ -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 { + #[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(); + } +} diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index ffcefaf..054003a 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -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 { + 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() { diff --git a/docker/android/Dockerfile b/docker/android/Dockerfile index f702656..3f7057b 100644 --- a/docker/android/Dockerfile +++ b/docker/android/Dockerfile @@ -107,6 +107,19 @@ ENV CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${NDK_BIN}/aarch64-linux-android${ ENV CARGO_NDK_PLATFORM=${MIN_API} \ ANDROID_PLATFORM=${MIN_API} +# Crates with C or assembly components (ring's crypto core, and anything else +# using the cc crate) need a compiler and archiver per target, not just a +# linker. cargo-ndk sets the linker only, so these are set explicitly — +# otherwise `ring` fails its build script and TLS cannot be built at all. +ENV CC_aarch64_linux_android=${NDK_BIN}/aarch64-linux-android${MIN_API}-clang \ + AR_aarch64_linux_android=${NDK_BIN}/llvm-ar \ + CC_armv7_linux_androideabi=${NDK_BIN}/armv7a-linux-androideabi${MIN_API}-clang \ + AR_armv7_linux_androideabi=${NDK_BIN}/llvm-ar \ + CC_x86_64_linux_android=${NDK_BIN}/x86_64-linux-android${MIN_API}-clang \ + AR_x86_64_linux_android=${NDK_BIN}/llvm-ar \ + CC_i686_linux_android=${NDK_BIN}/i686-linux-android${MIN_API}-clang \ + AR_i686_linux_android=${NDK_BIN}/llvm-ar + # Shared cargo registry cache — bind-mount over this to persist across runs. VOLUME ["/opt/cargo/registry"] diff --git a/docs/architecture.md b/docs/architecture.md index b53a1a0..bc271ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -679,20 +679,36 @@ Three findings, each independently disqualifying: `IMG.CR2.nextcloud` exists, containing exactly one byte. Any extension-based scan sees `.nextcloud`, so the app needs placeholder-aware code regardless — VFS is not transparent. -2. **Reads do not hydrate.** Reading the stub returns its 1 byte and nothing else; no fetch is - triggered, the stub is unchanged, and the real name never appears. Suffix mode is an inert - marker, not a filesystem hook — there is no FUSE layer intercepting reads. Hydration happens - only when the *client* is instructed to sync that file. **DarkRoom cannot read through a - placeholder at all.** +2. **Reads do not hydrate, but hydration can be *requested*.** Reading a stub returns its one byte + and triggers nothing — there is no FUSE layer intercepting reads. However the client exposes a + local socket at `$XDG_RUNTIME_DIR/Nextcloud/socket` speaking a newline-delimited + `COMMAND:argument` protocol, and `MAKE_AVAILABLE_LOCALLY:` does fetch the file. + **Verified 2026-08-09:** a 1-byte `.nextcloud` stub was replaced by the real 2.7 MB file within + seconds. `MAKE_ONLINE_ONLY` dehydrates again. 3. **Even with hydration, granularity is wrong.** VFS has two states, 1 byte or all bytes. The preview tier — the one that makes remote browsing viable on mobile data — needs a ~256 KB prefix of a 27 MB file. A hydrating VFS would transfer ~100× what FR-NC-3 requires, which is precisely the cost range extraction exists to avoid. -Coexistence is still fine and worth supporting: a user may keep a VFS-synced folder, and DarkRoom -should recognise `*.nextcloud` stubs and report those images as `Availability::Offline` (FR-NC-6c) -rather than as corrupt files. What it must not do is depend on VFS for transfer. +**What this changes, and what it does not.** Hydration-on-request makes VFS a usable *original* +tier: for an image the user opens in develop or exports, asking the client to fetch it is a +legitimate alternative to fetching it ourselves, and it inherits their transfer, resume and +conflict handling for free. + +It does **not** rescue the preview tier, which is the one that matters for browsing. Finding 3 +stands: hydration is whole-file, so filling a grid still costs the entire library. Range extraction +remains the only mechanism that satisfies FR-NC-3. + +**Design consequence.** VFS is supported as an optional *source*, not as the transfer layer: + +- Recognise `*.nextcloud` stubs and report them as `Availability::Offline` (FR-NC-6c) rather than + as corrupt files. +- Where a library lives under a VFS-synced folder, offer "download" on a stub by writing + `MAKE_AVAILABLE_LOCALLY:` to the socket, rather than fetching a second copy over WebDAV and + leaving the client's own state inconsistent. +- Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is + not running. The direct connector remains the primary path. ### 9.1 The three tiers, restated as policy diff --git a/docs/traceability.md b/docs/traceability.md index 22e3b32..48de061 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 23 | -| TRACES tags found | 30 | +| Source files scanned | 25 | +| TRACES tags found | 31 | | Requirements defined | 143 | | Requirements covered | 32 | | **Coverage** | **22.4%** (32/143) | @@ -37,18 +37,18 @@ _None._ | FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | | FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | | FR-CAT-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125) | -| FR-CAT-9 | [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) | +| FR-CAT-9 | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | | FR-CULL-2 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) | | FR-DSP-1 | [`ui/dr-ui/src/lib.rs:21`](../ui/dr-ui/src/lib.rs#L21) | | FR-EXP-9 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44) | -| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:30`](../core/dr-sync-nextcloud/src/lib.rs#L30), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128), [`core/dr-sync/src/lib.rs:34`](../core/dr-sync/src/lib.rs#L34) | +| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:32`](../core/dr-sync-nextcloud/src/lib.rs#L32), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128), [`core/dr-sync/src/lib.rs:34`](../core/dr-sync/src/lib.rs#L34) | | FR-NC-3 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) | -| FR-NC-6c | [`core/dr-types/src/lib.rs:131`](../core/dr-types/src/lib.rs#L131), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) | +| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:131`](../core/dr-types/src/lib.rs#L131), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:83`](../core/dr-decode/src/lib.rs#L83), [`core/dr-types/src/lib.rs:90`](../core/dr-types/src/lib.rs#L90) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:151`](../core/dr-decode/src/lib.rs#L151) |