Revert "control centre: ask for Wi-Fi passwords and Bluetooth PINs in a popup"
This reverts commit 4d4166dadc.
This commit is contained in:
-590
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user