srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/wayland/Cargo.toml10
-rw-r--r--crates/wayland/src/native_lock.rs211
-rw-r--r--docs/DEFAULTS.md23
-rw-r--r--docs/TODO.md44
4 files changed, 280 insertions, 8 deletions
diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml
index 3e2c40d..7ca69e5 100644
--- a/crates/wayland/Cargo.toml
+++ b/crates/wayland/Cargo.toml
@@ -52,6 +52,12 @@ xcursor = "0.3.11"
# other "pinned to smithay's own version" dependency above - both need to
# see one identical `Keysym` type, not two incompatible ones.
xkbcommon = { version = "0.8", features = ["wayland"] }
+# Raster decoding for the lock screen's user avatar (`~/.face` and the
+# AccountsService icon are conventionally JPEG or PNG, not SVG, so `resvg`
+# below cannot read them). Default features off: this needs two codecs, not
+# the whole format zoo, on a compositor that has to build on a low-spec
+# machine.
+image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] }
resvg = "0.48.1"
usvg = "0.48.1"
tiny-skia = "0.12.0"
@@ -84,3 +90,7 @@ features = [
"wayland_frontend",
"xwayland",
]
+
+[dev-dependencies]
+# Avatar decoding tests write a real image to a throwaway directory.
+tempfile = "3"
diff --git a/crates/wayland/src/native_lock.rs b/crates/wayland/src/native_lock.rs
index 96e4ac9..b0a5c44 100644
--- a/crates/wayland/src/native_lock.rs
+++ b/crates/wayland/src/native_lock.rs
@@ -685,6 +685,97 @@ fn local_time_now() -> (i32, i32, i32, i32, i32, i32) {
/// `round_bottom_corners` cut a rect's *corners* to an arc; neither fills
/// a standalone circle, so this is a small, self-contained addition
/// rather than a reuse of either).
+/// The user's own avatar picture, decoded and scaled to fill a circle of
+/// `radius`, as straight BGRA. `None` when there is no avatar to show or it
+/// cannot be read.
+///
+/// Looked for in the conventional places, most specific first:
+/// `~/.face`, `~/.face.icon`, then AccountsService's own per-user icon,
+/// which is where GNOME and KDE store the picture set through their
+/// settings UI. These are conventionally JPEG or PNG rather than SVG, which
+/// is why this needs a raster decoder rather than the `resvg` path the
+/// desktop icons use.
+///
+/// Scaled to *cover* the circle rather than fit inside it: a portrait
+/// letterboxed into a round frame looks like a mistake, and every desktop
+/// that shows one crops instead. The shorter side is matched to the
+/// diameter and the longer one centre-cropped.
+fn load_avatar(radius: i32) -> Option<Vec<u8>> {
+ let home = std::env::var("HOME").ok()?;
+ let user = std::env::var("USER").unwrap_or_default();
+ decode_avatar(&avatar_path(&home, &user)?, radius)
+}
+
+/// The first avatar file that exists, most specific first. Split from
+/// `load_avatar` so the search order and the decoding can each be tested
+/// without touching process-wide environment variables.
+fn avatar_path(home: &str, user: &str) -> Option<std::path::PathBuf> {
+ [
+ std::path::PathBuf::from(home).join(".face"),
+ std::path::PathBuf::from(home).join(".face.icon"),
+ std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user),
+ ]
+ .into_iter()
+ .find(|p| p.is_file())
+}
+
+fn decode_avatar(path: &std::path::Path, radius: i32) -> Option<Vec<u8>> {
+ let decoded = match image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode() {
+ Ok(image) => image,
+ Err(e) => {
+ log::debug!("lock: couldn't decode avatar {path:?} ({e}); falling back to the initial");
+ return None;
+ }
+ };
+ let diameter = (radius * 2).max(1) as u32;
+ // `resize_to_fill` is exactly the cover-and-centre-crop described above.
+ let scaled = decoded.resize_to_fill(diameter, diameter, image::imageops::FilterType::Lanczos3).to_rgba8();
+ let mut out = vec![0u8; (diameter * diameter * 4) as usize];
+ for (i, px) in scaled.pixels().enumerate() {
+ let [r, g, b, a] = px.0;
+ // Straight BGRA, the same convention `rgb_to_bgra` uses everywhere
+ // else in this codebase.
+ out[i * 4..i * 4 + 4].copy_from_slice(&[b, g, r, a]);
+ }
+ Some(out)
+}
+
+/// Blits `avatar` (a `2*radius` square, straight BGRA) into `buf` centred on
+/// `(cx, cy)`, masked to a circle with a one-pixel-soft edge so it does not
+/// read as a jagged cut-out.
+fn blit_avatar_circle(buf: &mut [u8], width: usize, height: usize, cx: i32, cy: i32, radius: i32, avatar: &[u8]) {
+ let diameter = radius * 2;
+ for row in 0..diameter {
+ for col in 0..diameter {
+ let (dx, dy) = (col - radius, row - radius);
+ let dist = ((dx * dx + dy * dy) as f32).sqrt();
+ // Feathered over the outermost pixel rather than a hard test, so
+ // the circle's edge is not visibly stepped.
+ let coverage = (radius as f32 - dist).clamp(0.0, 1.0);
+ if coverage <= 0.0 {
+ continue;
+ }
+ let (x, y) = (cx - radius + col, cy - radius + row);
+ if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
+ continue;
+ }
+ let src = ((row * diameter + col) * 4) as usize;
+ let dst = (y as usize * width + x as usize) * 4;
+ let alpha = (avatar[src + 3] as f32 / 255.0) * coverage;
+ if alpha <= 0.0 {
+ continue;
+ }
+ for channel in 0..3 {
+ let s = avatar[src + channel] as f32;
+ let d = buf[dst + channel] as f32;
+ buf[dst + channel] = (s * alpha + d * (1.0 - alpha)).round().clamp(0.0, 255.0) as u8;
+ }
+ let existing = buf[dst + 3] as f32 / 255.0;
+ buf[dst + 3] = ((alpha + existing * (1.0 - alpha)) * 255.0).round().clamp(0.0, 255.0) as u8;
+ }
+ }
+}
+
fn fill_circle_on_transparent(buf: &mut [u8], width: usize, height: usize, cx: i32, cy: i32, radius: i32, color: (u8, u8, u8)) {
for y in (cy - radius - 1).max(0)..(cy + radius + 1).min(height as i32) {
for x in (cx - radius - 1).max(0)..(cx + radius + 1).min(width as i32) {
@@ -753,13 +844,22 @@ fn render_header_box(native: &NativeLock, theme: &srdwm_core::LockConfig) -> (Ve
draw_centered(&mut buf, WIDTH, HEIGHT, &font, &date_str, 84.0, DATE_SIZE, theme.text_color);
let avatar_cy = 84.0 + 24.0 + AVATAR_RADIUS as f32;
- fill_circle_on_transparent(&mut buf, WIDTH, HEIGHT, WIDTH as i32 / 2, avatar_cy as i32, AVATAR_RADIUS, theme.avatar_bg);
- if let Some(font) = &font {
- let initial = native.username.chars().next().unwrap_or('?').to_ascii_uppercase();
- let (metrics, coverage) = font.rasterize(initial, AVATAR_RADIUS as f32);
- let glyph_x = WIDTH as i32 / 2 - metrics.width as i32 / 2;
- let glyph_y = avatar_cy as i32 - metrics.height as i32 / 2;
- blit_glyph_on_transparent(&mut buf, WIDTH, HEIGHT, glyph_x, glyph_y, &metrics, &coverage, theme.text_color);
+ // The user's real picture when there is one, the coloured initial only
+ // as a fallback. `~/.face` is the long-standing convention and was
+ // simply never read: this drew the initial unconditionally, so a
+ // machine with an avatar set still showed a letter.
+ match load_avatar(AVATAR_RADIUS) {
+ Some(avatar) => blit_avatar_circle(&mut buf, WIDTH, HEIGHT, WIDTH as i32 / 2, avatar_cy as i32, AVATAR_RADIUS, &avatar),
+ None => {
+ fill_circle_on_transparent(&mut buf, WIDTH, HEIGHT, WIDTH as i32 / 2, avatar_cy as i32, AVATAR_RADIUS, theme.avatar_bg);
+ if let Some(font) = &font {
+ let initial = native.username.chars().next().unwrap_or('?').to_ascii_uppercase();
+ let (metrics, coverage) = font.rasterize(initial, AVATAR_RADIUS as f32);
+ let glyph_x = WIDTH as i32 / 2 - metrics.width as i32 / 2;
+ let glyph_y = avatar_cy as i32 - metrics.height as i32 / 2;
+ blit_glyph_on_transparent(&mut buf, WIDTH, HEIGHT, glyph_x, glyph_y, &metrics, &coverage, theme.text_color);
+ }
+ }
}
let username_y = avatar_cy + AVATAR_RADIUS as f32 + 24.0;
@@ -921,6 +1021,16 @@ const fn wide_key(lower: &'static str, upper: &'static str, name: &'static str,
/// A plain, real, usable QWERTY-shaped layout - not a full XKB layout
/// translation (that needs real integration with this session's own
/// keymap, a separate and much larger piece of work), but every letter,
+/// digit and every ASCII punctuation character a US layout can type.
+///
+/// The punctuation is not decoration. This keyboard exists for a session
+/// with no reachable physical keyboard, and it previously offered only the
+/// digits' own shifted symbols (`!` through `)`) - so a password
+/// containing any of `-_=+[]{}\\|;:'\",.<>/?~` could not be entered at all,
+/// and the only way out of the lock screen was a keyboard the user did not
+/// have. Every ASCII character now has a key, shifted or unshifted.
+///
+/// Original doc continues: every letter,
/// digit, the digit row's own shifted symbols (covering the punctuation a
/// real password most commonly needs), Backspace, Return, Shift, and
/// Space. Scoped deliberately: a touchscreen session with no physical
@@ -939,6 +1049,8 @@ fn keyboard_rows() -> [Vec<KeySpec>; 5] {
key("8", "*"),
key("9", "("),
key("0", ")"),
+ key("-", "_"),
+ key("=", "+"),
wide_key("Back", "Back", "BackSpace", 1.6),
],
vec![
@@ -952,6 +1064,9 @@ fn keyboard_rows() -> [Vec<KeySpec>; 5] {
key("i", "I"),
key("o", "O"),
key("p", "P"),
+ key("[", "{"),
+ key("]", "}"),
+ key("\\", "|"),
],
vec![
key("a", "A"),
@@ -963,6 +1078,8 @@ fn keyboard_rows() -> [Vec<KeySpec>; 5] {
key("j", "J"),
key("k", "K"),
key("l", "L"),
+ key(";", ":"),
+ key("'", "\""),
wide_key("Enter", "Enter", "Return", 1.6),
],
vec![
@@ -974,8 +1091,11 @@ fn keyboard_rows() -> [Vec<KeySpec>; 5] {
key("b", "B"),
key("n", "N"),
key("m", "M"),
+ key(",", "<"),
+ key(".", ">"),
+ key("/", "?"),
],
- vec![wide_key("Space", "Space", "space", 6.0)],
+ vec![key("`", "~"), wide_key("Space", "Space", "space", 6.0)],
]
}
@@ -1085,6 +1205,81 @@ mod tests {
}
#[test]
+ fn an_avatar_is_decoded_and_scaled_to_the_circle() {
+ let dir = tempfile::tempdir().unwrap();
+ let face = dir.path().join(".face");
+ // A real encoded image on disk, not a stub - the point is that the
+ // decode path works, and `~/.face` is conventionally a photo.
+ // Saved with an explicit format: `.face` carries no extension for
+ // `save` to infer from, which is exactly why `decode_avatar` sniffs
+ // the content (`with_guessed_format`) rather than trusting the name.
+ image::RgbImage::from_fn(120, 90, |x, _| image::Rgb([x as u8, 0x40, 0x80]))
+ .save_with_format(&face, image::ImageFormat::Png)
+ .unwrap();
+
+ let radius = 28;
+ let pixels = decode_avatar(&face, radius).expect("a real image must decode");
+ let diameter = (radius * 2) as usize;
+ assert_eq!(pixels.len(), diameter * diameter * 4, "scaled to fill the circle's bounding square");
+ }
+
+ /// The real file on this machine, when there is one - a JPEG, which is
+ /// the format `~/.face` conventionally is and the reason a raster
+ /// decoder was needed at all. Skipped where no avatar is set.
+ #[test]
+ fn the_real_user_avatar_decodes_if_one_is_set() {
+ let Ok(home) = std::env::var("HOME") else { return };
+ let user = std::env::var("USER").unwrap_or_default();
+ let Some(path) = avatar_path(&home, &user) else { return };
+ let radius = 28;
+ let pixels = decode_avatar(&path, radius).unwrap_or_else(|| panic!("{path:?} exists but did not decode"));
+ assert_eq!(pixels.len(), ((radius * 2) * (radius * 2) * 4) as usize);
+ }
+
+ #[test]
+ fn a_missing_or_unreadable_avatar_falls_back_rather_than_failing() {
+ let dir = tempfile::tempdir().unwrap();
+ assert!(avatar_path(dir.path().to_str().unwrap(), "nobody-here").is_none(), "nothing to find");
+
+ let junk = dir.path().join(".face");
+ std::fs::write(&junk, b"this is not an image").unwrap();
+ assert_eq!(decode_avatar(&junk, 28), None, "a corrupt file must fall back, not panic");
+ }
+
+ #[test]
+ fn dot_face_is_preferred_over_the_other_locations() {
+ let dir = tempfile::tempdir().unwrap();
+ let face = dir.path().join(".face");
+ let icon = dir.path().join(".face.icon");
+ std::fs::write(&icon, b"x").unwrap();
+ std::fs::write(&face, b"x").unwrap();
+ assert_eq!(avatar_path(dir.path().to_str().unwrap(), "u"), Some(face));
+ }
+
+ /// A lock screen's on-screen keyboard is the only way in for a session
+ /// with no physical keyboard, so a password character it cannot type is
+ /// a lockout. Every printable ASCII character must be reachable.
+ #[test]
+ fn every_printable_ascii_character_can_be_typed() {
+ let mut typable: std::collections::HashSet<char> = std::collections::HashSet::new();
+ for row in keyboard_rows() {
+ for key in row {
+ for text in [key.lower, key.upper] {
+ // Space is a named key; its label is a word, not the
+ // character it produces.
+ if key.name == "space" {
+ typable.insert(' ');
+ } else if key.name.is_empty() {
+ typable.extend(text.chars());
+ }
+ }
+ }
+ }
+ let missing: Vec<char> = (0x20u8..0x7f).map(char::from).filter(|c| !typable.contains(c)).collect();
+ assert!(missing.is_empty(), "these characters cannot be typed on the lock screen: {missing:?}");
+ }
+
+ #[test]
fn render_keyboard_rows_stay_within_the_reported_bitmap_width() {
let (_, (width, _), keys) = render_keyboard(&NativeLock::new(Default::default()), &srdwm_core::LockConfig::default());
assert!(!keys.is_empty());
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index 014c6f5..ded5b03 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -1107,3 +1107,26 @@ own `chrome/` directory uses for `userChrome-traditional.css` versus
Firefox is a separate surface again: it draws its window buttons itself and
takes them from `userChrome.css`, which needs
`toolkit.legacyUserProfileCustomizations.stylesheets` set to `true`.
+
+## Lock screen: avatar and on-screen keyboard
+
+**Avatar.** The lock screen shows the user's own picture, looked for in the
+conventional places, most specific first:
+
+1. `~/.face`
+2. `~/.face.icon`
+3. `/var/lib/AccountsService/icons/$USER` - where GNOME and KDE store the
+ picture set through their own settings UI
+
+These are conventionally JPEG or PNG. The image is scaled to *cover* the
+circle and centre-cropped, not letterboxed into it, and masked with a
+one-pixel-soft edge. With no avatar set, or a file that cannot be decoded,
+it falls back to the coloured circle with the user's initial
+(`theme.lock.avatar_bg`).
+
+**On-screen keyboard.** `theme.lock.show_keyboard` (default on) draws a
+QWERTY layout for a session with no reachable physical keyboard. Every
+printable ASCII character is typable, shifted or unshifted - letters,
+digits, and all of ``-_=+[]{}\|;:'",.<>/?~`` plus space. A test asserts
+the full range, because a lock screen that cannot type some character in
+the password is a lockout rather than an inconvenience.
diff --git a/docs/TODO.md b/docs/TODO.md
index 18e551a..e086889 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,49 @@
# TODO / planned features - master checklist
+## Lock screen: the avatar was never read, and the keyboard could not type most passwords (2026-08-28)
+
+Two questions, both real gaps.
+
+**`~/.face` was never read.** The file exists on this machine (a 300x300
+JPEG, dated 2026-07-22) and nothing in the codebase ever opened it - a grep
+for `.face`/`AccountsService`/`avatar_path` returned nothing. The lock
+screen drew a coloured circle with the user's initial unconditionally, so a
+machine with an avatar set still showed a letter.
+
+Now looked for as `~/.face`, then `~/.face.icon`, then
+`/var/lib/AccountsService/icons/$USER`, which is where GNOME and KDE keep
+the picture their settings UI sets. Scaled to *cover* the circle and
+centre-cropped rather than letterboxed - a portrait fitted inside a round
+frame reads as a mistake, and every desktop that shows one crops. Masked
+with a one-pixel-soft edge so it is not a jagged cut-out. Falls back to the
+initial when there is no avatar or the file will not decode.
+
+This needed a raster decoder: `~/.face` is JPEG and the only image code here
+was `resvg`, which is SVG-only. Added `image` with default features off and
+just `jpeg` and `png` - two codecs, not the whole format zoo, on a
+compositor that has to build on a low-spec machine.
+
+**The on-screen keyboard could not type most passwords.** It had the
+letters, the digits, and the digits' own shifted symbols (`!` through `)`)
+- and nothing else. No `-`, `_`, `.`, `/`, `=`, `[`, `]`, `;`, `'`, `,`,
+`\`, or backtick. For the case this keyboard exists for - a session with no
+reachable physical keyboard - a password containing any of those meant no
+way in at all. That is a lockout, not an inconvenience.
+
+Every printable ASCII character now has a key, and a test asserts exactly
+that over the whole `0x20..0x7f` range rather than spot-checking a few.
+
+**Verification.** The avatar path is covered by four tests including one
+that decodes the real `~/.face` on this machine through the same function
+the lock screen calls. The lock screen itself could not be screenshotted:
+locking a nested instance hits the pre-existing EGL context-loss crash this
+file already records, confirmed again here (`BAD_SURFACE` on
+`eglSwapBuffers`, then `eglCreatePlatformWindowSurfaceEXT` failing). That is
+environmental and predates this change; the on-screen appearance needs the
+real session.
+
+277 core tests, 165 wayland, 529 total, clippy clean.
+
## srdwm now generates the GTK button stylesheet too (2026-08-28)
The previous entry recorded a deliberate decision *not* to write GTK CSS,