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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user