Many imorovments
This commit is contained in:
@@ -14,6 +14,11 @@ jobs:
|
|||||||
desktop:
|
desktop:
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
name: Desktop (Linux)
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -34,6 +39,17 @@ jobs:
|
|||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
|
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
|
- name: Format
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
@@ -92,11 +108,23 @@ jobs:
|
|||||||
layering:
|
layering:
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
name: Layer separation
|
name: Layer separation
|
||||||
|
# Node for the JS actions, as above. cargo comes from rustup below.
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
|
||||||
# `use slint::` costs headless golden-image testing and the
|
# `use slint::` costs headless golden-image testing and the
|
||||||
# one-operation-two-presentations property together, and nothing else
|
# one-operation-two-presentations property together, and nothing else
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ jobs:
|
|||||||
traceability:
|
traceability:
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
name: Requirement traces
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -39,6 +43,15 @@ jobs:
|
|||||||
target
|
target
|
||||||
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
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
|
# 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
|
# before it does. Untested gate logic is exactly how JellyTau's 158% went
|
||||||
# unnoticed for months.
|
# unnoticed for months.
|
||||||
|
|||||||
Generated
+1
@@ -1176,6 +1176,7 @@ name = "darkroom-android"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
|
"dr-sync-nextcloud",
|
||||||
"dr-ui",
|
"dr-ui",
|
||||||
"log",
|
"log",
|
||||||
"slint",
|
"slint",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ crate-type = ["cdylib"]
|
|||||||
# No backend feature to select: dr-ui picks its Slint backend from the target,
|
# No backend feature to select: dr-ui picks its Slint backend from the target,
|
||||||
# so building for aarch64-linux-android gets android-activity automatically.
|
# so building for aarch64-linux-android gets android-activity automatically.
|
||||||
dr-ui.workspace = true
|
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
|
# 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
|
# calls `slint::android::init`, both of which come from this crate. The backend
|
||||||
# feature comes from dr-ui's target-specific dependency.
|
# feature comes from dr-ui's target-specific dependency.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||||
here is a distribution manifest yet — no permissions are declared because
|
here is a distribution manifest yet. Only network access is declared: file
|
||||||
the library grid reads through SAF, which grants per-tree rather than by
|
access needs no manifest permission because the library grid reads through
|
||||||
manifest permission (ARCH §6.9).
|
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="paris.tourolle.darkroom">
|
package="paris.tourolle.darkroom">
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ fn android_main(app: slint::android::AndroidApp) {
|
|||||||
|
|
||||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
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) {
|
if let Err(e) = slint::android::init(app) {
|
||||||
log::error!("Slint Android backend failed to initialise: {e}");
|
log::error!("Slint Android backend failed to initialise: {e}");
|
||||||
return;
|
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
|
||||||
@@ -57,7 +57,7 @@ pub async fn begin(
|
|||||||
.header(reqwest::header::USER_AGENT, user_agent)
|
.header(reqwest::header::USER_AGENT, user_agent)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(super::map_send_error)?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(RemoteError::Server {
|
return Err(RemoteError::Server {
|
||||||
@@ -94,7 +94,7 @@ pub async fn poll(
|
|||||||
.body(format!("token={}", urlencode(&flow.poll.token)))
|
.body(format!("token={}", urlencode(&flow.poll.token)))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(super::map_send_error)?;
|
||||||
|
|
||||||
match resp.status().as_u16() {
|
match resp.status().as_u16() {
|
||||||
200 => {
|
200 => {
|
||||||
@@ -147,7 +147,7 @@ pub async fn revoke(
|
|||||||
.header("OCS-APIRequest", "true")
|
.header("OCS-APIRequest", "true")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(super::map_send_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ impl NextcloudBackend {
|
|||||||
.body(chunk.to_vec())
|
.body(chunk.to_vec())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
map_status(resp.status(), path.as_str())?;
|
map_status(resp.status(), path.as_str())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ impl NextcloudBackend {
|
|||||||
.header("OC-Total-Length", total.to_string())
|
.header("OC-Total-Length", total.to_string())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
map_status(resp.status(), path.as_str())?;
|
map_status(resp.status(), path.as_str())?;
|
||||||
|
|
||||||
// The MOVE response carries the assembled file's ETag on Nextcloud,
|
// The MOVE response carries the assembled file's ETag on Nextcloud,
|
||||||
@@ -191,7 +191,7 @@ impl NextcloudBackend {
|
|||||||
.basic_auth(&self.login, Some(&self.password))
|
.basic_auth(&self.login, Some(&self.password))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
|
|
||||||
// 405 is "already exists", which is exactly what we want.
|
// 405 is "already exists", which is exactly what we want.
|
||||||
if resp.status() == 405 {
|
if resp.status() == 405 {
|
||||||
@@ -218,12 +218,12 @@ impl NextcloudBackend {
|
|||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
|
|
||||||
map_status(resp.status(), path.as_str())?;
|
map_status(resp.status(), path.as_str())?;
|
||||||
resp.text()
|
resp.text()
|
||||||
.await
|
.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
|
let resp = req
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
map_status(status, &url)?;
|
map_status(status, &url)?;
|
||||||
|
|
||||||
let body = resp
|
let body = resp
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?
|
.map_err(map_send_error)?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
// Nextcloud does not advertise Accept-Ranges, so support is detected
|
// Nextcloud does not advertise Accept-Ranges, so support is detected
|
||||||
@@ -346,7 +346,7 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
map_status(resp.status(), path.as_str())?;
|
map_status(resp.status(), path.as_str())?;
|
||||||
|
|
||||||
resp.headers()
|
resp.headers()
|
||||||
@@ -374,7 +374,7 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
let resp = req
|
let resp = req
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
map_status(resp.status(), &url)
|
map_status(resp.status(), &url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
.header("Overwrite", "F")
|
.header("Overwrite", "F")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?;
|
.map_err(map_send_error)?;
|
||||||
|
|
||||||
map_status(resp.status(), &src)
|
map_status(resp.status(), &src)
|
||||||
}
|
}
|
||||||
@@ -451,7 +451,7 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
.basic_auth(&self.login, Some(&self.password))
|
.basic_auth(&self.login, Some(&self.password))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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
|
// 405 is "already a collection here", which is exactly what the
|
||||||
// caller wanted. Anything else is reported.
|
// caller wanted. Anything else is reported.
|
||||||
@@ -482,7 +482,7 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
.basic_auth(&self.login, Some(&self.password))
|
.basic_auth(&self.login, Some(&self.password))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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
|
// 404 means no preview for this file — expected for RAW, and a
|
||||||
// fall-through rather than an error.
|
// fall-through rather than an error.
|
||||||
@@ -494,12 +494,56 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
Ok(Some(
|
Ok(Some(
|
||||||
resp.bytes()
|
resp.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))?
|
.map_err(map_send_error)?
|
||||||
.to_vec(),
|
.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.
|
/// Build an HTTP client with the crypto provider already installed.
|
||||||
///
|
///
|
||||||
/// Every entry point that creates a client must go through here. The auth
|
/// 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
|
// verifier entirely (see its ClientBuilder TLS setup); the webpki-roots
|
||||||
// feature supplies the roots it then uses. Spike S3 revisits this to
|
// feature supplies the roots it then uses. Spike S3 revisits this to
|
||||||
// honour user-installed and enterprise CAs, which bundled roots cannot.
|
// 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.
|
// Not empty: webpki-roots trails Mozilla's store, so a root that is
|
||||||
.tls_certs_only(std::iter::empty())
|
// 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()
|
.build()
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))
|
.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.
|
/// Translate an HTTP status into a typed error.
|
||||||
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
||||||
match status.as_u16() {
|
match status.as_u16() {
|
||||||
@@ -713,4 +805,52 @@ mod tests {
|
|||||||
Err(RemoteError::Unsupported(_))
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,41 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::AppCredentials;
|
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.
|
/// A configured account, minus its credential.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
@@ -119,17 +154,16 @@ impl SessionStore {
|
|||||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
|
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
|
||||||
/// `~/.config` (FR-PLAT-LIN-1).
|
/// `~/.config` (FR-PLAT-LIN-1).
|
||||||
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
|
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
|
||||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
Self::open_at(config_dir().join("sessions.json"), secrets)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open at an explicit path — used by tests, and by anything wanting a
|
/// Open at an explicit path — used by tests, and by anything wanting a
|
||||||
/// non-default config location.
|
/// 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 {
|
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config_path,
|
config_path,
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ pub enum RemoteError {
|
|||||||
#[error("network error: {0}")]
|
#[error("network error: {0}")]
|
||||||
Network(String),
|
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}")]
|
#[error("unexpected server response: {status} {detail}")]
|
||||||
Server { status: u16, detail: String },
|
Server { status: u16, detail: String },
|
||||||
|
|
||||||
@@ -52,6 +66,9 @@ impl RemoteError {
|
|||||||
pub fn is_transient(&self) -> bool {
|
pub fn is_transient(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
RemoteError::Network(_) => true,
|
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, .. } => {
|
RemoteError::Server { status, .. } => {
|
||||||
// 5xx and 429 are worth retrying; other 4xx are not.
|
// 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
|
/// is just failing, and a retry is the right response rather than a
|
||||||
/// mode change. 429 and 423 likewise — those are the server working
|
/// mode change. 429 and 423 likewise — those are the server working
|
||||||
/// correctly under load.
|
/// 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 {
|
pub fn indicates_offline(&self) -> bool {
|
||||||
matches!(self, RemoteError::Network(_))
|
matches!(self, RemoteError::Network(_))
|
||||||
}
|
}
|
||||||
@@ -89,6 +110,29 @@ impl RemoteError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn transient_errors_are_retryable() {
|
fn transient_errors_are_retryable() {
|
||||||
assert!(RemoteError::Network("timeout".into()).is_transient());
|
assert!(RemoteError::Network("timeout".into()).is_transient());
|
||||||
|
|||||||
@@ -505,6 +505,38 @@ mod tests {
|
|||||||
assert_eq!(parsed.cache, CacheSettings::default());
|
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]
|
#[test]
|
||||||
fn upscaling_is_off_by_default() {
|
fn upscaling_is_off_by_default() {
|
||||||
// FR-EXP-3 requires an explicit opt-in.
|
// FR-EXP-3 requires an explicit opt-in.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ ARG NDK_VERSION=27.2.12479018
|
|||||||
ARG CMDLINE_TOOLS=13114758
|
ARG CMDLINE_TOOLS=13114758
|
||||||
# Must satisfy cargo-ndk's MSRV (4.1.x needs >= 1.86) as well as our own crates.
|
# Must satisfy cargo-ndk's MSRV (4.1.x needs >= 1.86) as well as our own crates.
|
||||||
ARG RUST_VERSION=1.92.0
|
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
|
# 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
|
# 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 \
|
build-essential cmake python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
# Android SDK + NDK
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -87,10 +87,9 @@ pub fn spawn_sync(
|
|||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
// A current-thread runtime here is what made the library scan itself
|
||||||
.enable_all()
|
// rather than adopt the shards the server already had; see net_runtime.
|
||||||
.build()
|
let rt = match crate::net_runtime::build() {
|
||||||
{
|
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||||
|
|||||||
@@ -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>) {
|
pub fn await_approval(&mut self, login_url: impl Into<String>) {
|
||||||
self.status = Some("Approve the sign-in in your browser.".into());
|
self.status = Some("Approve the sign-in in your browser.".into());
|
||||||
self.state = LaunchState::AwaitingApproval {
|
self.state = LaunchState::AwaitingApproval {
|
||||||
|
|||||||
+121
-7
@@ -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 ------------------------------------------------------
|
// --- sign out ------------------------------------------------------
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
@@ -279,11 +301,18 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
};
|
};
|
||||||
step("thread started");
|
step("thread started");
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||||
// `enable_all()` also enables the signal driver, which wants to own
|
// Multi-thread, not current-thread: a current-thread runtime drives
|
||||||
// process-wide signal handling and is not something a worker thread
|
// its reactor only while the thread sits inside `block_on`, and on
|
||||||
// inside an Android app can safely claim — the flow needs only IO
|
// Android that left reqwest's connection future never polled — the
|
||||||
// (reqwest) and time (the poll interval), so ask for just those.
|
// await never resolved, so the worker neither failed nor returned.
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
// 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_io()
|
||||||
.enable_time()
|
.enable_time()
|
||||||
.build()
|
.build()
|
||||||
@@ -313,6 +342,85 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
poll_channel(weak, ctl, rx);
|
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
|
/// The body of the login flow, split out so the worker above can wrap it in
|
||||||
/// `catch_unwind` without a deeply nested closure.
|
/// `catch_unwind` without a deeply nested closure.
|
||||||
fn run_login_flow(
|
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();
|
let user_id = session.user_id.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
// Multi-thread for the same reason as the login worker: a
|
||||||
.enable_all()
|
// 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();
|
.build();
|
||||||
let Ok(rt) = rt else {
|
let Ok(rt) = rt else {
|
||||||
let _ = tx.send(Err("runtime".into()));
|
let _ = tx.send(Err("runtime".into()));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
|
mod net_runtime;
|
||||||
mod develop;
|
mod develop;
|
||||||
mod labels;
|
mod labels;
|
||||||
mod library;
|
mod library;
|
||||||
|
|||||||
+72
-24
@@ -79,6 +79,13 @@ pub struct ThumbnailReady {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub rgba: Vec<u8>,
|
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.
|
/// 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();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -560,9 +565,7 @@ fn run_scan(
|
|||||||
}
|
}
|
||||||
let catalog = Catalog::open(&catalog_path).map_err(ScanFailure::local)?;
|
let catalog = Catalog::open(&catalog_path).map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(ScanFailure::local)?;
|
.map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
@@ -887,9 +890,7 @@ pub fn spawn_pin_fetch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1055,9 +1056,7 @@ pub fn spawn_full_fetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1160,6 +1159,7 @@ pub fn spawn_thumbnails(
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
rgba,
|
rgba,
|
||||||
|
from_cache: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A corrupt stored blob is a miss, not a failure.
|
// A corrupt stored blob is a miss, not a failure.
|
||||||
@@ -1202,9 +1202,7 @@ pub fn spawn_thumbnails(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1392,6 +1390,7 @@ async fn fetch_one(
|
|||||||
width: preview.width,
|
width: preview.width,
|
||||||
height: preview.height,
|
height: preview.height,
|
||||||
rgba: preview.rgba,
|
rgba: preview.rgba,
|
||||||
|
from_cache: false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,9 +1674,7 @@ pub fn spawn_sweep(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -2169,9 +2166,7 @@ mod tests {
|
|||||||
// The ordering guarantee is what lets a caller pair results back to
|
// 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
|
// their inputs; without it a lane's dates could be attributed to the
|
||||||
// wrong images.
|
// wrong images.
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let out = rt.block_on(async {
|
let out = rt.block_on(async {
|
||||||
@@ -2195,9 +2190,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn join_all_of_nothing_completes() {
|
fn join_all_of_nothing_completes() {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let out: Vec<i32> =
|
let out: Vec<i32> =
|
||||||
rt.block_on(async { futures_join_all(Vec::<std::future::Ready<i32>>::new()).await });
|
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");
|
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]
|
#[test]
|
||||||
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
|
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
|
||||||
// They sync to the server and are shared with other clients, so a
|
// They sync to the server and are shared with other clients, so a
|
||||||
|
|||||||
@@ -1650,14 +1650,22 @@ fn drain_thumbnails(
|
|||||||
// carry no embedded preview.
|
// carry no embedded preview.
|
||||||
ThumbnailMessage::Ready(t) => {
|
ThumbnailMessage::Ready(t) => {
|
||||||
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
|
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
|
||||||
// Bytes arrived, so the server is reachable. This is
|
// Bytes arrived *from the server*, so it is reachable.
|
||||||
// what clears the banner when a connection returns
|
// This is what clears the banner when a connection
|
||||||
// while the user is simply scrolling, without waiting
|
// returns while the user is simply scrolling, without
|
||||||
// for a probe or a manual retry.
|
// waiting for a probe or a manual retry.
|
||||||
if ctl_cb
|
//
|
||||||
.reachability
|
// Store hits are excluded deliberately: they are read
|
||||||
.borrow_mut()
|
// from local disk and say nothing about the network. A
|
||||||
.mark_reachable(std::time::Instant::now())
|
// 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");
|
log::info!("back online");
|
||||||
refresh_offline(&w, &ctl_cb);
|
refresh_offline(&w, &ctl_cb);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -175,9 +175,7 @@ pub fn spawn_move(
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let total = moves.len();
|
let total = moves.len();
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -291,9 +289,7 @@ pub fn spawn_purge(
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let total = paths.len();
|
let total = paths.len();
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match crate::net_runtime::build()
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ export component AppWindow inherits Window {
|
|||||||
in-out property <[bool]> launch-format-checked;
|
in-out property <[bool]> launch-format-checked;
|
||||||
|
|
||||||
callback launch-sign-in(string);
|
callback launch-sign-in(string);
|
||||||
|
/// server, username, app password
|
||||||
|
callback launch-sign-in-direct(string, string, string);
|
||||||
callback launch-sign-out();
|
callback launch-sign-out();
|
||||||
callback launch-choose-folder();
|
callback launch-choose-folder();
|
||||||
callback launch-open-library();
|
callback launch-open-library();
|
||||||
@@ -575,6 +577,9 @@ export component AppWindow inherits Window {
|
|||||||
format-checked: root.launch-format-checked;
|
format-checked: root.launch-format-checked;
|
||||||
|
|
||||||
sign-in(server) => { root.launch-sign-in(server); }
|
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(); }
|
sign-out() => { root.launch-sign-out(); }
|
||||||
choose-folder() => { root.launch-choose-folder(); }
|
choose-folder() => { root.launch-choose-folder(); }
|
||||||
open-library() => { root.launch-open-library(); }
|
open-library() => { root.launch-open-library(); }
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
|
|
||||||
// --- events out ---
|
// --- events out ---
|
||||||
callback sign-in(string);
|
callback sign-in(string);
|
||||||
|
/// server, username, app password
|
||||||
|
callback sign-in-direct(string, string, string);
|
||||||
callback sign-out();
|
callback sign-out();
|
||||||
callback choose-folder();
|
callback choose-folder();
|
||||||
callback open-library();
|
callback open-library();
|
||||||
@@ -196,6 +198,64 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
||||||
wrap: word-wrap;
|
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 ---
|
// --- login pending: the browser step ---
|
||||||
|
|||||||
@@ -528,6 +528,9 @@ export component Panel inherits Rectangle {
|
|||||||
export component Field inherits Rectangle {
|
export component Field inherits Rectangle {
|
||||||
in-out property <string> text;
|
in-out property <string> text;
|
||||||
in property <string> placeholder;
|
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
|
/// Whether the entry currently holds focus, so a caller can enable its
|
||||||
/// submit button from the same fact the border is drawn from.
|
/// submit button from the same fact the border is drawn from.
|
||||||
out property <bool> has-focus: input.has-focus;
|
out property <bool> has-focus: input.has-focus;
|
||||||
@@ -552,6 +555,7 @@ export component Field inherits Rectangle {
|
|||||||
width: parent.width - 2 * Theme.gap;
|
width: parent.width - 2 * Theme.gap;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
single-line: true;
|
single-line: true;
|
||||||
|
input-type: root.secret ? InputType.password : InputType.text;
|
||||||
accepted => { root.accepted(self.text); }
|
accepted => { root.accepted(self.text); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user