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
+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")
));
}
}