Many imorovments
Build and test / Desktop (Linux) (push) Failing after 1m8s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Canceled after 23s
Traceability / Requirement traces (push) Failing after 59s

This commit is contained in:
2026-08-12 22:16:15 +02:00
parent fa12afed18
commit 4d78041d1d
24 changed files with 711 additions and 78 deletions
+28
View File
@@ -14,6 +14,11 @@ jobs:
desktop:
runs-on: linux/amd64
name: Desktop (Linux)
# actions/checkout and actions/cache are JavaScript actions: the runner
# executes them with Node from inside this container. The bare runner image
# has none, so the job failed at checkout before reaching any build step.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
@@ -34,6 +39,17 @@ jobs:
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
# The act image ships Node but no Rust. Pinned to the workspace
# rust-version so CI, the Android image, and local builds agree — a
# floating toolchain turns an unrelated push into a mystery failure.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal \
--default-toolchain 1.92.0 --component rustfmt,clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Format
run: cargo fmt --all -- --check
@@ -92,11 +108,23 @@ jobs:
layering:
runs-on: linux/amd64
name: Layer separation
# Node for the JS actions, as above. cargo comes from rustup below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# `cargo tree` resolves the dependency graph, so it needs the registry
# index but no system libraries — this job builds nothing.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
# `use slint::` costs headless golden-image testing and the
# one-operation-two-presentations property together, and nothing else
+13
View File
@@ -23,6 +23,10 @@ jobs:
traceability:
runs-on: linux/amd64
name: Requirement traces
# Node for actions/checkout and actions/cache, which the bare runner image
# cannot execute. Rust is installed below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
@@ -39,6 +43,15 @@ jobs:
target
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Source-comment and markdown parsing only, so the minimal profile is
# enough — no system libraries and no components beyond cargo itself.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The gate's own arithmetic is the thing being trusted, so its tests run
# before it does. Untested gate logic is exactly how JellyTau's 158% went
# unnoticed for months.
Generated
+1
View File
@@ -1176,6 +1176,7 @@ name = "darkroom-android"
version = "0.1.0"
dependencies = [
"android_logger",
"dr-sync-nextcloud",
"dr-ui",
"log",
"slint",
+3
View File
@@ -16,6 +16,9 @@ crate-type = ["cdylib"]
# No backend feature to select: dr-ui picks its Slint backend from the target,
# so building for aarch64-linux-android gets android-activity automatically.
dr-ui.workspace = true
# For `session::set_data_dir`: only the platform entry point knows where Android
# lets this app keep files, and it must be set before any store is opened.
dr-sync-nextcloud.workspace = true
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
# calls `slint::android::init`, both of which come from this crate. The backend
# feature comes from dr-ui's target-specific dependency.
@@ -4,9 +4,9 @@
Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet — no permissions are declared because
the library grid reads through SAF, which grants per-tree rather than by
manifest permission (ARCH §6.9).
here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="paris.tourolle.darkroom">
+14
View File
@@ -34,6 +34,20 @@ fn android_main(app: slint::android::AndroidApp) {
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Before anything opens a store: Android has no $HOME and no XDG
// directories, so the default guess resolves to a path the app cannot
// write. Nothing failed loudly — the session list went to a doomed path, so
// the account survived only as long as the process and backgrounding the app
// lost the sign-in. `internal_data_path` is the app's private directory
// (ARCH §6.9).
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
dr_sync_nextcloud::session::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
if let Err(e) = slint::android::init(app) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIICpjCCAiugAwIBAgIRAIchZfw0tuX7qK3Vs3BftTowCgYIKoZIzj0EAwMwTzEL
MAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNo
IEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDIwHhcNMjYwNTEzMDAwMDAwWhcN
MzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4G
A1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDwS/6vhrcVqcbBo
+wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7XyFjWsPYhIPt/wzOqxTd2
b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW87j77GaOB6zCB6DAOBgNV
HQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB
/zAdBgNVHQ4EFgQUo8gmWo6hTNA1Y/ybI8g6rlbzT1YwHwYDVR0jBBgwFoAUfEKW
rt5LSDv6kviejM9ti6lyN5UwMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZo
dHRwOi8veDIuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1Ud
HwQgMB4wHKAaoBiGFmh0dHA6Ly94Mi5jLmxlbmNyLm9yZy8wCgYIKoZIzj0EAwMD
aQAwZgIxAMU19WCtmxVND8UHBZRoma49Z7jPs64Dma0eTu1OChVbB/2J7GV3nvYK
Ax54uk1G9QIxAO0miLVJu8PLNiXXXkiE/gsK3CTRTF/aeo4bMX42Zw40csRU6AC2
6hSW1/IWaas6dg==
-----END CERTIFICATE-----
3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
a:PKEY: EC, (secp384r1); sigalg: sha256WithRSAEncryption
v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep 2 23:59:59 2032 GMT
+3 -3
View File
@@ -57,7 +57,7 @@ pub async fn begin(
.header(reqwest::header::USER_AGENT, user_agent)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(super::map_send_error)?;
if !resp.status().is_success() {
return Err(RemoteError::Server {
@@ -94,7 +94,7 @@ pub async fn poll(
.body(format!("token={}", urlencode(&flow.poll.token)))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(super::map_send_error)?;
match resp.status().as_u16() {
200 => {
@@ -147,7 +147,7 @@ pub async fn revoke(
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(super::map_send_error)?;
Ok(())
}
+156 -16
View File
@@ -145,7 +145,7 @@ impl NextcloudBackend {
.body(chunk.to_vec())
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), path.as_str())?;
}
@@ -164,7 +164,7 @@ impl NextcloudBackend {
.header("OC-Total-Length", total.to_string())
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), path.as_str())?;
// The MOVE response carries the assembled file's ETag on Nextcloud,
@@ -191,7 +191,7 @@ impl NextcloudBackend {
.basic_auth(&self.login, Some(&self.password))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
// 405 is "already exists", which is exactly what we want.
if resp.status() == 405 {
@@ -218,12 +218,12 @@ impl NextcloudBackend {
.body(body)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), path.as_str())?;
resp.text()
.await
.map_err(|e| RemoteError::Network(e.to_string()))
.map_err(map_send_error)
}
}
@@ -284,14 +284,14 @@ impl RemoteBackend for NextcloudBackend {
let resp = req
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
let status = resp.status();
map_status(status, &url)?;
let body = resp
.bytes()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?
.map_err(map_send_error)?
.to_vec();
// Nextcloud does not advertise Accept-Ranges, so support is detected
@@ -346,7 +346,7 @@ impl RemoteBackend for NextcloudBackend {
.body(body)
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), path.as_str())?;
resp.headers()
@@ -374,7 +374,7 @@ impl RemoteBackend for NextcloudBackend {
let resp = req
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), &url)
}
@@ -413,7 +413,7 @@ impl RemoteBackend for NextcloudBackend {
.header("Overwrite", "F")
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
map_status(resp.status(), &src)
}
@@ -451,7 +451,7 @@ impl RemoteBackend for NextcloudBackend {
.basic_auth(&self.login, Some(&self.password))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
// 405 is "already a collection here", which is exactly what the
// caller wanted. Anything else is reported.
@@ -482,7 +482,7 @@ impl RemoteBackend for NextcloudBackend {
.basic_auth(&self.login, Some(&self.password))
.send()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?;
.map_err(map_send_error)?;
// 404 means no preview for this file — expected for RAW, and a
// fall-through rather than an error.
@@ -494,12 +494,56 @@ impl RemoteBackend for NextcloudBackend {
Ok(Some(
resp.bytes()
.await
.map_err(|e| RemoteError::Network(e.to_string()))?
.map_err(map_send_error)?
.to_vec(),
))
}
}
/// Roots that are publicly trusted but not yet in the compiled-in set.
///
/// # Why this exists
///
/// `webpki-roots` is generated from Mozilla's CA program, and lags it. A CA
/// that has begun issuing from a new root is therefore trusted by every
/// browser and by the platform store while still being unknown to this binary
/// — and because [`http_client`] deliberately bypasses the platform store to
/// keep Android working, there is nothing to fall back to. The handshake fails
/// with `UnknownIssuer` against a server that is entirely healthy.
///
/// Observed 2026-08-12: a Let's Encrypt chain anchored at ISRG Root YE. The
/// server offered the full cross-signed path up to ISRG Root X1, which *is*
/// bundled, but rustls anchors on the first certificate it recognises as
/// self-issued and stops rather than continuing to the cross-sign — so the
/// complete chain did not help.
///
/// Each entry is a root that Mozilla already trusts. Removing one once
/// `webpki-roots` catches up is safe; the set is additive and duplicates are
/// harmless.
const EXTRA_ROOTS: &[(&str, &[u8])] = &[(
// ISRG Root YE — cross-signed by ISRG Root X2, valid to 2032-09-02.
// SHA-256 0FC0901CCA2BAE9E9FDBB02D50D02F1094F7B366720869 91B9E897626DC485F0
"ISRG Root YE",
include_bytes!("../certs/isrg-root-ye.pem"),
)];
/// Parse [`EXTRA_ROOTS`] into reqwest certificates.
///
/// A malformed entry is skipped rather than fatal: it costs one root, and
/// failing here would take down every connection including the ones that never
/// needed the extra anchor.
fn extra_roots() -> impl Iterator<Item = reqwest::Certificate> {
EXTRA_ROOTS.iter().filter_map(|(name, pem)| {
match reqwest::Certificate::from_pem(pem) {
Ok(c) => Some(c),
Err(e) => {
log::warn!("bundled root {name} unusable: {e}");
None
}
}
})
}
/// Build an HTTP client with the crypto provider already installed.
///
/// Every entry point that creates a client must go through here. The auth
@@ -525,9 +569,18 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
// verifier entirely (see its ClientBuilder TLS setup); the webpki-roots
// feature supplies the roots it then uses. Spike S3 revisits this to
// honour user-installed and enterprise CAs, which bundled roots cannot.
// Empty: the flag is what matters, and the roots come from the
// webpki-roots feature rather than from certificates passed here.
.tls_certs_only(std::iter::empty())
//
// Not empty: webpki-roots trails Mozilla's store, so a root that is
// already issuing publicly-trusted certificates can be missing from it
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
// webpki-roots set is still installed alongside.
.tls_certs_only(extra_roots())
// A request that hangs forever is indistinguishable from a worker that
// died, and cost a long time to tell apart once. Connect and total
// timeouts turn that into an error the UI can show. Generous enough for
// a slow phone on mobile data; the login poll has its own deadline.
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| RemoteError::Network(e.to_string()))
}
@@ -550,6 +603,45 @@ fn install_crypto_provider() {
});
}
/// Translate a transport failure into a typed error.
///
/// `reqwest::Error`'s own `Display` is uninformative for exactly the failures
/// that matter — every one of them renders as "error sending request for url
/// (…)" regardless of cause. The reason lives in the `source()` chain, so this
/// walks it: without that, a rejected certificate and an unplugged cable
/// produce identical text, and the app cannot tell "you are offline" from
/// "I do not trust this server".
///
/// TLS is separated because it is the one transport failure that waiting never
/// fixes, and the one that must not trigger offline mode (FR-CAT-9).
fn map_send_error(e: reqwest::Error) -> RemoteError {
// The full chain, which is what makes the log line actionable: rustls
// reports "invalid peer certificate: UnknownIssuer" only at depth 1.
let mut detail = e.to_string();
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
let mut tls = false;
while let Some(s) = src {
let text = s.to_string();
// rustls surfaces every verification failure through this wording:
// UnknownIssuer, Expired, NotValidForName, BadSignature.
if text.contains("invalid peer certificate")
|| text.contains("certificate")
|| text.contains("CertificateError")
{
tls = true;
}
detail.push_str(": ");
detail.push_str(&text);
src = std::error::Error::source(s);
}
if tls {
RemoteError::Tls(detail)
} else {
RemoteError::Network(detail)
}
}
/// Translate an HTTP status into a typed error.
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
match status.as_u16() {
@@ -713,4 +805,52 @@ mod tests {
Err(RemoteError::Unsupported(_))
));
}
#[test]
fn every_bundled_root_parses() {
// A typo'd or truncated PEM would otherwise be discovered only as a
// silently missing anchor on the one server that needs it.
assert_eq!(
extra_roots().count(),
EXTRA_ROOTS.len(),
"every bundled root must parse"
);
}
#[test]
fn isrg_root_ye_is_bundled_until_webpki_roots_carries_it() {
// The anchor this crate had to supply itself. Delete this and the
// entry it guards once webpki-roots ships Root YE.
assert!(EXTRA_ROOTS.iter().any(|(n, _)| *n == "ISRG Root YE"));
}
}
/// Tests that need the real server. Run with `--ignored`.
#[cfg(test)]
mod live {
/// TRACES: FR-NC-12
/// The regression: a Root YE chain must complete the handshake.
///
/// Ignored because it needs the network, but kept because the unit tests
/// cannot catch this — a bundled root that parses is not the same as a
/// bundled root that verifies, and the gap between those two is exactly
/// what reported a healthy server as offline for a day.
#[tokio::test]
#[ignore = "needs network"]
async fn a_lets_encrypt_root_ye_server_verifies() {
let client = super::http_client("DarkRoom-test").expect("client");
// Any host on the newer ISRG anchor exercises this.
let r = client
.get("https://nextcloud.tourolle.paris/remote.php/dav/files/dtourolle/")
.send()
.await;
match r {
// 401 is a completed TLS handshake; auth is not what is under test.
Ok(resp) => assert_eq!(resp.status().as_u16(), 401, "handshake completed"),
Err(e) => panic!("handshake failed: {e}"),
}
}
}
+41 -7
View File
@@ -20,6 +20,41 @@ use serde::{Deserialize, Serialize};
use crate::AppCredentials;
/// Where configuration is written, when the platform has told us.
///
/// Android has no `$HOME` and no XDG directories, so the guess below resolves
/// to a path the app cannot write. Nothing failed loudly: the session list went
/// to a doomed path, so credentials survived only as long as the process did and
/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a
/// filesystem path on Android).
///
/// The platform layer sets this once at startup, before any store is opened.
static DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// TRACES: FR-NC-2
/// Declare the per-app directory configuration belongs in.
///
/// Call before opening any store; later calls are ignored rather than racing.
/// On Android this is `AndroidApp::internal_data_path`, which is private to the
/// app and survives being backgrounded. Desktop needs no call — the XDG
/// fallback is correct there.
pub fn set_data_dir(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The directory configuration lives in.
fn config_dir() -> PathBuf {
if let Some(d) = DATA_DIR.get() {
return d.clone();
}
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom")
}
/// A configured account, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
@@ -119,17 +154,16 @@ impl SessionStore {
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1).
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join("sessions.json"), secrets)
Self::open_at(config_dir().join("sessions.json"), secrets)
}
/// Open at an explicit path — used by tests, and by anything wanting a
/// non-default config location.
/// Where configuration lives, for callers that need to sit files beside it.
pub fn data_dir() -> PathBuf {
config_dir()
}
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
Self {
config_path,
+44
View File
@@ -37,6 +37,20 @@ pub enum RemoteError {
#[error("network error: {0}")]
Network(String),
/// The TLS handshake was refused: an untrusted issuer, an expired
/// certificate, a hostname mismatch.
///
/// **Distinct from [`Network`](Self::Network) on purpose.** The server is
/// there and answering — the connection is refused on trust grounds, which
/// no amount of waiting repairs. Folding it into `Network` puts the app
/// into offline mode and tells the user their connection is down, sending
/// them to inspect a network that is working perfectly (observed
/// 2026-08-12: a Let's Encrypt chain anchored at ISRG Root YE, absent from
/// the compiled-in root store, reported as "you are offline" on a server
/// answering in 19 ms).
#[error("TLS error: {0}")]
Tls(String),
#[error("unexpected server response: {status} {detail}")]
Server { status: u16, detail: String },
@@ -52,6 +66,9 @@ impl RemoteError {
pub fn is_transient(&self) -> bool {
match self {
RemoteError::Network(_) => true,
// Not transient: a rejected certificate is rejected identically on
// every retry. Retrying one buys nothing and hides the cause.
RemoteError::Tls(_) => false,
RemoteError::Server { status, .. } => {
// 5xx and 429 are worth retrying; other 4xx are not.
//
@@ -80,6 +97,10 @@ impl RemoteError {
/// is just failing, and a retry is the right response rather than a
/// mode change. 429 and 423 likewise — those are the server working
/// correctly under load.
///
/// [`Tls`](Self::Tls) is likewise not offline. The host resolved, the
/// socket connected, and the peer answered; only trust failed. Reporting
/// that as offline is what made a root-store gap look like a dead network.
pub fn indicates_offline(&self) -> bool {
matches!(self, RemoteError::Network(_))
}
@@ -89,6 +110,29 @@ impl RemoteError {
mod tests {
use super::*;
#[test]
fn a_tls_failure_is_not_offline() {
// The regression this guards: a rejected certificate was reported as
// `Network`, which put the whole app into offline mode and told the
// user their connection was down — while the server answered in 19 ms.
let e = RemoteError::Tls("invalid peer certificate: UnknownIssuer".into());
assert!(
!e.indicates_offline(),
"trust failure is not unreachability"
);
assert!(
!e.is_transient(),
"a rejected certificate is rejected identically on every retry"
);
}
#[test]
fn a_dead_connection_is_still_offline() {
// The other side of the split: separating TLS out must not stop a
// genuine transport failure from reaching offline mode.
assert!(RemoteError::Network("dns error".into()).indicates_offline());
}
#[test]
fn transient_errors_are_retryable() {
assert!(RemoteError::Network("timeout".into()).is_transient());
+32
View File
@@ -505,6 +505,38 @@ mod tests {
assert_eq!(parsed.cache, CacheSettings::default());
}
#[test]
fn the_cache_defaults_to_a_bounded_budget_that_keeps_what_it_opens() {
// A default of "unlimited" would let the passive cache fill a disk
// before the user knew there was a setting; a default of "keep
// nothing" would re-download every image they reopened. Both budgets
// must therefore be `Some`, and keeping opened originals must be on.
let cache = CacheSettings::default();
assert_eq!(
cache.original_budget_bytes,
Some(DEFAULT_ORIGINAL_BUDGET_BYTES)
);
assert_eq!(
cache.thumbnail_budget_bytes,
Some(DEFAULT_THUMBNAIL_BUDGET_BYTES)
);
assert!(cache.keep_opened_originals);
}
#[test]
fn the_two_cache_budgets_are_independent() {
// Sharing them would let a day of browsing originals evict the
// thumbnails that make the library navigable.
let mut s = Settings::default();
s.cache.original_budget_bytes = None;
s.sanitise();
assert_eq!(
s.cache.thumbnail_budget_bytes,
Some(DEFAULT_THUMBNAIL_BUDGET_BYTES),
"clearing one budget must not disturb the other"
);
}
#[test]
fn upscaling_is_off_by_default() {
// FR-EXP-3 requires an explicit opt-in.
+16
View File
@@ -25,6 +25,9 @@ ARG NDK_VERSION=27.2.12479018
ARG CMDLINE_TOOLS=13114758
# Must satisfy cargo-ndk's MSRV (4.1.x needs >= 1.86) as well as our own crates.
ARG RUST_VERSION=1.92.0
# Gitea runs JavaScript actions (actions/checkout, actions/cache) with Node from
# inside the job container. Bookworm ships 18; current actions expect 20+.
ARG NODE_MAJOR=20
# JDK 17, not the host's 25: the Android Gradle Plugin supports 17 as its
# stable target, and newer JDKs regularly break Gradle in ways that cost more
@@ -51,6 +54,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake python3 \
&& rm -rf /var/lib/apt/lists/*
# ---------------------------------------------------------------------------
# Node — required by the CI runner, not by the Android build
#
# This image is the job container for the Android CI job, and Gitea executes
# actions/checkout inside it using the container's own Node. Without this the
# job fails at checkout with "Cannot find: node in PATH", before any Rust or
# Gradle step runs. Local builds never invoke it.
# ---------------------------------------------------------------------------
RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
# ---------------------------------------------------------------------------
# Android SDK + NDK
# ---------------------------------------------------------------------------
+3 -4
View File
@@ -87,10 +87,9 @@ pub fn spawn_sync(
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
// A current-thread runtime here is what made the library scan itself
// rather than adopt the shards the server already had; see net_runtime.
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e.to_string()));
+18
View File
@@ -253,6 +253,24 @@ impl LaunchModel {
};
}
/// TRACES: FR-NC-1
/// Begin a sign-in with an app password the user supplied directly.
///
/// The browser flow is the recommended route because it never exposes a
/// credential to us, but it needs a browser and a round trip through one.
/// An app password is what Nextcloud offers instead: device-scoped,
/// individually revocable, and created by the user under Settings →
/// Security. That makes it the workable option where no browser can
/// complete the handshake.
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.error = None;
self.state = LaunchState::Busy {
message: "Checking the credentials…".into(),
session: None,
};
}
pub fn await_approval(&mut self, login_url: impl Into<String>) {
self.status = Some("Approve the sign-in in your browser.".into());
self.state = LaunchState::AwaitingApproval {
+121 -7
View File
@@ -106,6 +106,28 @@ where
});
}
// --- sign in with an app password -----------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_sign_in_direct(move |server, login, password| {
let Some(w) = weak.upgrade() else { return };
ctl.model
.borrow_mut()
.begin_direct_sign_in(server.to_string());
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
spawn_direct_login(
w.as_weak(),
ctl.clone(),
server,
login.to_string(),
password.to_string(),
);
});
}
// --- sign out ------------------------------------------------------
{
let weak = window.as_weak();
@@ -279,11 +301,18 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
};
step("thread started");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
// `enable_all()` also enables the signal driver, which wants to own
// process-wide signal handling and is not something a worker thread
// inside an Android app can safely claim — the flow needs only IO
// (reqwest) and time (the poll interval), so ask for just those.
let rt = match tokio::runtime::Builder::new_current_thread()
// Multi-thread, not current-thread: a current-thread runtime drives
// its reactor only while the thread sits inside `block_on`, and on
// Android that left reqwest's connection future never polled — the
// await never resolved, so the worker neither failed nor returned.
// A multi-thread runtime owns worker threads that poll the reactor
// regardless. One worker is plenty for a single login flow.
//
// Only IO and time are enabled; `enable_all()` would also start the
// signal driver, which wants process-wide signal handling that an
// Android app's runtime already owns.
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build()
@@ -313,6 +342,85 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
poll_channel(weak, ctl, rx);
}
/// TRACES: FR-NC-1
/// Verify an app password the user supplied, then keep it.
///
/// No browser and no polling: one authenticated request establishes both that
/// the credential works and what the account's canonical user id is, which is
/// what the DAV paths are built from. Reuses the same channel and drain loop as
/// the browser flow, so success and failure land in the UI identically.
fn spawn_direct_login(
weak: slint::Weak<AppWindow>,
ctl: Rc<LaunchController>,
server: String,
login: String,
password: String,
) {
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
std::thread::spawn(move || {
let panic_tx = tx.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
// Multi-thread for the reason the browser flow is: a current-thread
// runtime left reqwest's future unpolled on Android.
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
return;
}
};
rt.block_on(async {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
return;
}
};
let creds = dr_sync_nextcloud::AppCredentials {
server,
login_name: login,
app_password: password,
};
// The credential is only worth storing if it actually works,
// and this is the cheapest request that proves it. A 401 comes
// back as an error here rather than as a puzzling failure on
// the first listing.
match fetch_user_id(&client, &creds).await {
Ok(user_id) => {
let _ = tx.send(LoginMessage::Success(Box::new((creds, user_id))));
}
Err(e) => {
let _ = tx.send(LoginMessage::Failed(format!(
"could not sign in with that app password: {e}"
)));
}
}
});
}));
if let Err(panic) = result {
let detail = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
let _ = panic_tx.send(LoginMessage::Failed(format!("internal error: {detail}")));
}
});
poll_channel(weak, ctl, rx);
}
/// The body of the login flow, split out so the worker above can wrap it in
/// `catch_unwind` without a deeply nested closure.
fn run_login_flow(
@@ -487,8 +595,14 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
let user_id = session.user_id.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
// Multi-thread for the same reason as the login worker: a
// current-thread runtime left reqwest's connection future unpolled on
// Android, so the await never resolved and the thread stopped without
// failing or returning.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build();
let Ok(rt) = rt else {
let _ = tx.send(Err("runtime".into()));
+1
View File
@@ -16,6 +16,7 @@
mod collections_ui;
mod derived_sync;
mod net_runtime;
mod develop;
mod labels;
mod library;
+72 -24
View File
@@ -79,6 +79,13 @@ pub struct ThumbnailReady {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
/// Whether these pixels came off local disk rather than the server.
///
/// The grid paints both identically, so this exists solely for
/// reachability: a store hit is evidence about the *cache*, not the
/// network, and treating one as proof of connectivity clears offline mode
/// before a single request has been attempted.
pub from_cache: bool,
}
/// Capture metadata read from the same header the thumbnail needed.
@@ -330,9 +337,7 @@ pub fn spawn_sidecar_writes(
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -560,9 +565,7 @@ fn run_scan(
}
let catalog = Catalog::open(&catalog_path).map_err(ScanFailure::local)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = crate::net_runtime::build()
.map_err(ScanFailure::local)?;
rt.block_on(async {
@@ -887,9 +890,7 @@ pub fn spawn_pin_fetch(
return;
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -1055,9 +1056,7 @@ pub fn spawn_full_fetch(
}
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -1160,6 +1159,7 @@ pub fn spawn_thumbnails(
width,
height,
rgba,
from_cache: true,
});
}
// A corrupt stored blob is a miss, not a failure.
@@ -1202,9 +1202,7 @@ pub fn spawn_thumbnails(
return;
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -1392,6 +1390,7 @@ async fn fetch_one(
width: preview.width,
height: preview.height,
rgba: preview.rgba,
from_cache: false,
}))
}
@@ -1675,9 +1674,7 @@ pub fn spawn_sweep(
return;
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -2169,9 +2166,7 @@ mod tests {
// The ordering guarantee is what lets a caller pair results back to
// their inputs; without it a lane's dates could be attributed to the
// wrong images.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = crate::net_runtime::build()
.unwrap();
let out = rt.block_on(async {
@@ -2195,9 +2190,7 @@ mod tests {
#[test]
fn join_all_of_nothing_completes() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = crate::net_runtime::build()
.unwrap();
let out: Vec<i32> =
rt.block_on(async { futures_join_all(Vec::<std::future::Ready<i32>>::new()).await });
@@ -2636,6 +2629,61 @@ mod tests {
assert!(out[2] > out[0], "channel order survived the round trip");
}
#[test]
fn a_store_hit_is_not_evidence_the_server_is_reachable() {
// The regression this guards: store hits were delivered as the same
// `Ready` the network path sends, and the UI took any `Ready` as proof
// of connectivity. A mostly-cached window then declared "back online"
// against a server that was down — clearing the banner and kicking off
// a sweep that immediately failed, on every scroll.
let dir = std::env::temp_dir().join(format!("dr-ui-provenance-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let rgba: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], 8 * 8)
.flatten()
.collect();
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(8, 8, &rgba).unwrap();
store
.put(99, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail {
width: 8,
height: 8,
bytes,
})
.unwrap();
// The split in `spawn_thumbnails`: a hit decodes off local disk and is
// reported with `from_cache` set, which is what the reachability gate
// keys on.
let stored = store.get(99, dr_thumbs::ThumbSize::Grid).unwrap().expect("stored");
let (width, height, rgba) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
let hit = ThumbnailReady {
row: 0,
width,
height,
rgba,
from_cache: true,
};
assert!(
hit.from_cache,
"a thumbnail read from the store must not be mistaken for a fetch"
);
// And the mechanism it feeds: an offline tracker must survive it.
let mut reach = dr_sync::Reachability::new();
let now = std::time::Instant::now();
reach.mark_unreachable("network error".into(), now);
if !hit.from_cache {
reach.mark_reachable(now);
}
assert!(
reach.is_offline(),
"replaying cached thumbnails must leave offline mode intact"
);
}
#[test]
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
// They sync to the server and are shared with other clients, so a
+16 -8
View File
@@ -1650,14 +1650,22 @@ fn drain_thumbnails(
// carry no embedded preview.
ThumbnailMessage::Ready(t) => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
// Bytes arrived, so the server is reachable. This is
// what clears the banner when a connection returns
// while the user is simply scrolling, without waiting
// for a probe or a manual retry.
if ctl_cb
.reachability
.borrow_mut()
.mark_reachable(std::time::Instant::now())
// Bytes arrived *from the server*, so it is reachable.
// This is what clears the banner when a connection
// returns while the user is simply scrolling, without
// waiting for a probe or a manual retry.
//
// Store hits are excluded deliberately: they are read
// from local disk and say nothing about the network. A
// window that is mostly cached delivers a run of them
// before the first request is even attempted, so
// counting them declared "back online" against a server
// that was plainly down.
if !t.from_cache
&& ctl_cb
.reachability
.borrow_mut()
.mark_reachable(std::time::Instant::now())
{
log::info!("back online");
refresh_offline(&w, &ctl_cb);
+34
View File
@@ -0,0 +1,34 @@
//! The tokio runtime every network worker is built from.
//!
//! One function rather than a builder repeated at each call site, because the
//! choice it encodes is not obvious and was wrong in eleven places at once.
//!
//! **Why not `new_current_thread`.** A current-thread runtime drives its
//! reactor only while the thread is inside `block_on`. On Android that left
//! reqwest's connection future unpolled: the await never resolved, so the
//! worker neither failed nor returned. Every symptom was an absence — no error,
//! no panic for `catch_unwind` to catch, no log line, and a channel that closed
//! only when the thread was finally torn down. What the user saw was a sign-in
//! that stopped, and a library that quietly scanned itself rather than adopting
//! the shards the server already held.
//!
//! A multi-thread runtime owns worker threads that poll the reactor regardless.
//! One worker is enough: these are single request-response flows, not
//! throughput-bound work.
//!
//! **Why not `enable_all`.** That also starts the signal driver, which wants
//! process-wide signal handling. Inside an Android app the runtime already owns
//! that, and a worker thread claiming it is asking for trouble. These flows need
//! IO (reqwest) and time (poll intervals, timeouts) and nothing else.
/// Build a runtime suitable for a network worker thread.
///
/// Call from the spawned thread, not from the caller: the runtime must live on
/// the thread that blocks on it.
pub fn build() -> std::io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build()
}
+2 -6
View File
@@ -175,9 +175,7 @@ pub fn spawn_move(
std::thread::spawn(move || {
let total = moves.len();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
@@ -291,9 +289,7 @@ pub fn spawn_purge(
std::thread::spawn(move || {
let total = paths.len();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
let rt = match crate::net_runtime::build()
{
Ok(rt) => rt,
Err(e) => {
+5
View File
@@ -216,6 +216,8 @@ export component AppWindow inherits Window {
in-out property <[bool]> launch-format-checked;
callback launch-sign-in(string);
/// server, username, app password
callback launch-sign-in-direct(string, string, string);
callback launch-sign-out();
callback launch-choose-folder();
callback launch-open-library();
@@ -575,6 +577,9 @@ export component AppWindow inherits Window {
format-checked: root.launch-format-checked;
sign-in(server) => { root.launch-sign-in(server); }
sign-in-direct(server, user, pw) => {
root.launch-sign-in-direct(server, user, pw);
}
sign-out() => { root.launch-sign-out(); }
choose-folder() => { root.launch-choose-folder(); }
open-library() => { root.launch-open-library(); }
+60
View File
@@ -114,6 +114,8 @@ export component LaunchScreen inherits Rectangle {
// --- events out ---
callback sign-in(string);
/// server, username, app password
callback sign-in-direct(string, string, string);
callback sign-out();
callback choose-folder();
callback open-library();
@@ -196,6 +198,64 @@ export component LaunchScreen inherits Rectangle {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
wrap: word-wrap;
}
// --- or: an app password entered directly ---
//
// The browser flow above is preferred because no credential
// ever reaches us. This exists for the cases it cannot
// serve: no browser able to complete the handshake, or a
// user who would rather paste a token they can revoke.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: "USERNAME"; }
user-input := Field {
text: "";
placeholder: "your Nextcloud username";
}
PanelHeading { text: "APP PASSWORD"; }
pass-input := Field {
text: "";
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
secret: true;
accepted(pw) => {
root.sign-in-direct(server-input.text, user-input.text, pw);
}
}
FormButton {
text: root.busy ? "Connecting…" : "Connect directly";
enabled: !root.busy
&& server-input.text != ""
&& user-input.text != ""
&& pass-input.text != "";
clicked => {
root.sign-in-direct(
server-input.text,
user-input.text,
pass-input.text,
);
}
}
Caption {
text: "Create one in Nextcloud under Settings → Security → Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
+4
View File
@@ -528,6 +528,9 @@ export component Panel inherits Rectangle {
export component Field inherits Rectangle {
in-out property <string> text;
in property <string> placeholder;
/// Masks the entry, for a credential that should not be readable over the
/// user's shoulder. The placeholder still shows while the field is empty.
in property <bool> secret: false;
/// Whether the entry currently holds focus, so a caller can enable its
/// submit button from the same fact the border is drawn from.
out property <bool> has-focus: input.has-focus;
@@ -552,6 +555,7 @@ export component Field inherits Rectangle {
width: parent.width - 2 * Theme.gap;
height: 100%;
single-line: true;
input-type: root.secret ? InputType.password : InputType.text;
accepted => { root.accepted(self.text); }
}