Complete polkit authentication dialog and harden session handling

This commit is contained in:
Samuel Onoja committed 2026-10-01 09:46:18 +01:00
1 parent 4137f787bf
commit cb6fe73e44
4 files changed
+1207 -139

No files matched your search

+421 -47
View File
@@ -5,11 +5,11 @@ use std::time::Duration;
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{Alignment, Color, Length, Size, Subscription, window};
use cosmic::widget::{button, column, container, row};
use cosmic::widget::{button, column, container, row, scrollable};
use cosmic::{Element, theme, widget};
use zeroize::{Zeroize, Zeroizing};
use crate::PromptEvent;
use crate::{AuthenticationRequest, Generation, PromptEvent};
#[derive(Clone)]
struct Appearance {
@@ -69,12 +69,14 @@ impl Appearance {
#[derive(Clone)]
enum Message {
ThemeChanged(ferese_config::theme::Snapshot),
ThemeChanged(Box<ferese_config::theme::Snapshot>),
WindowOpened(cosmic::iced::window::Id),
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
Tick,
Input(String),
Submit,
SelectIdentity(usize),
ToggleDetails,
Cancel,
}
@@ -89,12 +91,15 @@ struct Prompt {
material: Option<ferese_theme::material::ModalMaterial>,
appearance: Appearance,
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
description: String,
user: String,
request: AuthenticationRequest,
identity_names: Vec<String>,
details_expanded: bool,
action_icon: Option<widget::icon::Handle>,
question: String,
echo: bool,
answer: Zeroizing<String>,
waiting: bool,
awaiting_ack: bool,
error: Option<String>,
info: Option<String>,
height: f32,
@@ -109,28 +114,56 @@ fn content_height(description: &str, status: Option<&str>) -> f32 {
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
}
fn dialog_height(request: &AuthenticationRequest, status: Option<&str>, expanded: bool) -> f32 {
let extra = if request.identities.len() > 1 { 54. } else { 0. }
+ if has_details(request) { 40. } else { 0. }
+ if expanded { 96. } else { 0. };
(content_height(&request.message, status) + extra).min(360.)
}
fn has_details(request: &AuthenticationRequest) -> bool {
!request.details.is_empty() || !request.vendor_name.is_empty() || !request.vendor_url.is_empty()
}
fn action_icon(name: &str) -> Option<widget::icon::Handle> {
if name.is_empty() {
return None;
}
// Resolve once; a missing icon uses Ferese's embedded lock, never a blank header.
widget::icon::from_name(name.to_owned())
.size(20)
.fallback(None)
.path()
.map(widget::icon::from_path)
}
fn read_events(lines: impl Iterator<Item = std::io::Result<String>>, queue: &Mutex<VecDeque<PromptEvent>>) {
for line in lines {
let Ok(line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
queue.lock().unwrap().push_back(event);
}
}
// The agent owns this pipe. EOF (including agent death) closes the prompt.
queue.lock().unwrap().push_back(PromptEvent::Cancel);
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let stdin = std::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let Some(Ok(first)) = lines.next() else {
return Err("Missing authentication request".into());
};
let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else {
let PromptEvent::Start(request) = serde_json::from_str(&first)? else {
return Err("Invalid authentication request".into());
};
let incoming = Arc::new(Mutex::new(VecDeque::new()));
let queue = incoming.clone();
std::thread::spawn(move || {
for line in lines {
let Ok(line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
queue.lock().unwrap().push_back(event);
}
}
queue.lock().unwrap().push_back(PromptEvent::Cancel);
read_events(lines, &queue);
});
let appearance = Appearance::load();
let height = content_height(&message, None);
let height = dialog_height(&request, None, false);
cosmic::app::run::<Prompt>(
Settings::default()
.size(Size::new(434., height))
@@ -140,7 +173,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
.default_font(appearance.font)
.default_text_size(14.)
.antialiasing(true),
(incoming, message, user, appearance),
(incoming, request, appearance),
)?;
Ok(())
}
@@ -152,7 +185,7 @@ fn reply(mut event: PromptEvent) {
let _ = stdout.flush();
line.zeroize();
}
if let PromptEvent::Response { value } = &mut event {
if let PromptEvent::Response { value, .. } = &mut event {
value.zeroize();
}
}
@@ -162,8 +195,36 @@ fn focus() -> Task<Message> {
}
impl Prompt {
fn reset_authentication(&mut self, uid: u32) {
self.request.selected_uid = uid;
self.answer.zeroize();
self.error = None;
self.info = None;
self.waiting = true;
self.echo = false;
self.question = "Waiting for authentication…".into();
}
fn select_identity(&mut self, index: usize) -> Option<u32> {
let uid = self.request.identities.get(index)?.uid;
if uid == self.request.selected_uid {
return None;
}
self.request.generation = Generation {
selection: self.request.generation.selection.checked_add(1)?,
attempt: 0,
};
self.reset_authentication(uid);
self.awaiting_ack = true;
Some(uid)
}
fn resize_to_content(&mut self) -> Task<Message> {
let height = content_height(&self.description, self.error.as_deref().or(self.info.as_deref()));
let height = dialog_height(
&self.request,
self.error.as_deref().or(self.info.as_deref()),
self.details_expanded,
);
if (height - self.height).abs() < 1.0 {
return Task::none();
}
@@ -177,7 +238,7 @@ impl Prompt {
impl cosmic::Application for Prompt {
type Executor = cosmic::executor::Default;
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, String, String, Appearance);
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, AuthenticationRequest, Appearance);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Authentication";
@@ -197,12 +258,18 @@ impl cosmic::Application for Prompt {
}))
}
fn init(mut core: Core, (incoming, description, user, appearance): Self::Flags) -> (Self, Task<Message>) {
fn init(mut core: Core, (incoming, request, appearance): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
core.window.content_container = false;
core.window.use_template = false;
let height = content_height(&description, None);
let height = dialog_height(&request, None, false);
let identity_names = request
.identities
.iter()
.map(|identity| identity.name.clone())
.collect();
let action_icon = action_icon(&request.icon_name);
let bounds = core
.main_window_id()
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
@@ -213,12 +280,15 @@ impl cosmic::Application for Prompt {
material: None,
appearance,
incoming,
description,
user,
request,
identity_names,
details_expanded: false,
action_icon,
question: "Waiting for authentication…".into(),
echo: false,
answer: Zeroizing::new(String::new()),
waiting: true,
awaiting_ack: true,
error: None,
info: None,
height,
@@ -229,7 +299,7 @@ impl cosmic::Application for Prompt {
fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged),
ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
@@ -258,16 +328,42 @@ impl cosmic::Application for Prompt {
let mut refocus = false;
for event in events {
match event {
PromptEvent::Request { prompt, echo } => {
PromptEvent::IdentityChanged { generation, uid }
if uid == self.request.selected_uid
&& generation.selection == self.request.generation.selection
&& (generation.attempt > self.request.generation.attempt
|| (generation == self.request.generation && self.awaiting_ack)) =>
{
self.request.generation = generation;
self.reset_authentication(uid);
self.awaiting_ack = false;
}
PromptEvent::Request {
generation,
prompt,
echo,
} if generation == self.request.generation && !self.awaiting_ack => {
self.answer.zeroize();
self.question = prompt;
self.echo = echo;
self.waiting = false;
refocus = true;
}
PromptEvent::Info { text } => self.info = Some(text),
PromptEvent::Error { text } => self.error = Some(text),
PromptEvent::Cancel => return cosmic::iced::exit(),
PromptEvent::Info { generation, text }
if generation == self.request.generation && !self.awaiting_ack =>
{
self.info = Some(text)
}
PromptEvent::Error { generation, text }
if generation == self.request.generation && !self.awaiting_ack =>
{
self.error = Some(text)
}
PromptEvent::Cancel => {
self.answer.zeroize();
self.waiting = true;
return cosmic::iced::exit();
}
_ => {}
}
}
@@ -287,9 +383,25 @@ impl cosmic::Application for Prompt {
}
Message::Submit if !self.waiting && !self.answer.is_empty() => {
let value = std::mem::take(&mut *self.answer);
reply(PromptEvent::Response { value });
reply(PromptEvent::Response {
generation: self.request.generation,
uid: self.request.selected_uid,
value,
});
self.waiting = true;
}
Message::SelectIdentity(index) => {
if let Some(uid) = self.select_identity(index) {
reply(PromptEvent::SelectIdentity {
generation: self.request.generation,
uid,
});
}
}
Message::ToggleDetails => {
self.details_expanded = !self.details_expanded;
return self.resize_to_content();
}
Message::Cancel => {
self.answer.zeroize();
reply(PromptEvent::Cancel);
@@ -325,7 +437,19 @@ impl cosmic::Application for Prompt {
.height(Length::Fixed(36.))
.padding([8, 16])
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
let lock_icon = ferese_theme::icons::tinted(ferese_theme::icons::LOCK, 20, palette.accent);
let lock_icon = if let Some(icon) = &self.action_icon {
widget::icon(icon.clone()).size(20)
} else {
ferese_theme::icons::tinted(ferese_theme::icons::LOCK, 20, palette.accent)
};
let selected = self
.request
.identities
.iter()
.position(|identity| identity.uid == self.request.selected_uid);
let user = selected
.map(|index| self.request.identities[index].name.as_str())
.unwrap_or("Unknown account");
let heading = row![
container(lock_icon)
.width(40)
@@ -335,7 +459,7 @@ impl cosmic::Application for Prompt {
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)),
column![
ferese_theme::text("Authentication required", palette.font).size(18),
ferese_theme::text(format!("Confirm as {}", self.user), palette.font)
ferese_theme::text(format!("Confirm as {user}"), palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
]
@@ -343,24 +467,81 @@ impl cosmic::Application for Prompt {
]
.spacing(12)
.align_y(Alignment::Center);
let mut body = column![
heading,
ferese_theme::text(&self.description, palette.font).size(13),
input
]
.spacing(14)
.width(Length::Fill);
if let Some(error) = &self.error {
body = body.push(
ferese_theme::text(error, palette.font)
.size(12)
.class(theme::Text::Color(Color::from_rgb8(235, 98, 98))),
let mut information = column![ferese_theme::text(&self.request.message, palette.font).size(13)]
.spacing(10)
.width(Length::Fill);
if has_details(&self.request) {
information = information.push(
button::custom(
ferese_theme::text(
if self.details_expanded {
"Hide details"
} else {
"Show details"
},
palette.font,
)
.size(12),
)
.class(ferese_theme::controls::button_style(palette.palette(), false))
.padding([6, 10])
.on_press(Message::ToggleDetails),
);
} else if let Some(info) = &self.info {
if self.details_expanded {
let mut details = column![].spacing(6).width(Length::Fill);
for (key, value) in &self.request.details {
details = details.push(detail_row(key, value, palette));
}
for (label, value) in [
("Vendor", &self.request.vendor_name),
("Vendor URL", &self.request.vendor_url),
] {
if !value.is_empty() {
details = details.push(detail_row(label, value, palette));
}
}
information = information.push(details);
}
}
let mut body = column![heading, scrollable(information).height(Length::Fill)]
.spacing(14)
.width(Length::Fill);
if self.request.identities.len() > 1 {
body = body.push(
ferese_theme::text(info, palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
container(
row![
ferese_theme::text("Account", palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
widget::dropdown(&self.identity_names[..], selected, Message::SelectIdentity)
.width(Length::Fill),
ferese_theme::icons::tinted(ferese_theme::icons::CHEVRON_DOWN, 16, palette.muted),
]
.spacing(10)
.align_y(Alignment::Center),
)
.padding([4, 10])
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.08), 8.)),
);
}
body = body.push(input);
if let Some((status, color)) = self
.error
.as_deref()
.map(|text| (text, Color::from_rgb8(235, 98, 98)))
.or_else(|| self.info.as_deref().map(|text| (text, palette.muted)))
{
let lines: usize = status
.lines()
.map(|line| line.chars().count().div_ceil(48).max(1))
.sum();
body = body.push(
scrollable(
ferese_theme::text(status, palette.font)
.size(12)
.class(theme::Text::Color(color)),
)
.height(Length::Fixed(18. * lines.clamp(1, 3) as f32)),
);
}
body = body.push(
@@ -382,9 +563,202 @@ impl cosmic::Application for Prompt {
}
}
fn detail_row<'a>(key: &'a str, value: &'a str, palette: &Appearance) -> Element<'a, Message> {
row![
ferese_theme::text(format!("{key}:"), palette.font)
.size(12)
.class(theme::Text::Color(palette.muted))
.width(Length::Fixed(106.)),
ferese_theme::text(value, palette.font).size(12).width(Length::Fill),
]
.spacing(8)
.into()
}
#[cfg(test)]
mod tests {
use super::content_height;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use cosmic::app::Core;
use super::{Appearance, Message, Prompt, action_icon, content_height, dialog_height, has_details};
use crate::tests::request_fixture;
#[test]
fn agent_pipe_eof_cancels_the_prompt_and_clears_the_password() {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use cosmic::Application;
use crate::PromptEvent;
let (reader, mut writer) = UnixStream::pair().unwrap();
let incoming = Arc::new(Mutex::new(VecDeque::new()));
let queue = incoming.clone();
let reader_thread = std::thread::spawn(move || super::read_events(BufReader::new(reader).lines(), &queue));
writeln!(
writer,
"{{\"type\":\"request\",\"generation\":{{\"selection\":0,\"attempt\":0}},\"prompt\":\"Password:\",\"echo\":false}}"
)
.unwrap();
drop(writer);
reader_thread.join().unwrap();
assert!(matches!(incoming.lock().unwrap().back(), Some(PromptEvent::Cancel)));
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default());
let (mut prompt, _) = Prompt::init(Core::default(), (incoming, request_fixture(), appearance));
*prompt.answer = "test-password".into();
prompt.waiting = false;
let _ = prompt.update(Message::Tick);
assert!(prompt.answer.is_empty());
assert!(prompt.waiting);
}
#[test]
fn stale_prompts_and_acknowledgements_cannot_undo_a_newer_selection() {
use cosmic::Application;
use crate::{Generation, PromptEvent};
let incoming = Arc::new(Mutex::new(VecDeque::new()));
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default());
let (mut prompt, _) = Prompt::init(Core::default(), (incoming.clone(), request_fixture(), appearance));
assert_eq!(prompt.select_identity(1), Some(1001));
let older = prompt.request.generation;
assert_eq!(prompt.select_identity(0), Some(1000));
let current = prompt.request.generation;
incoming.lock().unwrap().extend([
PromptEvent::IdentityChanged {
generation: older,
uid: 1001,
},
PromptEvent::Request {
generation: older,
prompt: "Old visible prompt".into(),
echo: true,
},
PromptEvent::IdentityChanged {
generation: Generation::default(),
uid: 1000,
},
PromptEvent::Request {
generation: Generation::default(),
prompt: "Original visible prompt".into(),
echo: true,
},
PromptEvent::Info {
generation: older,
text: "Old info".into(),
},
PromptEvent::Error {
generation: older,
text: "Old error".into(),
},
]);
let _ = prompt.update(Message::Tick);
assert_eq!(prompt.request.selected_uid, 1000);
assert_eq!(prompt.request.generation, current);
assert!(prompt.waiting && prompt.awaiting_ack);
assert!(!prompt.echo);
assert!(prompt.info.is_none() && prompt.error.is_none());
incoming.lock().unwrap().extend([
PromptEvent::IdentityChanged {
generation: current,
uid: 1000,
},
PromptEvent::Request {
generation: current,
prompt: "Current password".into(),
echo: false,
},
PromptEvent::IdentityChanged {
generation: current,
uid: 1000,
},
PromptEvent::Request {
generation: older,
prompt: "Delayed visible prompt".into(),
echo: true,
},
]);
let _ = prompt.update(Message::Tick);
assert!(!prompt.waiting && !prompt.awaiting_ack && !prompt.echo);
assert_eq!(prompt.question, "Current password");
// A fresh retry gets its own generation, even for the same account.
let retry = Generation { attempt: 1, ..current };
incoming.lock().unwrap().extend([
PromptEvent::IdentityChanged {
generation: retry,
uid: 1000,
},
PromptEvent::Request {
generation: retry,
prompt: "Retry password".into(),
echo: false,
},
PromptEvent::Request {
generation: current,
prompt: "Old retry prompt".into(),
echo: true,
},
PromptEvent::IdentityChanged {
generation: current,
uid: 1000,
},
]);
let _ = prompt.update(Message::Tick);
assert_eq!(prompt.request.generation, retry);
assert_eq!(prompt.question, "Retry password");
assert!(!prompt.waiting && !prompt.echo);
}
#[test]
fn selecting_another_account_clears_password_and_waits_for_a_fresh_prompt() {
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default());
let (mut prompt, _) = <Prompt as cosmic::Application>::init(
Core::default(),
(Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance),
);
*prompt.answer = "test-response".into();
prompt.waiting = false;
prompt.echo = true;
prompt.error = Some("Old account error".into());
assert_eq!(prompt.select_identity(1), Some(1001));
assert!(prompt.answer.is_empty());
assert!(prompt.error.is_none());
assert!(prompt.waiting);
assert!(!prompt.echo);
assert_eq!(prompt.request.selected_uid, 1001);
assert_eq!(prompt.select_identity(1), None);
assert_eq!(prompt.select_identity(20), None);
let _ = <Prompt as cosmic::Application>::update(&mut prompt, Message::ToggleDetails);
assert!(prompt.details_expanded);
assert!(prompt.height <= 360.);
}
#[test]
fn full_requests_and_expanded_details_keep_the_height_cap() {
let request = request_fixture();
assert_eq!(dialog_height(&request, None, false), 360.);
assert_eq!(dialog_height(&request, Some("Password not accepted"), true), 360.);
assert!(has_details(&request));
let mut short = request;
short.message = "Short request".into();
short.identities.truncate(1);
assert!(dialog_height(&short, None, true) > dialog_height(&short, None, false));
short.details.clear();
short.vendor_name.clear();
short.vendor_url.clear();
assert!(!has_details(&short));
}
#[test]
fn absent_or_unknown_action_icons_use_the_embedded_lock() {
assert!(action_icon("").is_none());
assert!(action_icon("ferese-nonexistent-action-icon-for-test").is_none());
}
#[test]
fn dialog_grows_for_long_messages_and_caps_its_height() {