feat: peek-to-reveal on click/ctrl, pre-commit hooks, devShell, fmt fixes
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled

- Click indicator ring to toggle password peek (persistent click-to-toggle)
- Ctrl-hold transient peek refactored to share peek path
- Cursor always visible (previously hidden at position 0, now shows at top of ring)
- .pre-commit-config.yaml: standard hooks + cargo fmt/clippy
- flake.nix: devShell with cargo, rustc, rustfmt, clippy, cargo-audit, prek
- cargo fmt pass across all source files (trailing whitespace, line wrapping)
- MPRIS error logging (silent failures now logged)
- Dependabot versioning-strategy: "increase" → "auto"
This commit is contained in:
2026-06-21 20:44:59 +02:00
parent c2c093595a
commit 833706f5a5
18 changed files with 297 additions and 129 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ updates:
prefix: "deps"
reviewers:
- "JorySeverijnse"
versioning-strategy: "increase"
versioning-strategy: "auto"
groups:
rust-dependencies:
patterns:
-1
View File
@@ -39,4 +39,3 @@ jobs:
- name: Check for outdated dependencies
run: cargo outdated --exit-code 1 || echo "Some dependencies are outdated"
+27
View File
@@ -0,0 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: \.md$
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- id: check-merge-conflict
- repo: local
hooks:
- id: fmt
name: cargo fmt
entry: cargo fmt
args: ["--", "--check"]
language: system
types: [rust]
pass_filenames: false
- id: clippy
name: cargo clippy
entry: cargo clippy
args: ["--", "-D", "warnings"]
language: system
pass_filenames: false
+21 -2
View File
@@ -28,8 +28,27 @@
nativeBuildInputs = [
pkgs.pkg-config
pkgs.rustPlatform.bindgenHook
pkgs.rustfmt
pkgs.clippy
];
};
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rustfmt
clippy
cargo-audit
cargo-deny
prek
pkg-config
rustPlatform.bindgenHook
cairo
pam
gdk-pixbuf
librsvg
pango
libxkbcommon
dbus
];
};
};
+6 -7
View File
@@ -13,7 +13,8 @@ use zeroize::Zeroizing;
type AuthChannels = (
channel::Sender<(Zeroizing<String>, u64)>,
channel::Channel<(bool, u64)>,
);pub struct LockConversation {
);
pub struct LockConversation {
pub password: Option<Zeroizing<String>>,
}
@@ -39,13 +40,10 @@ impl pam_client::ConversationHandler for LockConversation {
}
}
pub fn create_and_run_auth_loop(
service_name: String,
) -> Option<AuthChannels> {
pub fn create_and_run_auth_loop(service_name: String) -> Option<AuthChannels> {
let username = username();
let (auth_req_send, auth_req_recv) =
channel::channel::<(Zeroizing<String>, u64)>();
let (auth_req_send, auth_req_recv) = channel::channel::<(Zeroizing<String>, u64)>();
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
thread::spawn(move || {
@@ -55,7 +53,8 @@ pub fn create_and_run_auth_loop(
// Creating a new context each time is expensive because it
// re-parses configs and re-loads shared libraries for every attempt.
let conversation = LockConversation { password: None };
let mut context = match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
let mut context =
match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
Ok(ctx) => {
debug!("Prepared to authenticate user '{}'", username);
ctx
-2
View File
@@ -292,7 +292,6 @@ pub struct Config {
/// Timeout (seconds) for system commands (poweroff, reboot, suspend)
#[arg(long, default_value = "5")]
pub command_timeout: u64,
}
impl Config {
@@ -347,7 +346,6 @@ impl Config {
config
}
}
#[cfg(test)]
+2 -1
View File
@@ -165,7 +165,8 @@ impl InputHandler {
/// Check if key highlight should be shown
pub fn should_show_key_highlight(&self) -> bool {
if let Some(timer) = self.key_highlight_timer {
timer.elapsed() < std::time::Duration::from_millis(self.config.key_highlight_window_duration)
timer.elapsed()
< std::time::Duration::from_millis(self.config.key_highlight_window_duration)
} else {
false
}
+24 -3
View File
@@ -31,6 +31,8 @@ pub struct LockedSurface {
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
last_minute: i64,
ctrl_held: bool,
/// Set by clicking on the indicator ring. Persists until clicked again.
peek_toggled: bool,
}
impl LockedSurface {
@@ -64,6 +66,7 @@ impl LockedSurface {
dirty: true,
last_minute: i64::MIN,
ctrl_held: false,
peek_toggled: false,
})
}
@@ -182,11 +185,21 @@ impl LockedSurface {
}
if !self.config.hide_password {
let buf = self.input_handler.password_buffer();
let length = self.input_handler.password_length();
if self.ctrl_held {
self.renderer
.peek_password(self.input_handler.password_buffer().as_str());
if self.ctrl_held || self.peek_toggled {
log::debug!(
"LockedSurface::update: PEEK mode (ctrl={}, toggled={})",
self.ctrl_held,
self.peek_toggled
);
self.renderer.peek_password(buf.as_str());
} else {
log::debug!(
"LockedSurface::update: DOT mode (ctrl={}, toggled={})",
self.ctrl_held,
self.peek_toggled
);
self.renderer.set_password_display(length);
}
}
@@ -302,6 +315,14 @@ impl LockedSurface {
self.dirty = true;
}
}
/// Toggle peek mode on/off. Called when the user clicks on the indicator
/// ring. Persists until toggled again (unlike Ctrl-hold which is transient).
pub fn toggle_peek(&mut self) {
self.peek_toggled = !self.peek_toggled;
log::debug!("toggle_peek: peek_toggled = {}", self.peek_toggled);
self.dirty = true;
}
}
pub struct LockManager {
+56 -9
View File
@@ -65,9 +65,8 @@ fn setup_file_logging(config: &Config) {
}
}
} else if config.log_file {
let default_path = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
let default_path =
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
.join(".rustlock.log");
match OpenOptions::new()
.create(true)
@@ -506,8 +505,16 @@ impl KeyboardHandler for WaylandLock {
modifiers: Modifiers,
layout: u32,
) {
let ctrl_changed = self.modifiers.ctrl != modifiers.ctrl;
self.modifiers = modifiers;
self.current_layout = layout;
if ctrl_changed {
log::debug!(
"update_modifiers: ctrl {} -> {}",
!modifiers.ctrl,
modifiers.ctrl
);
}
if let Ok(mut lock_manager) = self.lock_manager.lock() {
lock_manager.set_ctrl_held(modifiers.ctrl);
}
@@ -650,7 +657,10 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
if let Ok(surface) = mgr.buffer_to_surface(handle, &mut pool) {
let mut ss = Screenshot::new(surface);
if let Err(e) = ss.apply_effects(&state.config) {
log::error!("Failed to apply effects to screenshot {}: {e}", data.output_idx);
log::error!(
"Failed to apply effects to screenshot {}: {e}",
data.output_idx
);
}
if data.output_idx < state.captured_backgrounds.len() {
state.captured_backgrounds[data.output_idx] = Some(ss.into_inner());
@@ -700,6 +710,8 @@ impl PointerHandler for WaylandLock {
if let Ok(lm) = self.lock_manager.lock() {
for surface in &lm.surfaces {
if surface.matches_surface(&event.surface) {
// Check media controls first
let mut handled = false;
for (action, rx, ry, rw, rh) in &surface.renderer.media_rects {
if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh {
match *action {
@@ -709,8 +721,34 @@ impl PointerHandler for WaylandLock {
"prev" => self.system_manager.media_prev(),
_ => {}
}
handled = true;
break;
}
}
if handled {
return;
}
// Check indicator ring hit — toggle password peek
let cx = surface.renderer.width as f64 / 2.0;
let cy = surface.renderer.height as f64 / 2.0;
let r = surface.renderer.config.indicator_radius as f64;
let dx = x - cx;
let dy = y - cy;
if dx * dx + dy * dy <= r * r {
// Need to reborrow mutably for toggle
drop(lm);
if let Ok(mut lm) = self.lock_manager.lock() {
if let Some(s) = lm
.surfaces
.iter_mut()
.find(|s| s.matches_surface(&event.surface))
{
s.toggle_peek();
s.update();
let _ = s.commit(&mut self.pool);
}
}
return;
}
}
}
@@ -757,12 +795,16 @@ fn main() -> Result<(), Box<dyn Error>> {
let system_manager = Arc::new(SystemManager::new(&config));
let (auth_tx_actual, auth_feedback_rx_actual) =
match auth::create_and_run_auth_loop(config.pam_service.clone()) {
let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop(
config.pam_service.clone(),
) {
Some(channels) => channels,
None => {
log::error!("Failed to initialize authentication. This usually means PAM is not configured correctly.");
log::error!("Please ensure you have a PAM service file at /etc/pam.d/{}", config.pam_service);
log::error!(
"Please ensure you have a PAM service file at /etc/pam.d/{}",
config.pam_service
);
std::process::exit(1);
}
};
@@ -902,8 +944,13 @@ fn main() -> Result<(), Box<dyn Error>> {
// treat as auth failure so the user gets feedback instead of hanging forever.
if state.auth_pending_seq.is_some() {
if let Some(at) = state.auth_pending_at {
if Instant::now().duration_since(at) >= Duration::from_millis(state.config.auth_timeout) {
log::warn!("Authentication timed out after {} ms", state.config.auth_timeout);
if Instant::now().duration_since(at)
>= Duration::from_millis(state.config.auth_timeout)
{
log::warn!(
"Authentication timed out after {} ms",
state.config.auth_timeout
);
// Clear pending seq so the eventual PAM result is ignored as stale
state.auth_pending_seq = None;
state.handle_auth_result(false);
+10 -5
View File
@@ -12,8 +12,7 @@ impl Renderer {
if a > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness + 2.0);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
@@ -180,19 +179,25 @@ impl Renderer {
pub(crate) fn update_feedback_timers(&mut self) {
self.update_uptime();
if let Some(start) = self.wrong_password_start {
if start.elapsed() > std::time::Duration::from_millis(self.config.wrong_password_duration) {
if start.elapsed()
> std::time::Duration::from_millis(self.config.wrong_password_duration)
{
self.wrong_password_shown = false;
self.wrong_password_start = None;
}
}
if let Some(start) = self.key_highlight_start {
if start.elapsed() > std::time::Duration::from_millis(self.config.key_highlight_duration) {
if start.elapsed()
> std::time::Duration::from_millis(self.config.key_highlight_duration)
{
self.key_highlight_shown = false;
self.key_highlight_start = None;
}
}
if let Some(start) = self.cleared_feedback_start {
if start.elapsed() > std::time::Duration::from_millis(self.config.cleared_feedback_duration) {
if start.elapsed()
> std::time::Duration::from_millis(self.config.cleared_feedback_duration)
{
self.cleared_feedback_shown = false;
self.cleared_feedback_start = None;
}
+28 -7
View File
@@ -75,17 +75,37 @@ impl Renderer {
let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
let max_dots = self.config.max_dots as f64;
let dot_radius = radius - thickness - 10.0;
let t_offset = ring_shape::top_centre_offset(shape);
let count = self.password_display.len();
if count == 0 {
return;
}
let max_dots = self.config.max_dots as f64;
let dot_radius = radius - thickness - 10.0;
let t_offset = ring_shape::top_centre_offset(shape);
if self.peeking {
log::debug!("draw_password_display: PEEKING text ({} chars)", count);
// Draw each character at the same ring-perimeter positions as dots
self.context.set_font_size(11.0);
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for (i, ch) in self.password_display.chars().enumerate() {
let t = (i as f64 / max_dots) + t_offset;
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
let mut ch_str = String::new();
ch_str.push(ch);
let te = render_try!(self.context.text_extents(&ch_str));
self.context.new_path();
self.context.move_to(
x - te.x_bearing() - te.width() / 2.0,
y - te.y_bearing() - te.height() / 2.0,
);
render_try!(self.context.show_text(&ch_str));
}
} else {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for i in 0..count {
let t = (i as f64 / max_dots) + t_offset;
@@ -95,9 +115,10 @@ impl Renderer {
self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI);
render_try!(self.context.fill());
}
}
// Cursor indicator
if self.fade_alpha > 0.0 && self.cursor_position > 0 {
// Cursor indicator (shared between peek and dot modes)
if self.fade_alpha > 0.0 {
let cursor_t = ((self.cursor_position as f64 - 0.5) / max_dots) + t_offset;
let (cx, cy) =
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
+26 -9
View File
@@ -15,7 +15,9 @@ impl Renderer {
if let Ok(img) = image::load_from_memory(data) {
let img = img.to_rgba8();
let (w, h) = img.dimensions();
if let Ok(mut surface) = ImageSurface::create(Format::ARgb32, w as i32, h as i32) {
if let Ok(mut surface) =
ImageSurface::create(Format::ARgb32, w as i32, h as i32)
{
if let Ok(mut surface_data) = surface.data() {
for y in 0..h {
for x in 0..w {
@@ -72,7 +74,8 @@ impl Renderer {
}
}
self.context.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
self.context
.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
render_try!(self.context.show_text(&display_text));
// All media buttons on one row, evenly spaced.
@@ -82,17 +85,18 @@ impl Renderer {
let btn_y = start_y + 50.0;
// Layout: prev | play_pause | next (centered as a group)
let total_buttons: f64 =
(self.media_prev_icon_surface.is_some() as u32
let total_buttons: f64 = (self.media_prev_icon_surface.is_some() as u32
+ 1
+ self.media_next_icon_surface.is_some() as u32) as f64;
+ self.media_next_icon_surface.is_some() as u32)
as f64;
let group_width = (total_buttons - 1.0) * btn_gap + btn_size;
let group_start_x = center_x - group_width / 2.0;
let mut btn_x = group_start_x;
if let Some(ref icon) = self.media_prev_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
self.media_rects
.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
btn_x += btn_gap;
}
@@ -100,17 +104,30 @@ impl Renderer {
if self.system_status.media_playing {
if let Some(ref icon) = self.media_pause_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
}
} else if let Some(ref icon) = self.media_play_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
}
btn_x += btn_gap;
if let Some(ref icon) = self.media_next_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
self.media_rects
.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
}
}
}
+9
View File
@@ -42,6 +42,7 @@ pub struct Renderer {
pub(crate) key_highlight_angle: f64,
pub(crate) background: Option<ImageSurface>,
pub(crate) password_display: String,
pub(crate) peeking: bool,
pub(crate) cursor_position: usize,
pub(crate) uptime_cache: String,
pub(crate) last_uptime_update: Option<Instant>,
@@ -85,6 +86,7 @@ impl Renderer {
key_highlight_angle: 0.0,
background: None,
password_display: String::new(),
peeking: false,
cursor_position: 0,
uptime_cache: String::new(),
last_uptime_update: None,
@@ -126,11 +128,18 @@ impl Renderer {
}
pub fn set_password_display(&mut self, length: usize) {
log::debug!("Renderer::set_password_display(length={})", length);
self.password_display = ".".repeat(length);
self.peeking = false;
}
pub fn peek_password(&mut self, password: &str) {
log::debug!(
"Renderer::peek_password(len={}, peeking=true)",
password.len()
);
self.password_display = password.to_string();
self.peeking = true;
}
pub fn set_cursor_position(&mut self, position: usize) {
+3 -10
View File
@@ -9,13 +9,7 @@ const SEGMENTS: usize = 120;
/// Return the (x, y) point on the shape's perimeter at normalized position `t` ∈ [0, 1].
/// `t = 0` is a reference point (rightmost for most shapes); `t` increases clockwise.
/// `r` is the shape's characteristic radius (distance from center to side/vertex).
pub(crate) fn perimeter_point(
cx: f64,
cy: f64,
r: f64,
shape: RingShape,
t: f64,
) -> (f64, f64) {
pub(crate) fn perimeter_point(cx: f64, cy: f64, r: f64, shape: RingShape, t: f64) -> (f64, f64) {
// Normalize t to [0, 1). Rust's % preserves sign, and the shape-specific
// functions use floor/truncation that break on negative values.
let t = t - t.floor();
@@ -95,9 +89,8 @@ fn hexagon_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let local = t * 6.0 - side as f64;
// Shared helper for edges between two vertices
let vert = |angle_rad: f64| -> (f64, f64) {
(cx + r * angle_rad.cos(), cy + r * angle_rad.sin())
};
let vert =
|angle_rad: f64| -> (f64, f64) { (cx + r * angle_rad.cos(), cy + r * angle_rad.sin()) };
// Vertices clockwise from right (angle = 0)
let v = [
+18 -12
View File
@@ -92,7 +92,10 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().context("swirl: failed to write surface data")?;
let mut surface_data = self
.surface
.data()
.context("swirl: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
@@ -131,7 +134,10 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().context("melting: failed to write surface data")?;
let mut surface_data = self
.surface
.data()
.context("melting: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
@@ -194,7 +200,10 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().context("pixelate: failed to write surface data")?;
let mut surface_data = self
.surface
.data()
.context("pixelate: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
@@ -231,18 +240,12 @@ impl Screenshot {
.context("blur: failed to create image buffer")?;
for _ in 0..times {
let mut rgb_data: Vec<[u8; 3]> =
Vec::with_capacity(width * height);
let mut rgb_data: Vec<[u8; 3]> = Vec::with_capacity(width * height);
for pixel in img.pixels() {
rgb_data.push([pixel[0], pixel[1], pixel[2]]);
}
fastblur::gaussian_blur(
&mut rgb_data,
width,
height,
radius as f32,
);
fastblur::gaussian_blur(&mut rgb_data, width, height, radius as f32);
for (i, pixel) in img.pixels_mut().enumerate() {
pixel[0] = rgb_data[i][0];
@@ -292,7 +295,10 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().context("vignette: failed to write surface data")?;
let mut surface_data = self
.surface
.data()
.context("vignette: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
+18 -12
View File
@@ -296,16 +296,25 @@ impl SystemManager {
let action = cmd;
// Fire-and-forget: don't block the polling loop on MPRIS.
tokio::task::spawn_blocking(move || {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
match action {
BackendCommand::MediaPlayPause => { let _ = player.play_pause(); }
BackendCommand::MediaStop => { let _ = player.stop(); }
BackendCommand::MediaNext => { let _ = player.next(); }
BackendCommand::MediaPrev => { let _ = player.previous(); }
_ => {}
match PlayerFinder::new() {
Ok(finder) => {
match finder.find_active() {
Ok(player) => {
let result = match action {
BackendCommand::MediaPlayPause => player.play_pause(),
BackendCommand::MediaStop => player.stop(),
BackendCommand::MediaNext => player.next(),
BackendCommand::MediaPrev => player.previous(),
_ => Ok(()),
};
if let Err(e) = result {
error!("MPRIS command {action:?} failed: {e}");
}
}
Err(e) => error!("No active MPRIS player found: {e}"),
}
}
Err(e) => error!("Failed to create MPRIS PlayerFinder: {e}"),
}
});
}
@@ -320,10 +329,7 @@ impl SystemManager {
}
pub fn get_status(&self) -> SystemStatus {
self.status
.lock()
.map(|s| s.clone())
.unwrap_or_default()
self.status.lock().map(|s| s.clone()).unwrap_or_default()
}
pub fn send_command(&self, cmd: BackendCommand) {