use std::{
io::{BufRead, BufReader, Read, Write},
net::Shutdown,
os::unix::net::UnixStream,
path::{Path, PathBuf},
sync::mpsc,
time::Duration,
};
use anyhow::{anyhow, Context, Result};
use clap::Parser;
use ksni::blocking::TrayMethods;
use log::{info, warn};
use crate::config::DEFAULT_CONTROL_SOCKET_PATH;
#[derive(Parser, Debug)]
pub struct NotifyArgs {
#[arg(long, default_value = DEFAULT_CONTROL_SOCKET_PATH)]
socket: PathBuf,
}
const MAX_RECENT: usize = 20;
const ICON_IDLE_PNG: &[u8] = include_bytes!("../assets/linprov-64.png");
const ICON_ATTN_PNG: &[u8] = include_bytes!("../assets/linprov-attention-64.png");
fn load_icon(png_bytes: &[u8]) -> Option<ksni::Icon> {
let mut reader = png::Decoder::new(png_bytes).read_info().ok()?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).ok()?;
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
return None;
}
let mut data = Vec::with_capacity(buf.len());
for px in buf[..info.buffer_size()].chunks_exact(4) {
data.extend_from_slice(&[px[3], px[0], px[1], px[2]]); }
Some(ksni::Icon {
width: info.width as i32,
height: info.height as i32,
data,
})
}
#[derive(Debug, Clone)]
struct RecentBlock {
token: String,
kind: String,
target: String,
creator: String,
}
#[derive(Debug, Clone, Copy)]
enum ActionKind {
Once,
Always,
}
#[derive(Debug)]
struct Action {
kind: ActionKind,
token: String,
target: String,
}
#[derive(Debug)]
struct LinprovTray {
recent: Vec<RecentBlock>,
tx: mpsc::Sender<Action>,
connected: bool,
}
impl LinprovTray {
fn push(&mut self, b: RecentBlock) {
self.recent.retain(|x| x.token != b.token); self.recent.insert(0, b);
self.recent.truncate(MAX_RECENT);
}
fn dispatch(&mut self, kind: ActionKind, token: &str) {
if let Some(b) = self.recent.iter().find(|x| x.token == token).cloned() {
let _ = self.tx.send(Action {
kind,
token: token.to_string(),
target: b.target,
});
}
self.recent.retain(|x| x.token != token);
}
}
impl ksni::Tray for LinprovTray {
fn id(&self) -> String {
"linprov".into()
}
fn title(&self) -> String {
"linprov".into()
}
fn activate(&mut self, _x: i32, _y: i32) {
let body = if self.recent.is_empty() {
"No pending blocks.".to_string()
} else {
let n = self.recent.len();
let s = if n == 1 { "" } else { "s" };
format!(
"{n} pending decision{s}. Right-click this icon for \
Allow once / Allow always / dismiss."
)
};
let icon = if self.recent.is_empty() {
"security-high"
} else {
"security-low"
};
let _ = notify_rust::Notification::new()
.summary("linprov")
.body(&body)
.icon(icon)
.show();
}
fn icon_name(&self) -> String {
String::new()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let png = if self.recent.is_empty() {
ICON_IDLE_PNG
} else {
ICON_ATTN_PNG
};
load_icon(png).into_iter().collect()
}
fn status(&self) -> ksni::Status {
if self.recent.is_empty() {
ksni::Status::Active
} else {
ksni::Status::NeedsAttention
}
}
fn attention_icon_pixmap(&self) -> Vec<ksni::Icon> {
load_icon(ICON_ATTN_PNG).into_iter().collect()
}
fn tool_tip(&self) -> ksni::ToolTip {
let description = if !self.connected {
"not connected to the linprov daemon (is linprov.service running \
with notifications = \"tray\"?)"
.to_string()
} else if self.recent.is_empty() {
"no pending blocks".to_string()
} else {
let n = self.recent.len();
let s = if n == 1 { "" } else { "s" };
format!("{n} pending decision{s} — right-click the icon to allow or dismiss")
};
ksni::ToolTip {
title: "linprov".to_string(),
description,
..Default::default()
}
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
let mut items: Vec<ksni::MenuItem<Self>> = Vec::new();
if self.recent.is_empty() {
items.push(
StandardItem {
label: "No recent blocks".into(),
enabled: false,
..Default::default()
}
.into(),
);
} else {
for b in &self.recent {
let label = format!("{} · {}", basename(&b.target), b.creator);
let (t_once, t_always, t_close) =
(b.token.clone(), b.token.clone(), b.token.clone());
items.push(
SubMenu {
label,
submenu: vec![
StandardItem {
label: "Allow once".into(),
activate: Box::new(move |t: &mut Self| {
t.dispatch(ActionKind::Once, &t_once)
}),
..Default::default()
}
.into(),
StandardItem {
label: "Allow always".into(),
activate: Box::new(move |t: &mut Self| {
t.dispatch(ActionKind::Always, &t_always)
}),
..Default::default()
}
.into(),
StandardItem {
label: "Close".into(),
activate: Box::new(move |t: &mut Self| {
t.recent.retain(|x| x.token != t_close)
}),
..Default::default()
}
.into(),
],
..Default::default()
}
.into(),
);
}
}
items.push(ksni::MenuItem::Separator);
items.push(
StandardItem {
label: "Quit linprov tray".into(),
icon_name: "application-exit".into(),
activate: Box::new(|_| std::process::exit(0)),
..Default::default()
}
.into(),
);
items
}
}
pub fn run(args: NotifyArgs) -> Result<()> {
let (tx, rx) = mpsc::channel::<Action>();
if lacks_linprov_group() {
warn!(
"this process is NOT in the `linprov` group, so it can't reach the \
daemon's control socket — the tray will stay empty. If `id -nG` \
shows you in the group, your launcher has a stale group set: a \
`systemd --user` service inherits the user manager's groups, which \
only refresh after a FULL logout/login or reboot (not \
`systemctl --user restart`/`daemon-reexec`/`newgrp`)."
);
}
let mut backoff = Duration::from_millis(500);
let handle = loop {
let tray = LinprovTray {
recent: Vec::new(),
tx: tx.clone(),
connected: false,
};
match tray.spawn() {
Ok(h) => break h,
Err(e) => {
warn!(
"tray registration failed ({e}); is a StatusNotifierHost \
running (e.g. waybar's tray module)? retrying in {:.0}s",
backoff.as_secs_f32()
);
std::thread::sleep(backoff);
backoff = (backoff * 2).min(Duration::from_secs(30));
}
}
};
let worker_socket = args.socket.clone();
std::thread::spawn(move || action_worker(rx, worker_socket));
info!(
"linprov tray agent started; subscribing to {}",
args.socket.display()
);
subscribe_loop(&args.socket, &handle);
Ok(())
}
fn lacks_linprov_group() -> bool {
let Some(gid) = linprov_gid() else {
return false; };
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Groups:") {
return !rest.split_whitespace().any(|g| g.parse::<u32>() == Ok(gid));
}
}
false
}
fn linprov_gid() -> Option<u32> {
let name = std::ffi::CString::new("linprov").ok()?;
let grp = unsafe { libc::getgrnam(name.as_ptr()) };
if grp.is_null() {
None
} else {
Some(unsafe { (*grp).gr_gid })
}
}
fn subscribe_loop(socket: &Path, handle: &ksni::blocking::Handle<LinprovTray>) {
loop {
if let Err(e) = subscribe_once(socket, handle) {
warn!("control socket: {e:#}");
}
handle.update(|t: &mut LinprovTray| t.connected = false);
std::thread::sleep(Duration::from_secs(2));
}
}
fn subscribe_once(socket: &Path, handle: &ksni::blocking::Handle<LinprovTray>) -> Result<()> {
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("connecting to {} (is the daemon running with notifications=tray, and are you in the linprov group?)", socket.display()))?;
stream
.write_all(b"subscribe\n")
.context("sending subscribe")?;
handle.update(|t: &mut LinprovTray| t.connected = true);
let reader = BufReader::new(stream);
for line in reader.lines() {
let line = line.context("reading block stream")?;
if let Some(b) = parse_block(&line) {
notify_block(&b);
let b2 = b.clone();
handle.update(move |t: &mut LinprovTray| t.push(b2));
}
}
Err(anyhow!("subscribe stream closed"))
}
fn parse_block(line: &str) -> Option<RecentBlock> {
let mut f = line.split('\t');
if f.next()? != "BLOCK" {
return None;
}
Some(RecentBlock {
token: f.next()?.to_string(),
kind: f.next()?.to_string(),
target: f.next()?.to_string(),
creator: f.next().unwrap_or("").to_string(),
})
}
fn action_worker(rx: mpsc::Receiver<Action>, socket: PathBuf) {
for action in rx {
let verb = match action.kind {
ActionKind::Once => "once",
ActionKind::Always => "allow",
};
match send_command(&socket, verb, &action.token) {
Ok(reply) => {
info!("{verb} {} -> {reply}", action.token);
notify_result(&action.target, reply.strip_prefix("OK ").is_some(), &reply);
}
Err(e) => {
warn!("{verb} {} failed: {e:#}", action.token);
notify_result(&action.target, false, &format!("{e:#}"));
}
}
}
}
fn send_command(socket: &Path, verb: &str, token: &str) -> Result<String> {
let mut stream = UnixStream::connect(socket).context("connecting to control socket")?;
stream.write_all(format!("{verb} {token}\n").as_bytes())?;
stream.shutdown(Shutdown::Write).ok();
let mut reply = String::new();
stream.read_to_string(&mut reply)?;
Ok(reply.trim().to_string())
}
fn notify_block(b: &RecentBlock) {
let _ = notify_rust::Notification::new()
.summary("linprov blocked an exec")
.body(&format!(
"{}\n{} · created by {}\nRight-click the linprov tray icon to allow.",
b.target, b.kind, b.creator
))
.icon("security-low")
.show();
}
fn notify_result(target: &str, ok: bool, detail: &str) {
let summary = if ok {
"linprov: allowed"
} else {
"linprov: allow failed"
};
let _ = notify_rust::Notification::new()
.summary(summary)
.body(&format!("{target}\n{detail}"))
.show();
}
fn basename(path: &str) -> &str {
path.rsplit_once('/').map(|(_, b)| b).unwrap_or(path)
}