diff --git a/Cargo.lock b/Cargo.lock index 2fc895d..f748f0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1627,6 +1627,22 @@ dependencies = [ name = "ferese-layout" version = "0.1.0" +[[package]] +name = "ferese-lock" +version = "0.1.0" +dependencies = [ + "ferese-config", + "image", + "jiff", + "libc", + "libcosmic", + "libloading", + "tempfile", + "tokio", + "wayland-client", + "zeroize", +] + [[package]] name = "ferese-protocols" version = "0.1.0" @@ -7187,6 +7203,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 209015f..05a5f2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/ferese-protocols", "shell/ferese-shell", "shell/ferese-settings", + "shell/ferese-lock", ] resolver = "3" diff --git a/docs/configuration.md b/docs/configuration.md index e718a7d..93e5eaa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -366,10 +366,16 @@ autostart { } ``` -`ferese-lock` uses swaylock/swaylock-effects and optional -`~/.config/ferese/swaylock.conf`. Test unlock in a nested preview before enabling -automatic locking. A crashed locker leaves the session locked; recovery requires -ending the session from another TTY. +`ferese-lock` is the native PAM-authenticated locker. It follows your wallpaper, +colors, font and shell corner radius. Its command returns successfully only after +the compositor confirms the lock; the UI keeps running until authentication +succeeds. `--foreground` keeps the command attached to its UI process. + +Use `ferese-lock --preview` for an ordinary window that never locks or checks a +password. See [Native locker](locking.md) for setup and verification. Test real +unlock in a nested compositor before enabling automatic locking. A crashed +locker leaves the session locked; recovery requires ending that session from +another TTY. ## Optional session protocols diff --git a/docs/locking.md b/docs/locking.md new file mode 100644 index 0000000..2fbe6f0 --- /dev/null +++ b/docs/locking.md @@ -0,0 +1,66 @@ +# Native locker + +Ferese Lock uses `ext-session-lock-v1`, not a fullscreen application overlay. +Each output receives its own surface, including outputs added while locked. +The compositor protects the desktop if the client exits or crashes. + +## Preview + +```sh +cargo build --release --locked -p ferese-lock +target/release/ferese-lock --preview +``` + +This is an ordinary, closable window. Submitting the field does not call PAM, +lock, or unlock anything. Do not type your actual password into a visual preview. + +Settings → Lock screen provides clock visibility, date visibility, time format, +wallpaper dimming and softness, and a safe preview button. Your account picture is loaded from +AccountsService or `~/.face`, with a user silhouette as a fallback. + +The locker reads Ferese's wallpaper, theme colors, typography, and shell radius +at startup. The wallpaper is softened once and shared across displays. Set softness +to zero to keep it sharp. Escape clears the password. Caps Lock is shown above authentication +status. Password verification runs on a worker, and repeated attempts are held +for two seconds after failure, in addition to delays enforced by PAM. + +## Installation and locking + +The normal `scripts/install.sh` includes the native binary and installs +`/etc/pam.d/ferese-lock` if absent. Existing administrator policies are preserved. +Fedora/Arch installations use `system-auth`; Debian-family installations use +`common-auth` and `common-account`; the fallback uses the system `login` stack. +There is no setuid binary, password command argument, or shadow-file reader. +Both authentication and account validation must succeed. Expired credentials +must be updated outside the locker. Interactive multi-factor enrollment and +password changes are not supported by this initial password UI. + +```sh +ferese-lock # returns after confirmation, UI stays running +ferese-lock --foreground # stays attached until unlock +``` + +The `swayidle -w` example in [Configuration](configuration.md#login-items-and-locking) +can invoke the default command. Auto-locking is opt-in. This change does not +install or start an idle daemon, replace your login manager, or change the Control Center's +suspend action. Test real password authentication inside a nested Ferese session +before enabling this on your desktop. + +## Security and recovery + +Never interpret the preview as proof of a locked desktop. A missing PAM policy +or unsupported compositor produces an error before a lock request. A failure +or authentication-worker panic never authorizes unlock. A crash after the lock +request may leave the session permanently locked; end that session from another +TTY if necessary. Do not restart the compositor to test crash behavior on a live +session with unsaved work. + +The pinned libcosmic backend emits `Locked` twice: once when requesting a lock +and once on the actual protocol callback. The startup handshake waits for the +second notification. Re-audit this behavior whenever upgrading libcosmic. The +parent times out with an error after 15 seconds and leaves the UI running; it +never reports success merely because the window appeared. + +Validation references: [session-lock protocol](https://wayland.app/protocols/ext-session-lock-v1), +[PAM authentication](https://man7.org/linux/man-pages/man3/pam_authenticate.3.html), +and [PAM account checks](https://man7.org/linux/man-pages/man3/pam_acct_mgmt.3.html). diff --git a/packaging/ferese-lock b/packaging/ferese-lock deleted file mode 100755 index cdbcc3f..0000000 --- a/packaging/ferese-lock +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -config=${XDG_CONFIG_HOME:-$HOME/.config}/ferese/swaylock.conf -if [[ -f $config ]]; then - exec swaylock --config "$config" --daemonize -fi -exec swaylock --color 0f1014 --daemonize diff --git a/packaging/pam.d/ferese-lock.debian b/packaging/pam.d/ferese-lock.debian new file mode 100644 index 0000000..4facad8 --- /dev/null +++ b/packaging/pam.d/ferese-lock.debian @@ -0,0 +1,3 @@ +#%PAM-1.0 +@include common-auth +@include common-account diff --git a/packaging/pam.d/ferese-lock.fedora b/packaging/pam.d/ferese-lock.fedora new file mode 100644 index 0000000..5ed0116 --- /dev/null +++ b/packaging/pam.d/ferese-lock.fedora @@ -0,0 +1,4 @@ +#%PAM-1.0 +# Authenticate the existing session owner; do not open a new PAM session. +auth include system-auth +account include system-auth diff --git a/packaging/pam.d/ferese-lock.generic b/packaging/pam.d/ferese-lock.generic new file mode 100644 index 0000000..124ac10 --- /dev/null +++ b/packaging/pam.d/ferese-lock.generic @@ -0,0 +1,3 @@ +#%PAM-1.0 +auth include login +account include login diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 7d60190..d6303df 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -39,12 +39,24 @@ if [[ -e $session_entry ]]; then echo "Different session entry exists: $session_entry" >&2; exit 1; } fi -for name in ferese ferese-shell ferese-settings feresectl; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } done for name in ferese.png ferese.svg; do [[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; } done +# Preserve administrator-supplied policies. New installs use the distro's +# authentication and account stacks, never a permissive fallback. +pam_policy=generic +if [[ -f /etc/pam.d/system-auth ]]; then + pam_policy=fedora +elif [[ -f /etc/pam.d/common-auth && -f /etc/pam.d/common-account ]]; then + pam_policy=debian +elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then + echo 'No supported PAM stack found; install /etc/pam.d/ferese-lock first.' >&2 + exit 1 +fi + desktop-file-validate "$repo_dir/packaging/ferese.desktop" desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" @@ -52,11 +64,10 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions if [[ -e $session_entry ]]; then install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" fi -for name in ferese ferese-shell ferese-settings feresectl; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" -install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock" install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot" install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl" install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt" @@ -65,6 +76,10 @@ install -d -m 0755 -- "$release_dir/wallpapers" install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png" install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg" +if [[ ! -e /etc/pam.d/ferese-lock ]]; then + install -D -m 0644 -- "$repo_dir/packaging/pam.d/ferese-lock.$pam_policy" /etc/pam.d/ferese-lock +fi + if [[ -L $install_root/current ]]; then ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous" fi @@ -78,4 +93,4 @@ install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/loc install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi -echo "Installed Ferese $release_id. Select Ferese in SDDM." +echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY." diff --git a/scripts/install.sh b/scripts/install.sh index fefdf9b..e5225e6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop" build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked - -p ferese -p ferese-shell -p ferese-settings -p feresectl) + -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock) if $offline; then build+=(--offline); fi if ! $skip_build; then ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' @@ -75,8 +75,8 @@ fi cd -- "$repo_dir" if ! $skip_build; then "${build[@]}"; fi -for name in ferese ferese-shell ferese-settings feresectl; do +for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do [[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" done "${installer[@]}" -echo 'Installation complete. Log out and select Ferese at the login screen.' +echo 'Installation complete. Log out and select Ferese at your login screen, or run ferese-session from a local TTY.' diff --git a/scripts/tests/test_lock_isolated.py b/scripts/tests/test_lock_isolated.py new file mode 100644 index 0000000..09e6dfc --- /dev/null +++ b/scripts/tests/test_lock_isolated.py @@ -0,0 +1,61 @@ +"""Opt-in lock protocol smoke test. Opens a temporary nested compositor. +Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py +Requires release binaries, bwrap and grim. Host PAM is never modified. +""" +import os +import unittest +from pathlib import Path + +@unittest.skipUnless(os.environ.get("FERESE_TEST_LOCK") == "1", "visible nested lock test is opt-in") +class NativeLockTest(unittest.TestCase): + def test_confirmation_capture_and_crash(self): + import os, pathlib, signal, subprocess, tempfile, time + os.chdir(Path(__file__).resolve().parents[2]) + root=pathlib.Path(tempfile.mkdtemp(prefix='ferese-lock-test-')) + runtime=root/'runtime';runtime.mkdir(mode=0o700) + config=root/'config'/'ferese';config.mkdir(parents=True) + (config/'config.kdl').write_text('') + pam=root/'pam';pam.mkdir() + # Deny-only policy confined to a mount namespace; never changes host PAM. + (pam/'ferese-lock').write_text('auth required pam_deny.so\naccount required pam_deny.so\n') + host_display=os.environ.get('WAYLAND_DISPLAY','wayland-1') + if not host_display.startswith('/'):host_display=str(pathlib.Path(os.environ['XDG_RUNTIME_DIR'])/host_display) + env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1') + log=(root/'compositor.log').open('w') + compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT) + locker=None + locker_log=(root/'locker.log').open('w') + try: + for _ in range(100): + if compositor.poll() is not None:raise RuntimeError('Nested compositor exited') + sockets=[p for p in runtime.glob('wayland-*') if not p.name.endswith('.lock')] + if sockets:break + time.sleep(.1) + childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1') + time.sleep(.6) + subprocess.run(['grim',str(root/'unlocked.png')],env=childenv,check=True,timeout=10,capture_output=True) + locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) + assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() + print('Native locker received compositor confirmation',flush=True) + result=subprocess.run(['grim',str(root/'locked.png')],env=childenv,timeout=10,capture_output=True) + assert result.returncode != 0, 'Capture unexpectedly succeeded during lock' + print('Screencopy denied while native locker is running',flush=True) + os.killpg(locker.pid, signal.SIGKILL) + time.sleep(.3) + result=subprocess.run(['grim',str(root/'after-crash.png')],env=childenv,timeout=10,capture_output=True) + assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' + print('Screencopy remains denied after locker crash',flush=True) + finally: + if locker is not None: + try: os.killpg(locker.pid, signal.SIGKILL) + except ProcessLookupError: pass + locker.wait() + compositor.terminate() + try:compositor.wait(timeout=5) + except subprocess.TimeoutExpired:compositor.kill();compositor.wait() + log.close() + locker_log.close() + print(root,flush=True) + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-lock/Cargo.toml b/shell/ferese-lock/Cargo.toml new file mode 100644 index 0000000..aea2fbb --- /dev/null +++ b/shell/ferese-lock/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ferese-lock" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version = "1.95" + +[dependencies] +ferese-config = { path = "../../crates/ferese-config" } +libcosmic.workspace = true +jiff.workspace = true +image = { version = "0.25.10", default-features = false } +libc.workspace = true +libloading = "0.8.9" +zeroize = "1.9" +wayland-client.workspace = true +tokio = { version = "1", features = ["rt", "time"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs new file mode 100644 index 0000000..850832b --- /dev/null +++ b/shell/ferese-lock/src/appearance.rs @@ -0,0 +1,216 @@ +use cosmic::{iced::Color, widget::image}; +use ferese_config::Document; +#[derive(Clone)] +pub struct Appearance { + pub dim: f32, + pub show_clock: bool, + pub show_date: bool, + pub twelve_hour: bool, + pub panel: Color, + pub text: Color, + pub accent: Color, + pub radius: f32, + pub font: cosmic::font::Font, + pub wallpaper: image::Handle, + pub avatar: Option, +} +impl Appearance { + pub fn load(user: &str, path: Option<&std::path::Path>) -> Self { + let doc = path + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| Document::parse(&s).ok()); + let string = |key: &str, fallback: &str| { + doc.as_ref() + .and_then(|d| d.get(key)) + .and_then(|v| v.as_str()) + .unwrap_or(fallback) + .to_owned() + }; + let color = |key, fallback| { + parse_color(&string(key, fallback)).unwrap_or_else(|| parse_color(fallback).unwrap()) + }; + let font = string("theme.typography.font_family", "Inter"); + let path = string("theme.background.path", ferese_config::default_wallpaper()); + let path = if let Some(tail) = path.strip_prefix("~/") { + std::env::var("HOME") + .map(|home| format!("{home}/{tail}")) + .unwrap_or(path) + } else { + path + }; + let wallpaper = image::Handle::from_path(path); + // Soften a bounded thumbnail once, before requesting the lock. The + // renderer scales this shared image across outputs; no per-frame blur. + let pixels = cosmic::iced::advanced::graphics::image::load(&wallpaper) + .or_else(|_| { + cosmic::iced::advanced::graphics::image::load(&image::Handle::from_bytes( + include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(), + )) + }) + .expect("bundled wallpaper is valid"); + let blur = doc + .as_ref() + .and_then(|d| d.get("lock_screen.background_blur")) + .and_then(|v| v.as_f64()) + .unwrap_or(18.) + .clamp(0., 40.) as f32; + let wallpaper = if blur > 0. { + let thumbnail = ::image::imageops::thumbnail(&pixels, 640, 640); + let softened = ::image::imageops::blur(&thumbnail, blur); + image::Handle::from_rgba(softened.width(), softened.height(), softened.into_raw()) + } else { + image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw()) + }; + let boolean = |key, fallback| { + doc.as_ref() + .and_then(|d| d.get(key)) + .and_then(|v| v.as_bool()) + .unwrap_or(fallback) + }; + Self { + dim: doc + .as_ref() + .and_then(|d| d.get("lock_screen.background_dim")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.48) + .clamp(0., 0.9) as f32, + show_clock: boolean("lock_screen.show_clock", true), + show_date: boolean("lock_screen.show_date", true), + twelve_hour: string("lock_screen.clock_format", "24h") == "12h", + panel: color("theme.colors.surface_base", "#111821"), + text: color("theme.colors.text_primary", "#F4F7FB"), + accent: color("theme.colors.accent", "#3D7BE6"), + radius: doc + .as_ref() + .and_then(|d| { + d.get("theme.geometry.shell_radius") + .or_else(|| d.get("appearance.corner_radius")) + }) + .and_then(|v| v.as_f64()) + .unwrap_or(14.) + .clamp(0., 64.) as f32, + font: cosmic::font::Font::with_name(Box::leak(font.into_boxed_str())), + wallpaper, + avatar: account_picture(user), + } + } + pub fn clock_format(&self) -> &'static str { + if self.twelve_hour { + "%I:%M %p" + } else { + "%H:%M" + } + } + pub fn theme(&self) -> cosmic::Theme { + use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba}; + let color = |c: Color| Srgba::new(c.r, c.g, c.b, c.a); + let builder = if self.panel.r + self.panel.g + self.panel.b > 1.5 { + ThemeBuilder::light() + } else { + ThemeBuilder::dark() + }; + let mut corners = cosmic::cosmic_theme::CornerRadii::default(); + corners.radius_xs = [self.radius.min(4.); 4]; + corners.radius_s = [self.radius.min(8.); 4]; + corners.radius_m = [self.radius; 4]; + corners.radius_l = [self.radius; 4]; + corners.radius_xl = [self.radius; 4]; + cosmic::Theme::custom(std::sync::Arc::new( + builder + .corner_radii(corners) + .bg_color(color(self.panel)) + .primary_container_bg(color(self.panel)) + .text_tint(color(self.text).color) + .accent(color(self.accent).color) + .build(), + )) + } +} + +fn parse_color(value: &str) -> Option { + let hex = value.strip_prefix('#')?; + let packed = u32::from_str_radix(hex, 16).ok()?; + let rgba = match hex.len() { + 6 => (packed << 8) | 255, + 8 => packed, + _ => return None, + }; + Some(Color::from_rgba8( + (rgba >> 24) as u8, + (rgba >> 16) as u8, + (rgba >> 8) as u8, + (rgba & 255) as f32 / 255., + )) +} + +fn account_picture(user: &str) -> Option { + let mut candidates = + vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)]; + if let Some(home) = std::env::var_os("HOME") { + candidates.push(std::path::PathBuf::from(home).join(".face")); + } + candidates.into_iter().find_map(|path| { + let pixels = + cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)).ok()?; + let side = pixels.width().min(pixels.height()); + if side == 0 { + return None; + } + let square = ::image::imageops::crop_imm( + &pixels, + (pixels.width() - side) / 2, + (pixels.height() - side) / 2, + side, + side, + ) + .to_image(); + let mut pixels = + ::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3); + // Clip the decoded thumbnail, so the avatar stays circular even on + // renderers that do not support rounded image clipping. + for (x, y, pixel) in pixels.enumerate_pixels_mut() { + let distance = + ((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt(); + let coverage = (128. - distance).clamp(0., 1.); + pixel[3] = (pixel[3] as f32 * coverage).round() as u8; + } + Some(image::Handle::from_rgba( + pixels.width(), + pixels.height(), + pixels.into_raw(), + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lock_options_share_shell_style_without_changing_security() { + let directory = tempfile::tempdir().unwrap(); + let config = directory.path().join("config.kdl"); + std::fs::write( + &config, + r##" + theme { + geometry { shell-radius 0; } + colors { accent "#FF5500"; } + } + lock-screen { + show-clock #false + show-date #false + clock-format "12h" + background-dim 0.7 + } + "##, + ) + .unwrap(); + let appearance = Appearance::load("no-such-test-user", Some(&config)); + assert_eq!(appearance.radius, 0.); + assert_eq!(appearance.accent, Color::from_rgb8(255, 85, 0)); + assert!(!appearance.show_clock && !appearance.show_date); + assert_eq!(appearance.clock_format(), "%I:%M %p"); + assert_eq!(appearance.dim, 0.7); + assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]); + } +} diff --git a/shell/ferese-lock/src/auth.rs b/shell/ferese-lock/src/auth.rs new file mode 100644 index 0000000..4c96f49 --- /dev/null +++ b/shell/ferese-lock/src/auth.rs @@ -0,0 +1,267 @@ +//! PAM stays on a worker thread. No password is placed in argv, logs or IPC. +use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use zeroize::Zeroizing; + +#[repr(C)] +struct Message { + style: c_int, + text: *const c_char, +} +#[repr(C)] +struct Response { + text: *mut c_char, + code: c_int, +} +#[repr(C)] +struct Conversation { + callback: + unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int, + data: *mut c_void, +} +struct Credentials { + user: CString, + password: Zeroizing>, + password_prompts: u8, +} +const SUCCESS: c_int = 0; +const CONV_ERR: c_int = 19; + +// Linux-PAM owns successful response allocations and frees them using free(). +unsafe extern "C" fn converse( + count: c_int, + messages: *mut *const Message, + out: *mut *mut Response, + data: *mut c_void, +) -> c_int { + if count <= 0 || count > 32 || messages.is_null() || out.is_null() || data.is_null() { + return CONV_ERR; + } + unsafe { + *out = std::ptr::null_mut(); + let credentials = &mut *(data as *mut Credentials); + let responses = + libc::calloc(count as usize, std::mem::size_of::()) as *mut Response; + if responses.is_null() { + return CONV_ERR; + } + for i in 0..count as usize { + let msg = *messages.add(i); + let bytes = if msg.is_null() { + None + } else { + match (*msg).style { + 1 => { + credentials.password_prompts = + credentials.password_prompts.saturating_add(1); + (credentials.password_prompts == 1) + .then_some(credentials.password.as_slice()) + } // Never send the password as a second-factor response. + 2 => Some(credentials.user.as_bytes_with_nul()), // PAM_PROMPT_ECHO_ON + 3 | 4 => Some(&[][..]), // PAM_ERROR_MSG / PAM_TEXT_INFO + _ => None, + } + }; + if let Some(bytes) = bytes { + if !bytes.is_empty() { + let allocation = libc::malloc(bytes.len()) as *mut c_char; + if !allocation.is_null() { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + allocation.cast(), + bytes.len(), + ); + (*responses.add(i)).text = allocation; + continue; + } + } else { + continue; + } + } + for j in 0..i { + let ptr = (*responses.add(j)).text; + if !ptr.is_null() { + libc::explicit_bzero(ptr.cast(), CStr::from_ptr(ptr).to_bytes().len()); + libc::free(ptr.cast()); + } + } + libc::free(responses.cast()); + return CONV_ERR; + } + *out = responses; + SUCCESS + } +} + +pub fn username() -> Result { + // Never trust USER/LOGNAME for the account being unlocked. + unsafe { + let mut entry: libc::passwd = std::mem::zeroed(); + let mut result = std::ptr::null_mut(); + let mut buffer = vec![0u8; 16384]; + let status = libc::getpwuid_r( + libc::getuid(), + &mut entry, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ); + if status != 0 || result.is_null() || entry.pw_name.is_null() { + return Err("Cannot determine the session user".into()); + } + Ok(CStr::from_ptr(entry.pw_name).to_string_lossy().into_owned()) + } +} + +pub fn available() -> bool { + std::path::Path::new("/etc/pam.d/ferese-lock").is_file() + && unsafe { libloading::Library::new("libpam.so.0") }.is_ok() +} + +pub fn authenticate(user: String, password: Zeroizing) -> bool { + authenticate_with(&user, &password, None) +} + +fn authenticate_with(user: &str, password: &str, config: Option<&CStr>) -> bool { + if password.is_empty() || password.contains('\0') { + return false; + } + let Ok(user) = CString::new(user) else { + return false; + }; + let mut secret = Zeroizing::new(password.as_bytes().to_vec()); + secret.push(0); + let mut credentials = Credentials { + user, + password: secret, + password_prompts: 0, + }; + unsafe { + let Ok(library) = libloading::Library::new("libpam.so.0") else { + return false; + }; + type Start = unsafe extern "C" fn( + *const c_char, + *const c_char, + *const Conversation, + *mut *mut c_void, + ) -> c_int; + type StartConf = unsafe extern "C" fn( + *const c_char, + *const c_char, + *const Conversation, + *const c_char, + *mut *mut c_void, + ) -> c_int; + type Check = unsafe extern "C" fn(*mut c_void, c_int) -> c_int; + let (Ok(start), Ok(auth), Ok(account), Ok(end)) = ( + library.get::(b"pam_start\0"), + library.get::(b"pam_authenticate\0"), + library.get::(b"pam_acct_mgmt\0"), + library.get::(b"pam_end\0"), + ) else { + return false; + }; + let conversation = Conversation { + callback: converse, + data: (&mut credentials as *mut Credentials).cast(), + }; + let mut handle = std::ptr::null_mut(); + let status = if let Some(config) = config { + let Ok(start) = library.get::(b"pam_start_confdir\0") else { + return false; + }; + start( + c"ferese-lock".as_ptr(), + credentials.user.as_ptr(), + &conversation, + config.as_ptr(), + &mut handle, + ) + } else { + start( + c"ferese-lock".as_ptr(), + credentials.user.as_ptr(), + &conversation, + &mut handle, + ) + }; + if status != SUCCESS || handle.is_null() { + return false; + } + let mut status = auth(handle, 1); // PAM_DISALLOW_NULL_AUTHTOK + if status == SUCCESS { + status = account(handle, 0); + } + let ended = end(handle, status); + status == SUCCESS && ended == SUCCESS + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pam_requires_authentication_and_account_permission() { + let directory = tempfile::tempdir().unwrap(); + let path = CString::new(directory.path().as_os_str().as_encoded_bytes()).unwrap(); + for (auth, account, allowed) in [ + ("permit", "permit", true), + ("deny", "permit", false), + ("permit", "deny", false), + ] { + std::fs::write( + directory.path().join("ferese-lock"), + format!("auth required pam_{auth}.so\naccount required pam_{account}.so\n"), + ) + .unwrap(); + assert_eq!(authenticate_with("test", "test-only", Some(&path)), allowed); + } + assert!(!authenticate_with("test", "", Some(&path))); + assert!(!authenticate_with("test", "bad\0value", Some(&path))); + } + #[test] + fn conversation_rejects_multiple_secret_prompts_and_unknown_styles() { + let mut credentials = Credentials { + user: CString::new("tester").unwrap(), + password: Zeroizing::new(b"test-only\0".to_vec()), + password_prompts: 0, + }; + let first = Message { + style: 1, + text: c"Password".as_ptr(), + }; + let mut prompts = [&first as *const Message, &first as *const Message]; + let mut responses = std::ptr::null_mut(); + unsafe { + assert_eq!( + converse( + 2, + prompts.as_mut_ptr(), + &mut responses, + (&mut credentials as *mut Credentials).cast() + ), + CONV_ERR + ); + assert!(responses.is_null()); + let unknown = Message { + style: 99, + text: std::ptr::null(), + }; + prompts[0] = &unknown; + assert_eq!( + converse( + 1, + prompts.as_mut_ptr(), + &mut responses, + (&mut credentials as *mut Credentials).cast() + ), + CONV_ERR + ); + assert!(responses.is_null()); + } + } + #[test] + fn current_user_comes_from_uid() { + assert!(!username().unwrap().is_empty()); + } +} diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs new file mode 100644 index 0000000..b66114f --- /dev/null +++ b/shell/ferese-lock/src/main.rs @@ -0,0 +1,540 @@ +mod appearance; +mod auth; +mod runtime; + +use cosmic::iced::platform_specific::shell::commands::session_lock; +use cosmic::{ + Element, + app::{Core, Settings, Task}, + iced::{ + self, Event, Length, Subscription, + event::{self, PlatformSpecific, wayland}, + window, + }, + theme, + widget::{self, column, container, image, row}, +}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; +use wayland_client::protocol::wl_output::WlOutput; +use zeroize::{Zeroize, Zeroizing}; + +fn main() -> iced::Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args == ["--help"] { + println!( + "Usage: ferese-lock [--preview | --foreground]\nWithout arguments, lock the session using PAM.\n--preview opens an ordinary window; it never locks or authenticates." + ); + return Ok(()); + } + let preview = args == ["--preview"] + || (args.len() == 3 && args[0] == "--preview" && args[1] == "--config"); + let config = if preview && args.len() == 3 { + Some(std::path::PathBuf::from(&args[2])) + } else { + ferese_config::config_path() + }; + if !args.is_empty() && !preview && args != ["--foreground"] { + eprintln!("Usage: ferese-lock [--preview | --foreground]"); + std::process::exit(2); + } + if !preview && !auth::available() { + eprintln!("ferese-lock: install the ferese-lock PAM policy before locking"); + std::process::exit(1); + } + if !preview { + if let Err(error) = runtime::check_protocol() { + eprintln!("ferese-lock: {error}"); + std::process::exit(1); + } + if args.is_empty() { + if let Err(error) = runtime::daemonize() { + eprintln!("ferese-lock: {error}"); + std::process::exit(1); + } + return Ok(()); + } + } + let user = auth::username().unwrap_or_else(|error| { + eprintln!("ferese-lock: {error}"); + std::process::exit(1) + }); + // Password-bearing UI processes must not create core dumps. + unsafe { + libc::prctl(libc::PR_SET_DUMPABLE, 0); + } + let appearance = appearance::Appearance::load(&user, config.as_deref()); + cosmic::app::run::( + Settings::default() + .no_main_window(!preview) + .is_daemon(!preview) + .client_decorations(false) + .default_text_size(14.) + .default_font(appearance.font) + .theme(appearance.theme()) + .antialiasing(true), + (preview, user, appearance), + ) +} + +#[derive(Clone)] +enum Message { + Event(Event), + Input(String), + Submit, + Authenticated(bool), + Tick, +} +// Never derive Debug for password-bearing messages. +impl std::fmt::Debug for Message { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("LockerMessage") + } +} +#[derive(Default, Debug, PartialEq)] +enum AuthState { + #[default] + Ready, + Checking, + Rejected(Instant), + Unlocking, +} +impl AuthState { + fn may_submit(&self) -> bool { + matches!(self, Self::Ready) + } + fn complete(&mut self, success: bool) -> bool { + if *self != Self::Checking { + return false; + } + *self = if success { + Self::Unlocking + } else { + Self::Rejected(Instant::now()) + }; + success + } +} +struct Locker { + core: Core, + preview: bool, + user: String, + appearance: appearance::Appearance, + password: Zeroizing, + state: AuthState, + status: &'static str, + outputs: HashMap, + started: bool, + clock: String, + date: String, + confirmation: runtime::Confirmation, + caps_lock: bool, +} +impl cosmic::Application for Locker { + type Executor = cosmic::executor::Default; + type Flags = (bool, String, appearance::Appearance); + type Message = Message; + const APP_ID: &'static str = "dev.ferese.Lock"; + fn core(&self) -> &Core { + &self.core + } + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + fn init(mut core: Core, (preview, user, appearance): Self::Flags) -> (Self, Task) { + core.window.show_headerbar = false; + let now = jiff::Zoned::now(); + let clock = now.strftime(appearance.clock_format()).to_string(); + let title_task = core + .main_window_id() + .map(|id| cosmic::command::set_title(id, "Ferese Lock Preview".to_owned())) + .unwrap_or_else(Task::none); + ( + Self { + core, + preview, + user, + appearance, + password: Zeroizing::new(String::new()), + state: AuthState::Ready, + status: "Press Enter to unlock", + outputs: HashMap::new(), + started: false, + confirmation: Default::default(), + caps_lock: false, + clock, + date: now.strftime("%A, %B %-d").to_string(), + }, + if preview { + Task::batch([title_task, focus()]) + } else { + session_lock::lock() + }, + ) + } + fn subscription(&self) -> Subscription { + Subscription::batch([ + event::listen_with(|event, _, _| match event { + Event::PlatformSpecific(PlatformSpecific::Wayland( + wayland::Event::Output(..) | wayland::Event::SessionLock(..), + )) + | Event::Window(window::Event::Opened { .. }) + | Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => { + Some(Message::Event(event)) + } + _ => None, + }), + iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick), + ]) + } + fn update(&mut self, message: Message) -> Task { + match message { + Message::Event(Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => { + self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK); + } + Message::Tick => { + let now = jiff::Zoned::now(); + self.clock = now.strftime(self.appearance.clock_format()).to_string(); + self.date = now.strftime("%A, %B %-d").to_string(); + if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) + { + self.state = AuthState::Ready; + } + } + Message::Input(mut value) => { + if self.state.may_submit() && value.len() <= 4096 { + self.password.zeroize(); + *self.password = std::mem::take(&mut value); + self.status = "Press Enter to unlock"; + } + value.zeroize(); + } + Message::Submit => { + if self.preview { + self.password.zeroize(); + self.status = "Preview only · your session is not locked"; + return focus(); + } + if !self.confirmation.confirmed() + || !self.state.may_submit() + || self.password.is_empty() + { + return Task::none(); + } + self.state = AuthState::Checking; + self.status = "Checking password…"; + let password = Zeroizing::new(std::mem::take(&mut *self.password)); + let user = self.user.clone(); + return Task::perform( + async move { + tokio::task::spawn_blocking(move || auth::authenticate(user, password)) + .await + .unwrap_or(false) + }, + |accepted| cosmic::Action::App(Message::Authenticated(accepted)), + ); + } + Message::Authenticated(accepted) => { + if self.state.complete(accepted) && !self.preview { + return session_lock::unlock(); + } + self.status = "Password not accepted. Try again."; + return focus(); + } + Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland( + wayland::Event::Output(event, output), + ))) if !self.preview => { + if matches!(event, wayland::OutputEvent::Removed) { + if let Some(id) = self.outputs.remove(&output) { + return session_lock::destroy_lock_surface(id); + } + } else if !self.outputs.contains_key(&output) { + let id = window::Id::unique(); + self.outputs.insert(output.clone(), id); + if self.started { + return session_lock::get_lock_surface(id, output); + } + } + } + Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland( + wayland::Event::SessionLock(event), + ))) if !self.preview => match event { + wayland::SessionLockEvent::Locked => { + if self.confirmation.observe() { + use std::io::Write; + if std::env::var_os("FERESE_LOCK_READY").is_some() { + let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED"); + } + } + if !self.started { + self.started = true; + return Task::batch(self.outputs.iter().map(|(output, id)| { + session_lock::get_lock_surface(*id, output.clone()) + })); + } + } + wayland::SessionLockEvent::Focused(..) => return focus(), + wayland::SessionLockEvent::Unlocked if self.state == AuthState::Unlocking => { + return iced::exit(); + } + wayland::SessionLockEvent::Finished | wayland::SessionLockEvent::NotSupported => { + eprintln!("ferese-lock: compositor refused the lock"); + return iced::exit(); + } + _ => {} + }, + Message::Event(Event::Window(window::Event::Opened { .. })) => return focus(), + _ => {} + } + Task::none() + } + fn on_escape(&mut self) -> Task { + self.password.zeroize(); + focus() + } + fn view(&self) -> Element<'_, Message> { + self.screen() + } + fn view_window(&self, _: window::Id) -> Element<'_, Message> { + self.screen() + } +} +fn focus() -> Task { + widget::text_input::focus(widget::Id::new("password")) +} +impl Locker { + fn label<'a>( + &self, + label: impl Into> + 'a, + size: u16, + ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + widget::text(label).font(self.appearance.font).size(size) + } + fn screen(&self) -> Element<'_, Message> { + iced::widget::responsive(move |size| self.screen_content(size)).into() + } + fn screen_content(&self, size: iced::Size) -> Element<'_, Message> { + let avatar_size = (size.height * 0.09).clamp(64., 112.); + let clock_size = (size.height * 0.165) + .min( + size.width + * if self.appearance.twelve_hour { + 0.075 + } else { + 0.12 + }, + ) + .clamp(40., 180.) as u16; + let margin = if size.width < 640. { 24. } else { 36. }; + let top = (size.height * 0.09).max(24.); + let a = &self.appearance; + let mut input = widget::text_input("Password", self.password.as_str()) + .password() + .padding([8, 18]) + .style(password_style(a.radius, a.accent, a.panel)) + .font(a.font) + .id(widget::Id::new("password")); + if self.state.may_submit() { + input = input.on_input(Message::Input).on_submit(|mut value| { + value.zeroize(); + Message::Submit + }); + } + let submit = widget::button::custom(widget::icon::from_name("go-next-symbolic").size(20)) + .class(theme::Button::Icon) + .padding(6) + .on_press_maybe(self.state.may_submit().then_some(Message::Submit)); + input = input.trailing_icon(container(submit).padding([0, 6]).into()); + let avatar: Element<'_, Message> = if let Some(photo) = &a.avatar { + image(photo.clone()) + .width(avatar_size) + .height(avatar_size) + .content_fit(iced::ContentFit::Cover) + .border_radius(avatar_size / 2.) + .into() + } else { + container(line_icon( + "M16 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M4 21v-2a8 8 0 0 1 16 0v2", + 46, + )) + .center_x(avatar_size) + .center_y(avatar_size) + .class(theme::Container::custom(move |_| { + widget::container::Style { + background: Some(iced::Background::Color(a.accent.scale_alpha(0.18))), + text_color: Some(a.accent), + border: iced::Border { + radius: (avatar_size / 2.).into(), + width: 1., + color: a.accent.scale_alpha(0.3), + }, + ..Default::default() + } + })) + .into() + }; + let identity = column![ + avatar, + self.label(&self.user, 16).class(iced::Color::WHITE), + widget::Space::new().height(6), + input, + self.label( + if self.caps_lock { + "Caps Lock is on" + } else { + self.status + }, + 12 + ) + .class(iced::Color::WHITE.scale_alpha(0.68)), + ] + .spacing(12) + .align_x(iced::Alignment::Center) + .width( + (size.height * 0.37) + .clamp(260., 420.) + .min((size.width - margin * 2.).max(160.)), + ); + let mut clock = column([]).spacing(0).align_x(iced::Alignment::Center); + if a.show_clock { + clock = clock.push( + self.label(&self.clock, clock_size) + .font(cosmic::font::Font { + weight: cosmic::iced::font::Weight::Light, + ..a.font + }) + .class(iced::Color::WHITE), + ); + } + if a.show_date { + clock = clock.push( + self.label(&self.date, 18) + .class(iced::Color::WHITE.scale_alpha(0.72)), + ); + } + let clock_height = if a.show_clock { + clock_size as f32 * 1.3 + } else { + 0. + } + if a.show_date { 24. } else { 0. }; + let gap = (size.height * 0.44 - top - clock_height).max(24.); + let foreground = container( + column![ + container(clock).center_x(Length::Fill), + widget::Space::new().height(gap), + container(identity).center_x(Length::Fill), + widget::Space::new().height(Length::Fill), + row![ + row![ferese_symbol(), self.label("Ferese", 13)] + .spacing(5) + .align_y(iced::Alignment::Center), + widget::Space::new().width(Length::Fill), + self.label( + if self.preview { + "Preview · your session is not locked" + } else { + "" + }, + 11 + ) + ] + .align_y(iced::Alignment::Center), + ] + .height(Length::Fill), + ) + .padding(iced::Padding { + top, + right: margin, + bottom: margin, + left: margin, + }) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(move |_| { + widget::container::Style { + text_color: Some(iced::Color::WHITE.scale_alpha(0.65)), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.025, 0.035, 0.06, a.dim, + ))), + ..Default::default() + } + })); + let background: Element<'_, Message> = image(a.wallpaper.clone()) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(iced::ContentFit::Cover) + .into(); + iced::widget::stack![background, foreground].into() + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_an_active_successful_attempt_unlocks() { + let mut state = AuthState::Ready; + assert!(!state.complete(true)); + state = AuthState::Checking; + assert!(!state.complete(false)); + assert!(!state.may_submit()); + assert!(!state.complete(true)); + state = AuthState::Checking; + assert!(state.complete(true)); + assert!(!state.complete(true)); + } + #[test] + fn password_messages_are_redacted() { + assert!(!format!("{:?}", Message::Input("secret".into())).contains("secret")); + } +} + +fn line_icon(path: &str, size: u16) -> widget::icon::Icon { + let svg = format!( + r#""# + ); + widget::icon(widget::icon::from_svg_bytes(svg.into_bytes()).symbolic(true)).size(size) +} +fn ferese_symbol() -> widget::icon::Icon { + widget::icon( + widget::icon::from_svg_bytes( + include_bytes!("../../../packaging/icons/ferese.svg").as_slice(), + ) + .symbolic(true), + ) + .size(30) +} + +fn password_style(radius: f32, accent: iced::Color, surface: iced::Color) -> theme::TextInput { + let appearance = move |focused: bool| widget::text_input::Appearance { + background: iced::Color::from_rgba( + (surface.r + 1.) * 0.5, + (surface.g + 1.) * 0.5, + (surface.b + 1.) * 0.5, + 0.24, + ) + .into(), + border_radius: (radius * 2.).min(26.).into(), + border_width: 1., + border_offset: None, + border_color: if focused { + accent.scale_alpha(0.7) + } else { + iced::Color::WHITE.scale_alpha(0.15) + }, + icon_color: Some(iced::Color::WHITE.scale_alpha(0.8)), + text_color: Some(iced::Color::WHITE), + placeholder_color: iced::Color::WHITE.scale_alpha(0.55), + selected_text_color: iced::Color::WHITE, + selected_fill: accent.scale_alpha(0.5), + label_color: iced::Color::WHITE, + }; + theme::TextInput::Custom { + active: Box::new(move |_| appearance(false)), + hovered: Box::new(move |_| appearance(true)), + focused: Box::new(move |_| appearance(true)), + error: Box::new(move |_| appearance(false)), + disabled: Box::new(move |_| appearance(false)), + } +} diff --git a/shell/ferese-lock/src/runtime.rs b/shell/ferese-lock/src/runtime.rs new file mode 100644 index 0000000..a25740d --- /dev/null +++ b/shell/ferese-lock/src/runtime.rs @@ -0,0 +1,100 @@ +//! Startup handshake: the invoking command exits successfully only after the +//! compositor's confirmation. The UI child remains alive for authentication. +use std::{ + io::{BufRead, BufReader}, + process::{Command, Stdio}, + time::Duration, +}; +use wayland_client::{ + Connection, Dispatch, QueueHandle, + globals::{GlobalListContents, registry_queue_init}, + protocol::wl_registry, +}; +struct Registry; +impl Dispatch for Registry { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} +pub fn check_protocol() -> Result<(), String> { + let connection = + Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?; + let (globals, _) = registry_queue_init::(&connection) + .map_err(|e| format!("Wayland registry: {e}"))?; + if globals + .contents() + .clone_list() + .iter() + .any(|g| g.interface == "ext_session_lock_manager_v1") + { + Ok(()) + } else { + Err("the compositor does not support secure session locking".into()) + } +} +pub fn daemonize() -> Result<(), String> { + let executable = std::env::current_exe().map_err(|e| format!("Locker executable: {e}"))?; + let mut child = Command::new(&executable) + .arg("--foreground") + .env("FERESE_LOCK_READY", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("Starting locker {}: {e}", executable.display()))?; + let stdout = child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + let ready = reader.read_line(&mut line).is_ok() && line.trim_end() == "FERESE_LOCKED"; + let _ = tx.send(ready); + }); + match rx.recv_timeout(Duration::from_secs(15)) { + Ok(true) => Ok(()), + Ok(false) => { + let _ = child.wait(); + Err("locker exited without confirming protection".into()) + } + Err(_) => Err("lock confirmation timed out; the locker was left running".into()), + } +} + +/// libcosmic revision 03d7dcb emits one synthetic Locked on the request and +/// another from SCTK's actual protocol callback. Requiring both is essential: +/// never release swayidle's delay inhibitor on the synthetic notification. +/// Re-audit iced/winit's session_lock Action::Lock and SessionLockHandler when +/// updating libcosmic. A missing callback times out closed, never signals ready. +#[derive(Default)] +pub struct Confirmation { + events: u8, +} +impl Confirmation { + pub fn observe(&mut self) -> bool { + self.events = self.events.saturating_add(1); + self.events == 2 + } + pub fn confirmed(&self) -> bool { + self.events >= 2 + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn synthetic_event_does_not_confirm_a_lock() { + let mut c = Confirmation::default(); + assert!(!c.confirmed()); + assert!(!c.observe()); + assert!(!c.confirmed()); + assert!(c.observe()); + assert!(c.confirmed()); + assert!(!c.observe()); + } +} diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 37ad9ac..f041ed5 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -58,6 +58,8 @@ enum Message { Undo, Preset(usize), PickWallpaper, + PreviewLock, + LockPreviewStarted(Result<(), String>), AddNote, NoteAction(String, widget::text_editor::Action), SaveNote(String, u64), @@ -248,6 +250,35 @@ impl cosmic::Application for App { } Err(error) => self.error = Some(format!("Config reload failed: {error}")), }, + Message::PreviewLock => { + let path = self.path.clone(); + return Task::perform( + async move { + let binary = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.join("ferese-lock"))) + .filter(|p| p.is_file()) + .unwrap_or_else(|| "ferese-lock".into()); + std::process::Command::new(binary) + .arg("--preview") + .arg("--config") + .arg(path) + .spawn() + .map(|mut child| { + std::thread::spawn(move || { + let _ = child.wait(); + }); + }) + .map_err(|error| format!("Could not open the lock preview: {error}")) + }, + |result| cosmic::Action::App(Message::LockPreviewStarted(result)), + ); + } + Message::LockPreviewStarted(result) => { + if let Err(error) = result { + self.error = Some(error); + } + } Message::Page(page) => { self.page = page; self.search.clear(); @@ -671,6 +702,13 @@ impl cosmic::Application for App { } match self.page { + Page::LockScreen => { + body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")); + body = body.push( + button::standard("Preview lock screen").on_press(Message::PreviewLock), + ); + body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); + } Page::Desktop => { let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 9db4bc1..da7d1a8 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -5,6 +5,7 @@ pub enum Page { Desktop, Bar, Notifications, + LockScreen, Windows, Motion, Keyboard, @@ -14,12 +15,13 @@ pub enum Page { } impl Page { - pub const ALL: [Self; 11] = [ + pub const ALL: [Self; 12] = [ Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar, Self::Notifications, + Self::LockScreen, Self::Windows, Self::Motion, Self::Keyboard, @@ -35,6 +37,7 @@ impl Page { Self::Desktop => "Desktop widgets", Self::Bar => "Menu bar", Self::Notifications => "Notifications", + Self::LockScreen => "Lock screen", Self::Windows => "Windows", Self::Motion => "Motion", Self::Keyboard => "Keyboard & mouse", @@ -51,6 +54,7 @@ impl Page { Self::Desktop => "A clock that feels at home on your wallpaper.", Self::Bar => "Everything you need, within reach.", Self::Notifications => "Stay informed on your terms.", + Self::LockScreen => "Your desktop, safely put away.", Self::Windows => "Make room for the way you work.", Self::Motion => "Find your rhythm.", Self::Keyboard => "Fine-tune the everyday details.", @@ -69,6 +73,7 @@ impl Page { Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", + Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z", Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5", Self::Keyboard => { @@ -544,6 +549,49 @@ pub fn note_fields(index: usize) -> Vec { pub fn fields(page: Page) -> Vec { match page { + Page::LockScreen => vec![ + toggle( + "lock_screen.show_clock", + "Show clock", + "Display the time above the sign-in card.", + true, + ), + toggle( + "lock_screen.show_date", + "Show date", + "Display the weekday and date.", + true, + ), + choice( + "lock_screen.clock_format", + "Clock format", + "Use a 12-hour or 24-hour clock.", + "24h", + &[("24h", "24-hour"), ("12h", "12-hour")], + ), + range( + "lock_screen.background_blur", + "Wallpaper softness", + "Blur the lock screen wallpaper. Zero keeps the original image sharp.", + 18.0, + 0.0, + 40.0, + 1.0, + "", + false, + ), + range( + "lock_screen.background_dim", + "Wallpaper dimming", + "Darken the wallpaper behind the clock and sign-in card.", + 0.48, + 0.0, + 0.9, + 0.02, + "", + false, + ), + ], Page::Appearance => vec![ range( "theme.geometry.shell_radius",