workspace: add bread-app crate and first-cut bread-polkit agent
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Successful in 43s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 1m46s
release bakery / build (push) Failing after 52s
release bread-theme / build (push) Failing after 14s
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Successful in 43s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 1m46s
release bakery / build (push) Failing after 52s
release bread-theme / build (push) Failing after 14s
bread-app is the GTK bootstrap new tools should use instead of another copied main.rs: com.breadway.* app id, singleton lock, optional gtk_popup re-export, optional bread.command.<app>.** listen loop. Tests cover app-id helpers and command-verb parse. Existing apps are not migrated. bread-polkit is an own PolicyKit1 session authentication agent with a bread-theme GTK4 password prompt (not a polkit-gnome wrapper). Autostart via contrib/bread-polkit.desktop or exec-once. Not a bakery product; not added to the BOS ISO lockfile.
This commit is contained in:
parent
c296d26408
commit
11c0e844e5
19 changed files with 2049 additions and 32 deletions
300
bread-polkit/src/agent.rs
Normal file
300
bread-polkit/src/agent.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! Session-bus registration and the PolicyKit1 AuthenticationAgent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gtk4::glib;
|
||||
use gtk4::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use zbus::zvariant::{OwnedValue, Type, Value};
|
||||
use zbus::{connection, interface, proxy, DBusError};
|
||||
|
||||
use bread_polkit::helper::{discover_transport, Transport};
|
||||
use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser};
|
||||
use bread_polkit::session::session_id;
|
||||
|
||||
use crate::auth::{self, Outcome};
|
||||
use crate::ui::{self, Prompt};
|
||||
|
||||
pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent";
|
||||
|
||||
/// Reply from the GTK prompt.
|
||||
#[derive(Debug)]
|
||||
pub enum UserAction {
|
||||
Submit { username: String, password: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, DBusError)]
|
||||
#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")]
|
||||
enum AgentError {
|
||||
#[zbus(error)]
|
||||
ZBus(zbus::Error),
|
||||
Failed(String),
|
||||
Cancelled(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Type)]
|
||||
struct Identity {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Type)]
|
||||
struct Subject {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[proxy(
|
||||
interface = "org.freedesktop.PolicyKit1.Authority",
|
||||
default_service = "org.freedesktop.PolicyKit1",
|
||||
default_path = "/org/freedesktop/PolicyKit1/Authority"
|
||||
)]
|
||||
trait Authority {
|
||||
fn register_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
locale: &str,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
fn unregister_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
transport: Transport,
|
||||
pending: Arc<Mutex<Option<mpsc::Sender<UserAction>>>>,
|
||||
}
|
||||
|
||||
#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
||||
impl Agent {
|
||||
async fn begin_authentication(
|
||||
&mut self,
|
||||
action_id: String,
|
||||
message: String,
|
||||
_icon_name: String,
|
||||
_details: HashMap<String, String>,
|
||||
cookie: String,
|
||||
identities: Vec<Identity>,
|
||||
) -> Result<(), AgentError> {
|
||||
tracing::info!(%action_id, %cookie, "BeginAuthentication");
|
||||
|
||||
let users = unix_users(&identities);
|
||||
let username = pick_user(&users, current_uid())
|
||||
.map(|u| u.name.clone())
|
||||
.ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?;
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
*self.pending.lock().await = Some(tx.clone());
|
||||
|
||||
let prompt = Prompt {
|
||||
cookie: cookie.clone(),
|
||||
message: message.clone(),
|
||||
action_id: action_id.clone(),
|
||||
username: username.clone(),
|
||||
reply: tx,
|
||||
};
|
||||
invoke_ui(move || {
|
||||
if let Some(app) = running_app() {
|
||||
ui::show_prompt(&app, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
let result = self.drive_prompt(&cookie, &username, &mut rx).await;
|
||||
|
||||
*self.pending.lock().await = None;
|
||||
let cookie_close = cookie.clone();
|
||||
invoke_ui(move || ui::close_prompt(&cookie_close));
|
||||
result
|
||||
}
|
||||
|
||||
async fn cancel_authentication(&self, cookie: String) {
|
||||
tracing::info!(%cookie, "CancelAuthentication");
|
||||
if let Some(tx) = self.pending.lock().await.as_ref() {
|
||||
let _ = tx.try_send(UserAction::Cancel);
|
||||
}
|
||||
invoke_ui(move || ui::close_prompt(&cookie));
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
async fn drive_prompt(
|
||||
&self,
|
||||
cookie: &str,
|
||||
default_user: &str,
|
||||
rx: &mut mpsc::Receiver<UserAction>,
|
||||
) -> Result<(), AgentError> {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
None => {
|
||||
return Err(AgentError::Cancelled("authentication prompt closed".into()));
|
||||
}
|
||||
Some(UserAction::Cancel) => {
|
||||
return Err(AgentError::Cancelled("user cancelled".into()));
|
||||
}
|
||||
Some(UserAction::Submit { username, password }) => {
|
||||
let user = if username.is_empty() {
|
||||
default_user
|
||||
} else {
|
||||
username.as_str()
|
||||
};
|
||||
match auth::authenticate(&self.transport, user, cookie, &password).await {
|
||||
Ok(Outcome::Success) => return Ok(()),
|
||||
Ok(Outcome::Failure { message }) => {
|
||||
let text = message
|
||||
.unwrap_or_else(|| auth::default_failure_message().to_string());
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("helper: {e:#}");
|
||||
let text = e.to_string();
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_users(identities: &[Identity]) -> Vec<UnixUser> {
|
||||
let mut uids = Vec::new();
|
||||
for identity in identities {
|
||||
if identity.kind != "unix-user" {
|
||||
continue;
|
||||
}
|
||||
if let Some(uid) = uid_from_details(&identity.details) {
|
||||
uids.push(uid);
|
||||
}
|
||||
}
|
||||
users_from_uids(&uids, &read_passwd())
|
||||
}
|
||||
|
||||
fn uid_from_details(details: &HashMap<String, OwnedValue>) -> Option<u32> {
|
||||
let value = details.get("uid")?;
|
||||
u32::try_from(value).ok().or_else(|| {
|
||||
i32::try_from(value)
|
||||
.ok()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn running_app() -> Option<gtk4::Application> {
|
||||
gtk4::gio::Application::default().and_then(|app| app.downcast::<gtk4::Application>().ok())
|
||||
}
|
||||
|
||||
/// GTK thread-default context, captured in [`spawn`] so the dbus thread
|
||||
/// can `invoke` onto the UI thread instead of its own empty context.
|
||||
static GTK_CTX: OnceLock<glib::MainContext> = OnceLock::new();
|
||||
|
||||
fn invoke_ui(f: impl FnOnce() + Send + 'static) {
|
||||
let ctx = GTK_CTX
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(glib::MainContext::default);
|
||||
ctx.invoke(f);
|
||||
}
|
||||
|
||||
fn unix_session_subject(id: &str) -> Result<Subject> {
|
||||
let value = Value::from(id.to_string());
|
||||
let owned = OwnedValue::try_from(value).context("session-id variant")?;
|
||||
let mut details = HashMap::new();
|
||||
details.insert("session-id".into(), owned);
|
||||
Ok(Subject {
|
||||
kind: "unix-session".into(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the system-bus agent on a background thread. Returns once the
|
||||
/// thread has been started; registration errors quit the GTK app.
|
||||
///
|
||||
/// Must be called from the GTK thread so the main context we capture is
|
||||
/// the one driving the password prompt.
|
||||
pub fn spawn() -> Result<()> {
|
||||
let _ = GTK_CTX.set(glib::MainContext::default());
|
||||
std::thread::Builder::new()
|
||||
.name("bread-polkit-dbus".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
invoke_ui(move || {
|
||||
eprintln!("bread-polkit: tokio runtime failed: {e}");
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
invoke_ui(|| {
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.context("spawn dbus thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let transport = discover_transport().context(
|
||||
"no polkit helper: expected /run/polkit/agent-helper.socket \
|
||||
or /usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
)?;
|
||||
tracing::info!(?transport, "using polkit helper");
|
||||
|
||||
let session = session_id().context(
|
||||
"no session id (XDG_SESSION_ID / /proc/self/sessionid); \
|
||||
cannot register a session authentication agent",
|
||||
)?;
|
||||
let subject = unix_session_subject(&session)?;
|
||||
let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into());
|
||||
|
||||
let agent = Agent {
|
||||
transport,
|
||||
pending: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
let connection = connection::Builder::system()?
|
||||
.serve_at(OBJECT_PATH, agent)?
|
||||
.build()
|
||||
.await
|
||||
.context("system bus")?;
|
||||
|
||||
let authority = AuthorityProxy::new(&connection)
|
||||
.await
|
||||
.context("PolicyKit1 authority proxy")?;
|
||||
authority
|
||||
.register_authentication_agent(&subject, &locale, OBJECT_PATH)
|
||||
.await
|
||||
.context("RegisterAuthenticationAgent")?;
|
||||
tracing::info!(%session, "registered as PolicyKit authentication agent");
|
||||
|
||||
std::future::pending::<()>().await;
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let _ = authority
|
||||
.unregister_authentication_agent(&subject, OBJECT_PATH)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
bread-polkit/src/auth.rs
Normal file
109
bread-polkit/src/auth.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! PAM conversation with the polkit agent helper.
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::process::Command;
|
||||
|
||||
use bread_polkit::helper::{parse_helper_line, HelperLine, Transport};
|
||||
|
||||
/// Outcome of one helper conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Success,
|
||||
Failure { message: Option<String> },
|
||||
}
|
||||
|
||||
/// Handshake + PAM loop for one password attempt.
|
||||
pub async fn authenticate(
|
||||
transport: &Transport,
|
||||
username: &str,
|
||||
cookie: &str,
|
||||
password: &str,
|
||||
) -> Result<Outcome> {
|
||||
match transport {
|
||||
Transport::Socket(path) => {
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.with_context(|| format!("connect {}", path.display()))?;
|
||||
stream.write_all(username.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.write_all(cookie.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
let (reader, writer) = stream.into_split();
|
||||
converse(BufReader::new(reader), writer, password).await
|
||||
}
|
||||
Transport::Exec(path) => {
|
||||
let mut child = Command::new(path)
|
||||
.arg(username)
|
||||
.env("LC_ALL", "C")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn {}", path.display()))?;
|
||||
let mut stdin = child.stdin.take().context("polkit helper has no stdin")?;
|
||||
let stdout = child.stdout.take().context("polkit helper has no stdout")?;
|
||||
stdin.write_all(cookie.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
let outcome = converse(BufReader::new(stdout), stdin, password).await;
|
||||
let _ = child.wait().await;
|
||||
outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn converse<R, W>(mut reader: BufReader<R>, mut writer: W, password: &str) -> Result<Outcome>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let mut last_info: Option<String> = None;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
match parse_helper_line(&line) {
|
||||
HelperLine::PromptEchoOff(_) => {
|
||||
writer.write_all(password.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::PromptEchoOn(_) => {
|
||||
// Visible prompt (username, etc.) — we already sent the
|
||||
// identity in the handshake. An empty line is safer than
|
||||
// echoing the password.
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => {
|
||||
if !msg.is_empty() {
|
||||
last_info = Some(msg);
|
||||
}
|
||||
}
|
||||
HelperLine::Success => return Ok(Outcome::Success),
|
||||
HelperLine::Failure => {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
HelperLine::Other(other) => {
|
||||
if !other.is_empty() {
|
||||
tracing::debug!("helper: {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared default when the helper gives no `PAM_*` text on failure.
|
||||
pub fn default_failure_message() -> &'static str {
|
||||
"Authentication failed. Try again."
|
||||
}
|
||||
205
bread-polkit/src/helper.rs
Normal file
205
bread-polkit/src/helper.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! `polkit-agent-helper-1` transport and PAM line parser.
|
||||
//!
|
||||
//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older
|
||||
//! builds still spawn the setuid helper at
|
||||
//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it
|
||||
//! exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How this agent will talk to polkit's helper.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
/// systemd socket-activated helper (polkit 127+).
|
||||
Socket(PathBuf),
|
||||
/// Legacy setuid helper binary.
|
||||
Exec(PathBuf),
|
||||
}
|
||||
|
||||
const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"];
|
||||
const HELPER_CANDIDATES: &[&str] = &[
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
"/usr/libexec/polkit-1/polkit-agent-helper-1",
|
||||
];
|
||||
|
||||
/// One stdout line from the helper after the cookie handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HelperLine {
|
||||
PromptEchoOff(String),
|
||||
PromptEchoOn(String),
|
||||
ErrorMsg(String),
|
||||
TextInfo(String),
|
||||
Success,
|
||||
Failure,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER`
|
||||
/// if set and present, otherwise the first existing well-known path.
|
||||
pub fn discover_transport() -> Option<Transport> {
|
||||
discover_transport_from(
|
||||
std::env::var_os("BREAD_POLKIT_SOCKET")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
std::env::var_os("BREAD_POLKIT_HELPER")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p.exists(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Testable discovery: `exists` is injected so unit tests do not need a
|
||||
/// real `/run/polkit` socket.
|
||||
pub fn discover_transport_from(
|
||||
socket_override: Option<&Path>,
|
||||
helper_override: Option<&Path>,
|
||||
sockets: &[&str],
|
||||
helpers: &[&str],
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> Option<Transport> {
|
||||
if let Some(path) = socket_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in sockets {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = helper_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in helpers {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse one helper protocol line. Prefix match is case-sensitive and
|
||||
/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens.
|
||||
pub fn parse_helper_line(line: &str) -> HelperLine {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line == "SUCCESS" || line.starts_with("SUCCESS") {
|
||||
return HelperLine::Success;
|
||||
}
|
||||
if line == "FAILURE" || line.starts_with("FAILURE") {
|
||||
return HelperLine::Failure;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") {
|
||||
return HelperLine::PromptEchoOff(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") {
|
||||
return HelperLine::PromptEchoOn(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") {
|
||||
return HelperLine::ErrorMsg(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") {
|
||||
return HelperLine::TextInfo(rest.trim().to_string());
|
||||
}
|
||||
HelperLine::Other(line.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_helper_line_known_tokens() {
|
||||
assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"),
|
||||
HelperLine::PromptEchoOff("Password:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF"),
|
||||
HelperLine::PromptEchoOff(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_ON login:"),
|
||||
HelperLine::PromptEchoOn("login:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_ERROR_MSG Authentication failure"),
|
||||
HelperLine::ErrorMsg("Authentication failure".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_TEXT_INFO Account locked"),
|
||||
HelperLine::TextInfo("Account locked".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("garbage"),
|
||||
HelperLine::Other("garbage".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_prefers_socket_over_exec() {
|
||||
let present: HashSet<PathBuf> = [
|
||||
"/run/polkit/agent-helper.socket",
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Socket(PathBuf::from(
|
||||
"/run/polkit/agent-helper.socket"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_falls_back_to_helper_binary() {
|
||||
let present: HashSet<PathBuf> = ["/usr/lib/polkit-1/polkit-agent-helper-1"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Exec(PathBuf::from(
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_override_socket_wins_when_present() {
|
||||
let override_path = Path::new("/tmp/bread-polkit-test.sock");
|
||||
let got = discover_transport_from(
|
||||
Some(override_path),
|
||||
None,
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p == override_path,
|
||||
);
|
||||
assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_none_when_nothing_exists() {
|
||||
let got =
|
||||
discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
128
bread-polkit/src/identity.rs
Normal file
128
bread-polkit/src/identity.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Unix-user identities from a PolicyKit `BeginAuthentication` call.
|
||||
|
||||
/// A `unix-user` identity the agent can authenticate as.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnixUser {
|
||||
pub uid: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines).
|
||||
pub fn name_for_uid(uid: u32, passwd: &str) -> Option<String> {
|
||||
for line in passwd.lines() {
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.split(':');
|
||||
let name = parts.next()?;
|
||||
let _pw = parts.next()?;
|
||||
let id = parts.next()?.parse::<u32>().ok()?;
|
||||
if id == uid && !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when
|
||||
/// `/etc/passwd` has no name.
|
||||
pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec<UnixUser> {
|
||||
uids.iter()
|
||||
.copied()
|
||||
.map(|uid| UnixUser {
|
||||
uid,
|
||||
name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prefer the process's own uid when it is in `users`, otherwise the first.
|
||||
pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option<u32>) -> Option<&'a UnixUser> {
|
||||
if let Some(uid) = current_uid {
|
||||
if let Some(user) = users.iter().find(|u| u.uid == uid) {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
users.first()
|
||||
}
|
||||
|
||||
/// Real uid from a `/proc/self/status` dump (`Uid:\t<real> ...`).
|
||||
pub fn uid_from_status(status: &str) -> Option<u32> {
|
||||
for line in status.lines() {
|
||||
let Some(rest) = line.strip_prefix("Uid:") else {
|
||||
continue;
|
||||
};
|
||||
return rest.split_whitespace().next()?.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Current real uid, or `None` if `/proc/self/status` is unreadable.
|
||||
pub fn current_uid() -> Option<u32> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
uid_from_status(&status)
|
||||
}
|
||||
|
||||
/// Contents of `/etc/passwd`, or empty if unreadable.
|
||||
pub fn read_passwd() -> String {
|
||||
std::fs::read_to_string("/etc/passwd").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "\
|
||||
# comment
|
||||
root:x:0:0:root:/root:/bin/sh
|
||||
alice:x:1000:1000:Alice:/home/alice:/bin/zsh
|
||||
bob:x:1001:1001:Bob:/home/bob:/bin/bash
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn name_for_uid_reads_passwd_lines() {
|
||||
assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root"));
|
||||
assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice"));
|
||||
assert_eq!(name_for_uid(99, PASSWD), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_from_uids_falls_back_to_uid_label() {
|
||||
let users = users_from_uids(&[1000, 42], PASSWD);
|
||||
assert_eq!(
|
||||
users,
|
||||
vec![
|
||||
UnixUser {
|
||||
uid: 1000,
|
||||
name: "alice".into()
|
||||
},
|
||||
UnixUser {
|
||||
uid: 42,
|
||||
name: "uid 42".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_prefers_current_uid() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(1000)).unwrap();
|
||||
assert_eq!(picked.name, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_falls_back_to_first() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(7)).unwrap();
|
||||
assert_eq!(picked.name, "root");
|
||||
assert!(pick_user(&[], Some(1000)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_from_status_reads_real_uid() {
|
||||
let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n";
|
||||
assert_eq!(uid_from_status(status), Some(1000));
|
||||
assert_eq!(uid_from_status("Name:\tfoo\n"), None);
|
||||
}
|
||||
}
|
||||
10
bread-polkit/src/lib.rs
Normal file
10
bread-polkit/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! Non-GTK PolicyKit helper logic for `bread-polkit`.
|
||||
//!
|
||||
//! The binary (`bread-polkit`) registers as a session authentication
|
||||
//! agent and shows a themed password prompt. This library is the
|
||||
//! transport / identity / session parsing that can be unit-tested
|
||||
//! without a display.
|
||||
|
||||
pub mod helper;
|
||||
pub mod identity;
|
||||
pub mod session;
|
||||
94
bread-polkit/src/main.rs
Normal file
94
bread-polkit/src/main.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! bread-polkit — themed PolicyKit authentication agent.
|
||||
//!
|
||||
//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent`
|
||||
//! interface and shows a bread-theme GTK4 password prompt. This is an
|
||||
//! agent, not a wrapper that execs `polkit-gnome`.
|
||||
//!
|
||||
//! Autostart: copy `contrib/bread-polkit.desktop` to
|
||||
//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland.
|
||||
|
||||
mod agent;
|
||||
mod auth;
|
||||
mod ui;
|
||||
|
||||
use bread_app::singleton::Acquire;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
const APP_NAME: &str = "bread-polkit";
|
||||
|
||||
fn main() {
|
||||
let arg = std::env::args().nth(1);
|
||||
match arg.as_deref() {
|
||||
Some("-h") | Some("--help") => {
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
Some("-V") | Some("--version") => {
|
||||
println!("bread-polkit {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("bread-polkit: unknown argument '{other}'");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let _guard = match bread_app::try_acquire(APP_NAME) {
|
||||
Ok(Acquire::Acquired(g)) => Some(g),
|
||||
Ok(Acquire::HeldByOther(pid)) => {
|
||||
eprintln!("bread-polkit: already running (pid {pid:?})");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let app_id = bread_app::application_id(APP_NAME).expect("static app name");
|
||||
let app = gtk4::Application::builder().application_id(&app_id).build();
|
||||
|
||||
app.connect_activate(|app| {
|
||||
bread_theme::gtk::apply_shared();
|
||||
bread_theme::gtk::apply_app_css(ui::app_css);
|
||||
// No window until polkit asks; hold so GApplication stays alive.
|
||||
std::mem::forget(app.hold());
|
||||
if let Err(e) = agent::spawn() {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
print!(
|
||||
"\
|
||||
bread-polkit — themed PolicyKit authentication agent
|
||||
|
||||
Usage:
|
||||
bread-polkit
|
||||
bread-polkit --help
|
||||
bread-polkit --version
|
||||
|
||||
Autostart (pick one):
|
||||
cp contrib/bread-polkit.desktop ~/.config/autostart/
|
||||
exec-once = bread-polkit # Hyprland
|
||||
|
||||
The agent talks to the polkit1 AuthenticationAgent API and prompts for
|
||||
a password. It does not exec polkit-gnome. Not a bakery product; not
|
||||
on the BOS ISO lockfile.
|
||||
"
|
||||
);
|
||||
}
|
||||
49
bread-polkit/src/session.rs
Normal file
49
bread-polkit/src/session.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Session subject for `RegisterAuthenticationAgent`.
|
||||
|
||||
/// Logind session id from `XDG_SESSION_ID`, falling back to
|
||||
/// `/proc/self/sessionid` when the kernel has one.
|
||||
pub fn session_id() -> Option<String> {
|
||||
let xdg = std::env::var("XDG_SESSION_ID").ok();
|
||||
let proc = std::fs::read_to_string("/proc/self/sessionid").ok();
|
||||
session_id_from(xdg.as_deref(), proc.as_deref())
|
||||
}
|
||||
|
||||
/// `None` when both sources are empty or the kernel reports the
|
||||
/// unsigned `-1` sentinel (`4294967295`) meaning "no session".
|
||||
pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option<String> {
|
||||
if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
let raw = proc_sessionid?.trim();
|
||||
if raw.is_empty() || raw == "4294967295" {
|
||||
return None;
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_xdg_session_id() {
|
||||
assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3"));
|
||||
assert_eq!(
|
||||
session_id_from(Some(" 3 "), Some("7")).as_deref(),
|
||||
Some("3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_proc_sessionid() {
|
||||
assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7"));
|
||||
assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unset_kernel_session() {
|
||||
assert_eq!(session_id_from(None, Some("4294967295")), None);
|
||||
assert_eq!(session_id_from(Some(""), Some("")), None);
|
||||
assert_eq!(session_id_from(None, None), None);
|
||||
}
|
||||
}
|
||||
285
bread-polkit/src/ui.rs
Normal file
285
bread-polkit/src/ui.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
//! GTK4 password prompt, themed with bread-theme.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::gdk::Key;
|
||||
use gtk4::glib::{self, Propagation};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label,
|
||||
Orientation,
|
||||
};
|
||||
|
||||
use bread_theme::tokens;
|
||||
|
||||
use crate::agent::UserAction;
|
||||
|
||||
const PANEL_WIDTH: i32 = 400;
|
||||
|
||||
struct Active {
|
||||
cookie: String,
|
||||
window: ApplicationWindow,
|
||||
password: Entry,
|
||||
error: Label,
|
||||
reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
username: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// App-specific rules layered on the shared bread-theme stylesheet.
|
||||
pub fn app_css() -> String {
|
||||
format!(
|
||||
".polkit-panel {{\
|
||||
background-color: @surface; color: @on-surface;\
|
||||
border-radius: {r}px; padding: {pad}px;\
|
||||
min-width: {w}px;\
|
||||
}}\n\
|
||||
.polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||||
.polkit-message {{ opacity: 0.85; }}\n\
|
||||
.polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||||
.polkit-error {{ color: @on-red; }}\n\
|
||||
.polkit-buttons {{ padding-top: {sm}px; }}\n",
|
||||
r = tokens::RADIUS_PRIMARY,
|
||||
pad = tokens::SPACE_XL,
|
||||
w = PANEL_WIDTH,
|
||||
sec = tokens::FONT_SIZE_SECONDARY,
|
||||
sm = tokens::SPACE_SM,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct Prompt {
|
||||
pub cookie: String,
|
||||
pub message: String,
|
||||
pub action_id: String,
|
||||
pub username: String,
|
||||
pub reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
}
|
||||
|
||||
/// Show (or replace) the password overlay for this cookie.
|
||||
pub fn show_prompt(app: &Application, prompt: Prompt) {
|
||||
close_if_other_cookie(&prompt.cookie);
|
||||
|
||||
if ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == prompt.cookie)
|
||||
}) {
|
||||
present_existing(&prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit");
|
||||
|
||||
let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32);
|
||||
panel.add_css_class("polkit-panel");
|
||||
panel.add_css_class("card");
|
||||
panel.set_halign(Align::Center);
|
||||
panel.set_valign(Align::Center);
|
||||
panel.set_size_request(PANEL_WIDTH, -1);
|
||||
|
||||
let title = Label::new(Some("Authentication required"));
|
||||
title.add_css_class("polkit-title");
|
||||
title.add_css_class("page-title");
|
||||
title.set_halign(Align::Start);
|
||||
title.set_wrap(true);
|
||||
panel.append(&title);
|
||||
|
||||
let message = if prompt.message.trim().is_empty() {
|
||||
prompt.action_id.clone()
|
||||
} else {
|
||||
prompt.message.clone()
|
||||
};
|
||||
let msg = Label::new(Some(&message));
|
||||
msg.add_css_class("polkit-message");
|
||||
msg.set_halign(Align::Start);
|
||||
msg.set_wrap(true);
|
||||
msg.set_xalign(0.0);
|
||||
panel.append(&msg);
|
||||
|
||||
if !prompt.username.is_empty() {
|
||||
let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username)));
|
||||
identity.add_css_class("polkit-identity");
|
||||
identity.add_css_class("dim-label");
|
||||
identity.set_halign(Align::Start);
|
||||
panel.append(&identity);
|
||||
}
|
||||
|
||||
let error = Label::new(None);
|
||||
error.add_css_class("polkit-error");
|
||||
error.set_halign(Align::Start);
|
||||
error.set_wrap(true);
|
||||
error.set_visible(false);
|
||||
panel.append(&error);
|
||||
|
||||
let password = Entry::builder()
|
||||
.visibility(false)
|
||||
.input_purpose(gtk4::InputPurpose::Password)
|
||||
.placeholder_text("Password")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
panel.append(&password);
|
||||
|
||||
let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32);
|
||||
buttons.add_css_class("polkit-buttons");
|
||||
buttons.set_halign(Align::End);
|
||||
let cancel = Button::with_label("Cancel");
|
||||
cancel.add_css_class("flat");
|
||||
let confirm = Button::with_label("Authenticate");
|
||||
confirm.add_css_class("suggested-action");
|
||||
buttons.append(&cancel);
|
||||
buttons.append(&confirm);
|
||||
panel.append(&buttons);
|
||||
|
||||
window.set_child(Some(&panel));
|
||||
|
||||
let reply = prompt.reply.clone();
|
||||
let cookie = prompt.cookie.clone();
|
||||
let username = prompt.username.clone();
|
||||
|
||||
let submit = {
|
||||
let password = password.clone();
|
||||
let reply = reply.clone();
|
||||
let username = username.clone();
|
||||
Rc::new(move || {
|
||||
let secret = password.text().to_string();
|
||||
password.set_text("");
|
||||
let _ = reply.try_send(UserAction::Submit {
|
||||
username: username.clone(),
|
||||
password: secret,
|
||||
});
|
||||
})
|
||||
};
|
||||
let cancel_fn = {
|
||||
let reply = reply.clone();
|
||||
let window = window.clone();
|
||||
Rc::new(move || {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
window.close();
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
})
|
||||
};
|
||||
|
||||
confirm.connect_clicked({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
password.connect_activate({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
cancel.connect_clicked({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_| cancel_fn()
|
||||
});
|
||||
|
||||
let keys = EventControllerKey::new();
|
||||
keys.connect_key_pressed({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_, key, _, _| {
|
||||
if key == Key::Escape {
|
||||
cancel_fn();
|
||||
Propagation::Stop
|
||||
} else {
|
||||
Propagation::Proceed
|
||||
}
|
||||
}
|
||||
});
|
||||
window.add_controller(keys);
|
||||
|
||||
bread_app::gtk_popup::close_on_outside_click(&window, &panel, {
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move || cancel_fn()
|
||||
});
|
||||
|
||||
window.connect_close_request({
|
||||
let reply = reply.clone();
|
||||
move |_| {
|
||||
let closing_ours = ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == cookie)
|
||||
});
|
||||
if closing_ours {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
ACTIVE.with(|a| {
|
||||
*a.borrow_mut() = Some(Active {
|
||||
cookie: prompt.cookie,
|
||||
window: window.clone(),
|
||||
password: password.clone(),
|
||||
error,
|
||||
reply,
|
||||
username,
|
||||
});
|
||||
});
|
||||
|
||||
window.present();
|
||||
password.grab_focus();
|
||||
}
|
||||
|
||||
fn present_existing(prompt: &Prompt) {
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(active) = a.borrow_mut().as_mut() {
|
||||
active.reply = prompt.reply.clone();
|
||||
active.username = prompt.username.clone();
|
||||
active.error.set_visible(false);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a retry message on the open dialog for `cookie`.
|
||||
pub fn show_retry(cookie: &str, message: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let mut guard = a.borrow_mut();
|
||||
let Some(active) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie != cookie {
|
||||
return;
|
||||
}
|
||||
active.error.set_label(message);
|
||||
active.error.set_visible(true);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the dialog if it is still showing `cookie`.
|
||||
pub fn close_prompt(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
active.window.close();
|
||||
} else {
|
||||
*a.borrow_mut() = Some(active);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn close_if_other_cookie(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
*a.borrow_mut() = Some(active);
|
||||
} else {
|
||||
active.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue