Add native themed session locker and Settings integration

This commit is contained in:
Samuel Onoja committed 2026-09-27 12:15:55 +01:00
1 parent 8eca921345
commit f8b8704f8b
18 files changed
+1422 -19

No files matched your search

Generated
+22
View File
@@ -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"
+1
View File
@@ -10,6 +10,7 @@ members = [
"crates/ferese-protocols",
"shell/ferese-shell",
"shell/ferese-settings",
"shell/ferese-lock",
]
resolver = "3"
+10 -4
View File
@@ -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
+66
View File
@@ -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).
-7
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#%PAM-1.0
@include common-auth
@include common-account
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#%PAM-1.0
auth include login
account include login
+19 -4
View File
@@ -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."
+3 -3
View File
@@ -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.'
+61
View File
@@ -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()
+20
View File
@@ -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
+216
View File
@@ -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<image::Handle>,
}
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<Color> {
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<image::Handle> {
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]);
}
}
+267
View File
@@ -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<Vec<u8>>,
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::<Response>()) 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<String, String> {
// 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<String>) -> 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::<Start>(b"pam_start\0"),
library.get::<Check>(b"pam_authenticate\0"),
library.get::<Check>(b"pam_acct_mgmt\0"),
library.get::<Check>(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::<StartConf>(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());
}
}
+540
View File
@@ -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::<Locker>(
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<String>,
state: AuthState,
status: &'static str,
outputs: HashMap<WlOutput, window::Id>,
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<Message>) {
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<Message> {
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<Message> {
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<Message> {
self.password.zeroize();
focus()
}
fn view(&self) -> Element<'_, Message> {
self.screen()
}
fn view_window(&self, _: window::Id) -> Element<'_, Message> {
self.screen()
}
}
fn focus() -> Task<Message> {
widget::text_input::focus(widget::Id::new("password"))
}
impl Locker {
fn label<'a>(
&self,
label: impl Into<std::borrow::Cow<'a, str>> + '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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"#
);
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)),
}
}
+100
View File
@@ -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<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
pub fn check_protocol() -> Result<(), String> {
let connection =
Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
let (globals, _) = registry_queue_init::<Registry>(&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());
}
}
+38
View File
@@ -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);
+49 -1
View File
@@ -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<Field> {
pub fn fields(page: Page) -> Vec<Field> {
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",