Add native NetworkManager popup and saved internet priority
This commit is contained in:
49
README.md
49
README.md
@@ -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
10
TOOD.md
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
155
src/app.rs
155
src/app.rs
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
701
src/model/network.rs
Normal 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()
|
||||
}
|
||||
@@ -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
1109
src/modules/network.rs
Normal file
File diff suppressed because it is too large
Load Diff
984
src/modules/network/tests.rs
Normal file
984
src/modules/network/tests.rs
Normal 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("/", ¤t).unwrap().metrics, [600, 600]);
|
||||
assert_eq!(
|
||||
text(¤t["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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
101
src/render.rs
101
src/render.rs
@@ -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
220
tests/network_popup.rs
Normal 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"))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user