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:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user