Add Wi-Fi and Bluetooth settings and compact controls

This commit is contained in:
Samuel Onoja committed 2026-10-01 15:28:16 +01:00
1 parent 94afc6eb5c
commit a3431cab5b
21 files changed
+3745 -225

No files matched your search

@@ -0,0 +1,322 @@
use std::sync::Arc;
use cosmic::app::Task;
use zeroize::{Zeroize, Zeroizing};
use super::ui::Input;
use super::{Client, Command, Network, Snapshot};
use crate::{App, Message};
impl App {
pub(crate) fn refresh_connections(&mut self) -> Task<Message> {
if self.connections.loading {
return Task::none();
}
self.connections.loading = true;
if let Some(client) = self.connections.client.clone() {
Task::perform(async move { Box::new(client.snapshot().await) }, |snapshot| {
cosmic::Action::App(Message::ConnectionsRefreshed(snapshot))
})
} else {
Task::perform(Client::new(), |client| {
cosmic::Action::App(Message::ConnectionsReady(client))
})
}
}
pub(crate) fn connections_ready(&mut self, result: Result<Arc<Client>, String>) -> Task<Message> {
self.connections.loading = false;
match result {
Ok(client) => {
self.connections.client = Some(client);
self.connections.error = None;
self.refresh_connections()
}
Err(error) => {
self.connections.error = Some(error);
Task::none()
}
}
}
pub(crate) fn connections_refreshed(&mut self, snapshot: Snapshot) {
self.connections.loading = false;
if let Ok(bluetooth) = &snapshot.bluetooth
&& !bluetooth
.adapters
.iter()
.any(|a| Some(&a.path) == self.connections.adapter.as_ref())
{
self.connections.adapter = bluetooth.adapters.first().map(|a| a.path.clone());
}
self.connections.snapshot = Some(snapshot);
}
pub(crate) fn connection_finished(&mut self, result: Result<(), String>) -> Task<Message> {
self.connections.busy = None;
self.connections.error = result.err();
self.connections.prompt = None;
self.connections.pairing_input.zeroize();
self.refresh_connections()
}
pub(crate) fn leave_connections(&mut self) -> Task<Message> {
self.connections.password.zeroize();
self.connections.pairing_input.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
self.connections.prompt = None;
self.connections.forget = None;
if let Some(client) = &self.connections.client {
client.leave_now();
}
Task::none()
}
pub(crate) fn connections_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Refresh => return self.refresh_connections(),
Input::PollPrompt => {
let prompt = self.connections.client.as_ref().and_then(|c| c.prompt());
if prompt != self.connections.prompt {
self.connections.pairing_input.zeroize();
let needs_input = prompt
.as_ref()
.is_some_and(|p| matches!(p.kind, super::agent::Kind::Pin | super::agent::Kind::Passkey));
self.connections.prompt = prompt;
let scroll = cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
return if needs_input {
Task::batch([
scroll,
cosmic::widget::text_input::focus(cosmic::widget::Id::new("bluetooth-passkey")),
])
} else {
scroll
};
}
}
Input::Tab(tab) => {
self.connections.tab = tab;
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::Adapter(path) => self.connections.adapter = Some(path),
Input::Password(value) => self.connections.password = value,
Input::HiddenName(name) => self.connections.hidden_name = name,
Input::HiddenSecurity(security) => self.connections.hidden_security = security,
Input::Select(network) => {
self.connections.password.zeroize();
self.connections.hidden = false;
self.connections.selected = Some(network);
self.connections.error = None;
return cosmic::widget::text_input::focus(cosmic::widget::Id::new("wifi-password"));
}
Input::Hidden(hidden) => {
self.connections.hidden = hidden;
self.connections.selected = None;
self.connections.password.zeroize();
self.connections.hidden_name.clear();
}
Input::CancelWifi => {
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::SubmitWifi => {
if let Some(network) = self.wifi_target()
&& self.connections.busy.is_none()
&& network.security.can_create()
&& super::wifi::valid_password(network.security, &self.connections.password)
{
let password = std::mem::replace(&mut self.connections.password, Zeroizing::new(String::new()));
return self.connections_input(Input::Run(Command::WifiConnect(network, password)));
}
}
Input::PairingValue(id, value) => {
if self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
self.connections.pairing_input = value;
}
}
Input::Answer(id, accepted) => {
if !self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
return Task::none();
}
if accepted
&& !super::agent::valid_answer(
&self.connections.prompt.as_ref().unwrap().kind,
&self.connections.pairing_input,
)
{
return Task::none();
}
if let Some(client) = &self.connections.client {
let value = accepted
.then(|| std::mem::replace(&mut self.connections.pairing_input, Zeroizing::new(String::new())));
client.reply(id, value);
}
self.connections.pairing_input.zeroize();
self.connections.prompt = None;
}
Input::Forget(command) => {
self.connections.forget = Some(command);
return cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
}
Input::CancelForget => self.connections.forget = None,
Input::ConfirmForget => {
if let Some(command) = self.connections.forget.take() {
return self.connections_input(Input::Run(command));
}
}
Input::Run(command) => {
if self.connections.busy.is_some() && !matches!(command, Command::CancelPairing) {
return Task::none();
}
let Some(client) = self.connections.client.clone() else {
return Task::none();
};
if matches!(command, Command::CancelPairing) {
// The original Pair task still owns completion and busy state.
return Task::perform(
async move {
let _ = client.cancel_pairing().await;
},
|_| cosmic::Action::App(Message::ConnectionsLeft),
);
}
self.connections.busy = Some(command.label());
self.connections.error = None;
self.connections.selected = None;
self.connections.hidden = false;
let epoch = client.epoch();
return Task::perform(async move { client.execute_checked(command, epoch).await }, |result| {
cosmic::Action::App(Message::ConnectionFinished(result))
});
}
}
Task::none()
}
pub(super) fn wifi_target(&self) -> Option<Network> {
if self.connections.hidden {
let wifi = self.connections.snapshot.as_ref()?.wifi.as_ref().ok()?;
let radio = wifi.radios.first()?;
let name = &self.connections.hidden_name;
if name.is_empty() || name.len() > 32 {
return None;
}
Some(Network {
ssid: name.as_bytes().to_vec(),
name: name.clone(),
security: self.connections.hidden_security,
strength: 0,
device: radio.path.clone(),
ap: "/".into(),
saved: None,
connected: false,
connecting: false,
hidden: true,
})
} else {
self.connections.selected.clone()
}
}
}
#[cfg(test)]
mod tests {
use cosmic::Application;
use super::*;
fn app() -> App {
App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0
}
#[test]
fn switching_tabs_and_leaving_clear_credentials_without_config_edits() {
let mut app = app();
let original = app.draft.source.clone();
let _ = app.connections_input(Input::Password(Zeroizing::new("secret".into())));
let _ = app.connections_input(Input::Tab(super::super::Tab::Bluetooth));
assert!(app.connections.password.is_empty());
let _ = app.connections_input(Input::Password(Zeroizing::new("another-secret".into())));
let _ = app.leave_connections();
assert!(app.connections.password.is_empty());
assert!(app.connections.pairing_input.is_empty());
assert!(app.pending.is_empty());
assert_eq!(app.draft.source, original);
}
#[test]
fn stale_pairing_input_and_submit_do_not_touch_the_current_question() {
let mut app = app();
app.connections.prompt = Some(super::super::agent::Prompt {
id: 2,
device: "/device/2".into(),
kind: super::super::agent::Kind::Passkey,
});
app.connections.pairing_input = Zeroizing::new("123456".into());
let _ = app.connections_input(Input::PairingValue(1, Zeroizing::new("old-value".into())));
let _ = app.connections_input(Input::Answer(1, true));
assert_eq!(app.connections.pairing_input.as_str(), "123456");
assert_eq!(app.connections.prompt.as_ref().unwrap().id, 2);
let _ = app.connections_input(Input::PairingValue(2, Zeroizing::new("bad-value".into())));
let _ = app.connections_input(Input::Answer(2, true));
assert!(app.connections.prompt.is_some());
}
#[test]
fn debug_messages_redact_network_and_pairing_secrets() {
let network = Network {
ssid: b"test".to_vec(),
name: "test".into(),
security: super::super::Security::Personal,
strength: 0,
device: "/device".into(),
ap: "/ap".into(),
saved: None,
connected: false,
connecting: false,
hidden: false,
};
for message in [
Input::Run(Command::WifiConnect(network, Zeroizing::new("never-log-this".into()))),
Input::Password(Zeroizing::new("never-log-this".into())),
Input::PairingValue(1, Zeroizing::new("never-log-this".into())),
] {
assert!(!format!("{message:?}").contains("never-log-this"));
}
}
#[test]
fn connection_page_search_and_startup_tab() {
assert!(crate::schema::Page::Connections.matches("bluetooth"));
assert!(crate::schema::Page::Connections.matches("wi-fi"));
let (app, _) = App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
Some(super::super::Tab::Bluetooth),
),
);
assert_eq!(app.page, crate::schema::Page::Connections);
assert_eq!(app.connections.tab, super::super::Tab::Bluetooth);
assert!(app.connections.loading);
}
}