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

+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());
}
}