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
+314
View File
@@ -0,0 +1,314 @@
//! Coordinate-domain operations — the geometry half of the pipeline.
//!
//! # Why this is not `Operation`
//!
//! Every [`crate::operation::Operation`] is a function from colour to colour:
//! `wgsl_body` receives `c: vec3<f32>` and produces one. That shape cannot
//! express lens correction, and the reason is worth stating precisely because
//! it is what justifies a second trait rather than an extension of the first.
//!
//! Distortion does not change a pixel's value; it changes **which pixel you
//! read**. Chromatic aberration is worse still: lateral CA is a per-channel
//! radial magnification, so red, green and blue must be fetched from three
//! *different* coordinates. No function of an already-fetched `vec3<f32>` can
//! recover that — by the time a colour reaches an `Operation`, the three
//! channels have been sampled together and the information is gone.
//!
//! So a warp runs **before** the fetch, and composes into the generated
//! shader ahead of it (ARCH §5.2 places lens corrections in the geometry
//! half of the chain).
//!
//! # Inverse mapping
//!
//! A warp declares where an output pixel's colour **came from**, not where an
//! input pixel goes. This is not a stylistic choice:
//!
//! - A forward map is a *scatter* — each input pixel writes somewhere. In a
//! compute shader that needs atomics, leaves holes where the map expands,
//! and races where it contracts.
//! - An inverse map is a *gather* — each output pixel reads somewhere. One
//! dispatch, one write per pixel, no contention, and hole-free by
//! construction.
//!
//! So `undistort` is expressed as "given this output position, which source
//! position feeds it?". For a barrel-distorting lens that means the warp
//! *magnifies* the radius, which reads backwards until you remember the
//! direction is inverse.
//!
//! # Coordinate space
//!
//! Warps work in **normalised centred** coordinates: the image centre is
//! `(0, 0)`, and the radius is scaled so that `r == 1` at the corner. Both
//! properties matter.
//!
//! Centring is what makes the polynomial meaningful — lens distortion is
//! radially symmetric about the optical axis, so a formula written about any
//! other origin would need cross terms to say the same thing.
//!
//! Corner normalisation is what makes a coefficient **portable across
//! resolutions and aspect ratios**: the same value describes the lens whether
//! applied to a full-resolution export, a 512px thumbnail, or a cropped
//! frame. Normalising to the shorter edge instead — the other obvious choice
//! — would make a coefficient mean different things on a 3:2 and a 16:9 body
//! wearing the same lens, which defeats the point of a lens profile.
use std::fmt::Write as _;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::{Helper, Uniform};
/// A coordinate-domain operation, applied before the source is sampled.
///
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
pub trait Warp: Send + Sync {
/// Static description, driving UI generation exactly as for an operation.
fn descriptor(&self) -> &'static OpDescriptor;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
/// Read a parameter back.
fn param(&self, id: ParamId) -> f32;
/// Whether this warp currently moves any pixel.
///
/// A warp at neutral is omitted from the shader entirely — and if *every*
/// warp is neutral the generated shader keeps its integer `textureLoad`
/// path rather than paying for a bilinear sample it does not need.
fn is_active(&self) -> bool;
/// The WGSL body of this warp's inverse coordinate transform.
///
/// Receives `p` (a `vec2<f32>`, normalised and centred per the module
/// docs) and must leave the **source** position in `p`.
///
/// A warp needing per-channel divergence writes `p_r` and `p_b` as well;
/// they enter the block equal to `p` and are carried out of it. A warp
/// that ignores them costs nothing — the composer drops the per-channel
/// path when no active warp declares [`Self::splits_channels`].
///
/// Uniforms are addressed by their bare declared names, as for an
/// operation; the composer rewrites them to their prefixed fields.
fn wgsl_body(&self) -> String;
/// Uniform values this warp's body reads.
fn uniforms(&self) -> Vec<Uniform>;
/// Whether this warp moves the channels independently.
///
/// True only for chromatic aberration. When no active warp declares it,
/// the composer emits a single sample instead of three — a 3× saving in
/// texture bandwidth for the common case of distortion alone, which at
/// 24 MP is the difference the tile budget is measured in.
fn splits_channels(&self) -> bool {
false
}
/// Any WGSL helper functions the body calls.
fn helpers(&self) -> &'static [Helper] {
&[]
}
}
/// The composed geometry stage: WGSL, uniforms, and what it needs from the
/// sampler.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ComposedWarp {
/// The WGSL block computing source coordinates, or empty when no warp is
/// active.
pub body: String,
/// Helper functions the body calls.
pub helpers: Vec<Helper>,
/// Uniform declarations, to be appended to the generated struct.
pub uniform_fields: String,
/// Uniform values, in declaration order.
pub uniforms: Vec<f32>,
/// Whether any active warp samples the channels separately.
pub splits_channels: bool,
}
impl ComposedWarp {
/// Whether any warp is active. When false the shader samples with an
/// integer `textureLoad` and no interpolation at all.
pub fn is_active(&self) -> bool {
!self.body.is_empty()
}
}
/// Compose the active warps into one coordinate transform.
///
/// Warps chain in order: each receives the position the previous one produced,
/// so correcting distortion and then CA composes as a single expression with
/// no intermediate buffer.
pub fn compose_warps(warps: &[Box<dyn Warp>]) -> ComposedWarp {
let active: Vec<&dyn Warp> = warps
.iter()
.map(|w| w.as_ref())
.filter(|w| w.is_active())
.collect();
if active.is_empty() {
return ComposedWarp::default();
}
let mut out = ComposedWarp {
splits_channels: active.iter().any(|w| w.splits_channels()),
..Default::default()
};
for warp in &active {
let id = warp.descriptor().id.0;
let prefix = sanitise(id);
let warp_uniforms = warp.uniforms();
if !warp_uniforms.is_empty() {
let _ = writeln!(out.uniform_fields, " // {id}");
}
for u in &warp_uniforms {
let _ = writeln!(out.uniform_fields, " {prefix}_{}: f32,", u.name);
out.uniforms.push(u.value);
}
for h in warp.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h);
}
}
let mut fragment = warp.wgsl_body();
for u in &warp_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{prefix}_{}", u.name),
);
}
let _ = writeln!(out.body, "\n // ---- warp: {id} ----");
let _ = writeln!(out.body, " {{");
for line in fragment.lines() {
let _ = writeln!(out.body, " {line}");
}
let _ = writeln!(out.body, " }}");
}
out
}
fn sanitise(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("warp_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("warp_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
};
struct Fake {
desc: &'static OpDescriptor,
amount: f32,
splits: bool,
}
impl Warp for Fake {
fn descriptor(&self) -> &'static OpDescriptor {
self.desc
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
}
fn param(&self, _id: ParamId) -> f32 {
self.amount
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"p = p * amount;".into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount,
}]
}
fn splits_channels(&self) -> bool {
self.splits
}
}
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
Box::new(Fake {
desc,
amount,
splits,
})
}
#[test]
fn no_active_warp_composes_to_nothing() {
// The property that keeps the common case free: an image with no lens
// correction must not pay for a bilinear sample.
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
assert!(!composed.is_active());
assert!(composed.uniforms.is_empty());
assert!(!composed.splits_channels);
}
#[test]
fn an_active_warp_appears_once() {
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
assert!(composed.is_active());
assert!(composed.body.contains("---- warp: warp_a ----"));
assert!(composed.body.contains("u.warp_a_amount"));
}
#[test]
fn uniforms_are_prefixed_so_warps_cannot_collide() {
// Both fakes declare `amount`; without prefixing the generated struct
// would carry a duplicate field and fail to compile.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
}
#[test]
fn channel_splitting_is_requested_by_any_active_warp() {
// One CA warp among several must switch the whole stage to the
// three-sample path.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
assert!(composed.splits_channels);
}
#[test]
fn an_inactive_splitting_warp_does_not_force_three_samples() {
// CA present but at neutral must cost nothing — otherwise every image
// with the panel visible pays triple bandwidth.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
assert!(composed.is_active());
assert!(!composed.splits_channels);
}
#[test]
fn warps_compose_in_order() {
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let a = composed.body.find("warp_a").expect("a present");
let b = composed.body.find("warp_b").expect("b present");
assert!(a < b, "warps must chain in graph order");
}
}