- init_fingerprint_async: hoist username before the await so a concurrent connect_monitor signal (hotplug / suspend-resume) cannot cause a RefCell panic. Re-borrow after the await for signal wiring. - set_avatar_from_file: decode via gio::File::read_future + Pixbuf::from_stream_at_scale_future so the GTK main thread stays responsive during monitor hotplug. Default icon shown while loading.
807 lines
29 KiB
Rust
807 lines
29 KiB
Rust
// ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons.
|
|
// ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow.
|
|
|
|
use gdk4 as gdk;
|
|
use gdk_pixbuf::Pixbuf;
|
|
use glib::clone;
|
|
use graphene_rs as graphene;
|
|
use gtk4::prelude::*;
|
|
use gtk4::{self as gtk, gio};
|
|
use std::cell::{Cell, RefCell};
|
|
use std::path::Path;
|
|
use std::rc::Rc;
|
|
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::auth;
|
|
use crate::config::Config;
|
|
use crate::fingerprint::FingerprintListener;
|
|
use crate::i18n::{faillock_warning, load_strings, Strings};
|
|
use crate::power::{self, PowerError};
|
|
use crate::users;
|
|
|
|
/// Handles returned from create_lockscreen_window for post-creation wiring.
|
|
pub struct LockscreenHandles {
|
|
pub window: gtk::ApplicationWindow,
|
|
pub fp_label: gtk::Label,
|
|
pub password_entry: gtk::PasswordEntry,
|
|
pub unlock_callback: Rc<dyn Fn()>,
|
|
pub username: String,
|
|
state: Rc<RefCell<LockscreenState>>,
|
|
}
|
|
|
|
const AVATAR_SIZE: i32 = 128;
|
|
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
|
|
const PAM_TIMEOUT_SECS: u64 = 30;
|
|
|
|
/// Shared mutable state for the lockscreen.
|
|
struct LockscreenState {
|
|
failed_attempts: u32,
|
|
fp_listener_rc: Option<Rc<RefCell<FingerprintListener>>>,
|
|
}
|
|
|
|
/// Create a lockscreen window for a single monitor.
|
|
/// Fingerprint is not initialized here — use `wire_fingerprint()` after async init.
|
|
/// The `blur_cache` and `avatar_cache` are shared across monitors for multi-monitor
|
|
/// setups, avoiding redundant GPU renders and SVG rasterizations.
|
|
pub fn create_lockscreen_window(
|
|
bg_texture: Option<&gdk::Texture>,
|
|
config: &Config,
|
|
app: >k::Application,
|
|
unlock_callback: Rc<dyn Fn()>,
|
|
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
|
avatar_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
|
) -> LockscreenHandles {
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.build();
|
|
window.add_css_class("lockscreen");
|
|
|
|
let strings = load_strings(None);
|
|
let user = match users::get_current_user() {
|
|
Some(u) => u,
|
|
None => {
|
|
log::error!("Failed to get current user");
|
|
let fp_label = gtk::Label::new(None);
|
|
fp_label.set_visible(false);
|
|
return LockscreenHandles {
|
|
window,
|
|
fp_label,
|
|
password_entry: gtk::PasswordEntry::new(),
|
|
unlock_callback,
|
|
username: String::new(),
|
|
state: Rc::new(RefCell::new(LockscreenState {
|
|
failed_attempts: 0,
|
|
fp_listener_rc: None,
|
|
})),
|
|
};
|
|
}
|
|
};
|
|
|
|
let state = Rc::new(RefCell::new(LockscreenState {
|
|
failed_attempts: 0,
|
|
fp_listener_rc: None,
|
|
}));
|
|
|
|
// Root overlay for background + centered content
|
|
let overlay = gtk::Overlay::new();
|
|
window.set_child(Some(&overlay));
|
|
|
|
// Background wallpaper (if available — otherwise GTK background color shows through)
|
|
if let Some(texture) = bg_texture {
|
|
let background = create_background_picture(texture, config.background_blur, blur_cache);
|
|
overlay.set_child(Some(&background));
|
|
}
|
|
|
|
// Centered vertical box
|
|
let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
main_box.set_halign(gtk::Align::Center);
|
|
main_box.set_valign(gtk::Align::Center);
|
|
overlay.add_overlay(&main_box);
|
|
|
|
// Login box
|
|
let login_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
login_box.set_halign(gtk::Align::Center);
|
|
login_box.add_css_class("login-box");
|
|
main_box.append(&login_box);
|
|
|
|
// Avatar
|
|
let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE);
|
|
avatar_frame.set_halign(gtk::Align::Center);
|
|
avatar_frame.set_overflow(gtk::Overflow::Hidden);
|
|
avatar_frame.add_css_class("avatar");
|
|
let avatar_image = gtk::Image::new();
|
|
avatar_image.set_pixel_size(AVATAR_SIZE);
|
|
avatar_frame.append(&avatar_image);
|
|
login_box.append(&avatar_frame);
|
|
|
|
// Load avatar — use shared cache to avoid redundant loading on multi-monitor setups.
|
|
// The cache is populated by the first monitor and reused by subsequent ones.
|
|
if let Some(ref cached) = *avatar_cache.borrow() {
|
|
avatar_image.set_paintable(Some(cached));
|
|
} else {
|
|
let avatar_path = users::get_avatar_path(&user.home, &user.username);
|
|
if let Some(path) = avatar_path {
|
|
set_avatar_from_file(&avatar_image, &path, avatar_cache);
|
|
} else {
|
|
set_default_avatar(&avatar_image, &window, avatar_cache);
|
|
}
|
|
}
|
|
|
|
// Username label
|
|
let username_label = gtk::Label::new(Some(&user.display_name));
|
|
username_label.add_css_class("username-label");
|
|
login_box.append(&username_label);
|
|
|
|
// Password entry
|
|
let password_entry = gtk::PasswordEntry::builder()
|
|
.placeholder_text(strings.password_placeholder)
|
|
.show_peek_icon(true)
|
|
.hexpand(true)
|
|
.build();
|
|
password_entry.add_css_class("password-entry");
|
|
login_box.append(&password_entry);
|
|
|
|
// Error label
|
|
let error_label = gtk::Label::new(None);
|
|
error_label.add_css_class("error-label");
|
|
error_label.set_visible(false);
|
|
login_box.append(&error_label);
|
|
|
|
// Fingerprint label — hidden until async fprintd init completes
|
|
let fp_label = gtk::Label::new(None);
|
|
fp_label.add_css_class("fingerprint-label");
|
|
fp_label.set_visible(false);
|
|
login_box.append(&fp_label);
|
|
|
|
// Confirm box area (for power confirm)
|
|
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
confirm_area.set_halign(gtk::Align::Center);
|
|
login_box.append(&confirm_area);
|
|
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
|
|
|
|
// Power buttons (bottom right)
|
|
let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
|
power_box.set_halign(gtk::Align::End);
|
|
power_box.set_valign(gtk::Align::End);
|
|
power_box.set_hexpand(true);
|
|
power_box.set_vexpand(true);
|
|
power_box.set_margin_end(16);
|
|
power_box.set_margin_bottom(16);
|
|
|
|
let reboot_btn = gtk::Button::new();
|
|
reboot_btn.set_icon_name("system-reboot-symbolic");
|
|
reboot_btn.add_css_class("power-button");
|
|
reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip));
|
|
reboot_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
#[weak]
|
|
error_label,
|
|
move |_| {
|
|
show_power_confirm(
|
|
strings.reboot_confirm,
|
|
power::reboot,
|
|
strings.reboot_failed,
|
|
strings,
|
|
&confirm_area,
|
|
&confirm_box,
|
|
&error_label,
|
|
);
|
|
}
|
|
));
|
|
power_box.append(&reboot_btn);
|
|
|
|
let shutdown_btn = gtk::Button::new();
|
|
shutdown_btn.set_icon_name("system-shutdown-symbolic");
|
|
shutdown_btn.add_css_class("power-button");
|
|
shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip));
|
|
shutdown_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
#[weak]
|
|
error_label,
|
|
move |_| {
|
|
show_power_confirm(
|
|
strings.shutdown_confirm,
|
|
power::shutdown,
|
|
strings.shutdown_failed,
|
|
strings,
|
|
&confirm_area,
|
|
&confirm_box,
|
|
&error_label,
|
|
);
|
|
}
|
|
));
|
|
power_box.append(&shutdown_btn);
|
|
|
|
overlay.add_overlay(&power_box);
|
|
|
|
// Password entry "activate" handler.
|
|
// A generation counter tracks which auth attempt is current. When the user
|
|
// submits a new password, the generation increments — stale PAM results from
|
|
// prior attempts are ignored (except success: a correct password always unlocks).
|
|
let username = user.username.clone();
|
|
let auth_generation = Rc::new(Cell::new(0u32));
|
|
password_entry.connect_activate(clone!(
|
|
#[strong]
|
|
state,
|
|
#[strong]
|
|
unlock_callback,
|
|
#[strong]
|
|
auth_generation,
|
|
#[weak]
|
|
error_label,
|
|
#[weak]
|
|
password_entry,
|
|
move |entry| {
|
|
let password = Zeroizing::new(entry.text().to_string());
|
|
if password.is_empty() {
|
|
return;
|
|
}
|
|
|
|
entry.set_sensitive(false);
|
|
let username = username.clone();
|
|
let unlock_cb = unlock_callback.clone();
|
|
|
|
// Invalidate stale timeouts/results from prior attempts
|
|
let auth_gen = auth_generation.get().wrapping_add(1);
|
|
auth_generation.set(auth_gen);
|
|
let gen_timeout = auth_generation.clone();
|
|
let gen_result = auth_generation.clone();
|
|
|
|
// If PAM hangs (e.g. broken LDAP module), the timeout re-enables the UI
|
|
glib::timeout_add_local_once(
|
|
std::time::Duration::from_secs(PAM_TIMEOUT_SECS),
|
|
clone!(
|
|
#[weak]
|
|
error_label,
|
|
#[weak]
|
|
password_entry,
|
|
move || {
|
|
if gen_timeout.get() != auth_gen {
|
|
return;
|
|
}
|
|
log::error!("PAM authentication timed out after {PAM_TIMEOUT_SECS}s");
|
|
let strings = load_strings(None);
|
|
password_entry.set_text("");
|
|
password_entry.set_sensitive(true);
|
|
password_entry.grab_focus();
|
|
error_label.set_text(strings.auth_timeout);
|
|
error_label.set_visible(true);
|
|
}
|
|
),
|
|
);
|
|
|
|
glib::spawn_future_local(clone!(
|
|
#[strong]
|
|
state,
|
|
#[weak]
|
|
error_label,
|
|
#[weak]
|
|
password_entry,
|
|
async move {
|
|
let user = username.clone();
|
|
let result = gio::spawn_blocking(move || {
|
|
auth::authenticate(&user, &password)
|
|
}).await;
|
|
|
|
// Stale result from a superseded attempt — only unlock on success
|
|
// (a correct password should always unlock, regardless of timing)
|
|
if gen_result.get() != auth_gen {
|
|
if matches!(result, Ok(true)) {
|
|
let s = state.borrow();
|
|
if let Some(ref fp_rc) = s.fp_listener_rc {
|
|
fp_rc.borrow_mut().stop();
|
|
}
|
|
drop(s);
|
|
unlock_cb();
|
|
}
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
Ok(true) => {
|
|
let s = state.borrow();
|
|
if let Some(ref fp_rc) = s.fp_listener_rc {
|
|
fp_rc.borrow_mut().stop();
|
|
}
|
|
drop(s);
|
|
unlock_cb();
|
|
}
|
|
_ => {
|
|
let mut s = state.borrow_mut();
|
|
s.failed_attempts += 1;
|
|
let count = s.failed_attempts;
|
|
let strings = load_strings(None);
|
|
password_entry.set_text("");
|
|
|
|
if count >= FAILLOCK_MAX_ATTEMPTS {
|
|
// Show warning but keep entry active — PAM decides lockout
|
|
error_label.set_text(strings.faillock_locked);
|
|
error_label.set_visible(true);
|
|
password_entry.set_sensitive(true);
|
|
password_entry.grab_focus();
|
|
} else {
|
|
password_entry.set_sensitive(true);
|
|
password_entry.grab_focus();
|
|
if let Some(warning) = faillock_warning(count, FAILLOCK_MAX_ATTEMPTS, strings) {
|
|
error_label.set_text(&warning);
|
|
} else {
|
|
error_label.set_text(strings.wrong_password);
|
|
}
|
|
error_label.set_visible(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
));
|
|
}
|
|
));
|
|
|
|
// Keyboard handling — Escape clears password and error
|
|
let key_controller = gtk::EventControllerKey::new();
|
|
key_controller.connect_key_pressed(clone!(
|
|
#[weak]
|
|
password_entry,
|
|
#[weak]
|
|
error_label,
|
|
#[upgrade_or]
|
|
glib::Propagation::Proceed,
|
|
move |_, keyval, _, _| {
|
|
if keyval == gdk::Key::Escape {
|
|
password_entry.set_text("");
|
|
error_label.set_visible(false);
|
|
password_entry.grab_focus();
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
}
|
|
}
|
|
));
|
|
window.add_controller(key_controller);
|
|
|
|
// Fade-in on map
|
|
window.connect_map(|w| {
|
|
glib::idle_add_local_once(clone!(
|
|
#[weak]
|
|
w,
|
|
move || {
|
|
w.add_css_class("visible");
|
|
}
|
|
));
|
|
});
|
|
|
|
// Focus password entry on realize
|
|
window.connect_realize(clone!(
|
|
#[weak]
|
|
password_entry,
|
|
move |_| {
|
|
glib::idle_add_local_once(move || {
|
|
password_entry.grab_focus();
|
|
});
|
|
}
|
|
));
|
|
|
|
LockscreenHandles {
|
|
window,
|
|
fp_label,
|
|
password_entry: password_entry.clone(),
|
|
unlock_callback,
|
|
username: user.username,
|
|
state: state.clone(),
|
|
}
|
|
}
|
|
|
|
/// Show the fingerprint label and store the listener reference for stop-on-unlock.
|
|
/// Does NOT start verification — call `start_fingerprint()` on one monitor for that.
|
|
pub fn show_fingerprint_label(
|
|
handles: &LockscreenHandles,
|
|
fp_rc: &Rc<RefCell<FingerprintListener>>,
|
|
) {
|
|
let strings = load_strings(None);
|
|
handles.fp_label.set_text(strings.fingerprint_prompt);
|
|
handles.fp_label.set_visible(true);
|
|
|
|
// Store the Rc reference for stop() on unlock
|
|
handles.state.borrow_mut().fp_listener_rc = Some(fp_rc.clone());
|
|
}
|
|
|
|
/// Start fingerprint verification on a single monitor's handles.
|
|
/// Wires up on_success/on_failure callbacks and calls start_async.
|
|
pub fn start_fingerprint(
|
|
handles: &LockscreenHandles,
|
|
fp_rc: &Rc<RefCell<FingerprintListener>>,
|
|
) {
|
|
let fp_label_success = handles.fp_label.clone();
|
|
let fp_label_fail = handles.fp_label.clone();
|
|
let unlock_cb_fp = handles.unlock_callback.clone();
|
|
|
|
let fp_rc_success = fp_rc.clone();
|
|
let fp_username = handles.username.clone();
|
|
let on_success = move || {
|
|
let label = fp_label_success.clone();
|
|
let cb = unlock_cb_fp.clone();
|
|
let fp = fp_rc_success.clone();
|
|
let username = fp_username.clone();
|
|
glib::idle_add_local_once(move || {
|
|
let strings = load_strings(None);
|
|
label.set_text(strings.fingerprint_success);
|
|
label.add_css_class("success");
|
|
// stop() is idempotent — cleanup_dbus() already ran inside on_verify_status,
|
|
// but this mirrors the PAM success path for defense-in-depth.
|
|
fp.borrow_mut().stop();
|
|
|
|
// Enforce PAM account policies (lockout, expiry) before unlocking.
|
|
// Fingerprint auth bypasses pam_authenticate, so we must explicitly
|
|
// check account restrictions via pam_acct_mgmt.
|
|
glib::spawn_future_local(async move {
|
|
let user = username.clone();
|
|
let result = gio::spawn_blocking(move || {
|
|
auth::check_account(&user)
|
|
}).await;
|
|
match result {
|
|
Ok(true) => cb(),
|
|
_ => {
|
|
log::error!("PAM account check failed after fingerprint auth");
|
|
let strings = load_strings(None);
|
|
label.set_text(strings.wrong_password);
|
|
label.remove_css_class("success");
|
|
label.add_css_class("failed");
|
|
// Restart FP verification after delay — the failure may be
|
|
// transient (e.g. PAM module timeout). If the account is truly
|
|
// locked, check_account will fail again on next match.
|
|
glib::timeout_add_local_once(
|
|
std::time::Duration::from_secs(2),
|
|
move || {
|
|
label.set_text(load_strings(None).fingerprint_prompt);
|
|
label.remove_css_class("failed");
|
|
glib::spawn_future_local(async move {
|
|
FingerprintListener::resume_async(&fp, &username).await;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
let on_failure = move || {
|
|
let label = fp_label_fail.clone();
|
|
glib::idle_add_local_once(clone!(
|
|
#[weak]
|
|
label,
|
|
move || {
|
|
let strings = load_strings(None);
|
|
label.set_text(strings.fingerprint_failed);
|
|
label.add_css_class("failed");
|
|
// Reset after 2 seconds
|
|
glib::timeout_add_local_once(
|
|
std::time::Duration::from_secs(2),
|
|
clone!(
|
|
#[weak]
|
|
label,
|
|
move || {
|
|
label.set_text(load_strings(None).fingerprint_prompt);
|
|
label.remove_css_class("success");
|
|
label.remove_css_class("failed");
|
|
}
|
|
),
|
|
);
|
|
}
|
|
));
|
|
};
|
|
|
|
let fp_label_exhausted = handles.fp_label.clone();
|
|
let on_exhausted = move || {
|
|
let label = fp_label_exhausted.clone();
|
|
glib::idle_add_local_once(move || {
|
|
label.set_visible(false);
|
|
});
|
|
};
|
|
|
|
let username = handles.username.clone();
|
|
let fp_rc_clone = fp_rc.clone();
|
|
glib::spawn_future_local(async move {
|
|
FingerprintListener::start_async(
|
|
&fp_rc_clone, &username, on_success, on_failure, on_exhausted,
|
|
).await;
|
|
});
|
|
}
|
|
|
|
/// Load the wallpaper as a texture once, for sharing across all windows.
|
|
/// Returns None if no wallpaper path is provided or the file cannot be loaded.
|
|
/// Blur is applied at render time via GPU (GskBlurNode), not here.
|
|
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
|
let file = gio::File::for_path(bg_path);
|
|
match gdk::Texture::from_file(&file) {
|
|
Ok(texture) => Some(texture),
|
|
Err(e) => {
|
|
log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a Picture widget for the wallpaper background.
|
|
/// When `blur_radius` is `Some(sigma)` with sigma > 0, blur is applied via GPU
|
|
/// (GskBlurNode). The blur is rendered to a concrete texture on `realize` (when
|
|
/// the GPU renderer is available), avoiding lazy-render artifacts.
|
|
/// The `blur_cache` is shared across monitors — the first to realize renders the
|
|
/// blur, subsequent monitors reuse the cached texture.
|
|
fn create_background_picture(
|
|
texture: &gdk::Texture,
|
|
blur_radius: Option<f32>,
|
|
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
|
) -> gtk::Picture {
|
|
let background = gtk::Picture::for_paintable(texture);
|
|
background.set_content_fit(gtk::ContentFit::Cover);
|
|
background.set_hexpand(true);
|
|
background.set_vexpand(true);
|
|
|
|
if let Some(sigma) = blur_radius {
|
|
if sigma > 0.0 {
|
|
let texture = texture.clone();
|
|
let cache = blur_cache.clone();
|
|
background.connect_realize(move |picture| {
|
|
if let Some(ref cached) = *cache.borrow() {
|
|
picture.set_paintable(Some(cached));
|
|
return;
|
|
}
|
|
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
|
|
picture.set_paintable(Some(&blurred));
|
|
*cache.borrow_mut() = Some(blurred);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
background
|
|
}
|
|
|
|
/// Maximum texture dimension for blur input. Textures larger than this are
|
|
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
|
|
// are duplicated in moongreet/src/greeter.rs and moonset/src/panel.rs.
|
|
// Changes here must be mirrored to the other two projects.
|
|
|
|
/// downscaled before blurring — the blur destroys detail anyway, so there is
|
|
/// no visible quality loss, but GPU work is reduced significantly.
|
|
const MAX_BLUR_DIMENSION: f32 = 1920.0;
|
|
|
|
/// Render a blurred texture using the widget's GPU renderer.
|
|
/// Returns None if the renderer is not available.
|
|
///
|
|
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
|
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
|
|
/// is applied to the padded area, then cropped back to the original size.
|
|
///
|
|
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
|
|
/// reduce GPU work. The sigma is scaled proportionally.
|
|
fn render_blurred_texture(
|
|
widget: &impl IsA<gtk::Widget>,
|
|
texture: &gdk::Texture,
|
|
sigma: f32,
|
|
) -> Option<gdk::Texture> {
|
|
let native = widget.native()?;
|
|
let renderer = native.renderer()?;
|
|
|
|
let orig_w = texture.width() as f32;
|
|
let orig_h = texture.height() as f32;
|
|
|
|
// Downscale large textures to reduce GPU blur work
|
|
let max_dim = orig_w.max(orig_h);
|
|
let scale = if max_dim > MAX_BLUR_DIMENSION {
|
|
MAX_BLUR_DIMENSION / max_dim
|
|
} else {
|
|
1.0
|
|
};
|
|
let w = (orig_w * scale).round();
|
|
let h = (orig_h * scale).round();
|
|
let scaled_sigma = sigma * scale;
|
|
|
|
// Padding must cover the blur kernel radius (typically ~3x sigma)
|
|
let pad = (scaled_sigma * 3.0).ceil();
|
|
|
|
let snapshot = gtk::Snapshot::new();
|
|
// Clip output to scaled texture size
|
|
snapshot.push_clip(&graphene::Rect::new(pad, pad, w, h));
|
|
snapshot.push_blur(scaled_sigma as f64);
|
|
// Render texture with padding on all sides (edges repeat via oversized bounds)
|
|
snapshot.append_texture(texture, &graphene::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
|
|
snapshot.pop(); // blur
|
|
snapshot.pop(); // clip
|
|
|
|
let node = snapshot.to_node()?;
|
|
let viewport = graphene::Rect::new(pad, pad, w, h);
|
|
Some(renderer.render_texture(&node, Some(&viewport)))
|
|
}
|
|
|
|
/// Load an image file and set it as the avatar. Stores the texture in the cache.
|
|
/// Decoding runs via GIO async I/O + async pixbuf stream loader so the GTK main
|
|
/// loop stays responsive — avatars may be loaded inside the `connect_monitor`
|
|
/// signal handler at hotplug time, which must not block. The fallback icon is
|
|
/// shown immediately; the decoded texture replaces it when ready.
|
|
fn set_avatar_from_file(
|
|
image: >k::Image,
|
|
path: &Path,
|
|
cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
|
) {
|
|
image.set_icon_name(Some("avatar-default-symbolic"));
|
|
|
|
let display_path = path.to_path_buf();
|
|
let file = gio::File::for_path(path);
|
|
let image_clone = image.clone();
|
|
let cache_clone = cache.clone();
|
|
|
|
glib::spawn_future_local(async move {
|
|
let stream = match file.read_future(glib::Priority::default()).await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log::warn!("Failed to open avatar {}: {e}", display_path.display());
|
|
return;
|
|
}
|
|
};
|
|
match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await {
|
|
Ok(pixbuf) => {
|
|
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
|
image_clone.set_paintable(Some(&texture));
|
|
*cache_clone.borrow_mut() = Some(texture);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Failed to decode avatar from {}: {e}", display_path.display());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Load the default avatar SVG from GResources, tinted with the foreground color.
|
|
/// Stores the texture in the cache for reuse on additional monitors.
|
|
fn set_default_avatar(
|
|
image: >k::Image,
|
|
window: >k::ApplicationWindow,
|
|
cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
|
) {
|
|
let resource_path = users::get_default_avatar_path();
|
|
if let Ok(bytes) =
|
|
gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE)
|
|
{
|
|
let svg_text = String::from_utf8_lossy(&bytes);
|
|
let rgba = window.color();
|
|
let fg_color = format!(
|
|
"#{:02x}{:02x}{:02x}",
|
|
(rgba.red() * 255.0) as u8,
|
|
(rgba.green() * 255.0) as u8,
|
|
(rgba.blue() * 255.0) as u8,
|
|
);
|
|
let tinted = svg_text.replace("#PLACEHOLDER", &fg_color);
|
|
let svg_bytes = tinted.as_bytes();
|
|
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
|
|
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
|
|
if loader.write(svg_bytes).is_ok() {
|
|
let _ = loader.close();
|
|
if let Some(pixbuf) = loader.pixbuf() {
|
|
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
|
image.set_paintable(Some(&texture));
|
|
*cache.borrow_mut() = Some(texture);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
image.set_icon_name(Some("avatar-default-symbolic"));
|
|
}
|
|
|
|
/// Show inline power confirmation.
|
|
fn show_power_confirm(
|
|
message: &'static str,
|
|
action_fn: fn() -> Result<(), PowerError>,
|
|
error_message: &'static str,
|
|
strings: &'static Strings,
|
|
confirm_area: >k::Box,
|
|
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
error_label: >k::Label,
|
|
) {
|
|
dismiss_power_confirm(confirm_area, confirm_box);
|
|
|
|
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
new_box.set_halign(gtk::Align::Center);
|
|
new_box.set_margin_top(16);
|
|
|
|
let confirm_label = gtk::Label::new(Some(message));
|
|
confirm_label.add_css_class("confirm-label");
|
|
new_box.append(&confirm_label);
|
|
|
|
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
button_row.set_halign(gtk::Align::Center);
|
|
|
|
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
|
|
yes_btn.add_css_class("confirm-yes");
|
|
yes_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
#[weak]
|
|
error_label,
|
|
move |_| {
|
|
dismiss_power_confirm(&confirm_area, &confirm_box);
|
|
execute_power_action(action_fn, error_message, &error_label);
|
|
}
|
|
));
|
|
button_row.append(&yes_btn);
|
|
|
|
let no_btn = gtk::Button::with_label(strings.confirm_no);
|
|
no_btn.add_css_class("confirm-no");
|
|
no_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
move |_| {
|
|
dismiss_power_confirm(&confirm_area, &confirm_box);
|
|
}
|
|
));
|
|
button_row.append(&no_btn);
|
|
|
|
new_box.append(&button_row);
|
|
confirm_area.append(&new_box);
|
|
*confirm_box.borrow_mut() = Some(new_box);
|
|
no_btn.grab_focus();
|
|
}
|
|
|
|
/// Remove the power confirmation prompt.
|
|
fn dismiss_power_confirm(confirm_area: >k::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
|
|
if let Some(box_widget) = confirm_box.borrow_mut().take() {
|
|
confirm_area.remove(&box_widget);
|
|
}
|
|
}
|
|
|
|
/// Execute a power action in a background thread.
|
|
fn execute_power_action(
|
|
action_fn: fn() -> Result<(), PowerError>,
|
|
error_message: &'static str,
|
|
error_label: >k::Label,
|
|
) {
|
|
glib::spawn_future_local(clone!(
|
|
#[weak]
|
|
error_label,
|
|
async move {
|
|
let result = gio::spawn_blocking(move || action_fn()).await;
|
|
match result {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(e)) => {
|
|
log::error!("Power action failed: {e}");
|
|
error_label.set_text(error_message);
|
|
error_label.set_visible(true);
|
|
}
|
|
Err(_) => {
|
|
log::error!("Power action panicked");
|
|
error_label.set_text(error_message);
|
|
error_label.set_visible(true);
|
|
}
|
|
}
|
|
}
|
|
));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn faillock_threshold() {
|
|
assert_eq!(FAILLOCK_MAX_ATTEMPTS, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn avatar_size_matches_css() {
|
|
assert_eq!(AVATAR_SIZE, 128);
|
|
}
|
|
}
|