Add native NetworkManager popup and saved internet priority

This commit is contained in:
Lightbar contributors
2026-09-29 13:51:21 +02:00
parent 0fa34f8e36
commit ac799872d6
13 changed files with 3360 additions and 263 deletions

View File

@@ -15,6 +15,8 @@ and the deliberately deferred stress tests are tracked in [TOOD.md](TOOD.md).
- Sway workspaces, binding mode, scratchpad list and direct IPC actions;
- exact-boundary clock and calendar popup;
- NetworkManager, PipeWire audio, battery, backlight, and hwmon temperature;
- wired, Wi-Fi, and mobile broadband connections, Wi-Fi scanning and passwords,
and saved internet priority through NetworkManager;
- default output and microphone selection, volume and mute controls, backlight
sliders, and external advanced settings launchers;
- interval and streaming custom commands with limits, timeout, stale state, and
@@ -124,6 +126,53 @@ See [examples/config.toml](examples/config.toml) and
[examples/theme.toml](examples/theme.toml) for every built-in used by the
current layout.
### Network
The network module uses NetworkManager's system D-Bus service. NetworkManager
1.44 or newer is required for guarded profile updates. The bar follows its
primary connection; the popup reports the actual IPv4 and IPv6 defaults
separately, including defaults provided by a VPN. Internet connectivity status
comes from NetworkManager's connectivity check, when enabled.
Left-click the network module to see wired LAN, Wi-Fi, and mobile broadband
devices. Select a device to see its addresses, saved connections, and controls:
- **Scan for Wi-Fi networks** requests a scan and updates the list when it
completes. Networks show signal strength, security, and saved/connected state.
- Select a saved connection to activate it. New open, enhanced-open, WPA/WPA2
Personal, and WPA3 Personal networks can be joined directly. Password entry
is masked and sent over D-Bus to NetworkManager for normal profile storage.
- **Disconnect** disconnects that device. Wi-Fi and mobile broadband radio
switches appear in the overview; hardware blocks are shown separately.
- **Prefer for internet** saves route metric `1` for the selected profile's
eligible IPv4/IPv6 settings and reapplies it to active connections. Conflicting
wired, Wi-Fi, or mobile profiles with metric `0`/`1` are moved to `2` as needed;
other priorities are preserved. IPv6 metric `0` retains its kernel meaning.
This preference uses NetworkManager configuration, without a separate Lightbar
preference file. It does not change autoconnect priority. Default indicators
continue to reflect NetworkManager's current routes: an unavailable gateway,
IPv6 availability, or VPN policy can produce a different default. VPNs, virtual
connections, and `never-default` settings are preserved. Explicit default routes
and policy routing require the connection editor. Failed priority updates attempt
to restore the previous metrics and report any rollback failure in the popup.
Use **Open connection editor** (`nm-connection-editor`) for enterprise Wi-Fi,
hidden networks, legacy WEP, password changes on saved profiles, SIM/APN setup,
and advanced routing. Existing mobile broadband profiles can be activated in
the popup. NetworkManager's permissions and secret-agent policies still apply;
authorization failures and connection failures appear in the popup.
Use Tab/Shift+Tab or Up/Down to move focus, Enter/Space to activate controls,
Backspace to edit a password, and Escape to close. Lists support scrolling and
Previous/More buttons. Devices, defaults, and saved settings update from D-Bus
events without idle polling. Controls remain available while disconnected.
`format_connected` accepts `{icon}`, `{kind}`, `{connection}`, `{interface}`,
and `{ssid}`. An optional `interface` limits the bar's displayed connection;
the popup continues to show all supported devices. The example uses
`{icon} {connection}` so wired and mobile defaults have appropriate icons.
### Audio
Audio requires PipeWire, WirePlumber (`wpctl`), and `pw-dump` on `PATH`.

10
TOOD.md
View File

@@ -58,8 +58,16 @@ StatusNotifier tray.
Sway IPC disconnects instead of replacing it with an empty snapshot.
- [x] Implement a navigable scratchpad window popover and reveal/move actions.
- [x] Implement an exact-boundary clock and navigable calendar popover.
- [ ] Implement NetworkManager status and detail through D-Bus, with an
- [x] Implement NetworkManager status and detail through D-Bus, with an
`nm-connection-editor` launcher for advanced management.
- [x] Add a keyboard-accessible network popup for LAN, Wi-Fi scans and
connections, WWAN profiles, and actual IPv4/IPv6 default connections.
- [x] Set internet preference through saved NetworkManager route metrics,
preserve VPN routing, and report authorization or activation failures.
- [x] Verify network controls with isolated services and popup tests, document
supported connection types, and validate the installed build.
Verified with 54 automated tests, a read-only comparison with live
NetworkManager, and native popup controls in an isolated Sway session.
- [ ] Implement UPower battery status and detail through D-Bus.
- [x] Implement PulseAudio/PipeWire-Pulse volume events, mute/volume controls,
and a `pavucontrol` launcher.

View File

@@ -38,7 +38,7 @@ format = "{mode}"
[modules.network]
kind = "network"
format_connected = " {ssid}"
format_connected = "{icon} {connection}"
format_disconnected = "睊 Disconnected"
[modules.network.common.actions.left]

View File

@@ -58,8 +58,8 @@ use wayland_protocols::xdg::shell::client::xdg_positioner;
use crate::{
config::{Action, BarConfig, BuiltinAction, ConfigBundle, Layer, ModuleConfig, Position},
model::{
AudioView, CalendarModel, Interaction, ModuleEvent, ModuleStore, PointerButton,
PopupContent, PopupModel, ScrollDirection,
AudioView, CalendarModel, Interaction, ModuleEvent, ModuleStore, NetworkView,
PointerButton, PopupContent, PopupModel, ScrollDirection,
},
modules::{
AudioAction, AudioTarget, ModuleRuntime, action_for_button, action_for_scroll,
@@ -403,6 +403,16 @@ impl App {
new.focused.clone_from(&old.focused);
new.offset = old.offset.min(new.controls().len().saturating_sub(1));
}
if let (PopupContent::Network(old), PopupContent::Network(new)) =
(&popup.model.content, &mut model.content)
{
new.preserve_ui(old);
let available = (f64::from(popup.height)
- 2.0 * f64::from(self.bundle.theme.popup.padding)
- 58.0)
.max(0.0);
new.ensure_focus_visible(available);
}
popup.model = model;
popup.dirty = true;
}
@@ -737,12 +747,16 @@ impl App {
&self.shm,
)?;
// Establish keyboard focus before the popup grab. Release it on dismissal.
bar.layer
.set_keyboard_interactivity(if matches!(model.content, PopupContent::Audio(_)) {
bar.layer.set_keyboard_interactivity(
if matches!(
model.content,
PopupContent::Audio(_) | PopupContent::Network(_)
) {
KeyboardInteractivity::Exclusive
} else {
KeyboardInteractivity::OnDemand
});
},
);
bar.layer.commit();
bar.layer.get_popup(popup.xdg_popup());
if let Some(seat) = &self.seat {
@@ -904,6 +918,10 @@ impl App {
let Some(id) = hit_box.segment.as_deref() else {
return;
};
if id.starts_with("network-") {
self.handle_network_control(&hit_box, qh);
return;
}
if id.starts_with("audio-") {
self.handle_audio_control(&hit_box, x, qh);
return;
@@ -991,6 +1009,106 @@ impl App {
}
}
fn handle_network_control(&mut self, hit: &HitBox, qh: &QueueHandle<Self>) {
let Some(popup) = self.popup.as_mut() else {
return;
};
let PopupContent::Network(network) = &mut popup.model.content else {
return;
};
let Some(id) = hit.segment.as_deref() else {
return;
};
let action = match id {
"network-scroll-up" => {
network.offset = network.offset.saturating_sub(1);
None
}
"network-scroll-down" => {
network.offset =
(network.offset + 1).min(network.controls().len().saturating_sub(1));
None
}
_ => network.activate(id),
};
if let Some(action) = action {
match self.module_runtime.network_action(&popup.module, action) {
Ok(()) => {
network.state.busy = true;
network.state.notice = Some("Applying network control…".into());
}
Err(error) => {
network.state.notice =
Some(format!("Could not queue network control: {error:#}"));
}
}
}
let available =
(f64::from(popup.height) - 2.0 * f64::from(self.bundle.theme.popup.padding) - 58.0)
.max(0.0);
if !id.starts_with("network-scroll-") {
network.ensure_focus_visible(available);
}
popup.dirty = true;
self.invalidate_all(qh);
}
fn handle_network_key(&mut self, event: &KeyEvent, qh: &QueueHandle<Self>) -> bool {
let Some(popup) = self.popup.as_mut() else {
return false;
};
let PopupContent::Network(network) = &mut popup.model.content else {
return false;
};
let available =
(f64::from(popup.height) - 2.0 * f64::from(self.bundle.theme.popup.padding) - 58.0)
.max(0.0);
if !network.state.busy
&& network.focused.as_deref() == Some("network-password")
&& matches!(network.view, NetworkView::Password { .. })
&& !matches!(
event.keysym,
Keysym::Tab
| Keysym::ISO_Left_Tab
| Keysym::Up
| Keysym::Down
| Keysym::Return
| Keysym::KP_Enter
)
{
if event.keysym == Keysym::BackSpace {
network.password.backspace();
} else if let Some(text) = &event.utf8 {
network.password.push(text);
}
} else {
match event.keysym {
Keysym::Tab | Keysym::Down => network.move_focus(false, available),
Keysym::ISO_Left_Tab | Keysym::Up => network.move_focus(true, available),
Keysym::Return | Keysym::KP_Enter | Keysym::space => {
let id = if network.focused.as_deref() == Some("network-password") {
Some("network-submit".to_owned())
} else {
network.focused.clone()
};
if let Some(id) = id {
let hit = HitBox {
rect: crate::render::Rect::default(),
module: popup.module.clone(),
segment: Some(id),
};
self.handle_popup_click(hit, 0.0, qh);
return true;
}
}
_ => {}
}
}
popup.dirty = true;
self.invalidate_all(qh);
true
}
fn handle_audio_control(&mut self, hit: &HitBox, x: f64, qh: &QueueHandle<Self>) {
let Some(popup) = self.popup.as_mut() else {
return;
@@ -1533,6 +1651,32 @@ impl PointerHandler for App {
self.handle_popup_click(hit, event.position.0, qh);
}
}
PointerEventKind::Axis { vertical, .. }
if matches!(popup.model.content, PopupContent::Network(_)) =>
{
let amount = if vertical.value120 != 0 {
f64::from(vertical.value120)
} else if vertical.discrete != 0 {
f64::from(vertical.discrete)
} else {
vertical.absolute
};
if amount != 0.0 {
let hit = HitBox {
rect: crate::render::Rect::default(),
module: popup.module.clone(),
segment: Some(
if amount < 0.0 {
"network-scroll-up"
} else {
"network-scroll-down"
}
.into(),
),
};
self.handle_network_control(&hit, qh);
}
}
PointerEventKind::Axis { vertical, .. }
if matches!(popup.model.content, PopupContent::Audio(_)) =>
{
@@ -1619,6 +1763,7 @@ impl KeyboardHandler for App {
) {
if event.keysym == Keysym::Escape {
self.close_popup();
} else if self.handle_network_key(&event, qh) {
} else if self.handle_audio_key(event.keysym, qh) {
} else if event.keysym == Keysym::Left {
self.change_calendar_month(-1, qh);

View File

@@ -514,7 +514,7 @@ impl Default for NetworkModule {
fn default() -> Self {
Self {
kind: "network".to_owned(),
format_connected: " {ssid}".to_owned(),
format_connected: "{icon} {connection}".to_owned(),
format_disconnected: "睊 Disconnected".to_owned(),
interface: None,
common: ModuleCommon::default(),

View File

@@ -1,5 +1,8 @@
use std::collections::BTreeMap;
mod network;
pub use network::*;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModuleSnapshot {
pub segments: Vec<Segment>,
@@ -88,6 +91,7 @@ pub enum PopupContent {
Calendar(CalendarModel),
Slider(SliderModel),
Audio(AudioModel),
Network(NetworkModel),
}
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)]

701
src/model/network.rs Normal file
View File

@@ -0,0 +1,701 @@
use std::fmt;
/// UI-owned credential, never formatted or included in module snapshots by the worker.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct NetworkSecret(Vec<u8>);
impl fmt::Debug for NetworkSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
impl Drop for NetworkSecret {
fn drop(&mut self) {
self.0.fill(0);
}
}
impl NetworkSecret {
/// # Panics
/// Panics if the private buffer violates its UTF-8 invariant.
#[must_use]
pub fn text(&self) -> &str {
std::str::from_utf8(&self.0).expect("credential contains typed UTF-8")
}
pub fn push(&mut self, text: &str) {
for c in text.chars().filter(|c| !c.is_control()) {
if self.0.len() + c.len_utf8() <= 128 {
let mut bytes = [0; 4];
self.0
.extend_from_slice(c.encode_utf8(&mut bytes).as_bytes());
}
}
}
pub fn backspace(&mut self) {
if let Some((index, _)) = self.text().char_indices().next_back() {
self.0[index..].fill(0);
self.0.truncate(index);
}
}
#[must_use]
pub fn masked(&self) -> String {
"•".repeat(self.text().chars().count())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkKind {
Wired,
Wifi,
Wwan,
}
impl NetworkKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Wired => "Wired LAN",
Self::Wifi => "Wi-Fi",
Self::Wwan => "Mobile broadband",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WifiSecurity {
Open,
Personal,
Sae,
Owe,
Enterprise,
Unsupported,
}
impl WifiSecurity {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Open => "Open",
Self::Personal => "WPA/WPA2",
Self::Sae => "WPA3",
Self::Owe => "Enhanced open",
Self::Enterprise => "Enterprise",
Self::Unsupported => "Configure in editor",
}
}
#[must_use]
pub fn password_valid(self, password: &str) -> bool {
match self {
Self::Personal => {
(8..=63).contains(&password.len())
|| (password.len() == 64 && password.bytes().all(|b| b.is_ascii_hexdigit()))
}
Self::Sae => (1..=63).contains(&password.len()),
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WifiNetwork {
pub path: String,
pub ssid: Vec<u8>,
pub label: String,
pub strength: u8,
pub security: WifiSecurity,
pub active: bool,
pub profile: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NetworkProfile {
pub path: String,
pub name: String,
pub uuid: String,
pub kind: Option<NetworkKind>,
pub ssid: Vec<u8>,
pub security: String,
pub metrics: [i64; 2],
}
#[derive(Debug, Clone, PartialEq)]
pub struct NetworkDevice {
pub path: String,
pub interface: String,
pub kind: NetworkKind,
pub state: u32,
pub state_label: String,
pub managed: bool,
pub carrier: Option<bool>,
pub active: Option<String>,
pub profile: Option<String>,
pub connection_name: String,
pub defaults: [bool; 2],
pub addresses: Vec<String>,
pub profiles: Vec<NetworkProfile>,
pub networks: Vec<WifiNetwork>,
pub last_scan: i64,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[allow(clippy::struct_excessive_bools)] // Independent radio, hardware, and operation states.
pub struct NetworkState {
pub devices: Vec<NetworkDevice>,
pub defaults: [Vec<String>; 2],
pub primary: String,
pub primary_interface: String,
pub primary_kind: String,
pub connectivity: String,
pub wifi_enabled: bool,
pub wifi_hardware: bool,
pub wwan_enabled: bool,
pub wwan_hardware: bool,
pub notice: Option<String>,
pub busy: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum NetworkView {
#[default]
Overview,
Device(String),
Password {
device: String,
ap: String,
label: String,
security: WifiSecurity,
},
}
/// Commands are owned by the network worker after enqueueing; callers never wait on D-Bus.
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkAction {
Scan(String),
Radio {
wifi: bool,
enabled: bool,
},
Connect {
device: String,
profile: String,
ap: String,
},
Join {
device: String,
ap: String,
password: NetworkSecret,
},
Disconnect(String),
Prefer(String),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NetworkModel {
pub state: NetworkState,
pub view: NetworkView,
pub focused: Option<String>,
pub offset: usize,
pub password: NetworkSecret,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NetworkControl {
pub id: Option<String>,
pub label: String,
pub detail: String,
pub active: bool,
}
impl NetworkControl {
#[must_use]
pub fn height(&self) -> f64 {
if self.detail.is_empty() { 32.0 } else { 52.0 }
}
}
impl NetworkModel {
pub fn set_view(&mut self, view: NetworkView) {
self.view = view;
self.focused = None;
self.offset = 0;
self.password = NetworkSecret::default();
}
pub fn preserve_ui(&mut self, old: &Self) {
self.view = old.view.clone();
let valid = match &self.view {
NetworkView::Overview => true,
NetworkView::Device(path) => self.state.devices.iter().any(|d| d.path == *path),
NetworkView::Password { device, ap, .. } => self
.state
.devices
.iter()
.any(|d| d.path == *device && d.networks.iter().any(|n| n.path == *ap)),
};
if !valid {
self.set_view(NetworkView::Overview);
return;
}
self.password = old.password.clone();
let controls = self.controls();
self.focused = if self.state.busy {
old.focused.clone()
} else {
old.focused
.clone()
.filter(|id| controls.iter().any(|c| c.id.as_ref() == Some(id)))
};
self.offset = old.offset.min(controls.len().saturating_sub(1));
}
#[must_use]
#[allow(clippy::too_many_lines)] // The three popup views share one ordered control list.
pub fn controls(&self) -> Vec<NetworkControl> {
let mut rows = Vec::new();
let mut row = |id: Option<String>, label: String, detail: String, active| {
rows.push(NetworkControl {
id,
label,
detail,
active,
});
};
if let Some(notice) = &self.state.notice {
// Split long errors into bounded lines so the actionable detail remains readable.
for line in wrap_notice(notice, 42) {
row(None, line, String::new(), false);
}
}
match &self.view {
NetworkView::Overview => {
for (family, defaults) in ["IPv4 default", "IPv6 default"]
.into_iter()
.zip(&self.state.defaults)
{
let names = if defaults.is_empty() {
"None".into()
} else {
defaults.join(", ")
};
row(None, format!("{family}: {names}"), String::new(), false);
}
row(None, self.state.connectivity.clone(), String::new(), false);
for (wifi, enabled, hardware, label) in [
(
true,
self.state.wifi_enabled,
self.state.wifi_hardware,
"Wi-Fi",
),
(
false,
self.state.wwan_enabled,
self.state.wwan_hardware,
"Mobile broadband",
),
] {
if self.state.devices.iter().any(|d| {
d.kind
== if wifi {
NetworkKind::Wifi
} else {
NetworkKind::Wwan
}
}) {
row(
hardware.then(|| format!("network-radio:{wifi}")),
format!(
"{label}: {}",
if !hardware {
"hardware blocked"
} else if enabled {
"On · Turn off"
} else {
"Off · Turn on"
}
),
String::new(),
enabled,
);
}
}
for kind in [NetworkKind::Wired, NetworkKind::Wifi, NetworkKind::Wwan] {
let devices: Vec<_> = self
.state
.devices
.iter()
.filter(|d| d.kind == kind)
.collect();
if devices.is_empty() {
row(
None,
format!("{} · No device", kind.label()),
String::new(),
false,
);
}
for device in devices {
row(
Some(format!("network-device:{}", device.path)),
format!("{} · {} ›", kind.label(), device.interface),
device.summary(),
device.defaults.iter().any(|v| *v),
);
}
}
}
NetworkView::Device(path) => {
row(
Some("network-back".into()),
"‹ All connections".into(),
String::new(),
false,
);
if let Some(device) = self.state.devices.iter().find(|d| d.path == *path) {
row(
None,
format!("{} · {}", device.kind.label(), device.interface),
device.summary(),
false,
);
for address in &device.addresses {
row(None, address.clone(), String::new(), false);
}
if let Some(profile) = device
.profiles
.iter()
.find(|p| Some(&p.path) == device.profile.as_ref())
{
row(
None,
"Saved route metrics (lower wins)".into(),
format!(
"IPv4 {} · IPv6 {}",
metric_label(profile.metrics[0]),
metric_label(profile.metrics[1])
),
false,
);
}
if device.active.is_some() && device.managed {
if device.state == 100 && device.profile.is_some() {
row(
Some("network-prefer".into()),
"Prefer for internet".into(),
"Save route priority in NetworkManager".into(),
false,
);
}
row(
Some("network-disconnect".into()),
"Disconnect".into(),
String::new(),
false,
);
}
if device.kind == NetworkKind::Wifi {
row(
(device.managed && self.state.wifi_enabled && self.state.wifi_hardware)
.then(|| "network-scan".into()),
"Scan for Wi-Fi networks".into(),
String::new(),
false,
);
for network in &device.networks {
row(
(device.managed
&& self.state.wifi_enabled
&& self.state.wifi_hardware
&& !network.active)
.then(|| format!("network-ap:{}", network.path)),
format!(
"{}{}",
if network.active { "✓ " } else { "" },
network.label
),
format!(
"{}% · {}{}",
network.strength,
network.security.label(),
if network.profile.is_some() {
" · Saved"
} else {
""
}
),
network.active,
);
}
if device.networks.is_empty() {
row(
None,
"No networks found".into(),
"Turn on Wi-Fi, then scan".into(),
false,
);
}
}
for profile in &device.profiles {
if Some(&profile.path) != device.profile.as_ref()
&& (device.kind != NetworkKind::Wifi
|| !device
.networks
.iter()
.any(|n| n.profile.as_ref() == Some(&profile.path)))
{
row(
(device.managed && device.state >= 30)
.then(|| format!("network-profile:{}", profile.path)),
format!("Connect: {}", profile.name),
"Saved connection".into(),
false,
);
}
}
if device.kind == NetworkKind::Wired
&& device.profiles.is_empty()
&& device.managed
&& device.carrier == Some(true)
{
row(
Some("network-auto-connect".into()),
"Connect wired network".into(),
"Create an automatic IP profile".into(),
false,
);
}
if device.kind == NetworkKind::Wwan && device.profiles.is_empty() {
row(
None,
"Set up mobile broadband in the editor".into(),
"Choose the provider and APN".into(),
false,
);
}
}
}
NetworkView::Password {
label, security, ..
} => {
row(
Some("network-back".into()),
"‹ Back to Wi-Fi networks".into(),
String::new(),
false,
);
row(
None,
format!("Connect to {label}"),
security.label().into(),
false,
);
row(
Some("network-password".into()),
"Password".into(),
if self.password.text().is_empty() {
"Type the Wi-Fi password".into()
} else {
self.password.masked()
},
false,
);
row(
security
.password_valid(self.password.text())
.then(|| "network-submit".into()),
"Connect".into(),
String::new(),
false,
);
row(
None,
if *security == WifiSecurity::Sae {
"1–63 bytes"
} else {
"8–63 bytes or 64 hexadecimal digits"
}
.into(),
String::new(),
false,
);
}
}
row(
Some("open-settings".into()),
"Open connection editor…".into(),
String::new(),
false,
);
if self.state.busy {
for row in &mut rows {
row.id = None;
}
}
rows
}
pub fn activate(&mut self, id: &str) -> Option<NetworkAction> {
if !self.controls().iter().any(|c| c.id.as_deref() == Some(id)) {
return None;
}
self.focused = Some(id.into());
if let Some(path) = id.strip_prefix("network-device:") {
self.set_view(NetworkView::Device(path.into()));
return None;
}
if id == "network-back" {
let view = match &self.view {
NetworkView::Password { device, .. } => NetworkView::Device(device.clone()),
_ => NetworkView::Overview,
};
self.set_view(view);
return None;
}
if let Some(wifi) = id.strip_prefix("network-radio:") {
let wifi = wifi == "true";
return Some(NetworkAction::Radio {
wifi,
enabled: if wifi {
!self.state.wifi_enabled
} else {
!self.state.wwan_enabled
},
});
}
if let NetworkView::Password { device, ap, .. } = &self.view {
if id == "network-submit" {
let action = NetworkAction::Join {
device: device.clone(),
ap: ap.clone(),
password: std::mem::take(&mut self.password),
};
self.set_view(NetworkView::Device(device.clone()));
return Some(action);
}
return None;
}
let NetworkView::Device(path) = &self.view else {
return None;
};
let device = self.state.devices.iter().find(|d| d.path == *path)?;
match id {
"network-scan" => Some(NetworkAction::Scan(path.clone())),
"network-prefer" => device.profile.clone().map(NetworkAction::Prefer),
"network-disconnect" => Some(NetworkAction::Disconnect(path.clone())),
"network-auto-connect" => Some(NetworkAction::Connect {
device: path.clone(),
profile: "/".into(),
ap: "/".into(),
}),
_ => {
if let Some(profile) = id.strip_prefix("network-profile:") {
return Some(NetworkAction::Connect {
device: path.clone(),
profile: profile.into(),
ap: "/".into(),
});
}
let ap = id.strip_prefix("network-ap:")?;
let network = device.networks.iter().find(|n| n.path == ap)?;
if let Some(profile) = &network.profile {
return Some(NetworkAction::Connect {
device: path.clone(),
profile: profile.clone(),
ap: ap.into(),
});
}
match network.security {
WifiSecurity::Personal | WifiSecurity::Sae => {
self.set_view(NetworkView::Password {
device: path.clone(),
ap: ap.into(),
label: network.label.clone(),
security: network.security,
});
self.focused = Some("network-password".into());
None
}
WifiSecurity::Open | WifiSecurity::Owe => Some(NetworkAction::Join {
device: path.clone(),
ap: ap.into(),
password: NetworkSecret::default(),
}),
_ => {
self.state.notice =
Some("Configure this network in the connection editor.".into());
None
}
}
}
}
}
pub fn ensure_focus_visible(&mut self, available: f64) {
let controls = self.controls();
let Some(index) = controls
.iter()
.position(|c| c.id.is_some() && c.id == self.focused)
else {
return;
};
self.offset = self.offset.min(index);
while self.offset < index
&& controls[self.offset..=index]
.iter()
.map(NetworkControl::height)
.sum::<f64>()
> available
{
self.offset += 1;
}
}
pub fn move_focus(&mut self, backwards: bool, available: f64) {
let controls = self.controls();
let indices: Vec<_> = controls
.iter()
.enumerate()
.filter_map(|(i, c)| c.id.as_ref().map(|_| i))
.collect();
if indices.is_empty() {
return;
}
let current = indices.iter().position(|&i| controls[i].id == self.focused);
let next = match (current, backwards) {
(Some(i), true) => (i + indices.len() - 1) % indices.len(),
(Some(i), false) => (i + 1) % indices.len(),
(None, true) => indices.len() - 1,
(None, false) => 0,
};
let index = indices[next];
self.focused.clone_from(&controls[index].id);
self.ensure_focus_visible(available);
}
}
impl NetworkDevice {
#[must_use]
pub fn summary(&self) -> String {
let mut parts = vec![self.state_label.clone()];
if !self.connection_name.is_empty() {
parts.push(self.connection_name.clone());
}
if self.defaults[0] {
parts.push("IPv4 default".into());
}
if self.defaults[1] {
parts.push("IPv6 default".into());
}
parts.join(" · ")
}
}
fn metric_label(value: i64) -> String {
if value < 0 {
"Auto".into()
} else {
value.to_string()
}
}
fn wrap_notice(text: &str, width: usize) -> Vec<String> {
let chars: Vec<_> = text.chars().filter(|c| !c.is_control()).take(504).collect();
chars
.chunks(width)
.map(|chunk| chunk.iter().collect())
.collect()
}

View File

@@ -1,6 +1,7 @@
mod audio;
mod clock;
mod command;
mod network;
mod process;
mod sway;
mod sysfs;
@@ -24,7 +25,7 @@ use crate::{
Action, AudioModule, BacklightModule, BuiltinAction, Config, ModuleConfig,
UnavailableBehavior,
},
model::{ModuleEvent, ModuleSnapshot},
model::{ModuleEvent, ModuleSnapshot, NetworkAction},
};
pub use audio::{AudioAction, AudioTarget};
@@ -39,6 +40,7 @@ pub struct ModuleRuntime {
children: Arc<Mutex<Vec<Arc<Mutex<Child>>>>>,
module_kinds: BTreeMap<String, &'static str>,
audio_actions: BTreeMap<String, tokio::sync::mpsc::Sender<AudioAction>>,
network_actions: BTreeMap<String, tokio::sync::mpsc::Sender<NetworkAction>>,
}
impl ModuleRuntime {
@@ -54,6 +56,7 @@ impl ModuleRuntime {
let mut threads = Vec::new();
let mut module_kinds = BTreeMap::new();
let mut audio_actions = BTreeMap::new();
let mut network_actions = BTreeMap::new();
let sway_modules = sway::ConfiguredSwayModules::from_config(config);
if !sway_modules.is_empty() {
@@ -91,13 +94,16 @@ impl ModuleRuntime {
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Network(settings) => threads.push(process::spawn_network(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
Arc::clone(&children),
)),
ModuleConfig::Network(settings) => {
let (worker, actions) = network::spawn(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
);
threads.push(worker);
network_actions.insert(name.clone(), actions);
}
ModuleConfig::Audio(settings) => {
let (worker, actions) = audio::spawn(
name.clone(),
@@ -133,6 +139,7 @@ impl ModuleRuntime {
children,
module_kinds,
audio_actions,
network_actions,
})
}
@@ -140,6 +147,16 @@ impl ModuleRuntime {
self.module_kinds.get(module).copied()
}
/// Queues an owned network command; execution and its result belong to the worker.
/// Returns an error if the module stopped or the bounded queue is full.
pub fn network_action(&self, module: &str, action: NetworkAction) -> Result<()> {
self.network_actions
.get(module)
.context("network module is not running")?
.try_send(action)
.context("could not queue network control")
}
/// Queues a control without blocking the UI; the module worker owns execution.
///
/// # Errors
@@ -157,6 +174,7 @@ impl Drop for ModuleRuntime {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
self.audio_actions.clear();
self.network_actions.clear();
for thread in &self.threads {
thread.thread().unpark();
}

1109
src/modules/network.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,984 @@
#![allow(clippy::needless_pass_by_value)] // D-Bus mock methods own deserialized arguments.
use super::*;
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
sync::Mutex,
};
const WIFI: &str = "/org/freedesktop/NetworkManager/Devices/2";
const MOBILE: &str = "/org/freedesktop/NetworkManager/Devices/3";
const ETHERNET: &str = "/org/freedesktop/NetworkManager/Devices/1";
const WIFI_PROFILE: &str = "/org/freedesktop/NetworkManager/Settings/2";
const MOBILE_PROFILE: &str = "/org/freedesktop/NetworkManager/Settings/3";
const WIFI_ACTIVE: &str = "/org/freedesktop/NetworkManager/ActiveConnection/2";
const MOBILE_ACTIVE: &str = "/org/freedesktop/NetworkManager/ActiveConnection/3";
const ACCESS_POINT: &str = "/org/freedesktop/NetworkManager/AccessPoint/1";
fn object_path(path: &str) -> OwnedValue {
BusValue::from(OwnedObjectPath::try_from(path).unwrap())
.try_to_owned()
.unwrap()
}
fn array<T>(value: Vec<T>) -> OwnedValue
where
Vec<T>: Into<BusValue<'static>>,
{
value.into().try_to_owned().unwrap()
}
fn object_paths(paths: &[&str]) -> OwnedValue {
array(
paths
.iter()
.map(|p| OwnedObjectPath::try_from(*p).unwrap())
.collect(),
)
}
fn profile(name: &str, uuid: &str, connection_type: &str, metric: i64) -> Settings {
let mut settings = Settings::from([
(
"connection".into(),
Properties::from([
("id".into(), string(name)),
("uuid".into(), string(uuid)),
("type".into(), string(connection_type)),
]),
),
(
"ipv4".into(),
Properties::from([
("method".into(), string("auto")),
("route-metric".into(), metric.into()),
]),
),
(
"ipv6".into(),
Properties::from([
("method".into(), string("auto")),
("route-metric".into(), metric.into()),
]),
),
]);
if connection_type == "802-11-wireless" {
settings.insert(
"802-11-wireless".into(),
Properties::from([("ssid".into(), array(b"Cafe: Guest".to_vec()))]),
);
settings.insert(
"802-11-wireless-security".into(),
Properties::from([("key-mgmt".into(), string("wpa-psk"))]),
);
}
settings
}
#[allow(clippy::too_many_lines)]
fn fixture() -> Snapshot {
let mut objects = Objects::new();
objects.insert(
ROOT.into(),
Settings::from([(
NM.into(),
Properties::from([
("Devices".into(), object_paths(&[ETHERNET, WIFI, MOBILE])),
(
"ActiveConnections".into(),
object_paths(&[WIFI_ACTIVE, MOBILE_ACTIVE]),
),
("PrimaryConnection".into(), object_path(MOBILE_ACTIVE)),
("Connectivity".into(), 4_u32.into()),
("WirelessEnabled".into(), true.into()),
("WirelessHardwareEnabled".into(), true.into()),
("WwanEnabled".into(), true.into()),
("WwanHardwareEnabled".into(), true.into()),
]),
)]),
);
for (path, profile, name, ty, device, default, default6) in [
(
WIFI_ACTIVE,
WIFI_PROFILE,
"Cafe: Guest",
"802-11-wireless",
WIFI,
false,
true,
),
(
MOBILE_ACTIVE,
MOBILE_PROFILE,
"Mobile plan",
"gsm",
MOBILE,
true,
false,
),
] {
objects.insert(
path.into(),
Settings::from([(
ACTIVE.into(),
Properties::from([
("Id".into(), string(name)),
("Type".into(), string(ty)),
("State".into(), 2_u32.into()),
("Connection".into(), object_path(profile)),
("Devices".into(), object_paths(&[device])),
("Default".into(), default.into()),
("Default6".into(), default6.into()),
]),
)]),
);
}
for (device, interface, ty, status, active, profile) in [
(ETHERNET, "lan0", 1_u32, 20_u32, "/", "/"),
(WIFI, "wifi0", 2, 100, WIFI_ACTIVE, WIFI_PROFILE),
(MOBILE, "cell0", 8, 100, MOBILE_ACTIVE, MOBILE_PROFILE),
] {
objects.insert(
device.into(),
Settings::from([(
DEVICE.into(),
Properties::from([
("Interface".into(), string(interface)),
("IpInterface".into(), string(interface)),
("DeviceType".into(), ty.into()),
("State".into(), status.into()),
("Managed".into(), true.into()),
("ActiveConnection".into(), object_path(active)),
(
"AvailableConnections".into(),
if profile == "/" {
object_paths(&[])
} else {
object_paths(&[profile])
},
),
]),
)]),
);
}
objects.get_mut(ETHERNET).unwrap().insert(
WIRED.into(),
Properties::from([("Carrier".into(), false.into())]),
);
objects.get_mut(WIFI).unwrap().insert(
WIRELESS.into(),
Properties::from([
("LastScan".into(), 100_i64.into()),
("ActiveAccessPoint".into(), object_path(ACCESS_POINT)),
("AccessPoints".into(), object_paths(&[ACCESS_POINT])),
]),
);
objects.insert(
ACCESS_POINT.into(),
Settings::from([(
AP.into(),
Properties::from([
("Ssid".into(), array(b"Cafe: Guest".to_vec())),
("Strength".into(), 70_u8.into()),
("Flags".into(), 1_u32.into()),
("RsnFlags".into(), 0x100_u32.into()),
]),
)]),
);
let saved: BTreeMap<String, SavedProfile> = BTreeMap::from([
(
WIFI_PROFILE.into(),
SavedProfile {
settings: profile("Cafe: Guest", "wifi-uuid", "802-11-wireless", 600),
version: 1,
},
),
(
MOBILE_PROFILE.into(),
SavedProfile {
settings: profile("Mobile plan", "mobile-uuid", "gsm", 100),
version: 1,
},
),
]);
for path in saved.keys() {
objects.insert(
path.clone(),
Settings::from([(
PROFILE.into(),
Properties::from([("VersionId".into(), 1_u64.into())]),
)]),
);
}
let profiles: Vec<_> = saved
.iter()
.map(|(p, s)| profile_model(p, &s.settings).unwrap())
.collect();
let state = build_state(&objects, &profiles).unwrap();
Snapshot {
state,
objects,
saved,
}
}
#[test]
fn reports_actual_mobile_default_and_separate_ipv6_wifi_default() {
let snapshot = fixture();
assert_eq!(
snapshot.state.defaults,
[
vec!["Mobile plan".to_string()],
vec!["Cafe: Guest".to_string()]
]
);
let view = module_snapshot(
&NetworkModule {
format_connected: "{kind}: {connection} on {interface}".into(),
..NetworkModule::default()
},
snapshot.state,
);
assert_eq!(
view.segments[0].text,
"Mobile broadband: Mobile plan on cell0"
);
let PopupContent::Network(popup) = view.popup.unwrap().content else {
panic!()
};
assert_eq!(popup.state.devices.len(), 3);
assert!(
popup
.state
.devices
.iter()
.any(|d| d.state_label == "Cable unplugged")
);
assert_eq!(
popup
.state
.devices
.iter()
.find(|d| d.kind == NetworkKind::Wifi)
.unwrap()
.networks[0]
.label,
"Cafe: Guest"
);
}
#[test]
fn disconnected_state_still_exposes_connection_controls_and_clears_defaults() {
let mut snapshot = fixture();
let root = snapshot.objects.get_mut(ROOT).unwrap().get_mut(NM).unwrap();
root.insert("ActiveConnections".into(), object_paths(&[]));
root.insert("PrimaryConnection".into(), object_path("/"));
let state = build_state(&snapshot.objects, &[]).unwrap();
assert!(state.primary.is_empty());
assert!(state.defaults.iter().all(Vec::is_empty));
let view = module_snapshot(&NetworkModule::default(), state);
assert!(view.popup.is_some());
assert_eq!(view.segments[0].state, "disconnected");
}
#[test]
fn deduplicates_same_ssid_and_security_but_keeps_active_access_point() {
let mut snapshot = fixture();
let other = "/org/freedesktop/NetworkManager/AccessPoint/2";
let mut ap = clone_settings(&snapshot.objects[ACCESS_POINT]).unwrap();
ap.get_mut(AP)
.unwrap()
.insert("Strength".into(), 99_u8.into());
snapshot.objects.insert(other.into(), ap);
snapshot
.objects
.get_mut(WIFI)
.unwrap()
.get_mut(WIRELESS)
.unwrap()
.insert("AccessPoints".into(), object_paths(&[other, ACCESS_POINT]));
let state = build_state(&snapshot.objects, &[]).unwrap();
let networks = &state
.devices
.iter()
.find(|d| d.path == WIFI)
.unwrap()
.networks;
assert_eq!(networks.len(), 1);
assert_eq!(networks[0].path, ACCESS_POINT);
}
#[test]
fn preference_uses_saved_metrics_and_preserves_vpn_and_never_default() {
let mut snapshot = fixture();
snapshot
.saved
.get_mut(MOBILE_PROFILE)
.unwrap()
.settings
.get_mut("ipv4")
.unwrap()
.insert("route-metric".into(), 1_i64.into());
snapshot
.saved
.get_mut(MOBILE_PROFILE)
.unwrap()
.settings
.get_mut("ipv6")
.unwrap()
.insert("never-default".into(), true.into());
snapshot.saved.insert(
"/vpn".into(),
SavedProfile {
settings: profile("VPN", "vpn-uuid", "vpn", 0),
version: 1,
},
);
let plan = route_plan(&snapshot, WIFI_PROFILE).unwrap();
assert_eq!(
plan,
vec![
RouteEdit {
path: MOBILE_PROFILE.into(),
metrics: [Some(2), None]
},
RouteEdit {
path: WIFI_PROFILE.into(),
metrics: [Some(1), Some(1)]
}
]
);
let mut settings = clone_settings(&snapshot.saved[WIFI_PROFILE].settings).unwrap();
let original_security = clone_settings(&settings)
.unwrap()
.remove("802-11-wireless-security")
.unwrap();
set_metrics(&mut settings, &plan[1].metrics).unwrap();
assert_eq!(settings["802-11-wireless-security"], original_security);
assert_eq!(text(&settings["connection"], "id").unwrap(), "Cafe: Guest");
}
#[test]
fn refuses_custom_route_tables_and_explicit_default_routes_before_any_write() {
let mut snapshot = fixture();
let ip = snapshot
.saved
.get_mut(WIFI_PROFILE)
.unwrap()
.settings
.get_mut("ipv4")
.unwrap();
ip.insert("route-table".into(), 200_u32.into());
assert!(
route_plan(&snapshot, WIFI_PROFILE)
.unwrap_err()
.to_string()
.contains("routing tables")
);
let ip = snapshot
.saved
.get_mut(WIFI_PROFILE)
.unwrap()
.settings
.get_mut("ipv4")
.unwrap();
ip.remove("route-table");
ip.insert(
"route-data".into(),
array(vec![Properties::from([
("dest".into(), string("0.0.0.0")),
("prefix".into(), 0_u32.into()),
("metric".into(), 100_u32.into()),
])]),
);
assert!(
route_plan(&snapshot, WIFI_PROFILE)
.unwrap_err()
.to_string()
.contains("explicit default routes")
);
}
#[test]
fn rollback_changes_only_metrics_and_does_not_overwrite_external_edits() {
let old = profile("Wi-Fi", "uuid", "802-11-wireless", 600);
let mut current = clone_settings(&old).unwrap();
set_metrics(&mut current, &[Some(1), Some(1)]).unwrap();
current
.get_mut("connection")
.unwrap()
.insert("id".into(), string("Externally renamed"));
restore_metrics(&mut current, &old, &[Some(1), Some(1)]).unwrap();
assert_eq!(profile_model("/", &current).unwrap().metrics, [600, 600]);
assert_eq!(
text(&current["connection"], "id").unwrap(),
"Externally renamed"
);
set_metrics(&mut current, &[Some(42), Some(42)]).unwrap();
assert!(restore_metrics(&mut current, &old, &[Some(1), Some(1)]).is_err());
}
#[test]
fn wifi_credentials_preserve_raw_ssid_and_never_appear_in_debug_output() {
let snapshot = fixture();
let mut network = snapshot
.state
.devices
.iter()
.find(|d| d.path == WIFI)
.unwrap()
.networks[0]
.clone();
network.ssid = vec![0xff, b':', b'\\'];
let mut secret = crate::model::NetworkSecret::default();
secret.push("example-password");
let settings = wifi_settings(&network, secret.text()).unwrap();
assert_eq!(
get::<Vec<u8>>(&settings["802-11-wireless"], "ssid", Vec::new()).unwrap(),
network.ssid
);
assert!(
!format!(
"{:?}",
NetworkAction::Join {
device: WIFI.into(),
ap: ACCESS_POINT.into(),
password: secret
}
)
.contains("example-password")
);
assert!(wifi_settings(&network, "short").is_err());
network.security = WifiSecurity::Enterprise;
assert!(wifi_settings(&network, "example-password").is_err());
}
struct TestBus(Child, String);
impl TestBus {
fn start() -> Self {
let mut child = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("test requires dbus-daemon");
let mut address = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
assert!(!address.trim().is_empty());
Self(child, address.trim().into())
}
}
impl Drop for TestBus {
fn drop(&mut self) {
if self.0.try_wait().expect("inspect test bus").is_none() {
self.0.kill().expect("stop test bus");
}
self.0.wait().expect("reap test bus");
}
}
struct FakeState {
snapshot: Snapshot,
calls: Vec<String>,
fail_reapply: bool,
applied: HashMap<String, Settings>,
scans: u32,
}
type Shared = Arc<Mutex<FakeState>>;
struct FakeObjects(Shared);
#[zbus::interface(name = "org.freedesktop.DBus.ObjectManager")]
impl FakeObjects {
fn get_managed_objects(&self) -> HashMap<OwnedObjectPath, Settings> {
self.0
.lock()
.unwrap()
.snapshot
.objects
.iter()
.map(|(p, v)| {
(
OwnedObjectPath::try_from(p.as_str()).unwrap(),
clone_settings(v).unwrap(),
)
})
.collect()
}
}
struct FakeProfile {
shared: Shared,
path: String,
}
#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings.Connection")]
impl FakeProfile {
fn get_settings(&self) -> Settings {
clone_settings(&self.shared.lock().unwrap().snapshot.saved[&self.path].settings).unwrap()
}
#[zbus(property)]
fn version_id(&self) -> u64 {
self.shared.lock().unwrap().snapshot.saved[&self.path].version
}
fn update2(
&self,
settings: Settings,
flags: u32,
args: Properties,
) -> zbus::fdo::Result<Properties> {
assert_eq!(flags, 1);
let mut shared = self.shared.lock().unwrap();
let saved = shared.snapshot.saved.get_mut(&self.path).unwrap();
if get(&args, "version-id", 0_u64).unwrap() != saved.version {
return Err(zbus::fdo::Error::Failed("profile changed".into()));
}
assert!(!settings.values().any(|s| s.contains_key("psk")));
saved.settings = settings;
saved.version += 1;
let version = saved.version;
shared
.snapshot
.objects
.get_mut(&self.path)
.unwrap()
.get_mut(PROFILE)
.unwrap()
.insert("VersionId".into(), version.into());
shared.calls.push(format!("save:{}", self.path));
Ok(Properties::new())
}
}
struct FakeDevice {
shared: Shared,
path: String,
}
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device")]
impl FakeDevice {
fn get_applied_connection(&self, flags: u32) -> (Settings, u64) {
assert_eq!(flags, 0);
(
clone_settings(&self.shared.lock().unwrap().applied[&self.path]).unwrap(),
1,
)
}
fn reapply(&self, settings: Settings, version: u64, flags: u32) -> zbus::fdo::Result<()> {
assert_eq!(version, 1);
assert_eq!(flags, 1);
let mut shared = self.shared.lock().unwrap();
if shared.fail_reapply {
shared.fail_reapply = false;
return Err(zbus::fdo::Error::Failed("simulated reapply failure".into()));
}
shared.applied.insert(self.path.clone(), settings);
shared.calls.push(format!("apply:{}", self.path));
Ok(())
}
fn disconnect(&self) {
self.shared
.lock()
.unwrap()
.calls
.push(format!("disconnect:{}", self.path));
}
}
struct FakeWireless(Shared);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")]
impl FakeWireless {
fn request_scan(&self, options: Properties) {
assert!(options.is_empty());
self.0.lock().unwrap().scans += 1;
}
}
struct FakeManager(Shared);
#[zbus::interface(name = "org.freedesktop.NetworkManager")]
impl FakeManager {
fn activate_connection(
&self,
profile: OwnedObjectPath,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> OwnedObjectPath {
self.0
.lock()
.unwrap()
.calls
.push(format!("connect:{profile}:{device}:{ap}"));
OwnedObjectPath::try_from(WIFI_ACTIVE).unwrap()
}
fn add_and_activate_connection(
&self,
settings: Settings,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> (OwnedObjectPath, OwnedObjectPath) {
assert_eq!(device.as_str(), WIFI);
assert_eq!(ap.as_str(), ACCESS_POINT);
assert_eq!(
text(&settings["802-11-wireless-security"], "psk").unwrap(),
"example-password"
);
self.0.lock().unwrap().calls.push("join".into());
(
OwnedObjectPath::try_from(WIFI_PROFILE).unwrap(),
OwnedObjectPath::try_from(WIFI_ACTIVE).unwrap(),
)
}
#[zbus(property)]
fn wireless_enabled(&self) -> bool {
get(
&self.0.lock().unwrap().snapshot.objects[ROOT][NM],
"WirelessEnabled",
false,
)
.unwrap()
}
#[zbus(property)]
fn set_wireless_enabled(&mut self, enabled: bool) {
self.0
.lock()
.unwrap()
.snapshot
.objects
.get_mut(ROOT)
.unwrap()
.get_mut(NM)
.unwrap()
.insert("WirelessEnabled".into(), enabled.into());
}
}
async fn fake_service(bus: &TestBus) -> (Connection, Connection, Shared) {
let fixture = fixture();
let applied = HashMap::from([
(
WIFI.into(),
clone_settings(&fixture.saved[WIFI_PROFILE].settings).unwrap(),
),
(
MOBILE.into(),
clone_settings(&fixture.saved[MOBILE_PROFILE].settings).unwrap(),
),
]);
let shared = Arc::new(Mutex::new(FakeState {
snapshot: fixture,
calls: Vec::new(),
fail_reapply: false,
applied,
scans: 0,
}));
let builder = zbus::connection::Builder::address(bus.1.as_str())
.unwrap()
.name(NM)
.unwrap()
.serve_at("/org/freedesktop", FakeObjects(shared.clone()))
.unwrap()
.serve_at(ROOT, FakeManager(shared.clone()))
.unwrap()
.serve_at(
WIFI_PROFILE,
FakeProfile {
shared: shared.clone(),
path: WIFI_PROFILE.into(),
},
)
.unwrap()
.serve_at(
MOBILE_PROFILE,
FakeProfile {
shared: shared.clone(),
path: MOBILE_PROFILE.into(),
},
)
.unwrap()
.serve_at(
WIFI,
FakeDevice {
shared: shared.clone(),
path: WIFI.into(),
},
)
.unwrap()
.serve_at(
MOBILE,
FakeDevice {
shared: shared.clone(),
path: MOBILE.into(),
},
)
.unwrap()
.serve_at(WIFI, FakeWireless(shared.clone()))
.unwrap();
let service = builder.build().await.unwrap();
let client = zbus::connection::Builder::address(bus.1.as_str())
.unwrap()
.method_timeout(Duration::from_secs(2))
.build()
.await
.unwrap();
(service, client, shared)
}
#[tokio::test(flavor = "current_thread")]
async fn dbus_controls_scan_connect_disconnect_and_save_live_priority() {
let bus = TestBus::start();
let (_service, client, shared) = fake_service(&bus).await;
let snapshot = read_snapshot(&client).await.unwrap();
let stop = AtomicBool::new(false);
for action in [
NetworkAction::Scan(WIFI.into()),
NetworkAction::Connect {
device: WIFI.into(),
profile: WIFI_PROFILE.into(),
ap: ACCESS_POINT.into(),
},
NetworkAction::Disconnect(WIFI.into()),
NetworkAction::Radio {
wifi: true,
enabled: false,
},
NetworkAction::Prefer(WIFI_PROFILE.into()),
] {
run_action(&client, action, &snapshot, &stop).await.unwrap();
}
let state = shared.lock().unwrap();
assert_eq!(state.scans, 1);
assert!(state.calls.iter().any(|c| c.starts_with("connect:")));
assert!(state.calls.iter().any(|c| c.starts_with("disconnect:")));
assert_eq!(
profile_model("/", &state.snapshot.saved[WIFI_PROFILE].settings)
.unwrap()
.metrics,
[1, 1]
);
assert_eq!(
profile_model("/", &state.applied[WIFI]).unwrap().metrics,
[1, 1]
);
assert_eq!(
profile_model("/", &state.snapshot.saved[MOBILE_PROFILE].settings)
.unwrap()
.metrics,
[100, 100]
);
assert!(!get(&state.snapshot.objects[ROOT][NM], "WirelessEnabled", true).unwrap());
}
#[tokio::test(flavor = "current_thread")]
async fn dbus_failed_reapply_restores_saved_and_active_priorities() {
let bus = TestBus::start();
let (_service, client, shared) = fake_service(&bus).await;
shared.lock().unwrap().fail_reapply = true;
let snapshot = read_snapshot(&client).await.unwrap();
let error = prefer(&client, &snapshot, WIFI_PROFILE, &AtomicBool::new(false))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("previous route priorities restored"),
"{error:#}"
);
let shared = shared.lock().unwrap();
assert_eq!(
profile_model("/", &shared.snapshot.saved[WIFI_PROFILE].settings)
.unwrap()
.metrics,
[600, 600]
);
assert_eq!(
profile_model("/", &shared.applied[WIFI]).unwrap().metrics,
[600, 600]
);
}
#[tokio::test(flavor = "current_thread")]
async fn dbus_new_wifi_password_is_passed_to_networkmanager_without_a_process() {
let bus = TestBus::start();
let (_service, client, shared) = fake_service(&bus).await;
let snapshot = read_snapshot(&client).await.unwrap();
let mut password = crate::model::NetworkSecret::default();
password.push("example-password");
run_action(
&client,
NetworkAction::Join {
device: WIFI.into(),
ap: ACCESS_POINT.into(),
password,
},
&snapshot,
&AtomicBool::new(false),
)
.await
.unwrap();
assert!(shared.lock().unwrap().calls.contains(&"join".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn monitor_follows_external_defaults_and_stops_when_command_owner_drops() {
let bus = TestBus::start();
let (service, client, shared) = fake_service(&bus).await;
let (sender, receiver) = calloop::channel::channel();
let (actions, mut requests) = mpsc::channel(4);
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
let worker = tokio::spawn(async move {
monitor(
&client,
"network",
&NetworkModule::default(),
&sender,
&worker_stop,
&mut requests,
)
.await
});
let first = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if let Ok(event) = receiver.try_recv() {
break event;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(first.snapshot.segments[0].text.contains("Mobile plan"));
shared
.lock()
.unwrap()
.snapshot
.objects
.get_mut(ROOT)
.unwrap()
.get_mut(NM)
.unwrap()
.insert("PrimaryConnection".into(), object_path(WIFI_ACTIVE));
service
.emit_signal(
None::<&str>,
ROOT,
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
&(
NM,
Properties::from([("PrimaryConnection".into(), object_path(WIFI_ACTIVE))]),
Vec::<String>::new(),
),
)
.await
.unwrap();
let event = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if let Ok(event) = receiver.try_recv() {
break event;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(event.snapshot.segments[0].text.contains("Cafe: Guest"));
stop.store(true, Ordering::Release);
drop(actions);
tokio::time::timeout(Duration::from_secs(1), worker)
.await
.unwrap()
.unwrap()
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "read-only comparison with the running NetworkManager service"]
async fn live_networkmanager_defaults_match_nmcli() {
let connection = zbus::connection::Builder::system()
.unwrap()
.method_timeout(CALL_TIMEOUT)
.build()
.await
.unwrap();
let snapshot = read_snapshot(&connection).await.unwrap();
let primary = proxy(&connection, ROOT, NM)
.await
.unwrap()
.get_property::<OwnedObjectPath>("PrimaryConnection")
.await
.unwrap();
if primary.as_str() != "/" {
let name: String = proxy(&connection, primary.as_str(), ACTIVE)
.await
.unwrap()
.get_property("Id")
.await
.unwrap();
assert_eq!(snapshot.state.primary, clean_label(&name));
}
for device in &snapshot.state.devices {
if device.state != 100 {
continue;
}
let active =
properties(&snapshot.objects, device.active.as_ref().unwrap(), ACTIVE).unwrap();
let uuid = text(active, "Uuid").unwrap();
let output = Command::new("nmcli")
.args([
"-g",
"GENERAL.DEFAULT,GENERAL.DEFAULT6",
"connection",
"show",
"uuid",
&uuid,
])
.output()
.unwrap();
assert!(output.status.success());
let values: Vec<_> = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(|s| s == "yes")
.collect();
assert_eq!(values, device.defaults);
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "fixture server for an isolated native Wayland UI smoke test"]
async fn serve_network_ui_fixture() {
let directory = std::path::PathBuf::from(
std::env::var_os("LIGHTBAR_NETWORK_UI_FIXTURE")
.expect("set the fixture artifact directory"),
);
let bus = TestBus::start();
let (_service, _client, shared) = fake_service(&bus).await;
{
let mut state = shared.lock().unwrap();
// The visible AP is a new network while the saved active profile has another SSID.
state
.snapshot
.saved
.get_mut(WIFI_PROFILE)
.unwrap()
.settings
.get_mut("802-11-wireless")
.unwrap()
.insert("ssid".into(), array(b"Previous network".to_vec()));
state
.snapshot
.objects
.get_mut(WIFI)
.unwrap()
.get_mut(WIRELESS)
.unwrap()
.insert("ActiveAccessPoint".into(), object_path("/"));
}
std::fs::write(directory.join("bus-address"), &bus.1).unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
while !directory.join("stop-fixture").exists() {
assert!(
tokio::time::Instant::now() < deadline,
"native UI fixture was not stopped"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
let shared = shared.lock().unwrap();
std::fs::write(
directory.join("actions.json"),
serde_json::to_vec(&serde_json::json!({"calls": shared.calls,"scans":shared.scans}))
.unwrap(),
)
.unwrap();
}

View File

@@ -1,200 +1,19 @@
use std::{
collections::BTreeMap,
io::{BufRead, BufReader},
process::{Child, Command, ExitStatus, Stdio},
process::{Child, Command, ExitStatus},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
thread,
time::Duration,
};
use anyhow::{Context, Result, bail};
use calloop::channel::Sender;
use anyhow::{Result, bail};
use crate::{
config::{AudioModule, NetworkModule},
format::{Value, expand},
model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow},
};
use crate::config::AudioModule;
type ChildRegistry = Arc<Mutex<Vec<Arc<Mutex<Child>>>>>;
pub fn spawn_network(
name: String,
settings: NetworkModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
children: ChildRegistry,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("lightbar-network-{name}"))
.spawn(move || {
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
if publish_network(&name, &settings, &sender).is_ok() {
failures = 0;
} else {
failures = failures.saturating_add(1);
}
if stop.load(Ordering::Acquire) {
break;
}
match monitored_lines(
"nmcli",
&["monitor"],
&stop,
&children,
Duration::from_millis(250),
|| publish_network(&name, &settings, &sender),
) {
Ok(()) if stop.load(Ordering::Acquire) => break,
Ok(()) => {}
Err(error) => {
tracing::warn!(module = %name, %error, "network monitor stopped");
failures = failures.saturating_add(1);
}
}
thread::park_timeout(backoff(failures));
}
})
.expect("network worker thread")
}
fn publish_network(
name: &str,
settings: &NetworkModule,
sender: &Sender<ModuleEvent>,
) -> Result<()> {
let output = Command::new("nmcli")
.args([
"--terse",
"--escape",
"no",
"--fields",
"DEVICE,TYPE,STATE,CONNECTION",
"device",
"status",
])
.output()
.context("could not run nmcli")?;
if !output.status.success() {
bail!("nmcli exited with {}", output.status);
}
let text = String::from_utf8(output.stdout)?;
let mut candidates = text.lines().filter_map(parse_network_line).filter(|entry| {
settings
.interface
.as_deref()
.is_none_or(|interface| entry.device == interface)
});
let selected = candidates
.clone()
.find(|entry| entry.connected && entry.kind == "wifi")
.or_else(|| candidates.find(|entry| entry.connected));
let mut snapshot = if let Some(entry) = selected {
let values = BTreeMap::from([
("ssid", Value::from(entry.connection.clone())),
("connection", Value::from(entry.connection.clone())),
("interface", Value::from(entry.device.clone())),
]);
let mut snapshot = ModuleSnapshot::text(expand(&settings.format_connected, &values));
let addresses = network_addresses(&entry.device).unwrap_or_default();
snapshot.tooltip = Some(format!(
"{} on {}{}",
entry.connection,
entry.device,
if addresses.is_empty() {
String::new()
} else {
format!(" · {}", addresses.join(", "))
}
));
snapshot.popup = Some(PopupModel {
title: "Network".to_owned(),
content: PopupContent::Rows(vec![
PopupRow {
id: None,
label: "Connection".to_owned(),
detail: Some(entry.connection.clone()),
state: "normal".to_owned(),
enabled: false,
},
PopupRow {
id: None,
label: "Interface".to_owned(),
detail: Some(entry.device.clone()),
state: "normal".to_owned(),
enabled: false,
},
PopupRow {
id: Some("open-settings".to_owned()),
label: "Open connection editor".to_owned(),
detail: None,
state: "normal".to_owned(),
enabled: true,
},
]),
});
snapshot
} else {
ModuleSnapshot::text(settings.format_disconnected.clone()).with_state("disconnected")
};
snapshot.visible = true;
sender
.send(ModuleEvent {
module: name.to_owned(),
snapshot,
})
.map_err(|_| anyhow::anyhow!("UI event channel closed"))
}
#[derive(Debug, Clone)]
struct NetworkEntry {
device: String,
kind: String,
connected: bool,
connection: String,
}
fn parse_network_line(line: &str) -> Option<NetworkEntry> {
let mut fields = line.splitn(4, ':');
let device = fields.next()?.to_owned();
let kind = fields.next()?.to_owned();
let state = fields.next()?.to_owned();
let connection = fields.next().unwrap_or_default().to_owned();
Some(NetworkEntry {
device,
kind,
connected: state.starts_with("connected"),
connection,
})
}
fn network_addresses(device: &str) -> Result<Vec<String>> {
let output = Command::new("nmcli")
.args([
"--terse",
"--get-values",
"IP4.ADDRESS",
"device",
"show",
device,
])
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8(output.stdout)?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
pub fn wpctl(args: &[&str]) -> Result<()> {
let status = Command::new("wpctl").args(args).status()?;
if !status.success() {
@@ -217,59 +36,6 @@ pub fn change_volume(settings: &AudioModule, increase: bool) -> Result<()> {
wpctl(&args)
}
fn monitored_lines<F>(
program: &str,
args: &[&str],
stop: &AtomicBool,
children: &ChildRegistry,
debounce: Duration,
mut callback: F,
) -> Result<()>
where
F: FnMut() -> Result<()>,
{
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("could not start {program}"))?;
let stdout = child
.stdout
.take()
.context("monitor stdout was not piped")?;
let child = Arc::new(Mutex::new(child));
register_child(children, &child);
if stop.load(Ordering::Acquire)
&& let Ok(mut process) = child.lock()
{
let _ = process.kill();
}
let mut last_update = Instant::now()
.checked_sub(debounce)
.unwrap_or_else(Instant::now);
for line in BufReader::new(stdout).lines() {
if stop.load(Ordering::Acquire) {
break;
}
line.with_context(|| format!("could not read {program} output"))?;
if last_update.elapsed() >= debounce {
if let Err(error) = callback() {
tracing::debug!(%error, program, "monitor refresh failed");
}
last_update = Instant::now();
}
}
let status = wait_child(&child, stop)?;
if !status.success() && !stop.load(Ordering::Acquire) {
bail!("{program} monitor exited with {status}");
}
unregister_child(children, &child);
Ok(())
}
pub(super) fn wait_child(child: &Arc<Mutex<Child>>, stop: &AtomicBool) -> Result<ExitStatus> {
loop {
let status = {
@@ -308,14 +74,6 @@ pub(super) fn backoff(failures: u32) -> Duration {
mod tests {
use super::*;
#[test]
fn parses_network_status() {
let parsed = parse_network_line("wlan0:wifi:connected:home").unwrap();
assert_eq!(parsed.device, "wlan0");
assert!(parsed.connected);
assert_eq!(parsed.connection, "home");
}
#[test]
fn stopped_child_wait_is_interruptible() {
let child = Command::new("sleep").arg("30").spawn().unwrap();

View File

@@ -173,6 +173,7 @@ impl Renderer {
),
// Reserve the same viewport when switching between mixer and device lists.
PopupContent::Audio(_) => 420,
PopupContent::Network(_) => 480,
};
(configured_width, content_height.min(maximum_height).max(80))
}
@@ -226,6 +227,106 @@ impl Renderer {
let mut result = RenderResult::default();
let mut y = padding + 34.0;
match &model.content {
PopupContent::Network(network) => {
let controls = network.controls();
let available = (logical_height - y - padding - 24.0).max(0.0);
let offset = network.offset.min(controls.len().saturating_sub(1));
let mut used = 0.0;
let mut shown = 0;
for control in controls.iter().skip(offset) {
let height = control.height();
if used + height > available {
break;
}
let rect = Rect {
x: padding,
y: y + used,
width: (logical_width - 2.0 * padding).max(1.0),
height: height - 2.0,
};
let focused = control.id.is_some() && control.id == network.focused;
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&control.label,
if focused || control.active {
"focused"
} else {
"normal"
},
Rect {
height: 28.0,
..rect
},
theme,
);
if !control.detail.is_empty() {
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&control.detail,
if focused { "focused" } else { "normal" },
Rect {
y: rect.y + 26.0,
height: 24.0,
..rect
},
theme,
);
}
if control.id.is_some() {
result.hit_boxes.push(HitBox {
rect,
module: module.into(),
segment: control.id.clone(),
});
}
used += height;
shown += 1;
}
if offset > 0 || offset + shown < controls.len() {
for (id, text, x, enabled) in [
("network-scroll-up", "↑ Previous", padding, offset > 0),
(
"network-scroll-down",
"↓ More",
logical_width / 2.0,
offset + shown < controls.len(),
),
] {
let rect = Rect {
x,
y: logical_height - padding - 24.0,
width: (logical_width / 2.0 - padding).max(1.0),
height: 24.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
text,
if enabled { "normal" } else { "disabled" },
rect,
theme,
);
if enabled {
result.hit_boxes.push(HitBox {
rect,
module: module.into(),
segment: Some(id.into()),
});
}
}
}
}
PopupContent::Audio(audio) => {
let controls = audio.controls();
let available = (logical_height - y - padding - 24.0).max(0.0);

220
tests/network_popup.rs Normal file
View File

@@ -0,0 +1,220 @@
use lightbar::{
config::ConfigBundle,
model::{
NetworkAction, NetworkDevice, NetworkKind, NetworkModel, NetworkSecret, NetworkState,
NetworkView, PopupContent, PopupModel, WifiNetwork, WifiSecurity,
},
render::{Renderer, hit_test},
};
fn fixture() -> NetworkModel {
NetworkModel {
state: NetworkState {
wifi_enabled: true,
wifi_hardware: true,
connectivity: "Internet connected".into(),
defaults: [vec!["Wired connection".into()], vec![]],
devices: vec![NetworkDevice {
path: "/wifi".into(),
interface: "wifi0".into(),
kind: NetworkKind::Wifi,
state: 30,
state_label: "Disconnected".into(),
managed: true,
carrier: None,
active: None,
profile: None,
connection_name: String::new(),
defaults: [false, false],
addresses: vec![],
profiles: vec![],
last_scan: 100,
networks: (0..24)
.map(|i| WifiNetwork {
path: format!("/ap/{i}"),
ssid: format!("Network {i}").into_bytes(),
label: format!("Network {i}"),
strength: 80,
security: WifiSecurity::Personal,
active: false,
profile: None,
})
.collect(),
}],
..NetworkState::default()
},
..NetworkModel::default()
}
}
#[test]
fn keyboard_reaches_scan_and_all_networks_with_visible_hitboxes_at_both_scales() {
let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap();
let mut renderer = Renderer::default();
for scale in [1, 2] {
let mut network = fixture();
network.set_view(NetworkView::Device("/wifi".into()));
let expected: Vec<_> = network
.controls()
.into_iter()
.filter_map(|c| c.id)
.collect();
let mut visited = Vec::new();
for _ in 0..expected.len() {
network.move_focus(
false,
180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0,
);
let popup = PopupModel {
title: "Network".into(),
content: PopupContent::Network(network.clone()),
};
let mut pixels = vec![0; (320 * 180 * scale * scale * 4) as usize];
let result = renderer.render_popup(
&mut pixels,
320 * scale,
180 * scale,
scale,
"network",
&popup,
&bundle.theme,
);
let focused = result
.hit_boxes
.iter()
.find(|h| h.segment == network.focused)
.expect("focused control must be visible");
assert_eq!(
hit_test(
&result.hit_boxes,
focused.rect.x + 1.0,
focused.rect.y + 1.0
),
Some(focused)
);
assert!(focused.rect.y + focused.rect.height <= 180.0);
visited.push(network.focused.clone().unwrap());
}
assert_eq!(visited, expected);
assert!(visited.iter().any(|id| id == "network-scan"));
assert!(network.offset > 0);
network.move_focus(true, 102.0);
assert_eq!(network.focused.as_deref(), Some("network-ap:/ap/23"));
}
}
#[test]
fn unsaved_wifi_opens_a_masked_password_form_and_emits_a_typed_command() {
let mut network = fixture();
network.set_view(NetworkView::Device("/wifi".into()));
assert!(network.activate("network-ap:/ap/2").is_none());
assert!(matches!(network.view, NetworkView::Password { .. }));
assert_eq!(network.focused.as_deref(), Some("network-password"));
assert!(
!network
.controls()
.iter()
.any(|c| c.id.as_deref() == Some("network-submit"))
);
network.password.push("example-password");
assert!(!format!("{:?}", network.controls()).contains("example-password"));
let command = network.activate("network-submit").unwrap();
let NetworkAction::Join {
device,
ap,
password,
} = command
else {
panic!()
};
assert_eq!(
(device.as_str(), ap.as_str(), password.text()),
("/wifi", "/ap/2", "example-password")
);
assert!(network.password.text().is_empty());
assert_eq!(network.view, NetworkView::Device("/wifi".into()));
}
#[test]
fn updates_keep_input_and_navigation_but_remove_disappeared_device_state() {
let mut network = fixture();
network.set_view(NetworkView::Device("/wifi".into()));
network.activate("network-ap:/ap/0");
network.password.push("example-password");
let mut update = fixture();
update.preserve_ui(&network);
assert_eq!(update.password.text(), "example-password");
assert_eq!(update.focused, network.focused);
update.state.devices.clear();
update.preserve_ui(&network);
assert_eq!(update.view, NetworkView::Overview);
assert!(update.password.text().is_empty());
}
#[test]
fn busy_controls_and_stale_clicks_cannot_issue_network_actions() {
let mut network = fixture();
network.set_view(NetworkView::Device("/wifi".into()));
assert!(matches!(
network.activate("network-scan"),
Some(NetworkAction::Scan(_))
));
network.state.busy = true;
assert!(network.activate("network-scan").is_none());
assert!(network.controls().iter().all(|c| c.id.is_none()));
network.state.busy = false;
network.state.devices.clear();
assert!(network.activate("network-ap:/ap/0").is_none());
}
#[test]
fn password_backspace_is_unicode_safe_and_debug_is_redacted() {
let mut secret = NetworkSecret::default();
secret.push("é🔑x\n");
secret.backspace();
assert_eq!(secret.text(), "é🔑");
assert_eq!(secret.masked(), "••");
secret.backspace();
secret.backspace();
secret.backspace();
assert!(secret.text().is_empty());
assert_eq!(format!("{secret:?}"), "<redacted>");
}
#[test]
fn asynchronous_controls_preserve_focus_while_disabled_and_after_completion() {
let mut old = fixture();
old.set_view(NetworkView::Device("/wifi".into()));
old.activate("network-scan");
old.state.busy = true;
let mut busy = fixture();
busy.state.busy = true;
busy.preserve_ui(&old);
assert_eq!(busy.focused.as_deref(), Some("network-scan"));
let mut finished = fixture();
finished.preserve_ui(&busy);
assert_eq!(finished.focused.as_deref(), Some("network-scan"));
finished.move_focus(false, 400.0);
assert_eq!(finished.focused.as_deref(), Some("network-ap:/ap/0"));
}
#[test]
fn newly_opened_password_input_is_visible_in_a_short_popup() {
let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap();
let mut network = fixture();
network.set_view(NetworkView::Device("/wifi".into()));
network.activate("network-ap:/ap/0");
network.ensure_focus_visible(180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0);
let popup = PopupModel {
title: "Network".into(),
content: PopupContent::Network(network),
};
let mut renderer = Renderer::default();
let mut pixels = vec![0; 320 * 180 * 4];
let result = renderer.render_popup(&mut pixels, 320, 180, 1, "network", &popup, &bundle.theme);
assert!(
result
.hit_boxes
.iter()
.any(|h| h.segment.as_deref() == Some("network-password"))
);
}