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

The shell registers as iwd's agent and as BlueZ's default agent (KeyboardDisplay),
each only while its daemon runs (GetNameOwner, never a call that would start it), and
again after a restart. RequestPassphrase, RequestPinCode, RequestPasskey,
RequestConfirmation, RequestAuthorization, DisplayPinCode and DisplayPasskey show a
small popup over the control centre that takes the keyboard (Enter answers, Escape
cancels); the answer is the reply to the daemon's call. Amane can only reply with
success, so Cancel leaves the call unanswered and cancels the pairing or the joining.
New Wi-Fi networks with a password are clickable on the map now; IPC control prompt
answer|cancel.
This commit is contained in:
server2
2026-10-11 08:56:13 +02:00
parent 8fa951fe15
commit 4d4166dadc
5 changed files with 635 additions and 7 deletions
+18 -2
View File
@@ -21,8 +21,8 @@ The pill shows only what runs: Wi-Fi signal while iwd runs (dim while its radio
The panel has:
- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it, the connected one to disconnect. A new network with a password has to be joined once with `iwctl` (the map can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (pairing works for devices without a PIN).
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it, the connected one to disconnect. A new network with a password asks for it in a small popup (see [Passwords and PINs](#passwords-and-pins)); enterprise (802.1x) networks still need `iwctl` once. "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair; a device that needs a PIN, a passkey or a confirmation asks in the popup.
- *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`.
- *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup).
- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first.
@@ -36,9 +36,24 @@ Changes that need root (starting services, the performance mode) run as `doas -n
```sh
amane ipc call control toggle # also: open, close
amane ipc call control menu wifi # also: bluetooth, vpn, sound, close
amane ipc call control prompt cancel # or: prompt answer <text>, for the popup's question
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%>
```
### Passwords and PINs
The shell registers itself as iwd's agent and as BlueZ's default agent (capability `KeyboardDisplay`), each only while its daemon runs. When joining a network or pairing a device needs something from you, the daemon asks the shell, and a small popup shows over the control centre:
| Asked for | Popup |
|---|---|
| a Wi-Fi password (iwd `RequestPassphrase`) | a password field; Join needs 8 to 63 characters |
| a Bluetooth PIN (`RequestPinCode`) or passkey (`RequestPasskey`) | a field for it |
| "does this code match?" (`RequestConfirmation`) | the code, large; Pair or Cancel |
| "may it pair?" (`RequestAuthorization`) | Pair or Cancel |
| "type this on the device" (`DisplayPinCode`, `DisplayPasskey`) | the code and a dot per digit typed on the device; it closes once the device is paired |
The popup takes the keyboard while it shows: Enter answers, Escape cancels. The answer goes back to the daemon as the reply to its question. Cancel cancels the pairing (`CancelPairing`) or the joining (`Station.Disconnect`, only while iwd is joining that network). A paired device asking to use one of its services (`AuthorizeService`) is allowed without asking: pairing it was the yes.
### Hotspot setup
iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi client while the hotspot is on (the internet then comes from `eth0`). Once, as root:
@@ -118,6 +133,7 @@ src/art.rs cover download (curl) and the palette file
src/workspaces.rs the workspace pill, top left on every monitor
src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC
src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it
src/agent.rs iwd's and BlueZ's agent: asks for passwords, PINs and passkeys in a popup
src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
+590
View File
@@ -0,0 +1,590 @@
/*
* 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))
}
+20 -5
View File
@@ -15,6 +15,7 @@ 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};
@@ -37,7 +38,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
const RIGHT: f32 = 12.0;
pub 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;
@@ -178,6 +179,11 @@ 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)
}
@@ -524,12 +530,18 @@ fn collapse_soon() {
!control.inside_panel && !control.inside_menu
};
if gone {
// a question in the popup keeps everything open: the pointer went to answer it
if gone && !agent::showing() {
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)
}
@@ -613,8 +625,9 @@ 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>|status"),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|status"),
}
String::from("ok")
@@ -1754,10 +1767,12 @@ 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 => "New · join with iwctl",
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",
};
let joinable = network.known || !network.secured;
let joinable = !network.enterprise;
let path = network.path.clone();
let on_click: Option<Box<dyn Fn()>> = match link {
+3
View File
@@ -1,3 +1,4 @@
mod agent;
mod art;
mod control;
mod glyph;
@@ -26,5 +27,7 @@ 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,6 +60,9 @@ 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,
@@ -179,6 +182,7 @@ 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,