Files
amane-config/src/prompt.rs
T
server2 b14df3a435 Control centre: popups via iwctl/bluetoothctl, one-shape menus, search rings, Ethernet and link speeds, auto-hiding bar
- prompt.rs: popup for Wi-Fi passwords, Bluetooth PINs/passkeys/codes, saved
  passwords (Copy through wl-copy on stdin) and failure reasons; joins through
  iwctl, pairs through a bluetoothctl session; only popups with a field take
  the keyboard (an exclusive grab let stray keystrokes close failure popups)
- menus: the panel is the only shape; the menu window fades its content in
  once the panel has taken the menu's size
- maps: clicking the bubble searches again (iwctl scan, bluetoothctl scan)
  with rings running out from the click
- Ethernet tile and pill icon from sysfs; link speed badges (Ethernet speed,
  Wi-Fi RxBitrate from iwd diagnostics, at most once a minute)
- edge.rs: bar hidden until the pointer rests on the top edge; nothing is
  read or drawn while hidden
- IPC: control wifi join|password|search, bluetooth pair|connect|search,
  prompt answer|cancel, status prompt=
2026-10-13 02:59:22 +02:00

682 lines
24 KiB
Rust

/*
* the small popup over the control centre: it asks what joining a Wi-Fi network or
* pairing a Bluetooth device needs, shows a known network's saved password, and says
* why a connection failed. the shell serves nothing on D-Bus for this: joining goes
* through iwctl and pairing through a bluetoothctl session, which bring their own
* agents. bluetoothctl's agent asks on its output and takes the answer on its input
*/
use std::collections::HashMap;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::thread;
use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin,
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, Start, Text, TextInput, Vertical, Weight, Widget,
Zone, children,
};
use crate::control::{self, Control, RIGHT};
use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled};
use crate::map::MAP_W;
use crate::parse::{self, Pairing};
use crate::system;
use crate::theme;
const POPUP_W: f32 = 360.0;
const POPUP_PAD: f32 = 16.0;
const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD;
const HEADER_H: f32 = 36.0;
const FIELD_H: f32 = 36.0;
const CODE_H: f32 = 40.0;
const LINE_H: f32 = 18.0;
const BUTTON_W: f32 = 92.0;
const BUTTON_H: f32 = 32.0;
const GAP: f32 = 14.0;
// a bluetoothctl session that hasn't ended by then is stopped
const SESSION_S: u64 = 90;
#[derive(Clone, PartialEq)]
pub enum Ask {
// something to type and send: a new network's password, a Bluetooth PIN or passkey
Password,
Pin,
Passkey,
// yes or no: does this passkey show on the device too (empty: may it pair at all)
Confirm(String),
// type this on the device's own keyboard
Show(String),
// a known network's saved password
Saved(String),
// why joining, pairing or connecting didn't work
Failed { title: String, reason: String },
}
#[derive(Clone, PartialEq)]
pub struct Request {
pub ask: Ask,
pub wifi: bool,
// the network's or device's name, and what iwctl joins: the SSID
pub name: String,
target: String,
// a text input of its own, so nothing typed for an earlier question shows up
input: &'static str,
}
pub struct Prompt {
request: Option<Request>,
// what's typed so far, for the button that sends it
typed: String,
// the saved password shown went to the clipboard
copied: bool,
appear: Animation,
hover: HashMap<u8, Animation>,
}
impl Service for Prompt {
fn new() -> Self {
Self { request: None, typed: String::new(), copied: false, appear: Animation::new(0.0), hover: HashMap::new() }
}
// changes only through input, iwctl's answers and bluetoothctl's questions
fn listen() {}
}
// the bluetoothctl that's pairing or connecting now: its input for the agent's answers, the process to stop it
struct Session {
id: u32,
child: Child,
input: ChildStdin,
}
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
// how many sessions and questions there have been, for their ids
static COUNT: Mutex<u32> = Mutex::new(0);
fn session() -> MutexGuard<'static, Option<Session>> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner)
}
fn next() -> u32 {
let mut count = COUNT.lock().unwrap_or_else(PoisonError::into_inner);
*count += 1;
*count
}
// ponytail: one leaked input id per question (a few bytes); fine for questions asked by hand
fn new_input() -> &'static str {
Box::leak(format!("magic-prompt-{}", next()).into_boxed_str())
}
fn open(ask: Ask, wifi: bool, name: &str, target: &str) {
let mut prompt = Prompt::write();
prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() });
prompt.typed.clear();
prompt.copied = false;
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220));
prompt.appear.to(1.0);
}
fn close() {
Prompt::write().request = None;
}
fn failed(wifi: bool, name: &str, what: &str, reason: String) {
control::clear_pending();
open(Ask::Failed { title: format!("Couldn't {what} {name}"), reason }, wifi, name, "");
}
// Wi-Fi
// the Wi-Fi map: a saved or open network is joined at once, a new one asks for its password first
pub fn join(ssid: &str, needs_password: bool) {
if needs_password {
open(Ask::Password, true, ssid, ssid);
} else {
joining(ssid.to_string(), None);
}
}
fn joining(ssid: String, passphrase: Option<String>) {
thread::spawn(move || {
if let Err(reason) = system::join_wifi(&ssid, passphrase.as_deref()) {
// iwd says "Operation failed" when the handshake fails, which a wrong password is the usual cause of
let reason = if passphrase.is_some() && reason == "Operation failed" { String::from("Operation failed. Is the password right?") } else { reason };
failed(true, &ssid, "join", reason);
}
system::refresh();
});
}
// a right-click on a saved network: its password, read from iwd's profile
pub fn reveal(ssid: &str) {
let ssid = ssid.to_string();
thread::spawn(move || match system::saved_password(&ssid) {
Ok(password) => open(Ask::Saved(password), true, &ssid, &ssid),
Err(reason) => open(Ask::Failed { title: format!("No password for {ssid}"), reason }, true, &ssid, ""),
});
}
// the shown password to the clipboard through wl-copy, on its input so it never shows in the process list
fn copy(password: &str) {
let password = password.to_string();
thread::spawn(move || {
let Ok(mut child) = Command::new("wl-copy").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
return;
};
let written = child.stdin.take().is_some_and(|mut input| input.write_all(password.as_bytes()).is_ok());
// wl-copy leaves a child of its own behind to serve the clipboard and exits
if child.wait().is_ok_and(|status| status.success()) && written {
Prompt::write().copied = true;
}
});
}
// Bluetooth
/*
* pairs (then trusts and connects) or connects a device through bluetoothctl. its
* agent's questions open the popup, whose answers go to its input; how it ends shows
* as a reason when it fails. one session at a time: a new one stops the last
*/
pub fn bluetooth(address: &str, name: &str, pair: bool) {
stop_session();
let (address, name) = (address.to_string(), name.to_string());
thread::spawn(move || run_session(&address, &name, pair));
}
fn run_session(address: &str, name: &str, pair: bool) {
let spawned = Command::new("bluetoothctl").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
let Ok(mut child) = spawned else {
failed(false, name, if pair { "pair" } else { "connect" }, String::from("bluetoothctl couldn't start"));
return;
};
let (Some(mut input), Some(mut output)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return;
};
// its own agent, the default one while it runs, so the device's questions come to it
let first = if pair { format!("pair {address}\n") } else { format!("connect {address}\n") };
let _ = input.write_all(format!("agent KeyboardDisplay\ndefault-agent\n{first}").as_bytes());
let id = next();
*session() = Some(Session { id, child, input });
// stopped if it hasn't ended in time, which ends the reading below too
thread::spawn(move || {
thread::sleep(Duration::from_secs(SESSION_S));
let late = session().as_ref().is_some_and(|session| session.id == id);
if late {
stop_session();
close();
}
});
let mut printed = Vec::new();
let mut chunk = [0_u8; 512];
// whole lines already looked at, and a question on the last, unfinished line already asked
let mut seen = 0;
let mut asked: Option<String> = None;
let mut paired = !pair;
while let Ok(read) = output.read(&mut chunk) {
if read == 0 {
break;
}
printed.extend_from_slice(&chunk[..read]);
let text = String::from_utf8_lossy(&printed).into_owned();
let finished = text.ends_with('\n') || text.ends_with('\r');
let lines = parse::plain_lines(&text);
// the agent's questions end without a newline: the last line counts once it's a question
let whole = if finished { lines.len() } else { lines.len().saturating_sub(1) };
let mut events: Vec<(String, Pairing)> = Vec::new();
for line in &lines[seen.min(whole)..whole] {
if asked.as_deref() != Some(line.as_str())
&& let Some(event) = parse::pairing_event(line)
{
events.push((line.clone(), event));
}
}
seen = whole;
if !finished
&& let Some(last) = lines.last()
&& asked.as_deref() != Some(last.as_str())
&& let Some(event) = parse::pairing_event(last)
{
asked = Some(last.clone());
events.push((last.clone(), event));
}
for (_, event) in events {
match event {
Pairing::AskPin => open(Ask::Pin, false, name, address),
Pairing::AskPasskey => open(Ask::Passkey, false, name, address),
Pairing::Confirm(code) => open(Ask::Confirm(code), false, name, address),
Pairing::Accept => open(Ask::Confirm(String::new()), false, name, address),
// a device asking to use one of its services: pairing it was the yes
Pairing::Authorize => send("yes\n"),
Pairing::Show(code) => open(Ask::Show(code), false, name, address),
Pairing::Paired => {
paired = true;
// a code it showed is done with; trusted, it reconnects by itself later
close_unless_failed();
send(&format!("trust {address}\nconnect {address}\n"));
}
Pairing::Connected => {
close_unless_failed();
send("quit\n");
}
Pairing::Failed(error) => {
send("quit\n");
failed(false, name, if paired { "connect" } else { "pair" }, parse::bluez_reason(&error));
}
}
}
}
// ended: reap it, unless a newer session took its place
let ended = {
let mut current = session();
if current.as_ref().is_some_and(|session| session.id == id) { current.take() } else { None }
};
if let Some(mut ended) = ended {
let _ = ended.child.wait();
}
control::clear_pending();
system::refresh();
}
// an answer for bluetoothctl's agent, or a command for bluetoothctl
fn send(text: &str) {
if let Some(session) = session().as_mut() {
let _ = session.input.write_all(text.as_bytes());
}
}
fn stop_session() {
let Some(mut stopped) = session().take() else {
return;
};
// its agent goes with it, so bluez gives up on the pairing
let _ = stopped.child.kill();
thread::spawn(move || {
let _ = stopped.child.wait();
});
}
// a question or code still showing goes; a reason already showing stays
fn close_unless_failed() {
let mut prompt = Prompt::write();
if !matches!(prompt.request.as_ref().map(|request| &request.ask), Some(Ask::Failed { .. })) {
prompt.request = None;
}
}
// the popup's answers
// whether what's typed can be sent
fn ready(ask: &Ask, typed: &str) -> bool {
match ask {
// wpa passphrases are 8 to 63 characters
Ask::Password => (8..=63).contains(&typed.chars().count()),
Ask::Pin => !typed.trim().is_empty(),
Ask::Passkey => typed.trim().parse::<u32>().is_ok_and(|passkey| passkey <= 999_999),
Ask::Show(_) => false,
Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } => true,
}
}
// the user's answer: typed text, or yes, or just closing what was shown
pub fn answer(typed: &str) {
let Some(request) = Prompt::read().request.clone() else {
return;
};
if !ready(&request.ask, typed) {
return;
}
close();
match request.ask {
Ask::Password => joining(request.target, Some(typed.to_string())),
Ask::Pin | Ask::Passkey => send(&format!("{}\n", typed.trim())),
Ask::Confirm(_) => send("yes\n"),
Ask::Show(_) | Ask::Saved(_) | Ask::Failed { .. } => {}
}
}
// no: a pairing stops (bluetoothctl and its agent go), a password not given joins nothing
pub fn refuse() {
let Some(request) = Prompt::write().request.take() else {
return;
};
match request.ask {
Ask::Password => control::clear_pending(),
Ask::Pin | Ask::Passkey | Ask::Confirm(_) | Ask::Show(_) => {
stop_session();
control::clear_pending();
}
Ask::Saved(_) | Ask::Failed { .. } => {}
}
}
// `amane ipc call control prompt answer <text>|cancel`, for scripts and tests
pub fn command(words: &[&str]) -> String {
match words {
["answer", text] => answer(text),
["answer"] => answer(""),
["cancel"] => refuse(),
_ => return String::from("usage: control prompt answer <text>|cancel"),
}
String::from("ok")
}
pub fn showing() -> bool {
Prompt::read().request.is_some()
}
// for `control status`: what the popup shows, as one word
pub fn status() -> &'static str {
match Prompt::read().request.as_ref().map(|request| &request.ask) {
None => "none",
Some(Ask::Password) => "password",
Some(Ask::Pin) => "pin",
Some(Ask::Passkey) => "passkey",
Some(Ask::Confirm(_)) => "confirm",
Some(Ask::Show(_)) => "show",
Some(Ask::Saved(_)) => "saved",
Some(Ask::Failed { .. }) => "failed",
}
}
fn hovering(spot: u8) -> impl Fn(bool) + 'static {
move |inside| {
Prompt::write()
.hover
.entry(spot)
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
.to(if inside { 1.0 } else { 0.0 });
}
}
/*
* the popup: a small card over the middle of the control centre's map, with the
* question, a field, a code or a reason, and its buttons. it takes the keyboard
* while it shows: Enter answers, Escape cancels. on one monitor only, the control
* centre's or the focused one
*/
pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let prompt = Prompt::read();
let here = {
let control = Control::read();
let on = if control.expanded() { control.opened_on().map(String::from) } else { island::focused_monitor() };
on.is_none_or(|name| name == monitor.name)
};
let window = |width: f32, height: f32| {
LayerWindow::new()
.width(width)
.height(height)
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Right)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-prompt")
};
// never hidden (see the island's view): a tiny, empty click-through window instead
let Some(request) = prompt.request.as_ref().filter(|_| here) else {
return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
};
let theme = theme::now();
let colors = theme.surfaces();
let name = &request.name;
let (title, question) = match &request.ask {
Ask::Password => (format!("Join {name}"), "Enter its Wi-Fi password"),
Ask::Pin => (format!("Pair {name}"), "Enter its PIN"),
Ask::Passkey => (format!("Pair {name}"), "Enter the six-digit passkey it shows"),
Ask::Confirm(code) if code.is_empty() => (format!("Pair {name}"), "Allow it to pair with this computer?"),
Ask::Confirm(_) => (format!("Pair {name}"), "Does the device show this code too?"),
Ask::Show(_) => (format!("Pair {name}"), "Type this code on it, then press Enter"),
Ask::Saved(_) => (name.clone(), "Its saved Wi-Fi password"),
Ask::Failed { title, .. } => (title.clone(), "What went wrong"),
};
let mark = match (&request.ask, request.wifi) {
(Ask::Failed { .. }, _) => Glyph::Muted,
(_, true) => Glyph::Lock,
(_, false) => Glyph::Bluetooth,
};
let header = Row::new(children![
Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)),
Column::new(children![
Text::new(title).size(13.0).weight(Weight::Bold).color(theme.text).elide(),
Text::new(question).size(11.0).color(theme.dim).elide(),
])
.gap(3.0)
.width(INNER_W - HEADER_H - 12.0),
])
.gap(12.0)
.align(Center);
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(header)];
let mut body_h = 0.0;
match &request.ask {
Ask::Password | Ask::Pin | Ask::Passkey => {
let field = TextInput::new(request.input)
.size(13.0)
.color(theme.text)
.placeholder(match request.ask {
Ask::Password => "password",
Ask::Pin => "PIN",
_ => "000000",
})
.focused()
.on_change(|typed| Prompt::write().typed = typed)
.on_submit(|typed| answer(&typed));
let field = if request.ask == Ask::Password { field.password() } else { field };
rows.push(Box::new(
Rectangle::new()
.width(INNER_W)
.height(FIELD_H)
.radius(10.0)
.fill(CHIP)
.border(1.0, theme.outline)
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
.align_child(Start, Center)
.child(field),
));
body_h = FIELD_H;
}
Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => {
rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent)));
body_h = CODE_H;
}
Ask::Saved(password) => {
let password = password.clone();
// a click on it copies it too
rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password))));
body_h = CODE_H;
}
Ask::Failed { reason, .. } => {
rows.push(Box::new(Rectangle::new().width(INNER_W).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide())));
body_h = LINE_H;
}
Ask::Confirm(_) | Ask::Show(_) => {}
}
// the buttons: Cancel while something is asked (Copy for a password), and what answers (just a close for what's only shown)
let lit = |spot: u8| prompt.hover.get(&spot).map_or(0.0, Animation::value);
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
if let Ask::Saved(password) = &request.ask {
let password = password.clone();
let label = if prompt.copied { "Copied" } else { "Copy" };
buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password))));
} else if !matches!(request.ask, Ask::Failed { .. }) {
buttons.push(Box::new(button("Cancel", Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse())));
}
let primary = match request.ask {
Ask::Password => Some("Join"),
Ask::Show(_) => None,
Ask::Saved(_) => Some("Close"),
Ask::Failed { .. } => Some("OK"),
_ => Some("Pair"),
};
if let Some(label) = primary {
let ok = ready(&request.ask, &prompt.typed);
let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { CHIP };
let ink = if ok { theme.on_accent } else { theme.dim };
let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1));
// a click answers with what's typed, as Enter does
let primary = if ok {
let typed = prompt.typed.clone();
primary.cursor(Pointer).on_click(move |_| answer(&typed))
} else {
primary
};
buttons.push(Box::new(primary));
}
rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box<dyn Widget>]).justify(End).width(INNER_W).height(BUTTON_H)));
let parts = if body_h > 0.0 { 3.0 } else { 2.0 };
let card_h = 2.0 * POPUP_PAD + HEADER_H + body_h + BUTTON_H + GAP * (parts - 1.0);
let appear = prompt.appear.value();
let content = Rectangle::new().width(POPUP_W).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP));
let card = Rectangle::new()
.width(POPUP_W)
.height(card_h)
.radius(22.0)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme.outline, theme.accent, 0.5))
.shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0))
.translate(0.0, 8.0 * (1.0 - appear))
.child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors));
// over the middle of the map menu, a little below the control centre's top
let right = RIGHT + (MAP_W - POPUP_W) / 2.0 - SHADOW;
let top = TOP_GAP + 110.0 - SHADOW;
/*
* the keyboard only while there's something to type: the other popups come on their
* own (a reason, a code) and mustn't take keys meant for another window; a click
* gives them the keyboard for Enter and Escape
*/
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey);
let window = window(POPUP_W + 2.0 * SHADOW, card_h + 2.0 * SHADOW)
.margin(Margin { top: top.max(0.0) as i32, right: right.max(0.0) as i32, bottom: 0, left: 0 })
.keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand })
.on_key(|key| match key {
Key::Escape => refuse(),
// nothing to type: Enter is the yes, or the close
Key::Enter => {
let answers = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. }));
if answers {
answer("");
}
}
_ => {}
});
let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: POPUP_W as i32, height: card_h.ceil() as i32 };
window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(SHADOW).align_child(Start, Start).child(card))
}
// a code spaced in threes, the way devices show it
fn spaced(code: &str) -> String {
code.chars().enumerate().flat_map(|(index, digit)| (index > 0 && index % 3 == 0).then_some(' ').into_iter().chain(std::iter::once(digit))).collect()
}
fn big_line(text: &str, size: f32, color: Color) -> Rectangle {
Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide())
}
fn button(label: &str, fill: Color, ink: Color, lit: f32) -> Rectangle {
Rectangle::new().width(BUTTON_W).height(BUTTON_H).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(12.0).weight(Weight::Bold).color(ink))
}