Revert "control centre: ask for Wi-Fi passwords and Bluetooth PINs in a popup"

This reverts commit 4d4166dadc.
This commit is contained in:
server2
2026-10-12 01:14:39 +02:00
parent 4d4166dadc
commit afe963dfea
5 changed files with 7 additions and 635 deletions
-590
View File
@@ -1,590 +0,0 @@
/*
* the shell as iwd's and bluez's agent. joining a Wi-Fi network that needs a password,
* or pairing a Bluetooth device that needs a PIN, a passkey or a yes, makes the daemon
* ask its agent; the agent asks the user in a small popup over the control centre and
* sends the answer back as the reply. amane can only reply with success, so a cancel
* leaves the call unanswered and cancels the pairing or the connecting itself.
* both agents register only while their daemon runs: a call to a daemon that isn't
* running would start it (d-bus activation)
*/
use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use amane::{
Animation, Argument, Blend, Bluetooth, Bus, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer,
LayerWindow, Margin, Method, 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::system::Links;
use crate::theme;
const IWD: &str = "net.connman.iwd";
const BLUEZ: &str = "org.bluez";
// where the two agents live on the shell's own system-bus connection
const IWD_AGENT: &str = "/amane/magic_island/iwd_agent";
const BLUEZ_AGENT: &str = "/amane/magic_island/bluez_agent";
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 DOTS_H: f32 = 10.0;
const BUTTON_W: f32 = 92.0;
const BUTTON_H: f32 = 32.0;
const GAP: f32 = 14.0;
#[derive(Clone, PartialEq)]
pub enum Ask {
// something to type and send: a Wi-Fi password, a Bluetooth PIN, a six-digit passkey
Password,
Pin,
Passkey,
// yes or no: does this passkey show on the device as well (empty: may it pair at all)
Confirm(String),
// type this on the device's own keyboard; it has typed `entered` digits so far
Show { code: String, entered: u32 },
}
#[derive(Clone, PartialEq)]
pub struct Request {
pub ask: Ask,
pub wifi: bool,
// the network or device, and its iwd or bluez object path
pub name: String,
pub object: String,
// a text input of its own, so nothing typed for an earlier question shows up
input: &'static str,
}
pub struct Prompt {
pub request: Option<Request>,
// what's typed so far, for the button that sends it
typed: String,
appear: Animation,
hover: HashMap<u8, Animation>,
}
impl Service for Prompt {
fn new() -> Self {
Self { request: None, typed: String::new(), appear: Animation::new(0.0), hover: HashMap::new() }
}
fn listen() {
thread::spawn(serve_bluez);
serve_iwd();
}
}
// the call waiting for the user's answer; the daemon hears back when it's replied to
static WAITING: Mutex<Option<Method>> = Mutex::new(None);
// how many questions have been asked, for each one's own text input id
static ASKED: Mutex<u32> = Mutex::new(0);
fn waiting() -> std::sync::MutexGuard<'static, Option<Method>> {
WAITING.lock().unwrap_or_else(PoisonError::into_inner)
}
fn serve_iwd() {
let bus = Bus::system();
// watched before registering, so iwd's first question isn't missed
let methods = bus.methods(IWD_AGENT, "net.connman.iwd.Agent");
thread::spawn(|| {
keep_registered(IWD, || {
Bus::system().call(IWD, "/net/connman/iwd", "net.connman.iwd.AgentManager", "RegisterAgent", &[Argument::Path(String::from(IWD_AGENT))]);
});
});
for method in methods {
match method.name() {
"RequestPassphrase" => {
let network = argument(&method, 0);
let name = network_name(&network);
ask(method, Ask::Password, true, name, network);
}
"Cancel" => {
method.reply(&[]);
dismiss();
}
"Release" => method.reply(&[]),
// enterprise (802.1x) logins aren't asked for here: unanswered, iwd gives up on its own
_ => {}
}
}
}
fn serve_bluez() {
let bus = Bus::system();
let methods = bus.methods(BLUEZ_AGENT, "org.bluez.Agent1");
thread::spawn(|| {
keep_registered(BLUEZ, || {
let bus = Bus::system();
let agent = || Argument::Path(String::from(BLUEZ_AGENT));
// can show codes and take typed ones, so every kind of pairing reaches the popup
bus.call(BLUEZ, "/org/bluez", "org.bluez.AgentManager1", "RegisterAgent", &[agent(), Argument::from("KeyboardDisplay")]);
bus.call(BLUEZ, "/org/bluez", "org.bluez.AgentManager1", "RequestDefaultAgent", &[agent()]);
});
});
for method in methods {
let device = argument(&method, 0);
let name = device_name(&device);
match method.name() {
"RequestPinCode" => ask(method, Ask::Pin, false, name, device),
"RequestPasskey" => ask(method, Ask::Passkey, false, name, device),
"RequestConfirmation" => {
let passkey = number(&method, 1);
ask(method, Ask::Confirm(format!("{passkey:06}")), false, name, device);
}
"RequestAuthorization" => ask(method, Ask::Confirm(String::new()), false, name, device),
"DisplayPinCode" => {
let code = argument(&method, 1);
method.reply(&[]);
show(code, 0, name, device);
}
"DisplayPasskey" => {
let (passkey, entered) = (number(&method, 1), number(&method, 2));
method.reply(&[]);
show(format!("{passkey:06}"), entered, name, device);
}
// a paired device asking to use one of its services: pairing it was the yes
"AuthorizeService" => method.reply(&[]),
"Cancel" => {
method.reply(&[]);
dismiss();
}
"Release" => method.reply(&[]),
_ => {}
}
}
}
/*
* registers again whenever the daemon (re)starts, which shows as a new owner of its
* name. GetNameOwner asks the bus, not the daemon, so it never starts one
*/
fn keep_registered(name: &str, register: impl Fn()) {
let mut registered = String::new();
loop {
let owner = Bus::system().call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", &[Argument::from(name)]).text().to_string();
if owner.is_empty() {
registered.clear();
} else if owner != registered {
register();
registered = owner;
}
thread::sleep(Duration::from_secs(3));
}
}
fn argument(method: &Method, index: usize) -> String {
method.arguments().get(index).map_or(String::new(), |value| value.text().to_string())
}
fn number(method: &Method, index: usize) -> u32 {
method.arguments().get(index).map_or(0, |value| value.number() as u32)
}
fn network_name(path: &str) -> String {
let known = Links::read().wifi.networks.iter().find(|network| network.path == path).map(|network| network.name.clone());
let name = known.unwrap_or_else(|| Bus::system().property(IWD, path, "net.connman.iwd.Network", "Name").text().to_string());
if name.is_empty() { String::from("the network") } else { name }
}
fn device_name(path: &str) -> String {
let known = Bluetooth::read().devices().iter().find(|device| device.path() == path).map(|device| device.name().to_string());
let name = known.unwrap_or_else(|| Bus::system().property(BLUEZ, path, "org.bluez.Device1", "Alias").text().to_string());
if name.is_empty() { String::from("the device") } else { name }
}
// ponytail: one leaked input id per question (a few bytes); fine for questions asked by hand
fn new_input() -> &'static str {
let mut asked = ASKED.lock().unwrap_or_else(PoisonError::into_inner);
*asked += 1;
Box::leak(format!("magic-prompt-{asked}").into_boxed_str())
}
// a question that waits for the user; a new one replaces an unanswered one, whose asker then gives up on its own
fn ask(method: Method, ask: Ask, wifi: bool, name: String, object: String) {
*waiting() = Some(method);
open(Request { ask, wifi, name, object, input: new_input() });
}
// a code to type on the device: nothing to reply, it goes once the device is paired, or on Cancel
fn show(code: String, entered: u32, name: String, object: String) {
{
let mut prompt = Prompt::write();
// the device reports every digit typed: only the count changes
if let Some(Request { ask: Ask::Show { code: shown, entered: count }, object: asked, .. }) = &mut prompt.request
&& *asked == object
&& *shown == code
{
*count = entered;
return;
}
}
open(Request { ask: Ask::Show { code, entered }, wifi: false, name, object: object.clone(), input: new_input() });
// bluez says nothing when it's done: close once the device shows as paired, or after a minute
thread::spawn(move || {
let since = Instant::now();
while since.elapsed() < Duration::from_secs(60) {
thread::sleep(Duration::from_millis(500));
let paired = Bluetooth::read().devices().iter().any(|device| device.path() == object && device.paired());
let still = Prompt::read().request.as_ref().is_some_and(|request| request.object == object);
if !still {
return;
}
if paired {
break;
}
}
dismiss();
});
}
fn open(request: Request) {
let mut prompt = Prompt::write();
prompt.request = Some(request);
prompt.typed.clear();
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220));
prompt.appear.to(1.0);
}
// the question went away (the daemon cancelled, or it's done): no answer is sent
fn dismiss() {
waiting().take();
Prompt::write().request = None;
}
// 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.is_empty(),
Ask::Passkey => typed.trim().parse::<u32>().is_ok_and(|passkey| passkey <= 999_999),
Ask::Confirm(_) => true,
Ask::Show { .. } => false,
}
}
// the user's answer: typed text, or yes; it goes back to the daemon as the reply to its question
pub fn answer(typed: &str) {
let Some(request) = Prompt::read().request.clone() else {
return;
};
if !ready(&request.ask, typed) {
return;
}
let Some(method) = waiting().take() else {
return;
};
match request.ask {
Ask::Password | Ask::Pin => method.reply(&[Argument::from(typed)]),
Ask::Passkey => method.reply(&[Argument::Unsigned(typed.trim().parse().unwrap_or(0))]),
Ask::Confirm(_) => method.reply(&[]),
Ask::Show { .. } => {}
}
Prompt::write().request = None;
}
/*
* no: the question stays unanswered, and the pairing or the joining is cancelled, so
* the daemon stops at once instead of waiting for its timeout
*/
pub fn refuse() {
let Some(request) = Prompt::write().request.take() else {
return;
};
waiting().take();
control::clear_pending();
thread::spawn(move || {
let bus = Bus::system();
if request.wifi {
// only if it's this network iwd is joining
let (device, joining) = {
let links = Links::read();
let joining = links.wifi.networks.iter().any(|network| network.path == request.object && network.connecting);
(links.wifi.device.clone(), joining)
};
if joining && !device.is_empty() {
bus.call(IWD, &device, "net.connman.iwd.Station", "Disconnect", &[]);
}
} else {
bus.call(BLUEZ, &request.object, "org.bluez.Device1", "CancelPairing", &[]);
}
});
}
// `amane ipc call control prompt answer <text>|cancel`, for scripts and tests
pub fn command(words: &[&str]) -> String {
match words {
["answer", text] => answer(text),
["cancel"] => refuse(),
_ => return String::from("usage: control prompt answer <text>|cancel"),
}
String::from("ok")
}
pub fn showing() -> bool {
Prompt::read().request.is_some()
}
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 or a code, and its buttons. it takes the keyboard while it shows:
* Enter sends, 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 (title, question) = match &request.ask {
Ask::Password => (format!("Join {}", request.name), "Enter its Wi-Fi password"),
Ask::Pin => (format!("Pair {}", request.name), "Enter its PIN"),
Ask::Passkey => (format!("Pair {}", request.name), "Enter the six-digit passkey it shows"),
Ask::Confirm(code) if code.is_empty() => (format!("Pair {}", request.name), "Allow it to pair with this computer?"),
Ask::Confirm(_) => (format!("Pair {}", request.name), "Does the device show this code too?"),
Ask::Show { .. } => (format!("Pair {}", request.name), "Type this code on it, then press Enter"),
};
let header = Row::new(children![
Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(if request.wifi { Glyph::Lock } else { Glyph::Bluetooth }, 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) if !code.is_empty() => {
rows.push(Box::new(code_line(code)));
body_h = CODE_H;
}
Ask::Show { code, entered } => {
let mut dots: Vec<Box<dyn Widget>> = Vec::new();
for digit in 0..code.chars().count() as u32 {
dots.push(Box::new(Rectangle::new().width(DOTS_H).height(DOTS_H).radius(Full).fill(if digit < *entered { theme.accent } else { CHIP_OPEN })));
}
rows.push(Box::new(code_line(code)));
rows.push(Box::new(Rectangle::new().width(INNER_W).height(DOTS_H).align_child(Center, Center).child(Row::new(dots).gap(8.0))));
body_h = CODE_H + GAP + DOTS_H;
}
Ask::Confirm(_) => {}
}
// the buttons: Cancel, and what sends the answer (nothing to send while a code shows)
let lit = |spot: u8| prompt.hover.get(&spot).map_or(0.0, Animation::value);
let mut buttons: Vec<Box<dyn Widget>> = vec![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 send = match request.ask {
Ask::Password => Some("Join"),
Ask::Show { .. } => None,
_ => Some("Pair"),
};
if let Some(label) = send {
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 sends 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;
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(Keyboard::Exclusive)
.on_key(|key| match key {
Key::Escape => refuse(),
// a yes needs no typing: Enter is enough
Key::Enter => {
let confirm = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_)));
if confirm {
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, large and spaced in threes, the way devices show it
fn code_line(code: &str) -> Rectangle {
let spaced: String = code.chars().enumerate().flat_map(|(index, digit)| (index > 0 && index % 3 == 0).then_some(' ').into_iter().chain(std::iter::once(digit))).collect();
Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(spaced).size(26.0).weight(Weight::Bold).color(theme::now().accent))
}
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))
}
+5 -20
View File
@@ -15,7 +15,6 @@ use amane::{
SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
};
use crate::agent;
use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled};
use crate::map::{self, AREA_H, Link, MAP_W, NODES, Node};
@@ -38,7 +37,7 @@ const OUTPUT_H: f32 = 22.0;
const OUTPUTS: usize = 8;
// the pill and the panel sit this far from the monitor's right edge
pub const RIGHT: f32 = 12.0;
const RIGHT: f32 = 12.0;
// both windows are as wide as a map menu, the widest the panel becomes
const WINDOW_W: f32 = RIGHT + MAP_W + SHADOW;
@@ -179,11 +178,6 @@ impl Control {
self.expanded
}
// the monitor the panel opened on; none: every monitor, without a workspace list
pub fn opened_on(&self) -> Option<&str> {
self.opened_on.as_deref()
}
fn lit(&self, spot: Spot) -> f32 {
self.hover.get(&spot).map_or(0.0, Animation::value)
}
@@ -530,18 +524,12 @@ fn collapse_soon() {
!control.inside_panel && !control.inside_menu
};
// a question in the popup keeps everything open: the pointer went to answer it
if gone && !agent::showing() {
if gone {
collapse();
}
});
}
// a connection attempt that was called off (see agent::refuse): its line stops running
pub fn clear_pending() {
Control::write().pending = None;
}
fn menu_opening() -> Animation {
Animation::new(0.0).duration(Duration::from_millis(MENU_MS)).easing(Easing::Linear)
}
@@ -625,9 +613,8 @@ pub fn command(arguments: &[String]) -> String {
open_menu(menu);
}
["prompt", rest @ ..] => return agent::command(rest),
["status"] => return status(),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|status"),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|status"),
}
String::from("ok")
@@ -1767,12 +1754,10 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent {
Link::Connecting => "Connecting…",
Link::Idle if network.known => "Saved",
Link::Idle if !network.secured => "Open",
Link::Idle if network.enterprise => "Enterprise · use iwctl",
// the shell is iwd's agent: joining asks for the password in a popup
Link::Idle => "New · click to join",
Link::Idle => "New · join with iwctl",
};
let joinable = !network.enterprise;
let joinable = network.known || !network.secured;
let path = network.path.clone();
let on_click: Option<Box<dyn Fn()>> = match link {
-3
View File
@@ -1,4 +1,3 @@
mod agent;
mod art;
mod control;
mod glyph;
@@ -27,7 +26,5 @@ fn main() {
.window_per_monitor(workspaces::view)
.window_per_monitor(control::view)
.window_per_monitor(control::menu_view)
// last, so the popup lies over the control centre
.window_per_monitor(agent::view)
.run();
}
-4
View File
@@ -60,9 +60,6 @@ pub struct Network {
pub path: String,
pub name: String,
pub secured: bool,
// 802.1x: a username and password, which the popup doesn't ask for
pub enterprise: bool,
pub known: bool,
pub connected: bool,
@@ -182,7 +179,6 @@ fn wifi() -> Wifi {
path: path.to_string(),
name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open",
enterprise: found.get("Type").text() == "8021x",
known: !found.get("KnownNetwork").text().is_empty(),
connected: path == connected && joined,
connecting: path == connected && !joined,