Merge pull request 'Fix audit findings, expand tests, add a CI quality gate' (#3) from fix/deepseek-audit into main
Some checks failed
CI / check (push) Failing after 2s
Some checks failed
CI / check (push) Failing after 2s
Reviewed-on: #3
This commit is contained in:
commit
758a5d9f9c
15 changed files with 1242 additions and 262 deletions
51
.forgejo/workflows/ci.yml
Normal file
51
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: [self-hosted, hestia]
|
||||
# Same container/no-JS-actions convention as package.yml: the archlinux
|
||||
# image has no Node, so every step is a shell command that installs its
|
||||
# own toolchain and clones manually. Keeps the gate identical to how
|
||||
# packages are actually built.
|
||||
container:
|
||||
image: archlinux:latest
|
||||
steps:
|
||||
- name: Install build deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm base-devel git rust cargo clippy rustfmt \
|
||||
libgit2 openssl pam wayland libxkbcommon gtk4
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Try the head/ref branch first (e.g. the PR branch); fall back to a
|
||||
# plain default-branch clone so tags/merge refs still check out.
|
||||
git clone --depth 1 --branch "$BRANCH" \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /src \
|
||||
|| git clone --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /src
|
||||
|
||||
- name: Format (rustfmt --check)
|
||||
working-directory: /src
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Lint (clippy, warnings as errors)
|
||||
working-directory: /src
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Test (all targets)
|
||||
working-directory: /src
|
||||
run: cargo test --workspace --all-targets
|
||||
|
||||
- name: Build (release, locked)
|
||||
working-directory: /src
|
||||
run: cargo build --release --locked
|
||||
|
|
@ -30,13 +30,22 @@ pub async fn run_actor<E>(cmd_rx: mpsc::UnboundedReceiver<Command>, emit: E)
|
|||
where
|
||||
E: FnMut(Event) + Send + 'static,
|
||||
{
|
||||
run_actor_with(cmd_rx, emit, Client::connect).await;
|
||||
run_actor_with(cmd_rx, emit, Client::connect, DEFAULT_ROUNDTRIP_TIMEOUT).await;
|
||||
}
|
||||
|
||||
/// Upper bound for a single greetd roundtrip. Without it, a hung PAM module
|
||||
/// would leave the actor blocked on a `read_from` forever and the UI stuck on
|
||||
/// the "Working" spinner. When it fires the conversation is cancelled and an
|
||||
/// [`Event::Error`] is surfaced. Generous on purpose: a slow disk or a
|
||||
/// deliberate password iterate shouldn't false-positive, but a wedged peer
|
||||
/// must not wedge the greeter.
|
||||
const DEFAULT_ROUNDTRIP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
async fn run_actor_with<E, C, Fut>(
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
|
||||
mut emit: E,
|
||||
mut connect: C,
|
||||
roundtrip_timeout: std::time::Duration,
|
||||
) where
|
||||
E: FnMut(Event),
|
||||
C: FnMut() -> Fut,
|
||||
|
|
@ -67,7 +76,26 @@ async fn run_actor_with<E, C, Fut>(
|
|||
}
|
||||
}
|
||||
|
||||
let result = exec_cmd(client.as_mut().expect("just connected"), cmd).await;
|
||||
let result = match tokio::time::timeout(
|
||||
roundtrip_timeout,
|
||||
exec_cmd(client.as_mut().expect("just connected"), cmd),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
// Hunger guard: the peer accepted our request but never answered.
|
||||
// Treat it as a wedged connection rather than going through the
|
||||
// normal `Roundtrip(Err)` handler, whose `cancel_session().await`
|
||||
// would itself block on a `read_from` against the same dead peer.
|
||||
// Dropping the client makes the next command reconnect fresh.
|
||||
Err(_elapsed) => {
|
||||
client = None;
|
||||
emit(Event::Error(format!(
|
||||
"greetd did not respond within {roundtrip_timeout:?}"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match result {
|
||||
CmdResult::Idle => {}
|
||||
CmdResult::Started => emit(Event::SessionStarted),
|
||||
|
|
@ -157,6 +185,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
},
|
||||
std::time::Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
|
@ -177,15 +206,12 @@ mod tests {
|
|||
Response::Success.write_to(&mut stream).await.unwrap();
|
||||
});
|
||||
|
||||
cmd_tx
|
||||
.send(Command::CreateSession("bob".into()))
|
||||
.unwrap();
|
||||
cmd_tx.send(Command::CreateSession("bob".into())).unwrap();
|
||||
let ev = ev_rx.recv().await.expect("actor should retry after bind");
|
||||
match ev {
|
||||
Event::Outcome(Outcome::Success) => {}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(cmd_tx);
|
||||
server.await.unwrap();
|
||||
actor.await.unwrap();
|
||||
|
|
@ -195,4 +221,131 @@ mod tests {
|
|||
);
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wedged_roundtrip_times_out_instead_of_hanging() {
|
||||
// A peer that accepts the request but never answers must not leave the
|
||||
// actor blocked on `read_from` forever; the roundtrip timeout fires,
|
||||
// the connection is dropped, and the UI is told so it can recover.
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let (ev_tx, mut ev_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let path = sock("timeout");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// Accept and read the CreateSession request, then stay wedged: the
|
||||
// socket stays open (holding `stream`) but no response is ever
|
||||
// written, so the client hits its roundtrip timeout rather than
|
||||
// an EOF.
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
let connect_path = path.clone();
|
||||
let actor = tokio::spawn(async move {
|
||||
run_actor_with(
|
||||
cmd_rx,
|
||||
move |ev| {
|
||||
let _ = ev_tx.send(ev);
|
||||
},
|
||||
move || {
|
||||
let connect_path = connect_path.clone();
|
||||
async move { Client::connect_to(&connect_path).await }
|
||||
},
|
||||
std::time::Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Let the listener bind and the actor connect, then drive the roundtrip.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
cmd_tx.send(Command::CreateSession("bob".into())).unwrap();
|
||||
|
||||
let ev = tokio::time::timeout(std::time::Duration::from_secs(5), ev_rx.recv())
|
||||
.await
|
||||
.expect("actor must surface a timeout promptly, not hang")
|
||||
.expect("actor must emit an event");
|
||||
match ev {
|
||||
Event::Error(msg) => assert!(
|
||||
msg.contains("did not respond"),
|
||||
"expected a roundtrip-timeout error, got {msg:?}"
|
||||
),
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
}
|
||||
|
||||
// The actor must not hang on a cancel read against the dead peer.
|
||||
drop(cmd_tx);
|
||||
actor.await.unwrap();
|
||||
// Abort the never-completing wedged server and reap it.
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roundtrip_connection_error_recovers_on_next_request() {
|
||||
// A roundtrip that dies mid-conversation (EOF) is a connection error:
|
||||
// the actor drops the client, surfaces the Error, and then reconnects
|
||||
// on the next command so a transient blip can't wedge the greeter.
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let (ev_tx, mut ev_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let path = sock("roundtrip-recover");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// First connection: read the request, then hang up -> the client
|
||||
// sees EOF mid-roundtrip.
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
drop(stream);
|
||||
// Second connection (after the actor reconnects): answer Success.
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
let _ = Response::Success.write_to(&mut stream).await;
|
||||
});
|
||||
|
||||
let connect_path = path.clone();
|
||||
let actor = tokio::spawn(async move {
|
||||
run_actor_with(
|
||||
cmd_rx,
|
||||
move |ev| {
|
||||
let _ = ev_tx.send(ev);
|
||||
},
|
||||
move || {
|
||||
let connect_path = connect_path.clone();
|
||||
async move { Client::connect_to(&connect_path).await }
|
||||
},
|
||||
std::time::Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
cmd_tx.send(Command::CreateSession("bob".into())).unwrap();
|
||||
let ev = ev_rx.recv().await.expect("first roundtrip error event");
|
||||
match ev {
|
||||
Event::Error(msg) => assert!(
|
||||
msg.contains("greetd IPC error"),
|
||||
"expected a connection (EOF) error, got {msg:?}"
|
||||
),
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
}
|
||||
|
||||
cmd_tx.send(Command::CreateSession("bob".into())).unwrap();
|
||||
let ev = ev_rx.recv().await.expect("recovery success event");
|
||||
match ev {
|
||||
Event::Outcome(Outcome::Success) => {}
|
||||
other => panic!("expected Success after reconnect, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(cmd_tx);
|
||||
server.await.unwrap();
|
||||
actor.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,10 +384,9 @@ impl App {
|
|||
Stage::Prompt | Stage::Working => {
|
||||
self.status_lbl.set_label("");
|
||||
self.status_lbl.remove_css_class("error");
|
||||
// `reset_to_username` dispatches the CancelSession itself, so
|
||||
// we don't double-send it here.
|
||||
self.reset_to_username();
|
||||
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
|
||||
self.show_error("Cannot reach greetd");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -416,6 +415,17 @@ impl App {
|
|||
self.stage = Stage::Username;
|
||||
self.username.clear();
|
||||
self.pam_status_held = false;
|
||||
// Abort any greetd conversation still open server-side. Without this,
|
||||
// the error/`show_error` reset path returns to the username entry but
|
||||
// leaves greetd holding a half-done PAM conversation, so the next
|
||||
// login attempt's CreateSession stacks on a stale session. On a
|
||||
// broken channel we set the failure label directly rather than
|
||||
// recursing into `show_error`, which would call back into
|
||||
// `reset_to_username` forever.
|
||||
if self.cmd_tx.send(greetd::Command::CancelSession).is_err() {
|
||||
self.status_lbl.set_label("Cannot reach greetd");
|
||||
self.status_lbl.add_css_class("error");
|
||||
}
|
||||
if !self.sessions.is_empty() {
|
||||
self.entry.grab_focus();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,23 +103,36 @@ fn split_exec(exec: &str) -> Vec<String> {
|
|||
fn tokenize_exec(exec: &str) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_quote = false;
|
||||
let mut in_quote = None; // Some('"') or Some('\'')
|
||||
let mut chars = exec.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => in_quote = !in_quote,
|
||||
'\\' if in_quote => {
|
||||
if let Some(n) = chars.next() {
|
||||
current.push(n);
|
||||
match in_quote {
|
||||
Some(q) => match c {
|
||||
// Closing the active quote just toggles back to unquoted.
|
||||
c if c == q => in_quote = None,
|
||||
// Inside double quotes a backslash escapes the next char
|
||||
// (freedesktop Exec). Inside single quotes it's literal.
|
||||
'\\' if q == '"' => match chars.next() {
|
||||
Some(n) => current.push(n),
|
||||
None => current.push('\\'),
|
||||
},
|
||||
_ => current.push(c),
|
||||
},
|
||||
None => match c {
|
||||
'"' | '\'' => in_quote = Some(c),
|
||||
// Single quotes have no quoting/escaping inside them.
|
||||
'\\' => match chars.next() {
|
||||
Some(n) => current.push(n),
|
||||
None => current.push('\\'),
|
||||
},
|
||||
c if c.is_whitespace() => {
|
||||
if !current.is_empty() {
|
||||
args.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
}
|
||||
c if c.is_whitespace() && !in_quote => {
|
||||
if !current.is_empty() {
|
||||
args.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(c),
|
||||
_ => current.push(c),
|
||||
},
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
|
|
@ -173,6 +186,80 @@ mod tests {
|
|||
assert_eq!(split_exec(r#"echo "100%%""#), vec!["echo", "100%"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_exec_handles_single_quotes_and_escapes() {
|
||||
// Single-quoted arguments are one token (previously these split).
|
||||
assert_eq!(split_exec(r#"cmd 'two words'"#), vec!["cmd", "two words"]);
|
||||
// A backslash outside quotes escapes the next character, so an
|
||||
// escaped space merges into the running token.
|
||||
assert_eq!(split_exec(r"cmd a\ b"), vec!["cmd", "a b"]);
|
||||
// `\"` inside double quotes is an escaped backslash then a close
|
||||
// quote, i.e. a literal backslash inside a quoted argument.
|
||||
assert_eq!(split_exec(r#"cmd "a\"b""#), vec!["cmd", "a\"b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenize_exec_quotes_spaces_and_escapes_edge_cases() {
|
||||
// Quoting preserves embedded spaces; a backslash escapes a space.
|
||||
assert_eq!(tokenize_exec("echo \"a b\""), vec!["echo", "a b"]);
|
||||
assert_eq!(tokenize_exec("echo 'a b'"), vec!["echo", "a b"]);
|
||||
assert_eq!(tokenize_exec("echo a\\ b"), vec!["echo", "a b"]);
|
||||
// Inside double quotes a backslash escapes the next character,
|
||||
// including the quote itself.
|
||||
assert_eq!(tokenize_exec(r#"echo "a\"b""#), vec!["echo", "a\"b"]);
|
||||
// An escaped backslash outside quotes yields one literal backslash.
|
||||
assert_eq!(tokenize_exec(r"echo a\\"), vec!["echo", "a\\"]);
|
||||
// Quoting can splice mid-word (the space is part of one argument).
|
||||
assert_eq!(
|
||||
tokenize_exec(r#"echo pre"mid dle"post"#),
|
||||
vec!["echo", "premid dlepost"]
|
||||
);
|
||||
// Plain whitespace splits greedily, tabs/newlines included.
|
||||
assert_eq!(tokenize_exec(" a b\t c\n "), vec!["a", "b", "c"]);
|
||||
// Field codes survive this layer; `split_exec` drops them later.
|
||||
assert_eq!(
|
||||
tokenize_exec("app %U --flag %f"),
|
||||
vec!["app", "%U", "--flag", "%f"]
|
||||
);
|
||||
// An unclosed quote swallows the remainder as one token (no panic).
|
||||
assert_eq!(tokenize_exec("app \"rest of"), vec!["app", "rest of"]);
|
||||
// Empty / whitespace-only input yields no tokens.
|
||||
assert!(tokenize_exec("").is_empty());
|
||||
assert!(tokenize_exec(" \t ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenize_exec_fuzz_never_panics_and_emits_only_nonempty_tokens() {
|
||||
// Pseudo-fuzz over quoting/escaping/whitespace/field-code characters:
|
||||
// whatever the input, tokenizing must not panic and must never emit an
|
||||
// empty token (the tokenizer only pushes non-empty buffers).
|
||||
let alphabet: [char; 7] = ['a', 'b', ' ', '\'', '"', '\\', '%'];
|
||||
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
|
||||
let mut rng = move || {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
state
|
||||
};
|
||||
for _ in 0..5_000 {
|
||||
let len = (rng() % 32) as usize;
|
||||
let input: String = (0..len)
|
||||
.map(|_| alphabet[(rng() as usize) % alphabet.len()])
|
||||
.collect();
|
||||
let tokens = tokenize_exec(&input);
|
||||
assert!(
|
||||
tokens.iter().all(|t| !t.is_empty()),
|
||||
"tokenize must not emit empty tokens for {input:?} -> {tokens:?}"
|
||||
);
|
||||
// Whitespace-only input must yield no tokens. (The converse is
|
||||
// deliberately not asserted: an input of only a quote/backslash
|
||||
// adds no word chars and so correctly yields nothing.)
|
||||
if input.chars().all(char::is_whitespace) {
|
||||
assert!(tokens.is_empty(), "{input:?} -> {tokens:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_no_directories_exist() {
|
||||
assert!(discover(
|
||||
|
|
@ -231,21 +318,15 @@ mod tests {
|
|||
assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]);
|
||||
assert_eq!(listed[0].kind, SessionKind::Wayland);
|
||||
assert_eq!(listed[2].kind, SessionKind::X11);
|
||||
assert!(
|
||||
listed[2]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_TYPE=x11".to_string())
|
||||
);
|
||||
assert!(
|
||||
listed[0]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_TYPE=wayland".to_string())
|
||||
);
|
||||
assert!(
|
||||
listed[0]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_DESKTOP=bos".to_string())
|
||||
);
|
||||
assert!(listed[2]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_TYPE=x11".to_string()));
|
||||
assert!(listed[0]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_TYPE=wayland".to_string()));
|
||||
assert!(listed[0]
|
||||
.start_env()
|
||||
.contains(&"XDG_SESSION_DESKTOP=bos".to_string()));
|
||||
|
||||
std::fs::remove_dir_all(&wayland).ok();
|
||||
std::fs::remove_dir_all(&x11).ok();
|
||||
|
|
|
|||
|
|
@ -107,7 +107,10 @@ mod tests {
|
|||
fn defaults_match_design_system() {
|
||||
let a = Appearance::default();
|
||||
assert_eq!(a.background.mode, BackgroundMode::Color);
|
||||
assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)");
|
||||
assert!(
|
||||
!a.background.ken_burns,
|
||||
"Ken Burns must be opt-in (CPU cost)"
|
||||
);
|
||||
assert_eq!(a.clock.format, "%H:%M");
|
||||
assert_eq!(a.clock.date_format, "%A · %b %d");
|
||||
assert_eq!(a.font.family, "Varela Round");
|
||||
|
|
|
|||
|
|
@ -213,7 +213,14 @@ impl TextRenderer {
|
|||
origin_y: f32,
|
||||
) {
|
||||
self.draw_line_weighted(
|
||||
pixmap, text, family, size_px, color, origin_x, origin_y, Weight::NORMAL,
|
||||
pixmap,
|
||||
text,
|
||||
family,
|
||||
size_px,
|
||||
color,
|
||||
origin_x,
|
||||
origin_y,
|
||||
Weight::NORMAL,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -317,9 +324,8 @@ fn blend_over(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) {
|
|||
// out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa).
|
||||
let da = dst.alpha() as u32;
|
||||
let out_a = (sa + da * (255 - sa) / 255) as u8;
|
||||
let out_c = |c: u8, dc: u8| -> u8 {
|
||||
(c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8
|
||||
};
|
||||
let out_c =
|
||||
|c: u8, dc: u8| -> u8 { (c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8 };
|
||||
if let Some(blended) = PremultipliedColorU8::from_rgba(
|
||||
out_c(r, dst.red()),
|
||||
out_c(g, dst.green()),
|
||||
|
|
@ -386,7 +392,10 @@ mod tests {
|
|||
0.0,
|
||||
);
|
||||
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap();
|
||||
assert!(full_max > 200, "full-alpha text should render bright, got {full_max}");
|
||||
assert!(
|
||||
full_max > 200,
|
||||
"full-alpha text should render bright, got {full_max}"
|
||||
);
|
||||
|
||||
let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
|
||||
let mut low = Pixmap::new(200, 40).unwrap();
|
||||
|
|
@ -439,10 +448,7 @@ mod tests {
|
|||
// Full-coverage glyph cores are legitimately opaque, but the AA
|
||||
// edges must carry real intermediate alphas — the old forced-255
|
||||
// blend made *every* drawn pixel (edges included) fully opaque.
|
||||
let has_edge = t
|
||||
.pixels()
|
||||
.iter()
|
||||
.any(|p| p.alpha() > 0 && p.alpha() < 255);
|
||||
let has_edge = t.pixels().iter().any(|p| p.alpha() > 0 && p.alpha() < 255);
|
||||
assert!(
|
||||
has_edge,
|
||||
"glyph AA edges must keep intermediate alphas onto a transparent pixmap"
|
||||
|
|
|
|||
|
|
@ -56,9 +56,25 @@ pub fn spawn_check(
|
|||
generation: u64,
|
||||
result_tx: Sender<AuthOutcome>,
|
||||
) {
|
||||
// Bound simultaneously-running PAM calls. libpam can't be cancelled, so a
|
||||
// wedged module would otherwise spawn one uncancellable thread per retry
|
||||
// (each pinned holding a `Zeroizing` password buffer) with no reclaim — a
|
||||
// rapid retry against a stuck backend could grow threads without bound.
|
||||
let Some(slot) = reserve_attempt() else {
|
||||
tracing::warn!(
|
||||
in_flight = IN_FLIGHT.load(Ordering::SeqCst),
|
||||
"PAM attempt rejected: at in-flight cap"
|
||||
);
|
||||
let _ = result_tx.send((generation, Err(AuthError::Authenticate)));
|
||||
return;
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
// The slot outlives the outer thread's `recv_timeout`: it is only
|
||||
// freed once the *real* PAM call returns, not when we hand back a
|
||||
// timed-out failure, so the cap bounds actual outstanding PAM work.
|
||||
let _slot = slot;
|
||||
let result = pam::check(&username, &password);
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
|
|
@ -75,3 +91,66 @@ pub fn spawn_check(
|
|||
let _ = result_tx.send((generation, result));
|
||||
});
|
||||
}
|
||||
|
||||
/// Maximum PAM callbacks in flight at once (see [`spawn_check`]).
|
||||
const MAX_IN_FLIGHT: usize = 4;
|
||||
|
||||
/// Live PAM-call count backing the cap.
|
||||
static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// RAII claim on one in-flight PAM slot; releasing happens on drop, wherever
|
||||
/// that thread ends. Holding it in the worker thread (not the timouter) is
|
||||
/// what keeps the cap honest about real outstanding PAM work.
|
||||
struct InFlightSlot;
|
||||
|
||||
impl Drop for InFlightSlot {
|
||||
fn drop(&mut self) {
|
||||
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically claim one as-yet-unclaimed in-flight slot, or return `None`
|
||||
/// once [`MAX_IN_FLIGHT`] are running.
|
||||
fn reserve_attempt() -> Option<InFlightSlot> {
|
||||
loop {
|
||||
let current = IN_FLIGHT.load(Ordering::SeqCst);
|
||||
if current >= MAX_IN_FLIGHT {
|
||||
return None;
|
||||
}
|
||||
if IN_FLIGHT
|
||||
.compare_exchange_weak(current, current + 1, Ordering::SeqCst, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
return Some(InFlightSlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inflight_cap_denies_at_limit_and_recovers_on_drop() {
|
||||
// Steer the shared counter to the cap, then confirm a new reserve is
|
||||
// refused.
|
||||
IN_FLIGHT.store(MAX_IN_FLIGHT, Ordering::SeqCst);
|
||||
assert!(
|
||||
reserve_attempt().is_none(),
|
||||
"a reserve must be denied at the concurrency cap"
|
||||
);
|
||||
IN_FLIGHT.store(0, Ordering::SeqCst);
|
||||
|
||||
// A free slot is granted, tracked, and released on drop.
|
||||
let slot = reserve_attempt().expect("a free slot must be granted");
|
||||
assert_eq!(IN_FLIGHT.load(Ordering::SeqCst), 1);
|
||||
drop(slot);
|
||||
assert_eq!(
|
||||
IN_FLIGHT.load(Ordering::SeqCst),
|
||||
0,
|
||||
"dropping the slot must release the reservation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,10 +143,7 @@ mod tests {
|
|||
#[test]
|
||||
fn cstr_to_username_copies_nul_terminated_name() {
|
||||
let raw = CString::new("breadway").unwrap();
|
||||
assert_eq!(
|
||||
cstr_to_username(raw.as_ptr()),
|
||||
Some("breadway".to_string())
|
||||
);
|
||||
assert_eq!(cstr_to_username(raw.as_ptr()), Some("breadway".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -177,7 +177,21 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear:
|
|||
let di = (drow + col) * 4;
|
||||
// SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds);
|
||||
// di + 4 < target size; rows in bounds per above.
|
||||
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
|
||||
unsafe {
|
||||
lerp4(
|
||||
sdata,
|
||||
i00,
|
||||
i10,
|
||||
i00 + 4,
|
||||
i10 + 4,
|
||||
di,
|
||||
wx,
|
||||
wx_inv,
|
||||
wy,
|
||||
wy_inv,
|
||||
dst,
|
||||
)
|
||||
};
|
||||
}
|
||||
// Last column of this row: clamp x1.
|
||||
let i00 = (r0 + ix + tw - 1) * 4;
|
||||
|
|
@ -198,7 +212,21 @@ fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear:
|
|||
let i10 = (r1 + ix + col) * 4;
|
||||
let di = (drow + col) * 4;
|
||||
// SAFETY: in bounds as in the interior loop.
|
||||
unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) };
|
||||
unsafe {
|
||||
lerp4(
|
||||
sdata,
|
||||
i00,
|
||||
i10,
|
||||
i00 + 4,
|
||||
i10 + 4,
|
||||
di,
|
||||
wx,
|
||||
wx_inv,
|
||||
wy,
|
||||
wy_inv,
|
||||
dst,
|
||||
)
|
||||
};
|
||||
}
|
||||
// Last column of the last row (both clamps).
|
||||
let i00 = (r0 + ix + tw - 1) * 4;
|
||||
|
|
@ -339,7 +367,12 @@ impl Background {
|
|||
-scaled.pan_y * (0.5 + 0.5 * phase.cos()),
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
// Static wallpaper: center the crop. The source is scaled
|
||||
// to cover-fit (larger than the target on at least one
|
||||
// axis) and `blit_translate` samples the region that
|
||||
// starts at `-tx`, so centering means starting the sample
|
||||
// window at half the overhang on each axis.
|
||||
(-scaled.pan_x * 0.5, -scaled.pan_y * 0.5)
|
||||
};
|
||||
// The cached pixmap is already output-sized, so this per-frame
|
||||
// draw is a 1:1 copy with at most a translation. `draw_pixmap`
|
||||
|
|
@ -409,6 +442,49 @@ mod tests {
|
|||
assert_eq!(px[3].green(), 189);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_translate_positive_offset_clamps_to_source_start() {
|
||||
// A positive offset shifts the window before the source's origin and
|
||||
// must clamp up, showing the top-left of the source rather than
|
||||
// reading before the buffer or leaving holes.
|
||||
let src = source_grid();
|
||||
let mut dst = Pixmap::new(2, 2).unwrap();
|
||||
blit_translate(&mut dst, &src, 5.0, 5.0, false);
|
||||
let px = dst.pixels();
|
||||
assert_eq!(px[0].red(), 0, "positive dx clamps to src(0,0) red");
|
||||
assert_eq!(px[0].green(), 0, "positive dy clamps to src(0,0) green");
|
||||
assert_eq!(px[3].red(), 63, "(1,1) is src(1,1) red");
|
||||
assert_eq!(px[3].green(), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_translate_clamps_each_axis_independently() {
|
||||
// dx over-clamps to the left edge while dy lands inside the source's
|
||||
// overhang, so the visible window is src[x 0..2, y 2..4] — each axis
|
||||
// must clamp in isolation.
|
||||
let src = source_grid();
|
||||
let mut dst = Pixmap::new(2, 2).unwrap();
|
||||
blit_translate(&mut dst, &src, 5.0, -3.0, false);
|
||||
let px = dst.pixels();
|
||||
assert_eq!(px[0].red(), 0, "x clamps to source column 0");
|
||||
assert_eq!(px[0].green(), 126, "y window starts at source row 2");
|
||||
assert_eq!(px[3].red(), 63, "(1,1) is src(1,3) red");
|
||||
assert_eq!(px[3].green(), 189);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_translate_exact_fit_is_identity() {
|
||||
// Equal sizes with zero offset is a plain copy.
|
||||
let src = source_grid();
|
||||
let mut dst = Pixmap::new(4, 4).unwrap();
|
||||
blit_translate(&mut dst, &src, 0.0, 0.0, false);
|
||||
assert_eq!(
|
||||
dst.data(),
|
||||
src.data(),
|
||||
"zero offset at equal size is a copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ken_burns_pan_never_exposes_edges() {
|
||||
// A small solid-color image panned through a full cycle must cover
|
||||
|
|
@ -424,7 +500,10 @@ mod tests {
|
|||
for i in 0..90 {
|
||||
bg.paint(&mut target, i as f32, true);
|
||||
assert!(
|
||||
target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30),
|
||||
target
|
||||
.pixels()
|
||||
.iter()
|
||||
.all(|p| p.red() == 200 && p.green() == 30),
|
||||
"frame {i} exposed an edge"
|
||||
);
|
||||
}
|
||||
|
|
@ -461,7 +540,86 @@ mod tests {
|
|||
});
|
||||
let mut target = Pixmap::new(60, 30).unwrap();
|
||||
bg.paint(&mut target, 0.0, true);
|
||||
assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30));
|
||||
assert!(target
|
||||
.pixels()
|
||||
.iter()
|
||||
.all(|p| p.red() == 200 && p.green() == 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_image_crop_is_centered_not_top_left() {
|
||||
// Regression: a static (non-Ken-Burns) cover-fit image used to render
|
||||
// the crop anchored at the source's top-left. Here the target is wider
|
||||
// than it is tall, so the cover-fit layer overhangs vertically. The
|
||||
// visible region must be centered (matching the GPU path and
|
||||
// breadgreet), i.e. started at half the overhang.
|
||||
//
|
||||
// Source 32x16, target 32x12 -> cover scale 1.0, scaled 32x16,
|
||||
// pan_y = 4, so the visible window is rows 2..14 when centered but
|
||||
// rows 0..12 when top-left anchored. A red band in rows 12..16 is
|
||||
// therefore visible only in the centered crop (rows 12, 13 are within
|
||||
// 2..14 but outside 0..12), so this fails against the old top-left
|
||||
// anchoring.
|
||||
let mut source = Pixmap::new(32, 16).unwrap();
|
||||
source.fill(tiny_skia::Color::from_rgba8(255, 220, 0, 255)); // yellow
|
||||
for y in 12..16 {
|
||||
for x in 0..32 {
|
||||
source.pixels_mut()[y * 32 + x] =
|
||||
tiny_skia::PremultipliedColorU8::from_rgba(255, 0, 0, 255).unwrap();
|
||||
}
|
||||
}
|
||||
let bg = Background::Image(ImageBg {
|
||||
source,
|
||||
ken_burns: false,
|
||||
cache: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
let mut target = Pixmap::new(32, 12).unwrap();
|
||||
bg.paint(&mut target, 0.0, false); // integer offset -> pixel-exact memcpy
|
||||
|
||||
// Centered window rows 2..14 includes the red band rows 12..16; a
|
||||
// top-left window (0..12) would show none.
|
||||
assert!(
|
||||
target
|
||||
.pixels()
|
||||
.iter()
|
||||
.any(|p| p.red() == 255 && p.green() == 0),
|
||||
"centered crop should include the bottom red band; first row {:?}",
|
||||
target.pixels()[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_image_crop_is_horizontally_centered() {
|
||||
// Complementary to the vertical test above: a wide source and a
|
||||
// equal-height target overhang horizontally. Centering puts the
|
||||
// visible window at source columns 8..24 (into a 16px target) whereas
|
||||
// a top-left anchor would use columns 0..16. A red band in columns
|
||||
// 16..24 is therefore only visible in the centered crop.
|
||||
let mut source = Pixmap::new(32, 8).unwrap();
|
||||
source.fill(tiny_skia::Color::from_rgba8(255, 220, 0, 255)); // yellow
|
||||
for x in 16..24 {
|
||||
for y in 0..8 {
|
||||
source.pixels_mut()[y * 32 + x] =
|
||||
tiny_skia::PremultipliedColorU8::from_rgba(255, 0, 0, 255).unwrap();
|
||||
}
|
||||
}
|
||||
let bg = Background::Image(ImageBg {
|
||||
source,
|
||||
ken_burns: false,
|
||||
cache: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
let mut target = Pixmap::new(16, 8).unwrap();
|
||||
bg.paint(&mut target, 0.0, false); // integer offset -> pixel-exact memcpy
|
||||
|
||||
assert!(
|
||||
target
|
||||
.pixels()
|
||||
.iter()
|
||||
.any(|p| p.red() == 255 && p.green() == 0),
|
||||
"centered crop should include the right red band"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -97,13 +97,24 @@ impl Default for Scene {
|
|||
/// (image background + Ken Burns, full chrome) in a loop and prints per-frame
|
||||
/// timings, so the software renderer's cost can be measured without Wayland.
|
||||
fn bench(args: &[String]) {
|
||||
let parse = |s: &str, d: &str| -> String { args.iter().find(|a| a.starts_with(s)).map(|a| a[s.len()..].to_string()).unwrap_or_else(|| d.to_string()) };
|
||||
let parse = |s: &str, d: &str| -> String {
|
||||
args.iter()
|
||||
.find(|a| a.starts_with(s))
|
||||
.map(|a| a[s.len()..].to_string())
|
||||
.unwrap_or_else(|| d.to_string())
|
||||
};
|
||||
let size: (u32, u32) = {
|
||||
let v: Vec<u32> = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect();
|
||||
let v: Vec<u32> = parse("--size=", "1920x1200")
|
||||
.split('x')
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
(v[0], v[1])
|
||||
};
|
||||
let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120);
|
||||
let path = parse("--wallpaper=", "/home/breadway/.config/breadlock/wallpaper.png");
|
||||
let path = parse(
|
||||
"--wallpaper=",
|
||||
"/home/breadway/.config/breadlock/wallpaper.png",
|
||||
);
|
||||
|
||||
let palette = theme::load_palette();
|
||||
let bg_cfg = breadlock_ui::config::Background {
|
||||
|
|
@ -125,25 +136,26 @@ fn bench(args: &[String]) {
|
|||
font_family: FONT,
|
||||
clock_text: "12:34",
|
||||
date_text: "Friday · Aug 21",
|
||||
clock_old: None, password_len: 6,
|
||||
password: "hunter2",
|
||||
reveal: false,
|
||||
caps_lock: false,
|
||||
layout_index: 0,
|
||||
idle_dim: 0.0,
|
||||
failed: false,
|
||||
failed_t: 0.0,
|
||||
dot_pop_t: 1.0,
|
||||
keystroke_age: None,
|
||||
t_secs: 0.0,
|
||||
breathe_t: 0.0,
|
||||
status_t: 1.0,
|
||||
status_text: None,
|
||||
info_text: "",
|
||||
appear_t: 1.0,
|
||||
unlock_t: 0.0,
|
||||
smooth_pan: true,
|
||||
};
|
||||
clock_old: None,
|
||||
password_len: 6,
|
||||
password: "hunter2",
|
||||
reveal: false,
|
||||
caps_lock: false,
|
||||
layout_index: 0,
|
||||
idle_dim: 0.0,
|
||||
failed: false,
|
||||
failed_t: 0.0,
|
||||
dot_pop_t: 1.0,
|
||||
keystroke_age: None,
|
||||
t_secs: 0.0,
|
||||
breathe_t: 0.0,
|
||||
status_t: 1.0,
|
||||
status_text: None,
|
||||
info_text: "",
|
||||
appear_t: 1.0,
|
||||
unlock_t: 0.0,
|
||||
smooth_pan: true,
|
||||
};
|
||||
compose(&mut text, &warm).expect("warm-up compose failed");
|
||||
|
||||
// Isolate the background pass cost (wallpaper blit + fills) alone.
|
||||
|
|
@ -157,7 +169,10 @@ fn bench(args: &[String]) {
|
|||
}
|
||||
bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let avg: f64 = bg_times.iter().sum::<f64>() / bg_times.len() as f64;
|
||||
println!("background.paint only: avg {avg:.2} ms max {:.2} ms", bg_times[bg_times.len() - 1]);
|
||||
println!(
|
||||
"background.paint only: avg {avg:.2} ms max {:.2} ms",
|
||||
bg_times[bg_times.len() - 1]
|
||||
);
|
||||
}
|
||||
|
||||
let mut times = Vec::with_capacity(frames as usize);
|
||||
|
|
@ -222,47 +237,138 @@ fn main() {
|
|||
std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
|
||||
|
||||
let palette = theme::load_palette();
|
||||
let background = background::Background::load(
|
||||
&breadlock_ui::config::Background::default(),
|
||||
&palette,
|
||||
);
|
||||
let background =
|
||||
background::Background::load(&breadlock_ui::config::Background::default(), &palette);
|
||||
|
||||
let scenes = [
|
||||
// ---- Staggered entrance: clock leads, pill pops in last (overshoot).
|
||||
Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() },
|
||||
Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() },
|
||||
Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() },
|
||||
Scene {
|
||||
name: "01-appear-start",
|
||||
appear_t: 0.0,
|
||||
..Scene::default()
|
||||
},
|
||||
Scene {
|
||||
name: "02-appear-clock",
|
||||
password_len: 4,
|
||||
appear_t: 0.25,
|
||||
..Scene::default()
|
||||
},
|
||||
Scene {
|
||||
name: "03-appear-pill",
|
||||
password_len: 4,
|
||||
appear_t: 0.55,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Rest pose: empty pill showing the "Enter password" hint.
|
||||
Scene { name: "04-rest-pose", t_secs: 0.5, ..Scene::default() },
|
||||
Scene {
|
||||
name: "04-rest-pose",
|
||||
t_secs: 0.5,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Idle breath: glow peak on the pill (accent ring + deeper shadow).
|
||||
Scene { name: "05-breathe-peak", breathe_t: 1.0, ..Scene::default() },
|
||||
Scene {
|
||||
name: "05-breathe-peak",
|
||||
breathe_t: 1.0,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Typing: newest dot mid-pop, caret solid.
|
||||
Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() },
|
||||
Scene {
|
||||
name: "06-typing-pop",
|
||||
password_len: 6,
|
||||
dot_pop_t: 0.4,
|
||||
keystroke_age: Some(0.2),
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle).
|
||||
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
|
||||
Scene {
|
||||
name: "07-idle-blink",
|
||||
password_len: 2,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Checking: status mid slide-in. Live submit() zeros the secret
|
||||
// so password_len is 0 — don't fake a filled pill here.
|
||||
Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, password_len: 0, password: "", ..Scene::default() },
|
||||
Scene {
|
||||
name: "08-checking",
|
||||
status: Some("Checking…"),
|
||||
status_t: 0.5,
|
||||
password_len: 0,
|
||||
password: "",
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Wrong password: mid-shake, red pill, red status (settled).
|
||||
Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() },
|
||||
Scene {
|
||||
name: "09-failed-shake",
|
||||
password_len: 6,
|
||||
failed: true,
|
||||
failed_t: 0.35,
|
||||
status: Some("Wrong password"),
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Success: green flash ring, dots cascading accent → white.
|
||||
Scene { name: "10-success-flash", password_len: 6, unlock_t: 0.12, ..Scene::default() },
|
||||
Scene {
|
||||
name: "10-success-flash",
|
||||
password_len: 6,
|
||||
unlock_t: 0.12,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Unlock fade-out: chrome faded, parallax drift (clock furthest).
|
||||
Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() },
|
||||
Scene {
|
||||
name: "11-unlock-fade",
|
||||
password_len: 6,
|
||||
unlock_t: 0.8,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Minute rollover: old clock fading out above, new fading in below.
|
||||
Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() },
|
||||
Scene {
|
||||
name: "12-clock-crossfade",
|
||||
clock: "12:35",
|
||||
clock_old: Some(("12:34", 0.5)),
|
||||
password_len: 4,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Caps Lock on: chip above the pill.
|
||||
Scene { name: "13-caps-lock", password_len: 4, caps_lock: true, ..Scene::default() },
|
||||
Scene {
|
||||
name: "13-caps-lock",
|
||||
password_len: 4,
|
||||
caps_lock: true,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Non-default layout: layout chip instead of caps.
|
||||
Scene { name: "14-layout-2", password_len: 4, layout_index: 1, ..Scene::default() },
|
||||
Scene {
|
||||
name: "14-layout-2",
|
||||
password_len: 4,
|
||||
layout_index: 1,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Hold-to-reveal: plain password characters instead of dots.
|
||||
Scene { name: "15-reveal", password_len: 7, password: "hunter2", reveal: true, ..Scene::default() },
|
||||
Scene {
|
||||
name: "15-reveal",
|
||||
password_len: 7,
|
||||
password: "hunter2",
|
||||
reveal: true,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Idle auto-dim: deepened veil (rest pose + full idle dim).
|
||||
Scene { name: "16-idle-dim", idle_dim: 1.0, ..Scene::default() },
|
||||
Scene {
|
||||
name: "16-idle-dim",
|
||||
idle_dim: 1.0,
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- Repeat failure: attempt counter in the status line.
|
||||
Scene { name: "17-failed-3x", password_len: 6, failed: true, failed_t: 0.8, status: Some("Wrong password — 3 failed attempts"), ..Scene::default() },
|
||||
Scene {
|
||||
name: "17-failed-3x",
|
||||
password_len: 6,
|
||||
failed: true,
|
||||
failed_t: 0.8,
|
||||
status: Some("Wrong password — 3 failed attempts"),
|
||||
..Scene::default()
|
||||
},
|
||||
// ---- D-Bus status: now-playing + battery under the clock.
|
||||
Scene { name: "18-status-info", info: "The War on Drugs — Red Eyes · 87% · charging", ..Scene::default() },
|
||||
Scene {
|
||||
name: "18-status-info",
|
||||
info: "The War on Drugs — Red Eyes · 87% · charging",
|
||||
..Scene::default()
|
||||
},
|
||||
];
|
||||
|
||||
let mut text = TextRenderer::new();
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ fn singleton_held(app: &str) -> bool {
|
|||
/// this binary, no args. The child is reaped on a background thread so
|
||||
/// a later unlock cannot leave a zombie under `breadlock listen`.
|
||||
pub fn start_locker() -> Result<(), String> {
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_none() {
|
||||
// The child would `Connection::connect_to_env().expect(...)` and die
|
||||
// with a panic shortly after spawn. Better to report the lock command
|
||||
// as failed than to leave a coredumping orphan in its stead.
|
||||
return Err("WAYLAND_DISPLAY is not set; cannot start a Wayland locker".into());
|
||||
}
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadlock"));
|
||||
let mut child = Command::new(exe)
|
||||
.stdin(Stdio::null())
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ pub struct wl_egl_window {
|
|||
|
||||
#[link(name = "wayland-egl")]
|
||||
extern "C" {
|
||||
fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window;
|
||||
fn wl_egl_window_create(
|
||||
surface: *mut wl_surface,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> *mut wl_egl_window;
|
||||
fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32);
|
||||
fn wl_egl_window_destroy(window: *mut wl_egl_window);
|
||||
}
|
||||
|
|
@ -236,10 +240,16 @@ impl GpuRenderer {
|
|||
egl.initialize(display).ok()?;
|
||||
egl.bind_api(egl::OPENGL_ES_API).ok()?;
|
||||
let mut configs = Vec::with_capacity(1);
|
||||
egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?;
|
||||
egl.choose_config(display, &EGL_ATTRIBS, &mut configs)
|
||||
.ok()?;
|
||||
let config = *configs.first()?;
|
||||
let context = egl
|
||||
.create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE])
|
||||
.create_context(
|
||||
display,
|
||||
config,
|
||||
None,
|
||||
&[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE],
|
||||
)
|
||||
.ok()?;
|
||||
// A 1x1 pbuffer is enough to make the context current for setup
|
||||
// before any real lock surface exists. The chosen config is
|
||||
|
|
@ -298,7 +308,9 @@ impl GpuRenderer {
|
|||
let wallpaper = match &bg_cfg.mode {
|
||||
BackgroundMode::Color => None,
|
||||
BackgroundMode::Image if bg_cfg.path.is_empty() => {
|
||||
tracing::warn!("background.mode = \"image\" but background.path is empty, using solid color");
|
||||
tracing::warn!(
|
||||
"background.mode = \"image\" but background.path is empty, using solid color"
|
||||
);
|
||||
None
|
||||
}
|
||||
BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) {
|
||||
|
|
@ -324,9 +336,21 @@ impl GpuRenderer {
|
|||
glow::TEXTURE_MIN_FILTER,
|
||||
glow::LINEAR_MIPMAP_LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MAG_FILTER,
|
||||
glow::LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_WRAP_S,
|
||||
glow::CLAMP_TO_EDGE as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_WRAP_T,
|
||||
glow::CLAMP_TO_EDGE as i32,
|
||||
);
|
||||
}
|
||||
Some(Wallpaper {
|
||||
tex,
|
||||
|
|
@ -356,8 +380,16 @@ impl GpuRenderer {
|
|||
glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])),
|
||||
);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MIN_FILTER,
|
||||
glow::NEAREST as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MAG_FILTER,
|
||||
glow::NEAREST as i32,
|
||||
);
|
||||
}
|
||||
|
||||
let bg = breadlock_ui::theme::tiny_skia_color(&palette.background);
|
||||
|
|
@ -365,12 +397,28 @@ impl GpuRenderer {
|
|||
|
||||
// Resolve all uniform locations up front, then drop the closure so
|
||||
// `gl` can move into the renderer.
|
||||
let (u_screen, u_uv_scale, u_uv_offset, u_tex, u_color, u_dim_top, u_dim_bottom, u_veil_alpha, u_screen_h) = {
|
||||
let (
|
||||
u_screen,
|
||||
u_uv_scale,
|
||||
u_uv_offset,
|
||||
u_tex,
|
||||
u_color,
|
||||
u_dim_top,
|
||||
u_dim_bottom,
|
||||
u_veil_alpha,
|
||||
u_screen_h,
|
||||
) = {
|
||||
let loc = |p: glow::Program, n: &str| unsafe { gl.get_uniform_location(p, n) };
|
||||
(
|
||||
[loc(bg_program, "u_screen"), loc(chrome_program, "u_screen")],
|
||||
[loc(bg_program, "u_uv_scale"), loc(chrome_program, "u_uv_scale")],
|
||||
[loc(bg_program, "u_uv_offset"), loc(chrome_program, "u_uv_offset")],
|
||||
[
|
||||
loc(bg_program, "u_uv_scale"),
|
||||
loc(chrome_program, "u_uv_scale"),
|
||||
],
|
||||
[
|
||||
loc(bg_program, "u_uv_offset"),
|
||||
loc(chrome_program, "u_uv_offset"),
|
||||
],
|
||||
[loc(bg_program, "u_tex"), loc(chrome_program, "u_tex")],
|
||||
loc(bg_program, "u_color"),
|
||||
loc(bg_program, "u_dim_top"),
|
||||
|
|
@ -408,7 +456,12 @@ impl GpuRenderer {
|
|||
|
||||
/// Wraps a lock surface's `wl_surface` in an EGL window + surface.
|
||||
/// Called once per surface from its first `configure`.
|
||||
pub fn create_surface(&self, surface: &WlSurface, width: u32, height: u32) -> Option<GpuSurface> {
|
||||
pub fn create_surface(
|
||||
&self,
|
||||
surface: &WlSurface,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<GpuSurface> {
|
||||
// SAFETY: the surface proxy is live (this is called from its
|
||||
// `configure` handler); the returned window is owned by us.
|
||||
let egl_window = unsafe {
|
||||
|
|
@ -424,8 +477,12 @@ impl GpuRenderer {
|
|||
}
|
||||
// SAFETY: `egl_window` is a valid wl_egl_window native window.
|
||||
let egl_surface = unsafe {
|
||||
self.egl
|
||||
.create_window_surface(self.display, self.config, egl_window as *mut c_void, None)
|
||||
self.egl.create_window_surface(
|
||||
self.display,
|
||||
self.config,
|
||||
egl_window as *mut c_void,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let egl_surface = match egl_surface {
|
||||
Ok(s) => s,
|
||||
|
|
@ -467,7 +524,12 @@ impl GpuRenderer {
|
|||
}
|
||||
if self
|
||||
.egl
|
||||
.make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context))
|
||||
.make_current(
|
||||
self.display,
|
||||
Some(surface.egl_surface),
|
||||
Some(surface.egl_surface),
|
||||
Some(self.context),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("eglMakeCurrent failed; skipping GPU frame");
|
||||
|
|
@ -477,7 +539,11 @@ impl GpuRenderer {
|
|||
unsafe { gl.viewport(0, 0, w as i32, h as i32) };
|
||||
self.draw_background(w, h, inputs);
|
||||
self.draw_chrome(surface, inputs, text);
|
||||
if self.egl.swap_buffers(self.display, surface.egl_surface).is_err() {
|
||||
if self
|
||||
.egl
|
||||
.swap_buffers(self.display, surface.egl_surface)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("eglSwapBuffers failed; skipping GPU frame");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -505,7 +571,11 @@ impl GpuRenderer {
|
|||
wf, 0.0, wf, hf, 0.0, hf,
|
||||
];
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo));
|
||||
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW);
|
||||
gl.buffer_data_u8_slice(
|
||||
glow::ARRAY_BUFFER,
|
||||
f32s_as_bytes(&verts),
|
||||
glow::DYNAMIC_DRAW,
|
||||
);
|
||||
|
||||
if let Some(loc) = self.u_screen[0].as_ref() {
|
||||
gl.uniform_2_f32(Some(loc), wf, hf);
|
||||
|
|
@ -567,7 +637,12 @@ impl GpuRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
fn draw_chrome(&mut self, surface: &mut GpuSurface, inputs: &FrameInputs, text: &mut TextRenderer) {
|
||||
fn draw_chrome(
|
||||
&mut self,
|
||||
surface: &mut GpuSurface,
|
||||
inputs: &FrameInputs,
|
||||
text: &mut TextRenderer,
|
||||
) {
|
||||
let (w, h) = (surface.width, surface.height);
|
||||
let dirty = surface
|
||||
.chrome_pixmap
|
||||
|
|
@ -686,7 +761,11 @@ impl GpuRenderer {
|
|||
xf0, yf0, xf1, yf0, xf0, yf1, //
|
||||
xf1, yf0, xf1, yf1, xf0, yf1,
|
||||
];
|
||||
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW);
|
||||
gl.buffer_data_u8_slice(
|
||||
glow::ARRAY_BUFFER,
|
||||
f32s_as_bytes(&verts),
|
||||
glow::DYNAMIC_DRAW,
|
||||
);
|
||||
if let Some(loc) = self.u_screen[1].as_ref() {
|
||||
gl.uniform_2_f32(Some(loc), wf, hf);
|
||||
}
|
||||
|
|
@ -711,7 +790,12 @@ impl GpuRenderer {
|
|||
|
||||
/// Visible source region of the wallpaper for the current pan phase — the
|
||||
/// same cover-fit + Ken Burns math as `background.rs`.
|
||||
fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32, f32, f32) {
|
||||
fn pan_region(
|
||||
wp: (u32, u32),
|
||||
target: (u32, u32),
|
||||
ken_burns: bool,
|
||||
t_secs: f32,
|
||||
) -> (f32, f32, f32, f32) {
|
||||
let (sw, sh) = (wp.0 as f32, wp.1 as f32);
|
||||
let (tw, th) = (target.0 as f32, target.1 as f32);
|
||||
let cover = (tw / sw).max(th / sh);
|
||||
|
|
@ -727,7 +811,10 @@ fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32)
|
|||
-pan_y * (0.5 + 0.5 * phase.cos()),
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
// Static wallpaper: center the crop, matching the software path in
|
||||
// `background.rs` (which also samples the static visible region from
|
||||
// the middle of the cover-fit overhang rather than its top-left).
|
||||
(-pan_x * 0.5, -pan_y * 0.5)
|
||||
};
|
||||
(-tx, -ty, scaled_w, scaled_h)
|
||||
}
|
||||
|
|
@ -807,7 +894,12 @@ mod tests {
|
|||
/// re-implemented here so the GPU `pan_region` can be checked against it.
|
||||
/// Software rounds the scaled dims to pixels; GPU keeps floats, so
|
||||
/// compare with a 1px tolerance.
|
||||
fn software_pan(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32) {
|
||||
fn software_pan(
|
||||
wp: (u32, u32),
|
||||
target: (u32, u32),
|
||||
ken_burns: bool,
|
||||
t_secs: f32,
|
||||
) -> (f32, f32) {
|
||||
let (sw, sh) = (wp.0 as f32, wp.1 as f32);
|
||||
let (tw, th) = (target.0 as f32, target.1 as f32);
|
||||
let cover = (tw / sw).max(th / sh);
|
||||
|
|
@ -833,8 +925,21 @@ mod tests {
|
|||
let wp = (3840, 2160);
|
||||
let target = (1920, 1200);
|
||||
let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4);
|
||||
assert_eq!(sx, 0.0, "no ken burns: no horizontal pan");
|
||||
assert_eq!(sy, 0.0, "no ken burns: no vertical pan");
|
||||
// No ken burns: the visible window is centred in the cover-fit
|
||||
// overhang, matching the software `Background::Image::paint` path
|
||||
// (this regressed to a top-left crop before the centering fix).
|
||||
let pan_x = sw - 1920.0;
|
||||
let pan_y = sh - 1200.0;
|
||||
assert!(
|
||||
(sx - pan_x * 0.5).abs() < 0.01,
|
||||
"static x crop should be centred, got {sx} vs {}",
|
||||
pan_x * 0.5
|
||||
);
|
||||
assert!(
|
||||
(sy - pan_y * 0.5).abs() < 0.01,
|
||||
"static y crop should be centred, got {sy} vs {}",
|
||||
pan_y * 0.5
|
||||
);
|
||||
// Cover fit: the scaled region covers the target in both axes.
|
||||
assert!(sw >= 1920.0 && sh >= 1200.0);
|
||||
// And it's the tightest cover: at least one axis exactly matches.
|
||||
|
|
@ -892,8 +997,14 @@ mod tests {
|
|||
let pan_x = sw - 1920.0;
|
||||
let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0);
|
||||
let pan_y = (2160.0 * (cover * KENBURNS_ZOOM)).round() - 1200.0;
|
||||
assert!((sx0 - pan_x * 0.5).abs() < 0.5, "at t=0 x should be half-panned, got {sx0}");
|
||||
assert!((sy0 - pan_y).abs() < 0.5, "at t=0 y should be fully panned (top), got {sy0}");
|
||||
assert!(
|
||||
(sx0 - pan_x * 0.5).abs() < 0.5,
|
||||
"at t=0 x should be half-panned, got {sx0}"
|
||||
);
|
||||
assert!(
|
||||
(sy0 - pan_y).abs() < 0.5,
|
||||
"at t=0 y should be fully panned (top), got {sy0}"
|
||||
);
|
||||
// Half a period later it has returned to the same spot.
|
||||
let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S);
|
||||
assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01);
|
||||
|
|
@ -925,8 +1036,15 @@ mod tests {
|
|||
]
|
||||
.concat();
|
||||
for name in [
|
||||
"u_screen", "u_uv_scale", "u_uv_offset", "u_tex", "u_color",
|
||||
"u_dim_top", "u_dim_bottom", "u_veil_alpha", "u_screen_h",
|
||||
"u_screen",
|
||||
"u_uv_scale",
|
||||
"u_uv_offset",
|
||||
"u_tex",
|
||||
"u_color",
|
||||
"u_dim_top",
|
||||
"u_dim_bottom",
|
||||
"u_veil_alpha",
|
||||
"u_screen_h",
|
||||
] {
|
||||
assert!(
|
||||
declared.iter().any(|d| d == name),
|
||||
|
|
@ -943,7 +1061,8 @@ mod tests {
|
|||
/// top) exactly, or the GPU veil renders upside-down.
|
||||
fn shader_dim_at(frag_y: f32, h: f32) -> f32 {
|
||||
let row = 1.0 - frag_y / h; // 1 at bottom (frag_y=0), 0 at top
|
||||
crate::render::DIM_ALPHA_TOP + (crate::render::DIM_ALPHA_BOTTOM - crate::render::DIM_ALPHA_TOP) * row
|
||||
crate::render::DIM_ALPHA_TOP
|
||||
+ (crate::render::DIM_ALPHA_BOTTOM - crate::render::DIM_ALPHA_TOP) * row
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -973,8 +1092,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn egl_attribs_are_none_terminated_pairs() {
|
||||
assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE");
|
||||
assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated");
|
||||
assert_eq!(
|
||||
EGL_ATTRIBS.len() % 2,
|
||||
1,
|
||||
"attribs must be key/value pairs + NONE"
|
||||
);
|
||||
assert_eq!(
|
||||
*EGL_ATTRIBS.last().unwrap(),
|
||||
egl::NONE,
|
||||
"attrib list must be NONE-terminated"
|
||||
);
|
||||
let mut saw_window = false;
|
||||
let mut saw_es2 = false;
|
||||
let mut saw_pbuffer = false;
|
||||
|
|
|
|||
|
|
@ -170,52 +170,55 @@ fn run_lock() {
|
|||
let loop_handle = event_loop.handle();
|
||||
|
||||
let auth_result_qh = qh.clone();
|
||||
let auth_tx = auth::register(&loop_handle, move |state: &mut AppState, generation, result| {
|
||||
if generation != state.auth_generation {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// Keep the lock surfaces up and fade the overlay out.
|
||||
// Compositor unlock() runs only after UNLOCK_MS — dying
|
||||
// mid-fade is fail-secure (session stays locked).
|
||||
tracing::info!("authenticated, fading out");
|
||||
state.failed_attempts = 0;
|
||||
state.auth_state = AuthState::Idle;
|
||||
state.checking_started = None;
|
||||
if state.unlocking.is_none() {
|
||||
state.unlocking = Some(std::time::Instant::now());
|
||||
}
|
||||
let auth_tx = auth::register(
|
||||
&loop_handle,
|
||||
move |state: &mut AppState, generation, result| {
|
||||
if generation != state.auth_generation {
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
match err {
|
||||
// A broken PAM setup (missing/invalid /etc/pam.d/breadlock,
|
||||
// context init failure) is a config problem, not a typo —
|
||||
// rendering it identically to "wrong password" would lock
|
||||
// the user out with zero indication of what's actually
|
||||
// wrong. Log loudly and show a distinct on-screen message.
|
||||
auth::AuthError::ContextInit => {
|
||||
tracing::error!(
|
||||
%err,
|
||||
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
|
||||
);
|
||||
state.enter_fail(AuthState::ConfigError);
|
||||
}
|
||||
auth::AuthError::Authenticate => {
|
||||
tracing::warn!(%err, "authentication failed");
|
||||
state.failed_attempts = state.failed_attempts.saturating_add(1);
|
||||
state.enter_fail(AuthState::Failed);
|
||||
}
|
||||
auth::AuthError::AccountInvalid => {
|
||||
tracing::warn!(%err, "account locked or expired");
|
||||
state.enter_fail(AuthState::AccountInvalid);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// Keep the lock surfaces up and fade the overlay out.
|
||||
// Compositor unlock() runs only after UNLOCK_MS — dying
|
||||
// mid-fade is fail-secure (session stays locked).
|
||||
tracing::info!("authenticated, fading out");
|
||||
state.failed_attempts = 0;
|
||||
state.auth_state = AuthState::Idle;
|
||||
state.checking_started = None;
|
||||
if state.unlocking.is_none() {
|
||||
state.unlocking = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
state.schedule_clear_failed(auth_result_qh.clone());
|
||||
Err(err) => {
|
||||
match err {
|
||||
// A broken PAM setup (missing/invalid /etc/pam.d/breadlock,
|
||||
// context init failure) is a config problem, not a typo —
|
||||
// rendering it identically to "wrong password" would lock
|
||||
// the user out with zero indication of what's actually
|
||||
// wrong. Log loudly and show a distinct on-screen message.
|
||||
auth::AuthError::ContextInit => {
|
||||
tracing::error!(
|
||||
%err,
|
||||
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
|
||||
);
|
||||
state.enter_fail(AuthState::ConfigError);
|
||||
}
|
||||
auth::AuthError::Authenticate => {
|
||||
tracing::warn!(%err, "authentication failed");
|
||||
state.failed_attempts = state.failed_attempts.saturating_add(1);
|
||||
state.enter_fail(AuthState::Failed);
|
||||
}
|
||||
auth::AuthError::AccountInvalid => {
|
||||
tracing::warn!(%err, "account locked or expired");
|
||||
state.enter_fail(AuthState::AccountInvalid);
|
||||
}
|
||||
}
|
||||
state.schedule_clear_failed(auth_result_qh.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
state.redraw_all(&auth_result_qh);
|
||||
});
|
||||
state.redraw_all(&auth_result_qh);
|
||||
},
|
||||
);
|
||||
|
||||
// D-Bus status (now-playing / battery): the poller posts snapshots here
|
||||
// and each one triggers a redraw so the line under the clock stays live.
|
||||
|
|
|
|||
|
|
@ -411,7 +411,8 @@ fn compose_impl(
|
|||
let status_e = ease_out_cubic(staggered_t(inputs.appear_t, STATUS_DELAY_MS));
|
||||
// Per-element vertical motion: the appear part is uniform, the unlock
|
||||
// drift is scaled per element for parallax.
|
||||
let elem_y = |e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift;
|
||||
let elem_y =
|
||||
|e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift;
|
||||
|
||||
// ---- Clock, large, centered in the upper third (size scales with the
|
||||
// surface). A minute rollover crossfades old text out (drifting up) while
|
||||
|
|
@ -428,7 +429,9 @@ fn compose_impl(
|
|||
if let Some(r) = rects.as_deref_mut() {
|
||||
let old_w = inputs
|
||||
.clock_old
|
||||
.map(|(t, _)| text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD))
|
||||
.map(|(t, _)| {
|
||||
text.measure_line_weighted(t, inputs.font_family, clock_size, Weight::BOLD)
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
let new_w = text.measure_line_weighted(
|
||||
inputs.clock_text,
|
||||
|
|
@ -437,12 +440,18 @@ fn compose_impl(
|
|||
Weight::BOLD,
|
||||
);
|
||||
let cw = old_w.max(new_w);
|
||||
r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size);
|
||||
r.expand(
|
||||
(w - cw) / 2.0,
|
||||
clock_y,
|
||||
(w + cw) / 2.0,
|
||||
clock_y + clock_size,
|
||||
);
|
||||
}
|
||||
match inputs.clock_old {
|
||||
Some((old, t)) => {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let old_w = text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD);
|
||||
let old_w =
|
||||
text.measure_line_weighted(old, inputs.font_family, clock_size, Weight::BOLD);
|
||||
text.draw_line_weighted(
|
||||
pixmap,
|
||||
old,
|
||||
|
|
@ -494,15 +503,27 @@ fn compose_impl(
|
|||
// now-playing / battery under that. Info is *not* nested under the date
|
||||
// — an empty `date_format` used to hide the status line too.
|
||||
let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX);
|
||||
let (clock_top, clock_height) =
|
||||
text.measure_box_weighted(inputs.clock_text, inputs.font_family, clock_size, Weight::BOLD);
|
||||
let mut below_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height
|
||||
let (clock_top, clock_height) = text.measure_box_weighted(
|
||||
inputs.clock_text,
|
||||
inputs.font_family,
|
||||
clock_size,
|
||||
Weight::BOLD,
|
||||
);
|
||||
let mut below_y = clock_y_rest
|
||||
+ elem_y(date_e, DRIFT_DATE)
|
||||
+ clock_top
|
||||
+ clock_height
|
||||
+ tokens::SPACE_SM as f32;
|
||||
if !inputs.date_text.is_empty() {
|
||||
let date_y = below_y;
|
||||
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size);
|
||||
if let Some(r) = rects.as_mut() {
|
||||
r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size);
|
||||
r.expand(
|
||||
(w - date_w) / 2.0,
|
||||
date_y,
|
||||
(w + date_w) / 2.0,
|
||||
date_y + date_size,
|
||||
);
|
||||
}
|
||||
text.draw_line(
|
||||
pixmap,
|
||||
|
|
@ -559,7 +580,11 @@ fn compose_impl(
|
|||
let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t;
|
||||
|
||||
let base_pill = if inputs.failed {
|
||||
lerp_color(surface_color, red_color, (inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0))
|
||||
lerp_color(
|
||||
surface_color,
|
||||
red_color,
|
||||
(inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0),
|
||||
)
|
||||
} else {
|
||||
surface_color
|
||||
};
|
||||
|
|
@ -570,8 +595,16 @@ fn compose_impl(
|
|||
};
|
||||
// The pill scales about its center (ease-out-back overshoot) instead of
|
||||
// rising like the text; while unlocking it stays at rest scale.
|
||||
let scale = if inputs.unlock_t > 0.0 { 1.0 } else { pill_scale };
|
||||
let shake_x = if inputs.failed { damped_shake_x(inputs.failed_t) } else { 0.0 };
|
||||
let scale = if inputs.unlock_t > 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
pill_scale
|
||||
};
|
||||
let shake_x = if inputs.failed {
|
||||
damped_shake_x(inputs.failed_t)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let cx = pill_x + pill_w / 2.0;
|
||||
let cy = pill_y + pill_h / 2.0;
|
||||
let pill_xf = Transform::from_row(
|
||||
|
|
@ -603,9 +636,13 @@ fn compose_impl(
|
|||
);
|
||||
}
|
||||
|
||||
if let Some(path) =
|
||||
rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32)
|
||||
{
|
||||
if let Some(path) = rounded_rect(
|
||||
pill_x,
|
||||
pill_y,
|
||||
pill_w,
|
||||
pill_h,
|
||||
tokens::RADIUS_SECONDARY as f32,
|
||||
) {
|
||||
// Soft drop shadow first (under the fill): concentric expanded copies
|
||||
// offset downward at fading alpha. The idle breath scales the glow.
|
||||
for (grow, alpha) in PILL_SHADOW {
|
||||
|
|
@ -632,13 +669,7 @@ fn compose_impl(
|
|||
let mut paint = Paint::default();
|
||||
paint.set_color(faded(pill_color, pill_alpha));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
pill_xf,
|
||||
None,
|
||||
);
|
||||
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
|
||||
|
||||
// Hairline border for depth — dropped on the wrong/success states
|
||||
// (the sketch sets `border-color: transparent` there).
|
||||
|
|
@ -663,7 +694,10 @@ fn compose_impl(
|
|||
..Default::default()
|
||||
};
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha));
|
||||
paint.set_color(faded(
|
||||
accent_color,
|
||||
BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha,
|
||||
));
|
||||
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
|
||||
}
|
||||
|
||||
|
|
@ -696,7 +730,8 @@ fn compose_impl(
|
|||
label.push_str(&format!("Layout {}", inputs.layout_index + 1));
|
||||
}
|
||||
let chip_size = tokens::FONT_SIZE_SECONDARY as f32;
|
||||
let chip_w = text.measure_line(&label, inputs.font_family, chip_size) + tokens::SPACE_MD as f32 * 2.0;
|
||||
let chip_w = text.measure_line(&label, inputs.font_family, chip_size)
|
||||
+ tokens::SPACE_MD as f32 * 2.0;
|
||||
let chip_h = chip_size * 1.9;
|
||||
let chip_x = (w - chip_w) / 2.0;
|
||||
// Clear of the pill: chip bottom sits a full SPACE_LG above the pill
|
||||
|
|
@ -711,7 +746,13 @@ fn compose_impl(
|
|||
// Slightly lifted surface color so it reads as a separate chip.
|
||||
paint.set_color(faded(surface_color, chip_alpha));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), None);
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
let stroke = tiny_skia::Stroke {
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
|
|
@ -808,13 +849,7 @@ fn compose_impl(
|
|||
let mut paint = Paint::default();
|
||||
paint.set_color(faded(dot_color, pill_alpha));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
pill_xf,
|
||||
None,
|
||||
);
|
||||
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -859,23 +894,12 @@ fn compose_impl(
|
|||
accent_color
|
||||
};
|
||||
let caret_h = pill_h * 0.5;
|
||||
if let Some(path) = rounded_rect(
|
||||
caret_x,
|
||||
dot_y - caret_h / 2.0,
|
||||
CARET_W,
|
||||
caret_h,
|
||||
1.0,
|
||||
) {
|
||||
if let Some(path) = rounded_rect(caret_x, dot_y - caret_h / 2.0, CARET_W, caret_h, 1.0)
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(faded(caret_color, pill_alpha));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
pill_xf,
|
||||
None,
|
||||
);
|
||||
pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, pill_xf, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -889,10 +913,18 @@ fn compose_impl(
|
|||
let status_anim = ease_out_cubic(inputs.status_t);
|
||||
let status_alpha = status_e * fade * status_anim;
|
||||
let color = if inputs.failed { red_color } else { on_surface };
|
||||
let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS)
|
||||
let status_y = pill_y_rest
|
||||
+ pill_h
|
||||
+ tokens::SPACE_MD as f32
|
||||
+ elem_y(status_e, DRIFT_STATUS)
|
||||
+ STATUS_SLIDE_PX * (1.0 - status_anim);
|
||||
if let Some(r) = rects.as_mut() {
|
||||
r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size);
|
||||
r.expand(
|
||||
(w - status_w) / 2.0,
|
||||
status_y,
|
||||
(w + status_w) / 2.0,
|
||||
status_y + status_size,
|
||||
);
|
||||
}
|
||||
text.draw_line(
|
||||
pixmap,
|
||||
|
|
@ -968,7 +1000,12 @@ mod tests {
|
|||
let top = px[0];
|
||||
let bottom = px[2 * 3];
|
||||
// DIM_ALPHA_TOP (0.34) > DIM_ALPHA_BOTTOM (0.16): top row darker.
|
||||
assert!(top.red() < bottom.red(), "top {} should be darker than bottom {}", top.red(), bottom.red());
|
||||
assert!(
|
||||
top.red() < bottom.red(),
|
||||
"top {} should be darker than bottom {}",
|
||||
top.red(),
|
||||
bottom.red()
|
||||
);
|
||||
// White at top dim 0.34 → 255 * (1 - 0.34) = 168.
|
||||
assert_eq!(top.red(), 168);
|
||||
// Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205.
|
||||
|
|
@ -1083,15 +1120,48 @@ mod tests {
|
|||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
// Wrong-password shake mid-flight.
|
||||
let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0);
|
||||
let failed = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
true,
|
||||
0.3,
|
||||
0.4,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let failed_px = compose(&mut text, &failed).expect("failed compose");
|
||||
assert_eq!((failed_px.width(), failed_px.height()), (400, 300));
|
||||
assert!(failed_px.pixels().iter().any(|p| p.alpha() > 0));
|
||||
// Success flash phase of the unlock: still fully opaque chrome (flash
|
||||
// holds rest pose), not already faded.
|
||||
let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12);
|
||||
let success = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.12,
|
||||
);
|
||||
let success_px = compose(&mut text, &success).expect("success compose");
|
||||
let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
|
||||
let rest = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let rest_px = compose(&mut text, &rest).expect("rest compose");
|
||||
// Flash frame should not be a near-empty fade — plenty of chrome left.
|
||||
let flash_lit = success_px.pixels().iter().filter(|p| p.alpha() > 0).count();
|
||||
|
|
@ -1101,7 +1171,18 @@ mod tests {
|
|||
"success flash should keep chrome visible, lit {flash_lit} vs rest {rest_lit}"
|
||||
);
|
||||
// Fully faded unlock returns just the background.
|
||||
let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
|
||||
let done = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
let done_px = compose(&mut text, &done).expect("done compose");
|
||||
assert_eq!((done_px.width(), done_px.height()), (400, 300));
|
||||
}
|
||||
|
|
@ -1130,11 +1211,17 @@ mod tests {
|
|||
fn reveal_fit_truncates_long_passwords() {
|
||||
let mut text = TextRenderer::new();
|
||||
// Short password fits unchanged.
|
||||
assert_eq!(ellipsize(&mut text, "hunter2", "sans-serif", 14.0, 200.0), "hunter2");
|
||||
assert_eq!(
|
||||
ellipsize(&mut text, "hunter2", "sans-serif", 14.0, 200.0),
|
||||
"hunter2"
|
||||
);
|
||||
// A very long one is trimmed and ends with an ellipsis.
|
||||
let long = "a".repeat(200);
|
||||
let fitted = ellipsize(&mut text, &long, "sans-serif", 14.0, 60.0);
|
||||
assert!(fitted.ends_with('…'), "trimmed reveal should end with an ellipsis");
|
||||
assert!(
|
||||
fitted.ends_with('…'),
|
||||
"trimmed reveal should end with an ellipsis"
|
||||
);
|
||||
assert!(fitted.len() < long.len());
|
||||
// And it actually fits the budget.
|
||||
assert!(text.measure_line(&fitted, "sans-serif", 14.0) <= 60.0);
|
||||
|
|
@ -1145,7 +1232,18 @@ mod tests {
|
|||
let bg = Background::Color(Color::BLACK);
|
||||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let mut base = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
|
||||
let mut base = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
base.caps_lock = true;
|
||||
base.password = "hunter2";
|
||||
// Caps chip visible, no reveal: dots path.
|
||||
|
|
@ -1234,8 +1332,14 @@ mod tests {
|
|||
// First FLASH_FRAC of unlock_t is the green flash at full opacity.
|
||||
let (a_rest, y_rest) = overlay_motion(1.0, 0.0);
|
||||
let (a_flash, y_flash) = overlay_motion(1.0, FLASH_FRAC * 0.5);
|
||||
assert!((a_flash - a_rest).abs() < 1e-6, "flash must not fade chrome, got {a_flash}");
|
||||
assert!((y_flash - y_rest).abs() < 1e-6, "flash must not drift chrome, got {y_flash}");
|
||||
assert!(
|
||||
(a_flash - a_rest).abs() < 1e-6,
|
||||
"flash must not fade chrome, got {a_flash}"
|
||||
);
|
||||
assert!(
|
||||
(y_flash - y_rest).abs() < 1e-6,
|
||||
"flash must not drift chrome, got {y_flash}"
|
||||
);
|
||||
// After the flash, fade/drift begin.
|
||||
let (a_fade, y_fade) = overlay_motion(1.0, (FLASH_FRAC + 1.0) * 0.5);
|
||||
assert!(a_fade < a_rest, "post-flash should fade, got {a_fade}");
|
||||
|
|
@ -1248,18 +1352,38 @@ mod tests {
|
|||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
|
||||
let inputs = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
||||
assert!(
|
||||
rect.x1 > rect.x0 && rect.y1 > rect.y0,
|
||||
"chrome rect must be non-empty, got {rect:?}"
|
||||
);
|
||||
// Clock sits at h*0.28 with glyph height ~ clock_size (400*0.075=30).
|
||||
assert!(rect.y0 < 300.0 * 0.28 + 40.0, "rect must cover the clock band");
|
||||
assert!(
|
||||
rect.y0 < 300.0 * 0.28 + 40.0,
|
||||
"rect must cover the clock band"
|
||||
);
|
||||
// Pill sits at h*0.5; with the 26px pad the rect must reach it.
|
||||
assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band");
|
||||
assert!(
|
||||
rect.y1 > 300.0 * 0.5 + 24.0,
|
||||
"rect must cover the pill band"
|
||||
);
|
||||
// Both are horizontally centered.
|
||||
assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center");
|
||||
assert!(
|
||||
rect.x0 < 200.0 && rect.x1 > 200.0,
|
||||
"rect must straddle center"
|
||||
);
|
||||
// Origin-stuck Default(0,0,…) used to pass the checks above (0.min
|
||||
// never leaves 0, and 0 < 200 && x1 > 200 still holds). Fail that.
|
||||
assert!(
|
||||
|
|
@ -1276,9 +1400,23 @@ mod tests {
|
|||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||
// unlock_t = 1 → chrome is gone; appear_t = 0 still draws (invisible)
|
||||
// chrome so the GPU dirty rect is valid from the first frame.
|
||||
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
|
||||
let inputs = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
||||
assert!(rect.is_empty(), "finished unlock must yield an empty rect, got {rect:?}");
|
||||
assert!(
|
||||
rect.is_empty(),
|
||||
"finished unlock must yield an empty rect, got {rect:?}"
|
||||
);
|
||||
assert!(
|
||||
pixmap.pixels().iter().all(|p| p.alpha() == 0),
|
||||
"finished unlock must leave the pixmap transparent"
|
||||
|
|
@ -1291,7 +1429,18 @@ mod tests {
|
|||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0);
|
||||
let inputs = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
let rect = compose_chrome(&mut pixmap, &mut text, &inputs);
|
||||
assert!(
|
||||
!rect.is_empty() && rect.x0 > 0.0 && rect.y0 > 0.0,
|
||||
|
|
@ -1305,9 +1454,31 @@ mod tests {
|
|||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||
let rest = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.0, 1.0, 1.0, 0.0);
|
||||
let rest = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
true,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let r0 = compose_chrome(&mut pixmap, &mut text, &rest);
|
||||
let mid = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0);
|
||||
let mid = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
true,
|
||||
0.3,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let r1 = compose_chrome(&mut pixmap, &mut text, &mid);
|
||||
assert!(
|
||||
(r0.x0 - r1.x0).abs() > 0.5 || (r0.x1 - r1.x1).abs() > 0.5,
|
||||
|
|
@ -1328,7 +1499,11 @@ mod tests {
|
|||
n += 1.0;
|
||||
}
|
||||
}
|
||||
if n > 0.0 { sx / n } else { 0.0 }
|
||||
if n > 0.0 {
|
||||
sx / n
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
let dx = (centroid_x(&shaken_px) - centroid_x(&rest_px)).abs();
|
||||
assert!(
|
||||
|
|
@ -1369,7 +1544,18 @@ mod tests {
|
|||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let mut pixmap = Pixmap::new(400, 300).unwrap();
|
||||
let mut with_status = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0);
|
||||
let mut with_status = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
true,
|
||||
0.3,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
with_status.status_text = Some("Wrong password");
|
||||
let rect = compose_chrome(&mut pixmap, &mut text, &with_status);
|
||||
// Status sits below the pill: pill bottom is h*0.5 + 24 (half of 48px),
|
||||
|
|
@ -1391,14 +1577,27 @@ mod tests {
|
|||
let bg = Background::Color(Color::from_rgba8(40, 60, 80, 255));
|
||||
let palette = breadlock_ui::theme::Palette::default();
|
||||
let mut text = TextRenderer::new();
|
||||
let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0);
|
||||
let inputs = inputs(
|
||||
&bg,
|
||||
&palette,
|
||||
"12:34",
|
||||
"Friday · Aug 21",
|
||||
4,
|
||||
false,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// Full single-pass compose.
|
||||
let full = compose(&mut text, &inputs).unwrap();
|
||||
|
||||
// Split: dim the background, then composite the chrome over it.
|
||||
let mut split = Pixmap::new(400, 300).unwrap();
|
||||
inputs.background.paint(&mut split, inputs.t_secs, inputs.smooth_pan);
|
||||
inputs
|
||||
.background
|
||||
.paint(&mut split, inputs.t_secs, inputs.smooth_pan);
|
||||
let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
|
||||
if veil_alpha > 0.0 {
|
||||
dim_rows(&mut split, veil_alpha);
|
||||
|
|
@ -1430,7 +1629,8 @@ mod tests {
|
|||
.iter()
|
||||
.zip(full.pixels())
|
||||
.map(|(a, b)| {
|
||||
(a.red() as i32 - b.red() as i32).abs()
|
||||
(a.red() as i32 - b.red() as i32)
|
||||
.abs()
|
||||
.max((a.green() as i32 - b.green() as i32).abs())
|
||||
.max((a.blue() as i32 - b.blue() as i32).abs())
|
||||
.max((a.alpha() as i32 - b.alpha() as i32).abs())
|
||||
|
|
@ -1449,5 +1649,3 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,10 @@ fn poll_now_playing_on(conn: &zbus::blocking::Connection) -> Result<String, ()>
|
|||
.map_err(|_| ())?;
|
||||
|
||||
let mut paused: Option<String> = None;
|
||||
for name in names.iter().filter(|n| n.starts_with("org.mpris.MediaPlayer2.")) {
|
||||
for name in names
|
||||
.iter()
|
||||
.filter(|n| n.starts_with("org.mpris.MediaPlayer2."))
|
||||
{
|
||||
let Some((status, title, artist)) = read_player(conn, name) else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -160,7 +163,10 @@ fn read_player(
|
|||
.to_string();
|
||||
let mut title = None;
|
||||
let mut artist = None;
|
||||
if let Some(metadata) = dict.get("Metadata").and_then(|v| v.downcast_ref::<Dict>().ok()) {
|
||||
if let Some(metadata) = dict
|
||||
.get("Metadata")
|
||||
.and_then(|v| v.downcast_ref::<Dict>().ok())
|
||||
{
|
||||
title = metadata
|
||||
.get::<&str, &str>(&"xesam:title")
|
||||
.ok()
|
||||
|
|
@ -194,12 +200,8 @@ fn read_player(
|
|||
/// capped; a Playing player with neither still yields the player name (or
|
||||
/// `"Playing"`) so it is not outranked by a later titled Paused player.
|
||||
fn format_now_playing(title: Option<&str>, artist: Option<&str>, player: &str) -> String {
|
||||
let title = title
|
||||
.map(sanitize_mpris_field)
|
||||
.filter(|s| !s.is_empty());
|
||||
let artist = artist
|
||||
.map(sanitize_mpris_field)
|
||||
.filter(|s| !s.is_empty());
|
||||
let title = title.map(sanitize_mpris_field).filter(|s| !s.is_empty());
|
||||
let artist = artist.map(sanitize_mpris_field).filter(|s| !s.is_empty());
|
||||
let line = match (title, artist) {
|
||||
(Some(t), Some(a)) => format!("{t} — {a}"),
|
||||
(Some(t), None) => t,
|
||||
|
|
@ -253,7 +255,11 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
|
|||
"GetDisplayDevice",
|
||||
&(),
|
||||
)
|
||||
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedObjectPath>())
|
||||
.and_then(|reply| {
|
||||
reply
|
||||
.body()
|
||||
.deserialize::<zbus::zvariant::OwnedObjectPath>()
|
||||
})
|
||||
.map_err(|_| ())?;
|
||||
let props = conn
|
||||
.call_method(
|
||||
|
|
@ -263,11 +269,7 @@ fn poll_battery_on(conn: &zbus::blocking::Connection) -> Result<String, ()> {
|
|||
"GetAll",
|
||||
&("org.freedesktop.UPower.Device",),
|
||||
)
|
||||
.and_then(|reply| {
|
||||
reply
|
||||
.body()
|
||||
.deserialize::<HashMap<String, OwnedValue>>()
|
||||
})
|
||||
.and_then(|reply| reply.body().deserialize::<HashMap<String, OwnedValue>>())
|
||||
.map_err(|_| ())?;
|
||||
// DisplayDevice always exists; without a battery IsPresent is false
|
||||
// and Percentage is often 0. Missing IsPresent is treated as absent.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue