357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
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_none_or(|p| p.id != id) {
|
|
return Task::none();
|
|
}
|
|
if accepted
|
|
&& !super::agent::valid_answer(
|
|
&self
|
|
.connections
|
|
.prompt
|
|
.as_ref()
|
|
.expect("the pairing reply branch requires an active prompt")
|
|
.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(crate::InitialPage::Connections(
|
|
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);
|
|
}
|
|
}
|