Add secure credential storage, sessions, and a launch screen

Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
This commit is contained in:
2026-08-09 15:20:39 +02:00
parent c8bb08e661
commit 09e3043f4c
33 changed files with 3506 additions and 155 deletions
Generated
+250 -2
View File
@@ -39,6 +39,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -201,6 +212,17 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -554,6 +576,24 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -712,6 +752,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.4.2"
@@ -773,6 +822,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clang-sys"
version = "1.9.1"
@@ -952,6 +1011,15 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1007,6 +1075,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctor"
version = "0.10.1"
@@ -1109,6 +1187,17 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1203,6 +1292,14 @@ dependencies = [
"wgpu",
]
[[package]]
name = "dr-lens"
version = "0.1.0"
dependencies = [
"lensfun",
"log",
]
[[package]]
name = "dr-pipeline"
version = "0.1.0"
@@ -1211,6 +1308,17 @@ dependencies = [
"log",
]
[[package]]
name = "dr-plat"
version = "0.1.0"
dependencies = [
"dr-types",
"env_logger",
"keyring",
"log",
"thiserror 2.0.20",
]
[[package]]
name = "dr-sync"
version = "0.1.0"
@@ -1228,6 +1336,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"dr-decode",
"dr-plat",
"dr-sync",
"dr-types",
"env_logger",
@@ -1257,6 +1366,8 @@ dependencies = [
"dr-decode",
"dr-gpu",
"dr-pipeline",
"dr-plat",
"dr-sync-nextcloud",
"dr-types",
"log",
"pollster",
@@ -1635,7 +1746,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.39.2",
"roxmltree",
"roxmltree 0.21.1",
"smallvec",
"windows 0.62.2",
"windows-core 0.62.2",
@@ -1801,6 +1912,16 @@ dependencies = [
"libc",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -2131,6 +2252,24 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "htmlparser"
version = "0.2.1"
@@ -2772,6 +2911,16 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "input"
version = "0.10.0"
@@ -3162,6 +3311,27 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "4.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
dependencies = [
"log",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3203,6 +3373,18 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "lensfun"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe52f943cdba39214e90c8ff14c10cebc0f597dcbbbcdab15a68902fc8392bd8"
dependencies = [
"flate2",
"regex",
"roxmltree 0.20.0",
"thiserror 2.0.20",
]
[[package]]
name = "libc"
version = "0.2.189"
@@ -4944,6 +5126,12 @@ dependencies = [
"text-size",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
@@ -5190,6 +5378,25 @@ dependencies = [
"tiny-skia 0.11.4",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5291,6 +5498,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -6225,6 +6443,12 @@ dependencies = [
"serde",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "udev"
version = "0.9.3"
@@ -6364,7 +6588,7 @@ dependencies = [
"kurbo",
"log",
"pico-args",
"roxmltree",
"roxmltree 0.21.1",
"rustybuzz",
"simplecss",
"siphasher",
@@ -6969,6 +7193,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -7458,6 +7695,17 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.18.0"
+32
View File
@@ -5,9 +5,11 @@ members = [
"core/dr-catalog",
"core/dr-decode",
"core/dr-gpu",
"core/dr-lens",
"core/dr-pipeline",
"core/dr-sync",
"core/dr-sync-nextcloud",
"platform/dr-plat",
"ui/dr-ui",
"apps/darkroom-desktop",
"tools/traceability",
@@ -26,7 +28,9 @@ dr-types = { path = "core/dr-types" }
dr-catalog = { path = "core/dr-catalog" }
dr-decode = { path = "core/dr-decode" }
dr-gpu = { path = "core/dr-gpu" }
dr-lens = { path = "core/dr-lens" }
dr-pipeline = { path = "core/dr-pipeline" }
dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-ui = { path = "ui/dr-ui" }
@@ -63,6 +67,13 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
# Platform secure storage: Secret Service on Linux, Keystore on Android
# (FR-NC-2). Credentials never touch the catalog or a plain file.
# keyring 4 restructured its features: `v1` is the default set and brings
# the zbus Secret Service backend, which is what GNOME Keyring and KWallet
# (via ksecretd) both speak.
keyring = { version = "4", features = ["v1"] }
# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the
# embedded previews rawler extracts.
# Catalog. `bundled` compiles SQLite from source rather than linking the
@@ -78,6 +89,27 @@ rawler = "0.7"
zune-jpeg = "0.4.21"
bytemuck = { version = "1", features = ["derive"] }
# Lens correction profiles. A pure-Rust port of Lensfun rather than a binding
# to the C library, for the same cross-compilation reason as the TLS and
# SQLite choices above: liblensfun would be a third C dependency to satisfy
# under the Android NDK.
#
# The database ships *inside* the crate — 56 XML files, gzipped at build time
# and decompressed on first lookup. That matters beyond convenience: Android
# gives us no filesystem path (ARCH §6.9), so a database loaded from a
# system directory would have nowhere to live there.
#
# Licence: LGPL-3.0-or-later, which upgrades cleanly into our GPLv3 (D8).
# The upstream Lensfun *database* is CC-BY-SA and is redistributed by the
# crate; attribution belongs in the about screen.
#
# Caveat worth remembering: this is a third-party port at 0.7.0, not upstream
# Lensfun. Verified working against the bundled database (interpolation
# between calibration points, and an unknown lens returning empty rather than
# panicking), but the pipeline talks to it through its own profile types so
# swapping it out is not a pipeline change.
lensfun = "0.7"
[profile.dev]
# Dependencies optimised even in dev builds — wgpu and image decoding are
# unusably slow otherwise, and they rarely need debugging.
+23 -2
View File
@@ -12,8 +12,8 @@
//! it. This is a diagnostic, not the export path (FR-EXP-*).
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::{colour, exposure, tone, white_balance};
use dr_pipeline::EditGraph;
use dr_pipeline::ops::{colour, colour_mixer, contrast, exposure, tone, white_balance};
use dr_pipeline::{EditGraph, ParamId};
fn main() {
env_logger::init();
@@ -65,6 +65,27 @@ fn main() {
graph.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 40.0);
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
}
// Contrast alone, so its effect can be judged without anything else
// moving.
"contrast" => {
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
}
"flat" => {
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
}
// The mixer, pushed hard on the two things this scene actually has:
// green vegetation and grey-blue rock.
"mixer" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
}
// One band only, to check the weighting really is selective rather
// than affecting the whole image.
"mixer_one" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
}
_ => {}
}
+226 -12
View File
@@ -21,10 +21,15 @@ use wgpu::util::DeviceExt;
use crate::{DemosaicedImage, GpuContext, GpuError};
/// Number of leading floats in the generated uniform block that the composer
/// reserves for base parameters — three padded matrix rows and the as-shot
/// white balance. Must match `BASE_UNIFORM_FIELDS` in dr-pipeline.
const BASE_FIELDS: usize = 16;
/// Leading floats the composer reserves before any operation's own uniforms:
/// three padded matrix rows, the as-shot white balance, and framing's block.
///
/// Imported rather than restated. It was a local literal, which was a latent
/// bug of exactly the kind that is invisible until it is severe: growing the
/// reserved block on the pipeline side would leave this short, and every
/// operation's uniforms would silently shift out from under the shader that
/// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// Runs composed operation chains against demosaiced images.
pub struct AdjustPass {
@@ -206,10 +211,11 @@ impl AdjustPass {
// Base uniforms: the camera matrix and as-shot white balance, which
// every generated shader reads regardless of which operations are
// active.
// active. Framing's slots follow them and are filled by the composer,
// which is why only the first sixteen are written here.
let mut uniforms = shader.uniforms.clone();
if uniforms.len() < BASE_FIELDS {
uniforms.resize(BASE_FIELDS, 0.0);
if uniforms.len() < RESERVED_FIELDS {
uniforms.resize(RESERVED_FIELDS, 0.0);
}
let m = source.color_matrix();
let wb = source.as_shot_wb();
@@ -373,7 +379,7 @@ fn numbered(src: &str) -> String {
mod tests {
use super::*;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour, exposure, tone, white_balance};
use dr_pipeline::ops::{colour, exposure};
use dr_pipeline::EditGraph;
use crate::Demosaicer;
@@ -420,6 +426,14 @@ mod tests {
}
fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] {
let (w, h) = (tex.width(), tex.height());
read_pixel(ctx, tex, w / 2, h / 2)
}
/// One pixel, by coordinate. What the geometry tests need: proving a
/// rotation moved content requires looking somewhere other than the
/// centre, which every rotation leaves fixed.
fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] {
let w = tex.width();
let h = tex.height();
let unpadded = w * 4;
@@ -466,7 +480,7 @@ mod tests {
rx.recv().expect("map").expect("map ok");
let data = slice.get_mapped_range();
let off = ((h / 2) * padded + (w / 2) * 4) as usize;
let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize;
let px = [data[off], data[off + 1], data[off + 2], data[off + 3]];
drop(data);
buf.unmap();
@@ -521,6 +535,195 @@ mod tests {
}
}
/// An image bright on one side and dark on the other, so a transform that
/// moves content is visible. A flat grey cannot show a rotation at all.
///
/// `vertical` puts the bright band at the top; otherwise at the left.
fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage {
let size = 32u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let near_start = if vertical { y } else { x } < size / 2;
data[(y * size + x) as usize] = if near_start { 12000 } else { 500 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
#[test]
fn a_quarter_turn_moves_a_vertical_edge_to_a_horizontal_one() {
// The end-to-end check that the coordinate permutation is wired the
// right way round. A left-bright image turned 90° clockwise must come
// out top-bright; getting the sign wrong yields bottom-bright, which
// compiles perfectly and is simply the wrong image.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.rotate_quarters(1);
let (w, h) = g.output_size(32, 32);
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
let top = read_pixel(&ctx, tex, w / 2, h / 8)[0];
let bottom = read_pixel(&ctx, tex, w / 2, h * 7 / 8)[0];
assert!(
top > bottom + 40,
"a left-bright image turned 90° clockwise should be top-bright, \
got top={top} bottom={bottom}"
);
}
#[test]
fn a_horizontal_flip_swaps_the_sides() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, 1.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
let left = read_pixel(&ctx, tex, 4, 16)[0];
let right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
right > left + 40,
"flipping a left-bright image should make it right-bright, \
got left={left} right={right}"
);
}
#[test]
fn cropping_to_one_half_shows_only_that_half() {
// The property a crop exists for, checked against content rather than
// against the output dimensions alone: a crop of the dark side must
// be dark everywhere, edge to edge.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_crop(dr_pipeline::CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 1.0,
});
let (w, h) = g.output_size(32, 32);
assert_eq!((w, h), (16, 32), "half a 32px frame is 16px wide");
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
assert_eq!((tex.width(), tex.height()), (16, 32));
for x in [1, w / 2, w - 2] {
let v = read_pixel(&ctx, tex, x, h / 2)[0];
assert!(v < 90, "cropped to the dark half, x={x} came out {v}");
}
}
#[test]
fn straightening_darkens_the_exposed_corners() {
// Rotating a frame inside its own bounds leaves no source pixel at the
// corners. They must read black rather than a smeared edge pixel — the
// difference between "the frame is rotated" and "the image is smudged".
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 30.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
// The top-left corner of a 30° rotation is off the source.
let corner = read_pixel(&ctx, tex, 0, 0);
assert_eq!(
corner,
[0, 0, 0, 255],
"an exposed corner must be black and opaque"
);
}
#[test]
fn dragging_the_crop_does_not_recompile() {
// The cache contract for framing, which is what makes an interactive
// crop drag viable: the rect changes every frame, and each frame must
// reuse the compiled pipeline.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for i in 1..=10 {
let inset = i as f32 * 0.02;
g.set_crop(dr_pipeline::CropRect {
x: inset,
y: inset,
width: 1.0 - 2.0 * inset,
height: 1.0 - 2.0 * inset,
});
let (w, h) = g.output_size(64, 64);
pass.render(&img, &g.compose(), w, h).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
1,
"ten crop rectangles must share one compiled pipeline"
);
}
#[test]
fn straightening_compiles_its_own_pipeline_but_reuses_it() {
// Straightening changes the sampling path from an integer load to a
// bilinear fetch, so it *must* compile a second pipeline — and then
// must stop at two however far the slider travels.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(pass.cached_pipelines(), 1);
for i in 1..=8 {
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
i as f32 * 0.5,
);
pass.render(&img, &g.compose(), 32, 32).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
2,
"straightening compiles one more pipeline, not one per angle"
);
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
@@ -543,10 +746,21 @@ mod tests {
let shader = g.compose();
assert_eq!(
shader.source.matches("---- ").count(),
g.descriptors().len(),
"every operation should be active"
// Every operation, plus framing — which emits a stage of its own
// rather than an operation block, and is not in `descriptors`.
g.descriptors().len() + 1,
"every operation and the framing should be active"
);
pass.render(&img, &shader, 32, 32)
assert!(
shader.source.contains("---- framing ----"),
"framing must reach the shader alongside the colour operations"
);
// Cropped, so the render is against an output size that is not the
// source size — the case where a wrong dispatch or a wrong texture
// allocation would show up.
let (w, h) = g.output_size(32, 32);
pass.render(&img, &shader, w, h)
.expect("the full chain must compile");
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "dr-lens"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# Isolated from dr-pipeline deliberately. That crate has no dependencies at
# all so its codegen stays testable without a device (ARCH §6.5a); pulling an
# XML parser and 5.5 MB of profile data into it would cost exactly the
# property it is organised around. The pipeline consumes the coefficient
# structs this crate produces, and never links the database.
[dependencies]
lensfun.workspace = true
log.workspace = true
+300
View File
@@ -0,0 +1,300 @@
//! Lens profile lookup — the Lensfun database, reduced to coefficients.
//!
//! # What this crate is for
//!
//! `dr-pipeline` knows the *maths* of lens correction: the `ptlens`
//! polynomial, the `poly3` per-channel scale, the `pa` vignetting curve. It
//! does not know which coefficients belong to which lens, and deliberately
//! has no dependencies with which to find out.
//!
//! This crate closes that gap. Given what EXIF reports — a lens name, a focal
//! length, an aperture — it returns the coefficients for that shot, and
//! nothing else. The pipeline consumes plain `f32`s and never links the
//! database.
//!
//! # Why a separate crate rather than a module
//!
//! Two reasons, both about keeping a dependency contained:
//!
//! - **`dr-pipeline` has no dependencies on purpose.** Its codegen is testable
//! without a GPU, and adding an XML parser plus 5.5 MB of profile data to it
//! would cost exactly the property it is organised around (ARCH §6.5a).
//! - **The `lensfun` crate is a third-party port**, not upstream Lensfun.
//! Confining it behind [`LensProfile`] means replacing it — with the C
//! library, with our own parser, with vendor-supplied profiles — touches
//! this crate and nothing downstream.
//!
//! # Matching is best-effort, and says so
//!
//! EXIF lens names are not clean identifiers. Different bodies report the same
//! lens differently, third-party lenses often report nothing, and adapted
//! manual lenses report nothing at all. So every lookup returns an `Option`,
//! and the UI is expected to say plainly whether a profile was found — an
//! automatic correction that silently did nothing is worse than one the user
//! can see is unavailable (ARCH §9.4 applies the same honesty rule to sync).
use std::sync::OnceLock;
/// The `ptlens` distortion coefficients, as Lensfun stores them.
///
/// Mirrors `dr_pipeline::ops::distortion::PtLens`. Duplicated rather than
/// shared because the dependency would have to run the wrong way: the
/// pipeline must not depend on the profile database.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Distortion {
pub a: f32,
pub b: f32,
pub c: f32,
}
/// Lateral chromatic aberration: a per-channel radial scale.
///
/// Red and blue are scaled about the optical axis; green is the reference and
/// is never moved, so a correction that is wrong still leaves the image
/// sharp in one channel rather than softening all three.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tca {
pub red_scale: f32,
pub blue_scale: f32,
}
/// The `pa` vignetting polynomial: `1 + k1·r² + k2·r⁴ + k3·r⁶`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vignetting {
pub k1: f32,
pub k2: f32,
pub k3: f32,
}
/// Everything known about one lens at one set of shooting parameters.
///
/// Each field is independently optional: the Lensfun database frequently
/// carries distortion for a lens but no vignetting, or covers only part of a
/// zoom's range. A partial profile is useful and must not be discarded.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LensProfile {
pub distortion: Option<Distortion>,
pub tca: Option<Tca>,
pub vignetting: Option<Vignetting>,
}
impl LensProfile {
/// Whether this profile carries anything at all.
pub fn is_empty(&self) -> bool {
self.distortion.is_none() && self.tca.is_none() && self.vignetting.is_none()
}
}
/// What EXIF reports about a shot, as far as lens correction cares.
#[derive(Debug, Clone, PartialEq)]
pub struct ShotInfo<'a> {
/// The lens model string. Frequently absent or unhelpful.
pub lens: &'a str,
/// Focal length in mm. Selects between a zoom's calibration points.
pub focal_length: f32,
/// Aperture as an f-number. Vignetting depends on it strongly — a lens
/// wide open can be two stops down in the corners and clean by f/8.
pub aperture: f32,
/// Focus distance in metres, when known. Vignetting varies with it, but
/// EXIF rarely reports it, so the default stands in for "far away".
pub distance: f32,
}
impl<'a> ShotInfo<'a> {
/// The distance Lensfun's calibrations use for "not a close-up".
///
/// Most database entries are measured at 1000 m — effectively infinity —
/// and EXIF almost never carries focus distance, so this is the value
/// nearly every lookup uses.
pub const FAR: f32 = 1000.0;
pub fn new(lens: &'a str, focal_length: f32, aperture: f32) -> Self {
Self {
lens,
focal_length,
aperture,
distance: Self::FAR,
}
}
}
/// The bundled Lensfun database, loaded once on first use.
///
/// Decompressing 56 XML files costs enough to be worth doing once, and little
/// enough not to be worth doing in the background. `OnceLock` rather than a
/// constructor the callers must thread through: this is a read-only reference
/// table, and making every call site own a handle to it would buy nothing.
fn database() -> Option<&'static lensfun::Database> {
static DB: OnceLock<Option<lensfun::Database>> = OnceLock::new();
DB.get_or_init(|| match lensfun::Database::load_bundled() {
Ok(db) => Some(db),
Err(e) => {
// Not fatal. Manual correction still works, so the develop panel
// stays usable with the automatic profile absent.
log::warn!("lens profile database unavailable: {e}");
None
}
})
.as_ref()
}
/// Look up correction coefficients for a shot.
///
/// Returns `None` when the lens is unknown — which is common and not an
/// error. A blank lens name short-circuits, because matching on one returns
/// arbitrary entries rather than no entries.
pub fn lookup(shot: &ShotInfo<'_>) -> Option<LensProfile> {
if shot.lens.trim().is_empty() {
return None;
}
let db = database()?;
let matches = db.find_lenses(None, shot.lens);
// The database returns candidates ranked by match quality; anything
// beyond the best is a different lens that merely reads similarly.
let lens = matches.first()?;
let profile = LensProfile {
distortion: lens
.interpolate_distortion(shot.focal_length)
.and_then(|d| match d.model {
lensfun::DistortionModel::Ptlens { a, b, c } => Some(Distortion { a, b, c }),
// Other models exist in the database (`poly3`, `fov1`). Rather
// than approximate one with another — which would correct by
// the wrong curve and look like a bad profile — report nothing
// and let the manual control take over.
_ => None,
}),
tca: lens
.interpolate_tca(shot.focal_length)
.and_then(|t| match t.model {
lensfun::TcaModel::Poly3 { red, blue } => Some(Tca {
// Index 0 is the linear radial scale `v`, which carries
// essentially all of the correction; the higher terms are
// zero throughout the database in practice.
red_scale: red[0],
blue_scale: blue[0],
}),
_ => None,
}),
vignetting: lens
.interpolate_vignetting(shot.focal_length, shot.aperture, shot.distance)
.and_then(|v| match v.model {
lensfun::VignettingModel::Pa { k1, k2, k3 } => Some(Vignetting { k1, k2, k3 }),
_ => None,
}),
};
// A match that yielded no usable coefficients is the same as no match, and
// reporting it as a hit would tell the user a correction is active when
// nothing is being corrected.
(!profile.is_empty()).then_some(profile)
}
#[cfg(test)]
mod tests {
use super::*;
/// A lens with dense calibration across a zoom range, so interpolation is
/// genuinely exercised.
const KNOWN: &str = "Canon EF 16-35mm f/2.8L USM";
#[test]
fn the_bundled_database_loads() {
// The property that makes this crate work on Android: no system
// library, no data directory, no filesystem path (ARCH §6.9).
assert!(database().is_some(), "the bundled database must load");
}
#[test]
fn a_known_lens_resolves() {
let profile = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).expect("a stocked lens");
assert!(profile.distortion.is_some(), "distortion expected");
assert!(!profile.is_empty());
}
#[test]
fn an_unknown_lens_is_none_rather_than_a_panic() {
// Adapted and third-party lenses report names absent from the
// database. That is the normal case, not an error.
assert!(lookup(&ShotInfo::new("Nonexistent 999mm f/0.5", 50.0, 2.0)).is_none());
}
#[test]
fn a_blank_lens_name_does_not_match_arbitrarily() {
// EXIF often carries no lens at all. Matching on an empty string
// returns unrelated entries, which would silently apply another
// lens's correction — worse than applying none.
for name in ["", " "] {
assert!(
lookup(&ShotInfo::new(name, 50.0, 2.0)).is_none(),
"{name:?}"
);
}
}
#[test]
fn coefficients_interpolate_between_calibration_points() {
// 20mm and 22mm are calibrated; 21mm is not. If interpolation were
// absent, a zoom would snap between corrections mid-range.
let at20 = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.distortion);
let at21 = lookup(&ShotInfo::new(KNOWN, 21.0, 2.8)).and_then(|p| p.distortion);
let at22 = lookup(&ShotInfo::new(KNOWN, 22.0, 2.8)).and_then(|p| p.distortion);
let (a, b, c) = (at20.unwrap(), at21.unwrap(), at22.unwrap());
assert_ne!(a, b, "21mm must not reuse the 20mm coefficients verbatim");
let between = (a.a.min(c.a)..=a.a.max(c.a)).contains(&b.a);
assert!(
between,
"21mm coefficient {} is outside [{}, {}]",
b.a, a.a, c.a
);
}
#[test]
fn vignetting_responds_to_aperture() {
// The reason aperture is in `ShotInfo` at all: a lens wide open
// vignettes heavily and is clean stopped down, so a correction that
// ignored aperture would be wrong at both ends.
let wide = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.vignetting);
let stopped = lookup(&ShotInfo::new(KNOWN, 20.0, 8.0)).and_then(|p| p.vignetting);
if let (Some(w), Some(s)) = (wide, stopped) {
assert_ne!(w, s, "aperture must change the vignetting curve");
}
}
#[test]
fn tca_leaves_green_as_the_reference() {
// Green is never scaled, so red and blue are corrected towards it.
// Both scales sit within a fraction of a percent of 1.0; anything
// far from that would be a unit error rather than a real correction.
if let Some(tca) = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.tca) {
for s in [tca.red_scale, tca.blue_scale] {
assert!(
(0.99..=1.01).contains(&s),
"{s} is not a plausible per-channel scale"
);
}
}
}
#[test]
fn a_far_focus_distance_is_the_default() {
// EXIF rarely carries focus distance, so nearly every real lookup
// relies on this standing in.
assert_eq!(ShotInfo::new(KNOWN, 20.0, 2.8).distance, ShotInfo::FAR);
}
#[test]
fn repeated_lookups_reuse_one_database() {
// The database is decompressed on first use; a second lookup must not
// pay for it again.
assert!(lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).is_some());
assert!(lookup(&ShotInfo::new(KNOWN, 24.0, 4.0)).is_some());
assert!(std::ptr::eq(
database().expect("loaded"),
database().expect("loaded")
));
}
}
+72 -19
View File
@@ -8,7 +8,7 @@
//! been answered. And a crop changes the output's dimensions and aspect
//! ratio, which no colour fragment can express.
//!
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
//! [`crate::lens::Warp`] is closer: it also rewrites coordinates before the
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
//! properties of the lens, defined about the optical axis, over the whole
//! frame the lens projected. Framing is a decision about *composition*, made
@@ -144,13 +144,16 @@ impl CropRect {
pub fn normalised(self) -> Self {
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
Self {
x,
y,
width,
height,
// `max` before `min`, not `f32::clamp`. With the origin at its
// limit, `1.0 - x` rounds to fractionally *below* `MIN_EXTENT` —
// an inverted range, which `clamp` panics on rather than
// resolving. Ordering it this way lets the lower bound win, which
// is also the answer that keeps the rect non-degenerate.
width: finite(self.width, 1.0).min(1.0 - x).max(Self::MIN_EXTENT),
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
}
}
}
@@ -168,6 +171,7 @@ fn finite(v: f32, fallback: f32) -> f32 {
}
}
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// Crop, straighten, rotation and flips for one image.
///
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
@@ -424,12 +428,25 @@ impl Framing {
/// position, ready for the warp chain.
///
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
/// exactly the space [`crate::warp`] documents, so lens correction
/// exactly the space [`crate::lens`] documents, so lens correction
/// composes on top of this without either stage naming the other.
///
/// `aspect` is left in scope alongside it, since the warp chain and the
/// sampler both need it to return to texture coordinates.
pub fn wgsl_prologue(&self) -> String {
// Neutral framing still has to produce `p`, since the warp chain and
// the sampler read it either way. It emits no `---- ` marker: those
// count active stages, and a neutral graph must generate none.
if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated.
let src_dims = textureDimensions(source);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect;
"
.into();
}
let mut s = String::new();
s.push_str(
@@ -444,19 +461,6 @@ impl Framing {
",
);
if !self.is_active() {
// Neutral framing still has to produce `p`, since the warp chain
// and the sampler read it either way. It is only the crop,
// rotation and flip steps that vanish.
s.push_str(
"
// Framing is neutral: the whole frame, unrotated.
var p = (uv - vec2<f32>(0.5)) * aspect;
",
);
return s;
}
s.push_str(
"
// Into the crop rect.
@@ -559,6 +563,18 @@ mod tests {
assert!(!src.contains("framing_angle"));
}
#[test]
fn neutral_framing_emits_no_stage_marker() {
// `---- ` markers count *active* stages, and a neutral graph must
// generate none — the assertion behind "opening an image shows the
// image" is written against that count.
assert!(!Framing::new().wgsl_prologue().contains("---- "));
let mut f = Framing::new();
f.set_param(ANGLE, 2.0);
assert!(f.wgsl_prologue().contains("---- framing ----"));
}
#[test]
fn an_active_framing_reads_the_crop_rect() {
let mut f = Framing::new();
@@ -664,6 +680,43 @@ mod tests {
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
}
#[test]
fn an_origin_at_its_limit_does_not_panic() {
// Found by the codegen test that drives every parameter to its
// maximum. With the origin at `1 - MIN_EXTENT`, `1.0 - x` rounds to
// just under `MIN_EXTENT`, and `f32::clamp` panics on an inverted
// range rather than resolving it — a crash reachable by dragging a
// crop handle to the edge.
for origin in [1.0 - CropRect::MIN_EXTENT, 0.99, 0.999_999, 1.0, f32::MAX] {
let c = CropRect {
x: origin,
y: origin,
width: 1.0,
height: 1.0,
}
.normalised();
assert!(
c.width >= CropRect::MIN_EXTENT && c.height >= CropRect::MIN_EXTENT,
"origin {origin} produced a degenerate rect: {c:?}"
);
}
}
#[test]
fn every_parameter_at_its_extremes_is_survivable() {
// The whole descriptor driven to both ends, which is what a codegen
// test does and what a corrupt sidecar can do.
for p in DESCRIPTOR.params {
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
let mut f = Framing::new();
f.set_param(p.id, p.clamp(value));
let (w, h) = f.output_size(6000, 4000);
assert!(w >= 1 && h >= 1, "{} at {value} gave {w}x{h}", p.id);
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn output_size_rounds_rather_than_truncating() {
// Truncation biases every crop smaller; half of 101 should be 51.
+23 -4
View File
@@ -398,7 +398,14 @@ mod tests {
// something the UI would have to hardcode.
let g = EditGraph::default_chain();
let caps = g.capabilities();
assert_eq!(caps.len(), g.descriptors().len());
// Every operation, plus framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().len() + 1);
assert!(
caps.iter().any(|c| c.id == crate::framing::ID),
"framing must appear in the capability list, or the UI cannot \
build a crop control without naming it"
);
for cap in &caps {
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
@@ -458,13 +465,25 @@ mod tests {
fn capabilities_survive_a_round_trip_through_set_param() {
// The UI reads a capability, writes the value back, and must get the
// same thing out — no hidden scaling between the two.
//
// Written at the parameter's declared precision, because that is what
// the UI can actually produce: a control declaring 0 decimals emits
// whole numbers, and a stage free to quantise to them is behaving
// correctly rather than losing the value.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let ParamKind::Scalar { max, .. } = p.kind {
let target = max * 0.5;
if let ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
assert_eq!(g.param(cap.id, p.id), Some(target));
assert_eq!(
g.param(cap.id, p.id),
Some(target),
"{}.{} did not round-trip",
cap.id,
p.id
);
}
}
}
+3
View File
@@ -34,6 +34,7 @@
pub mod descriptor;
pub mod framing;
pub mod graph;
pub mod lens;
pub mod operation;
pub mod ops;
@@ -42,8 +43,10 @@ pub use descriptor::{
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use lens::{compose_warps, ComposedWarp, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
RESERVED_UNIFORM_FIELDS,
};
#[cfg(test)]
+18 -4
View File
@@ -128,6 +128,14 @@ pub struct ComposedShader {
/// are needed by every generated shader in any case.
const BASE_UNIFORM_FIELDS: usize = 16;
/// Where an operation's own uniforms begin in the generated block.
///
/// The base fields, then framing's. Exported because `dr-gpu` writes the
/// camera matrix into the leading slots by index and would otherwise carry
/// its own copy of this arithmetic — a duplicate that silently corrupts every
/// operation's uniforms the moment either block changes size.
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
/// Compose enabled operations into a single compute shader.
///
/// Inactive operations are skipped entirely — they contribute no code, no
@@ -417,7 +425,7 @@ fn hash_structure(active: &[&dyn Operation]) -> u64 {
/// Whole-word matching matters: an operation with uniforms `amount` and
/// `amount_hi` must not have the first rewrite corrupt the second.
///
/// Shared with [`crate::warp`], which prefixes its uniforms by the same rule
/// Shared with [`crate::lens`], which prefixes its uniforms by the same rule
/// and must not diverge from it.
/// Comments are skipped. A fragment explaining what `factor` does should not
/// have its prose rewritten to `u.saturation_factor` — the generated source
@@ -545,11 +553,17 @@ mod tests {
);
assert_eq!(
shader.uniforms.len(),
BASE_UNIFORM_FIELDS,
PREAMBLE_FIELDS,
"it must contribute no uniforms either"
);
}
/// Uniform slots reserved before any operation's own: the camera matrix
/// and as-shot white balance, plus framing. The same constant `dr-gpu`
/// writes against, so these offsets cannot agree with each other while
/// disagreeing with the shader.
const PREAMBLE_FIELDS: usize = RESERVED_UNIFORM_FIELDS;
#[test]
fn an_active_operation_appears_once() {
let ops = vec![fake(&DESC_A, 2.0, false)];
@@ -575,8 +589,8 @@ mod tests {
fn uniform_values_follow_declaration_order() {
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
let shader = compose(&ops);
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS], 1.5);
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS + 1], 2.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
}
#[test]
+305
View File
@@ -0,0 +1,305 @@
//! Lateral chromatic aberration correction.
//!
//! The purple-and-green fringing on high-contrast edges toward the frame
//! corners. A lens focuses short wavelengths and long wavelengths at slightly
//! different magnifications, so the red, green and blue images it projects are
//! very slightly different sizes. Rescaling two of them about the optical axis
//! puts them back on top of each other.
//!
//! # Why this cannot be an `Operation`
//!
//! This is the correction that forced [`crate::lens::Warp`] to exist. An
//! [`crate::operation::Operation`] receives `c` — a colour already sampled,
//! with all three channels fetched from *one* coordinate. Lateral CA needs
//! three *different* coordinates, and by the time an operation runs, the
//! information needed to pick them is gone. So it declares
//! [`Warp::splits_channels`] and the composer emits the three-sample path.
//!
//! # The model
//!
//! Lensfun's `poly3`, reduced to its linear term: a per-channel radial scale
//! with green as the fixed reference.
//!
//! ```text
//! r_red = r · v_red
//! r_blue = r · v_blue
//! ```
//!
//! Green is never moved, and that is a deliberate asymmetry rather than an
//! arbitrary choice of reference. Green carries most of the luminance a Bayer
//! sensor records — twice the photosites of red or blue — so leaving it
//! untouched means a mis-set correction shifts the channels that contribute
//! least to perceived sharpness. Scaling all three about a virtual reference
//! would soften the image even when the correction is right.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
pub const ID: OpId = OpId("aberration");
pub const RED: ParamId = ParamId("red");
pub const BLUE: ParamId = ParamId("blue");
/// The radial scale at full slider travel, as a fraction.
///
/// Lateral CA is a tiny effect — the database's own coefficients sit within
/// ±0.1% — so a slider spanning ±0.5% covers every real lens with enough
/// resolution left to tune by eye at 100%.
const MAX_SCALE: f32 = 0.005;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.aberration"),
params: &[
// Two independent controls rather than one: the red and blue
// displacements are caused by different ends of the spectrum and are
// not symmetric, so a single "fringing" slider could not remove both.
ParamDescriptor::scalar(
"red",
"param.aberration.red",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"blue",
"param.aberration.blue",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
};
#[derive(Debug, Default, Clone)]
pub struct Aberration {
red: f32,
blue: f32,
/// Per-channel scales from a lens profile, when one is loaded.
profile: Option<(f32, f32)>,
}
impl Aberration {
pub fn new() -> Self {
Self::default()
}
/// Apply a profile's red and blue radial scales.
///
/// As with distortion, the sliders then trim rather than replace: CA
/// varies between copies of a lens and with focus distance, so a profile
/// gets close and the user finishes the job.
pub fn set_profile(&mut self, scales: Option<(f32, f32)>) {
self.profile = scales;
}
/// The effective per-channel scales, profile plus manual trim.
fn scales(&self) -> (f32, f32) {
let (base_r, base_b) = self.profile.unwrap_or((1.0, 1.0));
(
base_r + self.red / 100.0 * MAX_SCALE,
base_b + self.blue / 100.0 * MAX_SCALE,
)
}
}
impl Warp for Aberration {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
RED => self.red = value,
BLUE => self.blue = value,
_ => log::warn!("aberration: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
RED => self.red,
BLUE => self.blue,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
let (r, b) = self.scales();
r != 1.0 || b != 1.0
}
fn wgsl_body(&self) -> String {
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
// Green is deliberately absent: it is the reference and never moves.
"\
p_r = p * ca_red;
p_b = p * ca_blue;"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let (red, blue) = self.scales();
vec![
Uniform {
name: "ca_red",
value: red,
},
Uniform {
name: "ca_blue",
value: blue,
},
]
}
fn splits_channels(&self) -> bool {
true
}
fn helpers(&self) -> &'static [Helper] {
&[]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_does_nothing() {
let a = Aberration::new();
assert!(!a.is_active());
assert_eq!(a.scales(), (1.0, 1.0));
}
#[test]
fn a_neutral_correction_leaves_every_channel_coincident() {
// If the channels diverge at neutral, an image with no CA correction
// is resampled into colour fringing that was not there.
let (r, b) = Aberration::new().scales();
assert_eq!(r, 1.0);
assert_eq!(b, 1.0);
}
#[test]
fn the_channels_are_controlled_independently() {
// Red and blue displacement have different causes and are not
// symmetric; one slider could not remove both.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
let (r, b) = a.scales();
assert!(r > 1.0, "red should be scaled");
assert_eq!(b, 1.0, "blue must be untouched by the red control");
}
#[test]
fn green_is_never_scaled() {
// The reference channel. Asserted through the shader body, since that
// is where a stray green term would actually do damage.
let mut a = Aberration::new();
a.set_param(RED, 50.0);
a.set_param(BLUE, -50.0);
let body = a.wgsl_body();
assert!(body.contains("p_r"), "red must be displaced");
assert!(body.contains("p_b"), "blue must be displaced");
assert!(
!body.contains("p_g"),
"green is the reference and must never be displaced"
);
}
#[test]
fn the_centre_never_moves() {
// A radial scale about the optical axis leaves r = 0 fixed whatever
// the coefficients, which is why CA correction cannot shift a frame.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
a.set_param(BLUE, -100.0);
let (r, b) = a.scales();
for scale in [r, b] {
assert_eq!(0.0 * scale, 0.0);
}
}
#[test]
fn the_correction_stays_subpixel_at_the_extremes() {
// Lateral CA is a fraction of a percent. If full travel displaced a
// corner by more than a pixel or two on a 6000px frame, the slider
// would be a smear control rather than a correction.
let mut a = Aberration::new();
a.set_param(RED, 100.0);
a.set_param(BLUE, -100.0);
let (r, b) = a.scales();
// Half-diagonal of a 6000x4000 frame, the worst case.
let half_diag = ((6000.0f32 / 2.0).powi(2) + (4000.0f32 / 2.0).powi(2)).sqrt();
for scale in [r, b] {
let px = (scale - 1.0).abs() * half_diag;
assert!(px < 25.0, "full travel displaces the corner by {px} px");
}
}
#[test]
fn it_always_requests_the_per_channel_path() {
// The declaration that makes the composer emit three samples. Without
// it the fragment would write `p_r`/`p_b` that nothing reads.
assert!(Aberration::new().splits_channels());
}
#[test]
fn a_profile_corrects_with_both_sliders_at_zero() {
let mut a = Aberration::new();
assert!(!a.is_active());
a.set_profile(Some((1.0003211, 1.0000667)));
assert!(a.is_active());
assert_eq!(a.param(RED), 0.0);
assert_eq!(a.param(BLUE), 0.0);
}
#[test]
fn the_sliders_trim_a_loaded_profile() {
// CA varies between copies of a lens and with focus distance, so a
// profile must remain tunable rather than being all-or-nothing.
let profile = (1.0003, 1.0001);
let mut a = Aberration::new();
a.set_profile(Some(profile));
a.set_param(RED, 100.0);
let (r, b) = a.scales();
assert!((r - (profile.0 + MAX_SCALE)).abs() < 1e-9);
assert_eq!(b, profile.1, "the red trim must not disturb blue");
}
#[test]
fn a_profile_can_be_cleared() {
let mut a = Aberration::new();
a.set_profile(Some((1.0003, 1.0001)));
assert!(a.is_active());
a.set_profile(None);
assert!(!a.is_active());
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms() {
let mut a = Aberration::new();
a.set_param(RED, 50.0);
let body = a.wgsl_body();
for u in a.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
}
#[test]
fn every_default_is_neutral() {
let mut a = Aberration::new();
for p in DESCRIPTOR.params {
a.set_param(p.id, p.default);
}
assert!(!a.is_active(), "descriptor defaults must be neutral");
}
}
+3 -5
View File
@@ -1,7 +1,7 @@
//! Geometric distortion correction.
//!
//! Straightens the lines a lens bends: barrel distortion on wide angles,
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
//! pincushion on telephotos. A [`crate::lens::Warp`] rather than an
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
//! from rather than what its value becomes.
//!
@@ -26,11 +26,9 @@
//! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile.
use crate::descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
use crate::warp::Warp;
pub const ID: OpId = OpId("distortion");
pub const AMOUNT: ParamId = ParamId("amount");
+15 -3
View File
@@ -1,21 +1,33 @@
//! The develop operations.
//!
//! Each operation is a self-contained file implementing
//! [`crate::operation::Operation`]. Adding one means writing that file and
//! adding it to [`crate::graph::EditGraph::default_chain`] — no central
//! Each operation is a self-contained file. Adding one means writing that file
//! and adding it to [`crate::graph::EditGraph::default_chain`] — no central
//! shader to edit, no UI change (FR-DEV-3c).
//!
//! Most implement [`crate::operation::Operation`], a function from colour to
//! colour. The optical corrections ([`distortion`]) implement
//! [`crate::lens::Warp`] instead, because they rewrite *coordinates* before
//! the source is sampled rather than transforming a colour after it. Both
//! publish the same [`crate::descriptor::OpDescriptor`], so the UI builds
//! controls for them identically and never learns the difference.
pub mod aberration;
pub mod colour;
pub mod colour_mixer;
pub mod contrast;
pub mod distortion;
pub mod exposure;
pub mod helpers;
pub mod tone;
pub mod vignetting;
pub mod white_balance;
pub use aberration::Aberration;
pub use colour::{Brilliance, Saturation, Vibrance};
pub use colour_mixer::ColourMixer;
pub use contrast::Contrast;
pub use distortion::Distortion;
pub use exposure::Exposure;
pub use tone::{BlacksWhites, HighlightsShadows};
pub use vignetting::Vignetting;
pub use white_balance::WhiteBalance;
+375
View File
@@ -0,0 +1,375 @@
//! Vignetting correction — the corner falloff a lens imposes.
//!
//! Every lens delivers less light to the corners than to the centre, by up to
//! two stops wide open. This restores it.
//!
//! # Why this one *is* an `Operation`
//!
//! Distortion and CA are [`crate::lens::Warp`]s because they change which
//! pixel is read. Vignetting does not: it applies a gain to the pixel already
//! there. That the gain happens to depend on the pixel's *position* does not
//! make it a coordinate transform — the sampling is unchanged, so it composes
//! as an ordinary colour fragment and costs no extra texture read.
//!
//! It needs the pixel's normalised radius, which a colour fragment is not
//! otherwise given. The composer publishes `radius` in the shader prologue for
//! exactly this reason: it is derived from coordinates the prologue has
//! already computed, so making it available costs nothing.
//!
//! # The model
//!
//! Lensfun's `pa` polynomial, in even powers of the radius:
//!
//! ```text
//! attenuation = 1 + k1·r² + k2·r⁴ + k3·r⁶
//! ```
//!
//! Only even powers, because vignetting is symmetric about the optical axis —
//! an odd term would describe a lens brighter on one side than the other,
//! which is a mount fault rather than a lens characteristic.
//!
//! The value is what the lens *did*, so correcting means dividing by it. That
//! division is the whole reason this operation must run before the tonal
//! stages: a corner recovered by two stops has to be recovered while the
//! highlight headroom to hold it still exists (ARCH §5.2).
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
pub const ID: OpId = OpId("vignetting");
pub const AMOUNT: ParamId = ParamId("amount");
/// The `k1` coefficient at full manual travel.
///
/// Chosen so +100 lifts the extreme corner by roughly a stop, which covers a
/// fast prime wide open — the case that actually needs correcting.
const MAX_K1: f32 = -0.5;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.vignetting"),
// Bidirectional deliberately. Negative values *add* falloff, which is a
// legitimate creative choice as well as a correction, and a control that
// only removed vignetting would need a second one beside it to put any
// back.
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
};
/// The `pa` polynomial coefficients, as Lensfun stores them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pa {
pub k1: f32,
pub k2: f32,
pub k3: f32,
}
#[derive(Debug, Default, Clone)]
pub struct Vignetting {
amount: f32,
profile: Option<Pa>,
}
impl Vignetting {
pub fn new() -> Self {
Self::default()
}
/// Apply a lens profile's falloff coefficients.
pub fn set_profile(&mut self, profile: Option<Pa>) {
self.profile = profile;
}
/// The effective coefficients: profile plus manual trim on `k1`.
///
/// The trim lands on `k1` alone. It is the dominant term, and adjusting
/// the higher orders by eye would change the *shape* of the falloff rather
/// than its depth — which is not what a photographer reaching for this
/// slider wants.
fn coefficients(&self) -> Pa {
let trim = self.amount / 100.0 * MAX_K1;
match self.profile {
Some(p) => Pa {
k1: p.k1 + trim,
k2: p.k2,
k3: p.k3,
},
None => Pa {
k1: trim,
k2: 0.0,
k3: 0.0,
},
}
}
}
impl Operation for Vignetting {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
AMOUNT => self.amount = value,
_ => log::warn!("vignetting: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
AMOUNT => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
let c = self.coefficients();
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
}
fn wgsl_body(&self) -> String {
// `radius` comes from the prologue: the pixel's distance from the
// optical axis, normalised so the corner is 1.
"\
c = c / vignette_attenuation(radius, vig_k1, vig_k2, vig_k3);"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let c = self.coefficients();
vec![
Uniform {
name: "vig_k1",
value: c.k1,
},
Uniform {
name: "vig_k2",
value: c.k2,
},
Uniform {
name: "vig_k3",
value: c.k3,
},
]
}
fn helpers(&self) -> &'static [Helper] {
VIGNETTE
}
}
static VIGNETTE: &[Helper] = &[Helper {
name: "vignette_attenuation",
source: "\
// Lensfun's `pa` vignetting polynomial: 1 + k1*r^2 + k2*r^4 + k3*r^6.
//
// Returns what the lens *did* to this pixel, so correcting divides by it.
// Even powers only: vignetting is symmetric about the optical axis, and an
// odd term would describe a lens brighter on one side than the other.
//
// The result is floored well above zero. A profile evaluated slightly outside
// its calibrated range can produce a near-zero or negative attenuation, and
// dividing by that turns the extreme corners into blown or inverted pixels —
// a far more visible fault than the under-correction the floor causes.
fn vignette_attenuation(r: f32, k1: f32, k2: f32, k3: f32) -> f32 {
let r2 = r * r;
let a = 1.0 + r2 * (k1 + r2 * (k2 + r2 * k3));
return max(a, 0.05);
}",
}];
#[cfg(test)]
mod tests {
use super::*;
/// The attenuation the shader would compute, mirrored on the CPU so the
/// maths is testable without a device (ARCH §6.5a).
fn attenuation(c: Pa, r: f32) -> f32 {
let r2 = r * r;
(1.0 + r2 * (c.k1 + r2 * (c.k2 + r2 * c.k3))).max(0.05)
}
#[test]
fn neutral_does_nothing() {
let v = Vignetting::new();
assert!(!v.is_active());
let c = v.coefficients();
assert_eq!((c.k1, c.k2, c.k3), (0.0, 0.0, 0.0));
}
#[test]
fn a_neutral_polynomial_is_unity_everywhere() {
// Otherwise opening an image would rescale its brightness for nothing.
let c = Vignetting::new().coefficients();
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!((attenuation(c, r) - 1.0).abs() < 1e-6, "r={r} was scaled");
}
}
#[test]
fn the_centre_is_never_altered() {
// r = 0 kills every term, so the optical axis keeps its exposure
// whatever the coefficients. If this failed, the correction would be
// an exposure control with a gradient attached.
for amount in [-100.0, -50.0, 50.0, 100.0] {
let mut v = Vignetting::new();
v.set_param(AMOUNT, amount);
assert!(
(attenuation(v.coefficients(), 0.0) - 1.0).abs() < 1e-6,
"amount {amount} changed the centre"
);
}
}
#[test]
fn a_positive_amount_brightens_the_corners() {
// The correcting direction: the lens darkened the corners, so the
// attenuation there must be below 1 and the division lifts them.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let a = attenuation(v.coefficients(), 1.0);
assert!(a < 1.0, "corner attenuation was {a}, expected < 1");
}
#[test]
fn a_negative_amount_darkens_them_instead() {
// The creative direction. A control that only removed vignetting
// would need a second one beside it to add any back.
let mut v = Vignetting::new();
v.set_param(AMOUNT, -100.0);
assert!(attenuation(v.coefficients(), 1.0) > 1.0);
}
#[test]
fn falloff_increases_monotonically_with_radius() {
// Vignetting is a smooth darkening toward the corners. A polynomial
// that reversed partway would produce a visible bright ring, which
// reads as a rendering fault rather than as a wrong setting.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let c = v.coefficients();
let mut prev = f32::MAX;
for i in 0..=100 {
let a = attenuation(c, i as f32 / 100.0);
assert!(
a <= prev + 1e-6,
"attenuation rose at r={}",
i as f32 / 100.0
);
prev = a;
}
}
#[test]
fn full_correction_is_worth_about_a_stop() {
// The calibration behind MAX_K1. If this drifts, the slider either
// cannot fix a fast prime or overshoots wildly at half travel.
let mut v = Vignetting::new();
v.set_param(AMOUNT, 100.0);
let gain = 1.0 / attenuation(v.coefficients(), 1.0);
assert!(
(1.5..=2.5).contains(&gain),
"corner gain was {gain}x, expected roughly a stop"
);
}
#[test]
fn the_attenuation_never_reaches_zero() {
// Division by a near-zero attenuation blows the corners to white or
// inverts them. The floor must hold even for coefficients well past
// anything the sliders can reach.
let extreme = Pa {
k1: -5.0,
k2: -5.0,
k3: -5.0,
};
for i in 0..=100 {
let a = attenuation(extreme, i as f32 / 100.0);
assert!(a >= 0.05, "attenuation fell to {a}");
assert!(a.is_finite() && a > 0.0);
}
}
#[test]
fn a_profile_corrects_with_the_slider_at_zero() {
let mut v = Vignetting::new();
assert!(!v.is_active());
v.set_profile(Some(Pa {
k1: -0.3499,
k2: -0.914,
k3: 0.6689,
}));
assert!(v.is_active());
assert_eq!(v.param(AMOUNT), 0.0);
}
#[test]
fn a_real_profile_darkens_the_corners() {
// Coefficients taken from the Lensfun entry for the Canon EF 16-35mm
// f/2.8L at 20mm, f/2.8 — a real lens wide open, which is the case
// this correction exists for.
let profile = Pa {
k1: -0.3499,
k2: -0.914,
k3: 0.6689,
};
let mut v = Vignetting::new();
v.set_profile(Some(profile));
let c = v.coefficients();
assert!(attenuation(c, 1.0) < attenuation(c, 0.0));
assert!((attenuation(c, 0.0) - 1.0).abs() < 1e-6);
}
#[test]
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
let profile = Pa {
k1: -0.35,
k2: -0.91,
k3: 0.67,
};
let mut v = Vignetting::new();
v.set_profile(Some(profile));
v.set_param(AMOUNT, 100.0);
let c = v.coefficients();
assert_eq!(c.k2, profile.k2, "the higher orders must survive a trim");
assert_eq!(c.k3, profile.k3);
assert!((c.k1 - (profile.k1 + MAX_K1)).abs() < 1e-6);
}
#[test]
fn a_profile_can_be_cleared() {
let mut v = Vignetting::new();
v.set_profile(Some(Pa {
k1: -0.3,
k2: 0.0,
k3: 0.0,
}));
assert!(v.is_active());
v.set_profile(None);
assert!(!v.is_active());
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms_and_the_radius() {
let mut v = Vignetting::new();
v.set_param(AMOUNT, 50.0);
let body = v.wgsl_body();
for u in v.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
assert!(
body.contains("radius"),
"vignetting is radial and must read the prologue's radius"
);
}
#[test]
fn every_default_is_neutral() {
let mut v = Vignetting::new();
for p in DESCRIPTOR.params {
v.set_param(p.id, p.default);
}
assert!(!v.is_active());
}
}
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
[dependencies]
dr-types.workspace = true
dr-sync.workspace = true
dr-plat.workspace = true
reqwest.workspace = true
rustls.workspace = true
quick-xml.workspace = true
+70 -61
View File
@@ -20,11 +20,11 @@
//! FR-NC-2 requires the real app to use platform secure storage.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use dr_plat::PlatformSecretStore;
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
#[tokio::main]
async fn main() {
@@ -55,26 +55,33 @@ async fn main() {
dr_types::FormatFilter::all()
};
let creds = match load_cached(&server) {
Some(c) => {
println!("using cached credentials for {}", c.login_name);
c
}
None => match authenticate(&server).await {
Ok(c) => c,
// Sessions persist across runs: credentials in the platform keyring
// (FR-NC-2), everything else as ordinary config.
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
if !sessions.can_remember() {
println!("note: no secrets daemon — sign-in will not persist this session");
}
let existing = sessions
.current()
.filter(|s| s.server == server.trim_end_matches('/'));
let (session, creds) = match existing {
Some(s) => match sessions.credentials(&s) {
Ok(c) => {
println!("signed in: {}", s.describe());
(s, c)
}
Err(e) => {
eprintln!("authentication failed: {e}");
std::process::exit(1);
// Revoked server-side, or the keyring was cleared.
println!("stored credential unusable ({e}); signing in again");
sign_in(&server, &sessions).await
}
},
None => sign_in(&server, &sessions).await,
};
// The DAV base needs the *user id*, which may differ from the login name
// (a login can be an email address). OCS reports the real one.
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
eprintln!("could not resolve user id ({e}); falling back to login name");
creds.login_name.clone()
});
let user_id = session.user_id.clone();
println!("user id: {user_id}");
let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend");
@@ -205,7 +212,17 @@ async fn main() {
println!("\n[range] skipped — no RAW over 300KB found");
}
println!("\nscan complete");
// Remember what was scanned, so the next launch resumes here.
let mut updated = session.clone();
updated.root = start_path.clone();
updated.set_format_filter(&filter);
if let Err(e) = sessions.update(&updated) {
eprintln!("could not update session: {e}");
} else {
println!("\nremembered: {}", updated.describe());
}
println!("scan complete");
}
fn describe_filter(f: &dr_types::FormatFilter) -> String {
@@ -217,24 +234,48 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String {
}
}
async fn authenticate(server: &str) -> Result<AppCredentials, String> {
let client =
dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?;
/// Run Login Flow v2 and persist the result.
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
eprintln!("could not build http client: {e}");
std::process::exit(1);
}
};
let flow = auth::begin(&client, server, "DarkRoom (connect example)")
.await
.map_err(|e| e.to_string())?;
let flow = match auth::begin(&client, server, "DarkRoom (connect example)").await {
Ok(f) => f,
Err(e) => {
eprintln!("could not start login: {e}");
std::process::exit(1);
}
};
println!("\n Open this in a browser and approve:\n");
println!(" {}\n", flow.login_url);
println!(" waiting (20 minute limit)…");
let creds = auth::poll(&client, &flow)
.await
.map_err(|e| e.to_string())?;
let creds = match auth::poll(&client, &flow).await {
Ok(c) => c,
Err(e) => {
eprintln!("login failed: {e}");
std::process::exit(1);
}
};
println!(" authenticated as {}", creds.login_name);
save_cached(server, &creds);
Ok(creds)
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
eprintln!(" could not resolve user id ({e}); using login name");
creds.login_name.clone()
});
let session = Session::new(&creds, user_id);
match sessions.save(&session, &creds) {
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
Err(e) => eprintln!(" could not persist session: {e}"),
}
(session, creds)
}
/// Resolve the real user id, which the DAV path needs.
@@ -262,38 +303,6 @@ async fn fetch_user_id(creds: &AppCredentials) -> Result<String, String> {
.ok_or_else(|| "no id in OCS response".to_string())
}
fn cache_path(server: &str) -> PathBuf {
let dir = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"))
.join("darkroom");
let _ = std::fs::create_dir_all(&dir);
let key: String = server
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
dir.join(format!("{key}.json"))
}
fn load_cached(server: &str) -> Option<AppCredentials> {
let text = std::fs::read_to_string(cache_path(server)).ok()?;
serde_json::from_str(&text).ok()
}
fn save_cached(server: &str, creds: &AppCredentials) {
let path = cache_path(server);
if let Ok(json) = serde_json::to_string(creds) {
let _ = std::fs::write(&path, json);
// Testing convenience only — FR-NC-2 requires platform secure storage.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
println!(" cached credentials at {}", path.display());
}
}
fn human(bytes: u64) -> String {
match bytes {
b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9),
+2
View File
@@ -16,9 +16,11 @@ use dr_sync::{
pub mod auth;
pub mod desktop_client;
mod propfind;
pub mod session;
pub use auth::{AppCredentials, LoginFlow};
pub use desktop_client::DesktopClient;
pub use session::{Session, SessionError, SessionStore};
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
const CHUNKS: ChunkConstraints = ChunkConstraints {
+419
View File
@@ -0,0 +1,419 @@
//! Account sessions — logging in once and staying logged in.
//!
//! Splits deliberately in two:
//!
//! - **Credentials** go to platform secure storage (FR-NC-2). Never the
//! catalog, never a file, never a log line.
//! - **Everything else** — server, login, chosen root, format filter — is
//! ordinary configuration, safe to write as plain JSON.
//!
//! That split is what lets the app show "signed in as duncan, watching
//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly
//! if the credential has been revoked server-side.
use std::path::{Path, PathBuf};
use dr_plat::{SecretError, SecretRef, SecretStore};
use dr_sync::RemoteError;
use dr_types::{Format, FormatFilter};
use serde::{Deserialize, Serialize};
use crate::AppCredentials;
/// A configured account, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub server: String,
pub login: String,
/// The DAV path segment, which may differ from `login` — a login can be
/// an email address while the user id is something else.
pub user_id: String,
/// The folder chosen as the library root. Empty means the account root.
#[serde(default)]
pub root: String,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
/// Unix seconds of the last completed scan, for display.
#[serde(default)]
pub last_scan: Option<i64>,
}
impl Session {
pub fn new(creds: &AppCredentials, user_id: impl Into<String>) -> Self {
Self {
server: creds.server.trim_end_matches('/').to_string(),
login: creds.login_name.clone(),
user_id: user_id.into(),
root: String::new(),
formats: Vec::new(),
last_scan: None,
}
}
/// The stored format selection, defaulting to every supported format.
///
/// An unconfigured session must find everything rather than nothing.
pub fn format_filter(&self) -> FormatFilter {
if self.formats.is_empty() {
FormatFilter::all()
} else {
FormatFilter::from_formats(
self.formats
.iter()
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
)
}
}
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
self.formats = filter
.iter()
.map(|f| format!("{f:?}").to_lowercase())
.collect();
}
/// Where this session's credential lives.
pub fn secret_ref(&self) -> SecretRef {
SecretRef::app_password(&self.server, &self.login)
}
/// A short description for the UI.
pub fn describe(&self) -> String {
let host = self
.server
.trim_start_matches("https://")
.trim_start_matches("http://");
if self.root.is_empty() {
format!("{} on {host}", self.login)
} else {
format!("{} on {host}/{}", self.login, self.root)
}
}
}
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
/// Loads and saves sessions, keeping credentials in secure storage.
pub struct SessionStore {
config_path: PathBuf,
secrets: Box<dyn SecretStore>,
}
/// What is written to disk. Versioned so a format change is a migration
/// rather than a parse failure.
#[derive(Debug, Default, Serialize, Deserialize)]
struct ConfigFile {
#[serde(default = "one")]
version: u32,
#[serde(default)]
sessions: Vec<Session>,
}
fn one() -> u32 {
1
}
impl SessionStore {
/// Open the store at the platform config location.
///
/// 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)
}
/// Open at an explicit path — used by tests, and by anything wanting a
/// non-default config location.
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
Self {
config_path,
secrets,
}
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
/// Whether credentials can be remembered at all.
///
/// Where false the UI should say sign-in will not persist, rather than
/// letting the user discover it next launch.
pub fn can_remember(&self) -> bool {
self.secrets.is_available()
}
/// Every configured session. Missing or unreadable config yields an empty
/// list rather than an error — a first run is not a failure.
pub fn list(&self) -> Vec<Session> {
self.read_config().sessions
}
/// The most recently configured session, if any.
pub fn current(&self) -> Option<Session> {
self.read_config().sessions.into_iter().next_back()
}
/// Persist a session and its credential.
///
/// The credential goes to secure storage first: if that fails there is no
/// point recording a session that cannot authenticate.
pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> {
self.secrets
.store(&session.secret_ref(), &creds.app_password)?;
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
config.sessions.push(session.clone());
self.write_config(&config)
}
/// Update a session's settings, leaving its credential untouched.
pub fn update(&self, session: &Session) -> Result<(), SessionError> {
let mut config = self.read_config();
match config
.sessions
.iter_mut()
.find(|s| s.server == session.server && s.login == session.login)
{
Some(existing) => *existing = session.clone(),
None => config.sessions.push(session.clone()),
}
self.write_config(&config)
}
/// Rebuild credentials for a session from secure storage.
///
/// [`SecretError::NotFound`] means the credential was revoked or the
/// keyring was cleared — the caller re-runs the login flow.
pub fn credentials(&self, session: &Session) -> Result<AppCredentials, SessionError> {
let password = self.secrets.retrieve(&session.secret_ref())?;
Ok(AppCredentials {
server: session.server.clone(),
login_name: session.login.clone(),
app_password: password,
})
}
/// Forget a session and delete its credential.
///
/// The credential is removed even if the config write fails, so a logout
/// never leaves a usable secret behind.
pub fn forget(&self, session: &Session) -> Result<(), SessionError> {
let deleted = self.secrets.delete(&session.secret_ref());
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
let written = self.write_config(&config);
deleted?;
written
}
fn read_config(&self) -> ConfigFile {
std::fs::read_to_string(&self.config_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(config)?;
// Write and rename, so an interrupted save cannot truncate an
// existing config.
let tmp = self.config_path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &self.config_path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("secure storage: {0}")]
Secret(#[from] SecretError),
#[error("config io: {0}")]
Io(#[from] std::io::Error),
#[error("config format: {0}")]
Serde(#[from] serde_json::Error),
#[error(transparent)]
Remote(#[from] RemoteError),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan".into(),
app_password: "secret-token".into(),
}
}
fn store_in(dir: &Path) -> SessionStore {
SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("darkroom-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_saved_session_survives_reopening() {
let dir = tmpdir("survives");
let secrets = Box::new(EphemeralSecretStore::new());
// Same secret store instance, as a real process would have.
let store = SessionStore::open_at(dir.join("sessions.json"), secrets);
let mut s = Session::new(&creds(), "duncan");
s.root = "PhotosRaw".into();
store.save(&s, &creds()).unwrap();
let reloaded = store.current().expect("session persisted");
assert_eq!(reloaded.login, "duncan");
assert_eq!(reloaded.root, "PhotosRaw");
// Trailing slash normalised, so URLs built from it are consistent.
assert_eq!(reloaded.server, "https://cloud.example");
}
#[test]
fn the_credential_never_reaches_the_config_file() {
// NFR-SEC-2: the whole point of the split.
let dir = tmpdir("nocreds");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
assert!(!text.contains("secret-token"), "credential leaked to disk");
assert!(text.contains("duncan"), "session metadata should be there");
}
#[test]
fn credentials_round_trip_through_secure_storage() {
let dir = tmpdir("roundtrip");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let got = store.credentials(&s).unwrap();
assert_eq!(got.app_password, "secret-token");
assert_eq!(got.login_name, "duncan");
}
#[test]
fn forgetting_removes_both_halves() {
let dir = tmpdir("forget");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
store.forget(&s).unwrap();
assert!(store.current().is_none());
assert!(matches!(
store.credentials(&s),
Err(SessionError::Secret(SecretError::NotFound))
));
}
#[test]
fn saving_the_same_account_twice_does_not_duplicate_it() {
let dir = tmpdir("dedupe");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Photos".into();
store.save(&s, &creds()).unwrap();
assert_eq!(store.list().len(), 1);
assert_eq!(store.current().unwrap().root, "Photos");
}
#[test]
fn a_missing_config_is_a_first_run_not_an_error() {
let dir = tmpdir("firstrun");
let store = store_in(&dir);
assert!(store.list().is_empty());
assert!(store.current().is_none());
}
#[test]
fn a_corrupt_config_does_not_prevent_starting() {
// Better to present a first-run state than to refuse to launch.
let dir = tmpdir("corrupt");
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
let store = store_in(&dir);
assert!(store.list().is_empty());
}
#[test]
fn format_selection_round_trips() {
let dir = tmpdir("formats");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
store.save(&s, &creds()).unwrap();
let f = store.current().unwrap().format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Dng));
assert!(!f.allows(Format::Nef));
}
#[test]
fn an_unset_filter_means_every_format() {
// Never "no formats", which would silently find nothing.
let s = Session::new(&creds(), "duncan");
let f = s.format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Jpeg));
}
#[test]
fn describe_is_readable_and_hides_the_scheme() {
let mut s = Session::new(&creds(), "duncan");
assert_eq!(s.describe(), "duncan on cloud.example");
s.root = "PhotosRaw".into();
assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw");
}
#[test]
fn updating_settings_leaves_the_credential_alone() {
let dir = tmpdir("update");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Elsewhere".into();
store.update(&s).unwrap();
assert_eq!(store.current().unwrap().root, "Elsewhere");
assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token");
}
}
+26
View File
@@ -643,6 +643,16 @@ dir_validator(root)
`folders.etag` must exist from schema v1. Adding it later means a migration plus a full re-scan of
every user's library (ARCH §6.6).
**Validated against a real library, 2026-08-09.** A cold recursive scan of a 17,185-RAW library
(7,836 CR2 + 9,349 DNG) across 334 directories completed in **34.1 s** — `Depth: 1` per directory,
never `Depth: infinity`. Two implementation details worth keeping:
- **The format filter sees through VFS placeholder suffixes**, so a dehydrated `IMG.CR2.nextcloud`
matches as the CR2 it stands for rather than being skipped as an unknown type (§9.0).
- **Pruning is capability-gated, not assumed.** With `LocalEtags` a directory probe costs a request
and proves nothing about children, so it is pure overhead; a test asserts zero probes in that
case. Only `PropagatingEtags` makes an unchanged parent prove an unchanged subtree.
### 8.5 Sidecar conflict resolution
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
@@ -920,6 +930,22 @@ from schema v1.
primary path; server previews are opportunistic. `preview_max_filesize_image` defaults to 50 MB,
excluding many RAWs even where a provider exists.
**Measured against a real instance, 2026-08-09, and the case is stronger than assumed.**
`/core/preview` returned HTTP 400 for *every* parameter combination attempted — including bare
`?fileId=N`, and including a JPEG the same server reported as `nc:has-preview=true`. The cause is
unexplained: it is a server-side preview configuration issue, not a request-shape error on our side.
Recorded as **unexplained rather than understood**, because the distinction matters if someone later
tries to depend on this endpoint. Nothing does today — the connector treats a preview failure as a
miss and falls through to range extraction, which is the designed behaviour rather than a
workaround.
**Range extraction validated on the same library.** 262 KB read from a 21.5 MB DNG in 119 ms —
**1.22% of the file** — yielded camera model and ISO. Extrapolated across the 17,185-file test
library, cataloguing by whole-file fetch would move roughly 370 GB; the range path moves a few MB.
That ratio is the difference between a viable mobile experience and an unusable one, and it is why
§6.7 treats range reads as the mechanism and server previews as a bonus.
### 6.8 Sync is selective, not mirror-style
### 6.9 Android forbids the filesystem-scan model
+74 -3
View File
@@ -458,7 +458,12 @@ Not "on first connect" as a bulk operation. Thumbnails are generated:
For a local library this converges on "everything, eventually", because scrolling reaches everything
and the background pass has nothing else to do. For a remote library it converges on "what you
actually browsed", which is the difference between a few hundred megabytes and a hundred gigabytes.
actually browsed".
**Measured on a real 17,185-RAW library, 2026-08-09:** cataloguing it by whole-file fetch would move
roughly **370 GB**; the range-extract path moves a few MB for the images actually viewed. This is
the single largest cost difference in the design, and it is why §7.1 is a list of narrow triggers
rather than "generate them all on connect".
### 7.2 How, by availability
@@ -466,7 +471,7 @@ actually browsed", which is the difference between a few hundred megabytes and a
|---|---|---|
| `Original`, local | Embedded JPEG via `dr-decode` preview path | ~200 KB read, no demosaic |
| `Original`, no embedded preview | Full decode, downscale | Expensive — `Background` only |
| Remote | Range-extract embedded JPEG (FR-NC-3) | 1–3 MB vs 25–100 MB |
| Remote | Range-extract embedded JPEG (FR-NC-3) | 1–3 MB vs 25–100 MB — **measured: 262 KB of a 21.5 MB DNG, 119 ms, 1.22% of the file** |
| Placeholder / `Offline` | None — render the offline affordance | 0 |
The remote path deliberately does **not** ask the Nextcloud client to hydrate the file. ARCH §9.0
@@ -490,7 +495,73 @@ which never evict at all (FR-NC-6b).
---
## 8. What this document does not settle
## 8. Syncing the catalog file
Decided 2026-08-09. **This qualifies [architecture.md §6.12](architecture.md)** — the catalog
remains a rebuildable index, but the file itself now travels to Nextcloud. The qualification is
worth stating precisely, because the sidecar-authoritative model is load-bearing and this is the
one place it bends.
### 8.1 Why collections forced this
Every other thing the catalog holds has authoritative backing outside it. Ratings, labels,
keywords, and edit graphs live in sidecars next to the images, so a rebuild recovers them.
**Collections do not.** A manual collection is a set of images the user assembled by hand; nothing
in the filesystem records it. Losing the catalog loses them, and no rescan brings them back.
So collections need to be durable across devices somehow. Syncing the catalog file is the chosen
mechanism.
### 8.2 What the file sync does and does not carry
Only **collections and their membership** merge. The rest of a catalog describes *local* state —
folder mtimes, cache file paths, job rows, `tier_actual` — and importing another device's version
of those would be actively wrong. The downloaded remote is read for its collections and discarded.
This is what keeps §6.12 substantially intact: nothing here makes the local database authoritative
for anything a rebuild could not recover. The catalog is still deletable. What syncs is one table
pair that had no other home.
### 8.3 Two hazards the implementation must handle
**A WAL database is not one file.** Committed transactions can sit in `catalog.sqlite-wal` with the
main file lagging, so copying `catalog.sqlite` alone uploads a torn snapshot — internally consistent
as of some older point, silently missing everything since. Upload therefore runs a `TRUNCATE`
checkpoint and then SQLite's backup API, which serialises against concurrent writers rather than
racing them. It never copies the live file.
**Integer primary keys are not identities.** Two devices each allocate `collections.id = 1` for
different collections, so a row-level merge keyed on the integer id would collide them. Collections
therefore carry a **UUID**, and membership maps across devices by **image content hash**. The
integer ids stay local and are never compared across catalogs.
### 8.4 Merge rules
| Concern | Rule | Why |
|---|---|---|
| Which collection wins | Higher `revision` — a counter bumped per local edit. `modified` only breaks an exact tie | A device with a skewed clock cannot silently overwrite real work. The same reason FR-NC-9 avoids mtime for sidecars |
| Membership | **Set union**, not last-writer-wins | Two devices adding different images to one collection keep both. The exception — a removal racing an addition — resolves toward the addition, which is recoverable by removing it again. A lost addition is not |
| Deletion | Tombstone (`deleted = 1`) carrying a revision | Without it, merging against a device that still holds the collection resurrects it. With a revision, deletion competes on equal footing with a rename |
| An image the remote has and we do not | Skip the membership row | It joins on a later merge, once a scan has catalogued the file. Not an error |
| A remote from a newer schema | Decline before attaching | Attempting it would fail mid-transaction rather than declining cleanly |
Merging is idempotent: running it twice reports no changes the second time. That property is tested,
because a merge that oscillates would upload on every sync forever.
### 8.5 What was rejected
**Replace-if-newer.** The literal reading of "sync the file and take the newer one". Rejected
because it is not a merge: whichever device syncs second loses every collection the first did not
have. Binary SQLite files do not merge, so "newer wins" means "older is destroyed".
**A `collections.drsc` sidecar at the library root.** The alternative that would have kept §6.12
untouched, merging as text the way edit sidecars do. Viable, and cheaper in machinery, but it means
a second serialisation format and a second merge implementation for the same data. Recorded here
because if the SQLite path proves troublesome, this is the fallback with a known shape.
---
## 9. What this document does not settle
- **FTS.** `Selector::Text` is a `LIKE` scan over filename and keywords. Adequate at 50k; if free
text over description and title becomes a real workflow, an FTS5 table is the answer, and it is
+34 -34
View File
@@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 44 |
| TRACES tags found | 37 |
| Source files scanned | 63 |
| TRACES tags found | 49 |
| Requirements defined | 143 |
| Requirements covered | 36 |
| **Coverage** | **25.2%** (36/143) |
| Requirements covered | 49 |
| **Coverage** | **34.3%** (49/143) |
### By type
| Type | Covered | Defined |
|---|---|---|
| FR | 27 | 90 |
| NFR | 7 | 47 |
| FR | 36 | 90 |
| NFR | 11 | 47 |
| R | 2 | 6 |
## Orphan tags
@@ -33,36 +33,49 @@ _None._
| ID | Tagged in |
|---|---|
| FR-CAT-1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505) |
| FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:50`](../core/dr-sync/src/scan.rs#L50), [`core/dr-types/src/lib.rs:174`](../core/dr-types/src/lib.rs#L174), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505) |
| FR-CAT-1a | [`core/dr-types/src/lib.rs:36`](../core/dr-types/src/lib.rs#L36) |
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1) |
| FR-CAT-5 | [`core/dr-decode/src/lib.rs:216`](../core/dr-decode/src/lib.rs#L216) |
| FR-CAT-9 | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) |
| FR-CAT-6 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1) |
| FR-CAT-7 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1) |
| FR-CAT-9 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:93`](../core/dr-types/src/lib.rs#L93) |
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) |
| FR-CULL-2 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) |
| FR-DEV-3a | [`core/dr-pipeline/src/graph.rs:14`](../core/dr-pipeline/src/graph.rs#L14), [`core/dr-pipeline/src/graph.rs:32`](../core/dr-pipeline/src/graph.rs#L32), [`core/dr-pipeline/src/graph.rs:88`](../core/dr-pipeline/src/graph.rs#L88) |
| FR-DEV-3b | [`core/dr-pipeline/src/graph.rs:32`](../core/dr-pipeline/src/graph.rs#L32) |
| FR-DEV-3c | [`core/dr-pipeline/src/graph.rs:88`](../core/dr-pipeline/src/graph.rs#L88) |
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:325`](../core/dr-decode/src/lib.rs#L325), [`core/dr-decode/src/lib.rs:439`](../core/dr-decode/src/lib.rs#L439) |
| FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
| FR-DEV-3a | [`core/dr-pipeline/src/graph.rs:128`](../core/dr-pipeline/src/graph.rs#L128), [`core/dr-pipeline/src/graph.rs:15`](../core/dr-pipeline/src/graph.rs#L15), [`core/dr-pipeline/src/graph.rs:33`](../core/dr-pipeline/src/graph.rs#L33) |
| FR-DEV-3b | [`core/dr-pipeline/src/graph.rs:33`](../core/dr-pipeline/src/graph.rs#L33) |
| FR-DEV-3c | [`core/dr-pipeline/src/graph.rs:128`](../core/dr-pipeline/src/graph.rs#L128) |
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:325`](../core/dr-decode/src/lib.rs#L325), [`core/dr-decode/src/lib.rs:445`](../core/dr-decode/src/lib.rs#L445) |
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) |
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:31`](../ui/dr-ui/src/lib.rs#L31) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:242`](../core/dr-decode/src/lib.rs#L242) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:32`](../core/dr-sync-nextcloud/src/lib.rs#L32), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128), [`core/dr-sync/src/lib.rs:34`](../core/dr-sync/src/lib.rs#L34) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync/src/lib.rs:130`](../core/dr-sync/src/lib.rs#L130), [`core/dr-sync/src/lib.rs:36`](../core/dr-sync/src/lib.rs#L36) |
| FR-NC-3 | [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:128`](../core/dr-sync/src/lib.rs#L128) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:130`](../core/dr-sync/src/lib.rs#L130), [`core/dr-sync/src/scan.rs:50`](../core/dr-sync/src/scan.rs#L50) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:131`](../core/dr-types/src/lib.rs#L131), [`core/dr-types/src/lib.rs:80`](../core/dr-types/src/lib.rs#L80) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:174`](../core/dr-decode/src/lib.rs#L174), [`core/dr-types/src/lib.rs:90`](../core/dr-types/src/lib.rs#L90) |
| FR-NC-6a | [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:175`](../core/dr-types/src/lib.rs#L175), [`core/dr-types/src/lib.rs:93`](../core/dr-types/src/lib.rs#L93) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:36`](../core/dr-types/src/lib.rs#L36) |
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:174`](../core/dr-decode/src/lib.rs#L174), [`core/dr-types/src/lib.rs:103`](../core/dr-types/src/lib.rs#L103), [`core/dr-types/src/lib.rs:174`](../core/dr-types/src/lib.rs#L174) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:242`](../core/dr-decode/src/lib.rs#L242), [`core/dr-decode/src/lib.rs:70`](../core/dr-decode/src/lib.rs#L70) |
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:98`](../core/dr-decode/src/lib.rs#L98) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:39`](../ui/dr-ui/src/lib.rs#L39) |
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:39`](../ui/dr-ui/src/lib.rs#L39) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1) |
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
| NFR-P1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| NFR-P13 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1) |
| NFR-R5 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:31`](../ui/dr-ui/src/lib.rs#L31) |
@@ -72,7 +85,7 @@ _None._
## Not yet tagged
107 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
94 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
<details><summary>Show untagged requirements</summary>
@@ -81,10 +94,6 @@ _None._
- FR-CAT-12
- FR-CAT-13
- FR-CAT-14
- FR-CAT-3
- FR-CAT-4
- FR-CAT-6
- FR-CAT-7
- FR-CAT-8
- FR-CULL-3
- FR-CULL-4
@@ -93,8 +102,6 @@ _None._
- FR-CULL-7
- FR-DEV-1
- FR-DEV-2
- FR-DEV-3
- FR-DEV-3d
- FR-DEV-3f
- FR-DEV-3g
- FR-DEV-5
@@ -120,13 +127,10 @@ _None._
- FR-NC-11
- FR-NC-2
- FR-NC-6
- FR-NC-6a
- FR-NC-6b
- FR-NC-7
- FR-NC-8
- FR-NC-9
- FR-PLAT-AND-2
- FR-PLAT-AND-3
- FR-PLAT-AND-4
- FR-PLAT-AND-5
- FR-PLAT-AND-6
@@ -143,9 +147,7 @@ _None._
- NFR-A11Y-2
- NFR-A11Y-3
- NFR-ARCH-1
- NFR-ARCH-2
- NFR-ARCH-3
- NFR-ARCH-4
- NFR-COMPAT-1
- NFR-COMPAT-2
- NFR-OPS-2
@@ -167,11 +169,9 @@ _None._
- NFR-PORT-1
- NFR-PORT-2
- NFR-PORT-3
- NFR-R1
- NFR-R2
- NFR-R3
- NFR-R4
- NFR-R5
- NFR-R6
- NFR-RES-2
- NFR-RES-3
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "dr-plat"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
thiserror.workspace = true
log.workspace = true
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
keyring.workspace = true
[dev-dependencies]
env_logger.workspace = true
@@ -0,0 +1,64 @@
//! Verify credentials round-trip through the real platform secret store.
//!
//! cargo run -p dr-plat --example keyring_check
//!
//! Writes a test value, reads it back, deletes it. Touches nothing else.
use dr_plat::{PlatformSecretStore, SecretRef, SecretStore};
fn main() {
env_logger::init();
let store = PlatformSecretStore::new();
println!("store available: {}", store.is_available());
if !store.is_available() {
println!("no secrets daemon — the app would run in degraded mode");
return;
}
let r = SecretRef::app_password("https://test.invalid", "darkroom-selftest");
let secret = "test-token-do-not-reuse";
print!("store … ");
match store.store(&r, secret) {
Ok(()) => println!("ok"),
Err(e) => {
println!("FAILED: {e}");
std::process::exit(1);
}
}
print!("retrieve … ");
match store.retrieve(&r) {
Ok(v) if v == secret => println!("ok (round-tripped)"),
Ok(_) => {
println!("FAILED: wrong value");
std::process::exit(1);
}
Err(e) => {
println!("FAILED: {e}");
std::process::exit(1);
}
}
print!("delete … ");
match store.delete(&r) {
Ok(()) => println!("ok"),
Err(e) => {
println!("FAILED: {e}");
std::process::exit(1);
}
}
print!("confirm gone … ");
match store.retrieve(&r) {
Err(dr_plat::SecretError::NotFound) => println!("ok"),
Ok(_) => {
println!("FAILED: still present after delete");
std::process::exit(1);
}
Err(e) => println!("unexpected: {e}"),
}
println!("\nplatform secret store works (FR-NC-2)");
}
+11
View File
@@ -0,0 +1,11 @@
//! Platform abstraction for DarkRoom.
//!
//! Traits are defined here and implemented per platform, then injected at
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
//! ARCH §10).
pub mod secrets;
pub use secrets::{
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
};
+317
View File
@@ -0,0 +1,317 @@
//! Platform secure storage for credentials (FR-NC-2, NFR-SEC-2).
//!
//! Credentials are **never** written to the catalog, to a plain file, or to
//! logs. On Linux they go to the Secret Service (GNOME Keyring, or KWallet via
//! `ksecretd`, which exposes the same D-Bus interface). On Android they belong
//! in Keystore-backed storage.
//!
//! Absence of a secrets daemon is an explicit degraded mode, not a silent
//! fallback to plaintext: a headless box or a minimal window manager may have
//! none, and quietly writing a password to disk there would be worse than
//! refusing.
use std::fmt;
/// Which secret is being stored, so one account can hold several.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKind {
/// A Nextcloud app password from Login Flow v2. Device-scoped and
/// individually revocable — never the user's actual password.
AppPassword,
}
impl SecretKind {
fn as_str(self) -> &'static str {
match self {
SecretKind::AppPassword => "app-password",
}
}
}
/// Where a credential lives: one account on one server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretRef {
pub server: String,
pub login: String,
pub kind: SecretKind,
}
impl SecretRef {
pub fn app_password(server: impl Into<String>, login: impl Into<String>) -> Self {
Self {
server: server.into(),
login: login.into(),
kind: SecretKind::AppPassword,
}
}
/// The key under which the platform store holds this secret.
///
/// Includes the server so two accounts on different servers with the same
/// login do not collide.
fn entry_key(&self) -> String {
format!("{}@{}#{}", self.login, self.server, self.kind.as_str())
}
}
/// Deliberately opaque: the whole point is that a credential never appears in
/// a log line or an error message (NFR-SEC-2).
impl fmt::Display for SecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} on {}", self.login, self.server)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
/// No secrets daemon. The app runs in a degraded mode where the user
/// re-authenticates each session, rather than storing anything in plain.
#[error("no platform secret store available: {0}")]
Unavailable(String),
#[error("secret not found")]
NotFound,
#[error("secret store rejected the request: {0}")]
Denied(String),
#[error("secret store error: {0}")]
Other(String),
}
/// TRACES: FR-NC-2 | NFR-SEC-2 | M-2
/// Store, retrieve and delete credentials.
///
/// Implemented per platform and injected at construction, so `core/` contains
/// no `#[cfg(target_os)]` (NFR-PORT-1).
pub trait SecretStore: Send + Sync {
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError>;
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError>;
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError>;
/// Whether the store is usable right now.
///
/// Checked before offering to remember a login, so the UI can say
/// "you will need to sign in each time" rather than failing later.
fn is_available(&self) -> bool;
}
/// The service name entries are filed under.
const SERVICE: &str = "DarkRoom";
/// Secret Service implementation (GNOME Keyring, KWallet via ksecretd).
#[cfg(all(unix, not(target_os = "android")))]
pub struct PlatformSecretStore;
#[cfg(all(unix, not(target_os = "android")))]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
}
fn entry(r: &SecretRef) -> Result<keyring::Entry, SecretError> {
keyring::Entry::new(SERVICE, &r.entry_key()).map_err(map_err)
}
}
#[cfg(all(unix, not(target_os = "android")))]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(unix, not(target_os = "android")))]
impl SecretStore for PlatformSecretStore {
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
Self::entry(secret_ref)?
.set_password(secret)
.map_err(map_err)
}
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
Self::entry(secret_ref)?.get_password().map_err(map_err)
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
match Self::entry(secret_ref)?.delete_credential() {
Ok(()) => Ok(()),
// Deleting an absent secret is the desired end state, not a
// failure — logout must be idempotent.
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(map_err(e)),
}
}
fn is_available(&self) -> bool {
// Probing a name that will not exist distinguishes "daemon absent"
// from "secret absent": the former errors, the latter reports NoEntry.
match keyring::Entry::new(SERVICE, "__availability_probe__") {
Ok(e) => !matches!(
e.get_password(),
Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_))
),
Err(_) => false,
}
}
}
#[cfg(all(unix, not(target_os = "android")))]
fn map_err(e: keyring::Error) -> SecretError {
match e {
keyring::Error::NoEntry => SecretError::NotFound,
keyring::Error::NoStorageAccess(e) => SecretError::Unavailable(e.to_string()),
keyring::Error::PlatformFailure(e) => SecretError::Unavailable(e.to_string()),
other => SecretError::Other(other.to_string()),
}
}
/// Placeholder for platforms without an implementation yet.
///
/// Android needs Keystore-backed storage via JNI (FR-PLAT-AND-1). Failing
/// loudly is deliberate: a silent no-op store would look like it worked and
/// then lose the credential.
#[cfg(not(all(unix, not(target_os = "android"))))]
pub struct PlatformSecretStore;
#[cfg(not(all(unix, not(target_os = "android"))))]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
}
}
#[cfg(not(all(unix, not(target_os = "android"))))]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(all(unix, not(target_os = "android"))))]
impl SecretStore for PlatformSecretStore {
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
Err(SecretError::Unavailable(
"Keystore-backed storage is not implemented on this platform yet".into(),
))
}
fn retrieve(&self, _r: &SecretRef) -> Result<String, SecretError> {
Err(SecretError::Unavailable(
"Keystore-backed storage is not implemented on this platform yet".into(),
))
}
fn delete(&self, _r: &SecretRef) -> Result<(), SecretError> {
Ok(())
}
fn is_available(&self) -> bool {
false
}
}
/// An in-memory store for tests and for the degraded no-daemon mode.
///
/// Credentials live only as long as the process, so a user without a secrets
/// daemon re-authenticates each session — which is the honest behaviour.
#[derive(Default)]
pub struct EphemeralSecretStore {
entries: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl EphemeralSecretStore {
pub fn new() -> Self {
Self::default()
}
}
impl SecretStore for EphemeralSecretStore {
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
self.entries
.lock()
.map_err(|e| SecretError::Other(e.to_string()))?
.insert(secret_ref.entry_key(), secret.to_string());
Ok(())
}
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
self.entries
.lock()
.map_err(|e| SecretError::Other(e.to_string()))?
.get(&secret_ref.entry_key())
.cloned()
.ok_or(SecretError::NotFound)
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
self.entries
.lock()
.map_err(|e| SecretError::Other(e.to_string()))?
.remove(&secret_ref.entry_key());
Ok(())
}
fn is_available(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_separate_accounts_across_servers() {
// Same login on two servers must not collide, or signing into the
// second would overwrite the first.
let a = SecretRef::app_password("https://a.example", "duncan");
let b = SecretRef::app_password("https://b.example", "duncan");
assert_ne!(a.entry_key(), b.entry_key());
}
#[test]
fn keys_separate_logins_on_one_server() {
let a = SecretRef::app_password("https://a.example", "duncan");
let b = SecretRef::app_password("https://a.example", "someone");
assert_ne!(a.entry_key(), b.entry_key());
}
#[test]
fn display_never_reveals_the_secret_or_the_key() {
let r = SecretRef::app_password("https://cloud.example", "duncan");
let shown = r.to_string();
assert!(shown.contains("duncan"));
assert!(
!shown.contains("app-password"),
"internal key must not leak"
);
}
#[test]
fn ephemeral_round_trips() {
let s = EphemeralSecretStore::new();
let r = SecretRef::app_password("https://cloud.example", "duncan");
assert!(matches!(s.retrieve(&r), Err(SecretError::NotFound)));
s.store(&r, "token-value").unwrap();
assert_eq!(s.retrieve(&r).unwrap(), "token-value");
}
#[test]
fn deleting_is_idempotent() {
// Logout must succeed whether or not a credential is present.
let s = EphemeralSecretStore::new();
let r = SecretRef::app_password("https://cloud.example", "duncan");
assert!(s.delete(&r).is_ok());
s.store(&r, "x").unwrap();
assert!(s.delete(&r).is_ok());
assert!(matches!(s.retrieve(&r), Err(SecretError::NotFound)));
}
#[test]
fn storing_twice_overwrites() {
let s = EphemeralSecretStore::new();
let r = SecretRef::app_password("https://cloud.example", "duncan");
s.store(&r, "first").unwrap();
s.store(&r, "second").unwrap();
assert_eq!(s.retrieve(&r).unwrap(), "second");
}
}
+2
View File
@@ -12,6 +12,8 @@ dr-types.workspace = true
# must come off when S1 lands (ARCH §6.1, AC-8).
dr-gpu = { workspace = true, features = ["readback"] }
dr-decode.workspace = true
dr-plat.workspace = true
dr-sync-nextcloud.workspace = true
dr-pipeline.workspace = true
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
wgpu.workspace = true
+59 -6
View File
@@ -11,7 +11,7 @@
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit};
use dr_pipeline::{CropRect, EditGraph, OpId, ParamId, ParamKind, Unit};
use crate::labels;
use crate::ParamRow;
@@ -135,8 +135,13 @@ impl DevelopSession {
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
// Fit the render to the viewport while preserving aspect, so the
// pass does no work on pixels the view will letterbox away.
//
// Fitted against the *framed* size, not the sensor's: a crop changes
// the aspect ratio, and fitting the uncropped shape would letterbox
// to the wrong box and render the crop squashed.
let (sw, sh) = self.demosaiced.size();
let (w, h) = fit(sw, sh, width.max(1), height.max(1));
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
let shader = self.graph.compose();
self.adjust
@@ -149,11 +154,46 @@ impl DevelopSession {
Ok(slint::Image::from_rgba8(buffer))
}
/// The image's natural aspect ratio, for sizing the viewport.
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
/// cropped image against the wrong shape.
pub fn source_size(&self) -> (u32, u32) {
let (w, h) = self.demosaiced.size();
self.graph.output_size(w, h)
}
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
/// frame, since that is what the user is selecting *from*.
pub fn sensor_size(&self) -> (u32, u32) {
self.demosaiced.size()
}
/// Set the crop rectangle, in fractions of the source.
pub fn set_crop(&mut self, rect: CropRect) {
self.graph.set_crop(rect);
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.graph.rotate_quarters(turns);
}
/// The largest centred crop that, at the current straightening angle,
/// contains no undefined area. What a "straighten and fill" action
/// applies.
pub fn max_inscribed_crop(&self) -> CropRect {
let (w, h) = self.demosaiced.size();
self.graph.framing().max_inscribed_crop(w, h)
}
/// How many shader pipelines have been compiled. Surfaced so the status
/// strip can show that slider movement is not recompiling.
pub fn compiled_pipelines(&self) -> usize {
@@ -203,10 +243,23 @@ mod tests {
#[test]
fn every_capability_becomes_exactly_one_row() {
// The UI shows what the pipeline offers — no more, and nothing
// dropped.
// dropped. Asserted against the chain rather than a literal count,
// so operations can be added without editing this, and so the test
// actually checks the correspondence rather than restating a number.
let graph = EditGraph::default_chain();
let expected: usize = graph.capabilities().iter().map(|c| c.params.len()).sum();
assert_eq!(expected, 10, "seven operations, ten parameters");
let caps = graph.capabilities();
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
assert!(expected > 0, "the chain must expose some parameters");
// Every (operation, parameter) pair must be reachable as a distinct
// row index; a collision would route two sliders to one parameter.
let mut seen = std::collections::HashSet::new();
for (oi, cap) in caps.iter().enumerate() {
for (pi, _) in cap.params.iter().enumerate() {
assert!(seen.insert((oi, pi)), "duplicate row index");
}
}
assert_eq!(seen.len(), expected);
}
#[test]
+12
View File
@@ -20,6 +20,7 @@ pub fn resolve(key: &str) -> String {
"op.brilliance" => "Brilliance".into(),
"op.vibrance" => "Vibrance".into(),
"op.saturation" => "Saturation".into(),
"op.framing" => "Crop & Rotate".into(),
// Parameters
"param.temperature" => "Temperature".into(),
@@ -33,6 +34,17 @@ pub fn resolve(key: &str) -> String {
"param.vibrance" => "Vibrance".into(),
"param.saturation" => "Saturation".into(),
// Framing. "Straighten" rather than "Angle" because that is the task
// the control performs; the number it reports is still degrees.
"param.angle" => "Straighten".into(),
"param.rotation" => "Rotate".into(),
"param.flip_h" => "Flip Horizontal".into(),
"param.flip_v" => "Flip Vertical".into(),
"param.crop_x" => "Crop Left".into(),
"param.crop_y" => "Crop Top".into(),
"param.crop_w" => "Crop Width".into(),
"param.crop_h" => "Crop Height".into(),
other => derive(other),
}
}
+384
View File
@@ -0,0 +1,384 @@
//! Launch screen state: connect an account, choose a library, sign out.
//!
//! The state machine lives here, separate from the Slint bindings, so it can
//! be tested without a display server. `dr-ui` owns presentation; what a
//! login *is* belongs to the connector.
use dr_sync_nextcloud::{Session, SessionStore};
use dr_types::{Format, FormatFilter};
/// What the launch screen is currently doing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchState {
/// No account configured; waiting for a server address.
SignedOut,
/// A login flow is open and the user must approve it in a browser.
///
/// Carries the URL so the screen can show and copy it — the app never
/// handles the password itself (FR-NC-1).
AwaitingApproval { login_url: String },
/// An account is configured.
SignedIn { session: Session },
/// Working; the reason is shown so a pause is never unexplained.
///
/// Carries the session where there is one, so a failure mid-work returns
/// to the signed-in screen rather than signing the user out.
Busy {
message: String,
session: Option<Box<Session>>,
},
}
/// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4
/// Everything the launch screen renders from.
#[derive(Debug, Clone)]
pub struct LaunchModel {
pub state: LaunchState,
/// Last-used server, prefilled so a returning user need not retype it.
pub server_url: String,
pub error: Option<String>,
pub status: Option<String>,
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
/// up front rather than letting the user discover it next launch.
pub can_remember: bool,
/// Which formats to scan for, in `Format::ALL` order.
pub formats: Vec<(Format, bool)>,
}
impl Default for LaunchModel {
fn default() -> Self {
Self {
state: LaunchState::SignedOut,
server_url: String::new(),
error: None,
status: None,
can_remember: true,
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(),
}
}
}
impl LaunchModel {
/// Build from stored sessions, resuming the last account if there is one.
pub fn from_store(store: &SessionStore) -> Self {
let can_remember = store.can_remember();
match store.current() {
Some(session) => {
let filter = session.format_filter();
Self {
server_url: session.server.clone(),
formats: Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
.collect(),
state: LaunchState::SignedIn { session },
can_remember,
..Default::default()
}
}
None => Self {
can_remember,
..Default::default()
},
}
}
pub fn is_signed_in(&self) -> bool {
matches!(self.state, LaunchState::SignedIn { .. })
}
pub fn is_busy(&self) -> bool {
matches!(self.state, LaunchState::Busy { .. })
}
pub fn session(&self) -> Option<&Session> {
match &self.state {
LaunchState::SignedIn { session } => Some(session),
// A session survives a busy period; a scan failure must not log
// the user out.
LaunchState::Busy {
session: Some(s), ..
} => Some(s),
_ => None,
}
}
/// The account line, e.g. "duncan on cloud.example".
pub fn account_label(&self) -> String {
self.session().map(|s| s.describe()).unwrap_or_default()
}
/// The chosen library folder, empty until one is picked.
pub fn library_root(&self) -> String {
self.session().map(|s| s.root.clone()).unwrap_or_default()
}
/// The login URL while approval is pending.
pub fn login_url(&self) -> String {
match &self.state {
LaunchState::AwaitingApproval { login_url } => login_url.clone(),
_ => String::new(),
}
}
/// Whether "Open library" should be clickable.
///
/// Requires a signed-in account *and* a chosen folder: opening without one
/// would scan the whole account, which on a real library is thousands of
/// directories the user did not ask for.
pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty()
}
pub fn format_filter(&self) -> FormatFilter {
FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f))
}
/// Toggle one format tick-box.
pub fn set_format(&mut self, index: usize, enabled: bool) {
if let Some(entry) = self.formats.get_mut(index) {
entry.1 = enabled;
}
}
// --- transitions ---------------------------------------------------
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.error = None;
self.state = LaunchState::Busy {
message: "Contacting server…".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 {
login_url: login_url.into(),
};
}
pub fn signed_in(&mut self, session: Session) {
self.error = None;
self.status = None;
self.server_url = session.server.clone();
let filter = session.format_filter();
// Adopt the session's stored selection, so a returning user sees the
// tick-boxes they left.
if !session.formats.is_empty() {
self.formats = Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
.collect();
}
self.state = LaunchState::SignedIn { session };
}
pub fn signed_out(&mut self) {
self.error = None;
self.status = None;
self.state = LaunchState::SignedOut;
}
pub fn fail(&mut self, message: impl Into<String>) {
self.status = None;
self.error = Some(message.into());
// Return to whichever resting state makes sense, so a failure never
// strands the screen in Busy with no way forward.
self.state = match self.session() {
Some(s) => LaunchState::SignedIn { session: s.clone() },
None => LaunchState::SignedOut,
};
}
pub fn busy(&mut self, message: impl Into<String>) {
self.error = None;
let session = self.session().cloned().map(Box::new);
self.state = LaunchState::Busy {
message: message.into(),
session,
};
}
}
/// Normalise a server address typed by hand.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the user's
/// behalf without telling them.
pub fn normalise_server(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("https://") {
s.to_string()
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS the
// connection fails loudly, which is the correct outcome.
format!("https://{rest}")
} else {
format!("https://{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
use dr_sync_nextcloud::AppCredentials;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example".into(),
login_name: "duncan".into(),
app_password: "token".into(),
}
}
fn session_with_root(root: &str) -> Session {
let mut s = Session::new(&creds(), "duncan");
s.root = root.into();
s
}
#[test]
fn a_fresh_model_is_signed_out_with_raw_preselected() {
let m = LaunchModel::default();
assert!(!m.is_signed_in());
// RAW ticked, JPEG not: a RAW editor's sensible default.
let f = m.format_filter();
assert!(f.allows(Format::Cr2));
assert!(!f.allows(Format::Jpeg));
}
#[test]
fn opening_a_library_needs_both_an_account_and_a_folder() {
let mut m = LaunchModel::default();
assert!(!m.can_open_library(), "signed out");
m.signed_in(session_with_root(""));
assert!(
!m.can_open_library(),
"no folder — would scan the whole account"
);
m.signed_in(session_with_root("PhotosRaw"));
assert!(m.can_open_library());
}
#[test]
fn a_failure_never_strands_the_screen_in_busy() {
let mut m = LaunchModel::default();
m.begin_sign_in("cloud.example");
assert!(m.is_busy());
m.fail("server unreachable");
assert!(!m.is_busy(), "must return to a resting state");
assert_eq!(m.state, LaunchState::SignedOut);
assert!(m.error.is_some());
}
#[test]
fn a_failure_while_signed_in_returns_to_signed_in() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
m.busy("Scanning…");
m.fail("scan failed");
assert!(m.is_signed_in(), "a failed scan must not sign the user out");
assert!(m.error.is_some());
}
#[test]
fn the_login_url_is_exposed_only_while_pending() {
let mut m = LaunchModel::default();
assert_eq!(m.login_url(), "");
m.await_approval("https://cloud.example/login/v2/flow/abc");
assert!(m.login_url().contains("/flow/abc"));
m.signed_in(session_with_root(""));
assert_eq!(m.login_url(), "", "must not linger after sign-in");
}
#[test]
fn server_addresses_are_normalised_to_https() {
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
assert_eq!(
normalise_server("https://cloud.example/"),
"https://cloud.example"
);
assert_eq!(
normalise_server(" cloud.example "),
"https://cloud.example"
);
assert_eq!(normalise_server(""), "");
}
#[test]
fn plain_http_is_upgraded_rather_than_accepted() {
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
// credential in the clear.
assert_eq!(
normalise_server("http://cloud.example"),
"https://cloud.example"
);
}
#[test]
fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default();
let jpeg = Format::ALL.iter().position(|f| *f == Format::Jpeg).unwrap();
m.set_format(jpeg, true);
assert!(m.format_filter().allows(Format::Jpeg));
// Must not panic on a stale index from the UI.
m.set_format(9999, true);
}
#[test]
fn a_stored_session_is_resumed_with_its_format_selection() {
let dir = std::env::temp_dir().join("darkroom-launch-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let mut s = session_with_root("PhotosRaw");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
store.save(&s, &creds()).unwrap();
let m = LaunchModel::from_store(&store);
assert!(m.is_signed_in());
assert_eq!(m.library_root(), "PhotosRaw");
assert!(m.format_filter().allows(Format::Cr2));
assert!(!m.format_filter().allows(Format::Dng));
assert_eq!(m.server_url, "https://cloud.example");
}
#[test]
fn no_stored_session_yields_a_signed_out_model() {
let dir = std::env::temp_dir().join("darkroom-launch-empty");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let m = LaunchModel::from_store(&store);
assert!(!m.is_signed_in());
}
#[test]
fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), "");
}
}
+2
View File
@@ -26,6 +26,8 @@ use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
pub mod launch;
slint::include_modules!();
/// TRACES: FR-DSP-1 | NFR-RES-1
+349
View File
@@ -0,0 +1,349 @@
import { Theme } from "theme.slint";
// Launch screen: connect an account, or resume a saved one.
//
// Deliberately separate from AppWindow. It is the first thing a user sees
// with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4).
// One tick-box in the format selection.
component FormatCheck inherits Rectangle {
in property <string> label;
in-out property <bool> checked;
callback toggled(bool);
// FR-UI-3: 44pt minimum hit target under touch. The drawn row is
// shorter, so the touch area extends beyond the visible bounds.
height: 32px;
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
border-width: 1px;
border-color: root.checked ? Theme.accent : Theme.rule;
background: root.checked ? Theme.accent : transparent;
Text {
text: "✓";
color: #fff;
font-size: 12px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
Text {
text: root.label;
color: touch.has-hover ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
vertical-alignment: center;
}
}
}
component Button inherits Rectangle {
in property <string> label;
in property <bool> primary: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.touch-target;
border-radius: 4px;
background: root.primary
? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent)
: (touch.has-hover && root.enabled ? Theme.surface-raised : transparent);
border-width: root.primary ? 0px : 1px;
border-color: Theme.rule;
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
}
Text {
text: root.label;
color: root.primary ? #fff : Theme.ink;
font-size: Theme.text;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
export component LaunchScreen inherits Rectangle {
// --- state in ---
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
in property <string> server-url: "";
in property <bool> busy: false;
in property <string> status: "";
in property <string> error: "";
/// The URL to approve in a browser, shown while a login is pending.
in property <string> login-url: "";
/// False where no secrets daemon exists — the user must be told sign-in
/// will not persist rather than discovering it next launch.
in property <bool> can-remember: true;
in-out property <[string]> format-labels;
in-out property <[bool]> format-checked;
// --- events out ---
callback sign-in(string);
callback sign-out();
callback choose-folder();
callback open-library();
callback format-toggled(int, bool);
callback copy-login-url();
background: Theme.ground;
VerticalLayout {
alignment: center;
padding: Theme.gap-lg;
Rectangle {
max-width: 460px;
horizontal-stretch: 0;
VerticalLayout {
spacing: Theme.gap-lg;
// --- masthead ---
VerticalLayout {
spacing: Theme.gap-sm;
Text {
text: "DarkRoom";
color: Theme.ink;
font-size: Theme.text-xl;
font-weight: 800;
letter-spacing: -0.5px;
}
Text {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
color: Theme.ink-dim;
font-size: Theme.text;
}
}
Rectangle { height: 1px; background: Theme.rule; }
// --- signed out: server entry ---
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "SERVER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Rectangle {
height: Theme.touch-target;
border-radius: 4px;
border-width: 1px;
border-color: server-input.has-focus ? Theme.accent : Theme.rule;
background: Theme.surface;
server-input := TextInput {
text: root.server-url;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
width: parent.width - 2 * Theme.gap;
x: Theme.gap;
height: 100%;
single-line: true;
accepted => { root.sign-in(self.text); }
}
// Placeholder, since TextInput has none of its own.
Text {
text: "https://cloud.example.com";
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
x: Theme.gap;
height: 100%;
visible: server-input.text == "";
}
}
if !root.can-remember: Text {
text: "No system keyring found — you will need to sign in each time.";
color: Theme.warn-ink;
font-size: Theme.text-sm;
wrap: word-wrap;
}
Button {
label: root.busy ? "Connecting…" : "Sign in";
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Text {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "APPROVE IN YOUR BROWSER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Rectangle {
background: Theme.surface;
border-radius: 4px;
border-width: 1px;
border-color: Theme.rule;
VerticalLayout {
padding: Theme.gap;
Text {
text: root.login-url;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: char-wrap;
}
}
}
Button {
label: "Copy link";
clicked => { root.copy-login-url(); }
}
Text {
text: "Waiting for approval…";
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
}
// --- signed in ---
if root.signed-in: VerticalLayout {
spacing: Theme.gap;
Text {
text: "ACCOUNT";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.account;
color: Theme.ink;
font-size: Theme.text;
}
Rectangle { height: Theme.gap-sm; }
Text {
text: "LIBRARY FOLDER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
HorizontalLayout {
spacing: Theme.gap;
Text {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
color: root.library-root == "" ? Theme.ink-faint : Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Button {
label: "Choose…";
width: 110px;
clicked => { root.choose-folder(); }
}
}
Rectangle { height: Theme.gap-sm; }
Text {
text: "SCAN FOR";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
for label[i] in root.format-labels: FormatCheck {
label: label;
checked: root.format-checked[i];
toggled(on) => { root.format-toggled(i, on); }
}
Rectangle { height: Theme.gap; }
Button {
label: root.busy ? "Scanning…" : "Open library";
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
Button {
label: "Sign out";
clicked => { root.sign-out(); }
}
}
// --- feedback ---
if root.status != "": Text {
text: root.status;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: word-wrap;
}
if root.error != "": Text {
text: root.error;
color: Theme.accent;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
}
}
}
+3
View File
@@ -15,6 +15,9 @@ export global Theme {
out property <color> accent: #D9543C;
out property <color> accent-dim: #8F2E1E;
// Semantic, distinct from the accent: a caution is not an action.
out property <color> warn-ink: #C99A4A;
out property <length> gap-sm: 6px;
out property <length> gap: 12px;
out property <length> gap-lg: 20px;