feat: implement wifi-connection plugin

This commit is contained in:
imgurbot12 2023-08-14 15:44:40 -07:00
parent f00ad7ca49
commit 16ccc5cdef
11 changed files with 844 additions and 0 deletions

View File

@ -6,4 +6,5 @@ members = [
"plugin-run",
"plugin-desktop",
"plugin-audio",
"plugin-network",
]

View File

@ -21,6 +21,7 @@ deploy:
cp -vf ./target/release/desktop ${DEST}/drun
cp -vf ./target/release/run ${DEST}/run
cp -vf ./target/release/audio ${DEST}/audio
cp -vf ./target/release/rmenu-network ${DEST}/network
cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml
build: build-rmenu build-plugins
@ -32,3 +33,4 @@ build-plugins:
${CARGO} build -p run ${FLAGS}
${CARGO} build -p desktop ${FLAGS}
${CARGO} build -p audio ${FLAGS}
${CARGO} build -p rmenu-network ${FLAGS}

23
plugin-network/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "rmenu-network"
version = "0.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.72"
async-std = "1.12.0"
clap = { version = "4.3.21", features = ["derive"] }
dioxus = "0.4.0"
dioxus-desktop = "0.4.0"
dioxus-free-icons = { version = "0.7.0", features = ["font-awesome-regular"] }
env_logger = "0.10.0"
futures-channel = "0.3.28"
glib = { git = "https://github.com/gtk-rs/gtk-rs-core", version = "0.19.0" }
keyboard-types = "0.6.2"
log = "0.4.20"
nm = { git = "https://github.com/balena-io-modules/libnm-rs.git", version = "0.4.0" }
once_cell = "1.18.0"
rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" }
serde_json = "1.0.104"

View File

@ -0,0 +1,96 @@
html, body {
overflow: hidden;
font-size: 14px;
background-color: #e8edee;
}
#header {
padding: 0.15rem;
}
#controls {
display: flex;
justify-content: center;
align-items: center;
margin-top: 0.5rem;
}
#secret {
width: -webkit-fill-available;
height: 2rem;
border: none;
outline: none;
padding: 0 0.5rem;
background-color: #ccd2df;
}
#icon {
float: right;
position: relative;
z-index: 2;
margin-left: -2.5rem;
display: flex;
align-items: center;
height: 2rem;
border: none;
background: none;
}
@keyframes movein {
from { margin-top: 10rem; }
to { margin-top: 1rem; }
}
@keyframes moveout {
from { margin-top: 1rem; }
to { bottom: 10rem; }
}
#message {
display: flex;
justify-content: center;
margin-top: 1rem;
font-size: 15px;
font-style: italic;
animation: movein 0.5s ease-in-out;
}
#message.error {
color: white;
background-color: #de5a5a;
}
#message.success {
color: white;
background-color: #53c351;
}
#blackout {
display: none;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 100;
background-color: black;
filter: alpha(opacity=30);
opacity: 0.3;
}
#blackout > #spinner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#blackout.active {
display: block;
}

View File

@ -0,0 +1,78 @@
.lds-spinner {
color: official;
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-spinner div {
transform-origin: 40px 40px;
animation: lds-spinner 1.2s linear infinite;
}
.lds-spinner div:after {
content: " ";
display: block;
position: absolute;
top: 3px;
left: 37px;
width: 6px;
height: 18px;
border-radius: 20%;
background: #fff;
}
.lds-spinner div:nth-child(1) {
transform: rotate(0deg);
animation-delay: -1.1s;
}
.lds-spinner div:nth-child(2) {
transform: rotate(30deg);
animation-delay: -1s;
}
.lds-spinner div:nth-child(3) {
transform: rotate(60deg);
animation-delay: -0.9s;
}
.lds-spinner div:nth-child(4) {
transform: rotate(90deg);
animation-delay: -0.8s;
}
.lds-spinner div:nth-child(5) {
transform: rotate(120deg);
animation-delay: -0.7s;
}
.lds-spinner div:nth-child(6) {
transform: rotate(150deg);
animation-delay: -0.6s;
}
.lds-spinner div:nth-child(7) {
transform: rotate(180deg);
animation-delay: -0.5s;
}
.lds-spinner div:nth-child(8) {
transform: rotate(210deg);
animation-delay: -0.4s;
}
.lds-spinner div:nth-child(9) {
transform: rotate(240deg);
animation-delay: -0.3s;
}
.lds-spinner div:nth-child(10) {
transform: rotate(270deg);
animation-delay: -0.2s;
}
.lds-spinner div:nth-child(11) {
transform: rotate(300deg);
animation-delay: -0.1s;
}
.lds-spinner div:nth-child(12) {
transform: rotate(330deg);
animation-delay: 0s;
}
@keyframes lds-spinner {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@ -0,0 +1,14 @@
<div class="lds-spinner">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>

211
plugin-network/src/gui.rs Normal file
View File

@ -0,0 +1,211 @@
#![allow(non_snake_case)]
///! NetworkManager Authenticator GUI
use anyhow::{anyhow, Result};
use dioxus::prelude::*;
use dioxus_desktop::LogicalSize;
use dioxus_free_icons::icons::fa_regular_icons::{FaEye, FaEyeSlash};
use dioxus_free_icons::Icon;
use keyboard_types::Code;
use crate::network::Manager;
static SPINNER_CSS: &'static str = include_str!("../public/spinner.css");
static SPINNER_HTML: &'static str = include_str!("../public/spinner.html");
static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css");
/// Run GUI Application
pub fn run_app(ssid: &str, timeout: u32) {
let builder = dioxus_desktop::WindowBuilder::new()
.with_title("RMenu - Network Login")
.with_inner_size(LogicalSize {
width: 400,
height: 150,
})
.with_focused(true)
.with_decorations(false)
.with_always_on_top(true);
dioxus_desktop::launch_with_props(
App,
AppProp {
ssid: ssid.to_owned(),
timeout,
},
dioxus_desktop::Config::new().with_window(builder),
);
}
/// Message to send to GUI
#[derive(Debug, Clone, PartialEq)]
pub enum UserMessage {
Nothing,
Error(String),
Success(String),
}
impl UserMessage {
/// Retrieve CSS-Class to use on UserMessage
fn css_class(&self) -> &str {
match self {
Self::Error(_) => "error",
Self::Success(_) => "success",
Self::Nothing => "none",
}
}
/// Retrieve Message Value
fn message(&self) -> Option<String> {
match self {
Self::Nothing => None,
Self::Error(msg) => Some(msg.to_owned()),
Self::Success(msg) => Some(msg.to_owned()),
}
}
}
#[derive(Debug, PartialEq, Props)]
struct AppProp {
ssid: String,
timeout: u32,
}
//TODO: add auth timeout
/// Set Cursor/Keyboard Focus onto Input
#[inline]
fn focus<T>(cx: Scope<T>) {
let eval = use_eval(cx);
let js = "document.getElementById(`secret`).focus()";
let _ = eval(&js);
}
/// Complete Network Connection/Authentication Attempt
async fn connect_async(ssid: String, timeout: u32, secret: &str) -> Result<()> {
// retrieve access-point from manager
let manager = Manager::new().await?.with_timeout(timeout);
let access_point = manager
.access_points()
.into_iter()
.find(|a| a.ssid == ssid)
.ok_or_else(|| anyhow!("Unable to find Access Point: {ssid:?}"))?;
// attempt to establish connection w/ secret
log::debug!("Found AccessPoint: {ssid:?} | {:?}", access_point.security);
manager.connect(&access_point, Some(secret)).await
}
/// Simple Login GUI Application
fn App(cx: Scope<AppProp>) -> Element {
let secret = use_state(cx, || String::new());
let message = use_state(cx, || UserMessage::Nothing);
let show_secret = use_state(cx, || false);
let loading = use_state(cx, || false);
// always ensure focus
focus(cx);
// build keyboard actions event handler
let keyboard_controls = move |e: KeyboardEvent| match e.code() {
Code::Escape => std::process::exit(0),
Code::Enter => {
let ssid = cx.props.ssid.to_owned();
let timeout = cx.props.timeout.to_owned();
let secret = secret.get().to_owned();
let message = message.to_owned();
let loading = loading.to_owned();
loading.set(true);
message.set(UserMessage::Nothing);
cx.spawn(async move {
log::info!("connecting to ssid: {ssid:?}");
let result = connect_async(ssid, timeout, &secret).await;
log::info!("connection result: {result:?}");
match result {
Ok(_) => {
// update message and unlock gui
message.set(UserMessage::Success("Connection Complete!".to_owned()));
loading.set(false);
// exit program after timeout
let wait = std::time::Duration::from_secs(1);
async_std::task::sleep(wait).await;
std::process::exit(0);
}
Err(err) => {
message.set(UserMessage::Error(format!("Connection Failed: {err:?}")));
loading.set(false);
}
}
});
}
_ => {}
};
// retrieve message details / set input-type / get loading css-class
let msg_css = message.css_class();
let msg_str = message.message();
let input_type = match show_secret.get() {
true => "text",
false => "password",
};
let blackout_css = match loading.get() {
true => "active",
false => "",
};
// complete final rendering
cx.render(rsx! {
style { DEFAULT_CSS_CONTENT }
style { SPINNER_CSS }
div {
onkeydown: keyboard_controls,
label {
id: "header",
"for": "secret",
"Wi-Fi Network {cx.props.ssid:?} Requires a Password"
}
div {
id: "controls",
input {
id: "secret",
value: "{secret}",
placeholder: "Password",
oninput: move |e| secret.set(e.value.clone()),
"type": "{input_type}"
}
button {
id: "icon",
onclick: |_| show_secret.modify(|v| !v),
match show_secret.get() {
true => cx.render(rsx! {
Icon {
fill: "black",
icon: FaEye,
}
}),
false => cx.render(rsx! {
Icon {
fill: "black",
icon: FaEyeSlash,
}
})
}
}
}
if let Some(msg) = msg_str {
cx.render(rsx! {
div {
id: "message",
class: "{msg_css}",
"{msg}"
}
})
}
div {
id: "blackout",
class: "{blackout_css}",
div {
id: "spinner",
dangerous_inner_html: "{SPINNER_HTML}"
}
}
}
})
}

105
plugin-network/src/main.rs Normal file
View File

@ -0,0 +1,105 @@
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
use rmenu_plugin::Entry;
mod gui;
mod network;
#[derive(Debug, Subcommand)]
pub enum Commands {
ListAccessPoints,
Connect { ssid: String, timeout: Option<u32> },
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Cli {
#[clap(subcommand)]
command: Option<Commands>,
}
/// Get Signal Bars based on a signal-value
fn get_bars(signal: u8) -> String {
if signal >= 80 {
return "▂▄▆█".to_owned();
}
if signal > 50 {
return "▂▄▆_".to_owned();
}
if signal > 30 {
return "▂▄__".to_owned();
}
"▂___".to_owned()
}
/// List AccessPoints using NetworkManager
async fn list_aps() -> Result<()> {
let exe = std::env::current_exe()?.to_str().unwrap().to_string();
// spawn manager and complete scan (if nessesary)
let manager = network::Manager::new().await?;
if !manager.scanned_recently() {
log::info!("Scanning for Access-Points...");
manager.scan_wifi().await?;
}
// retrive access-points and print as entries
for ap in manager.access_points() {
let star = ap.is_active.then(|| " *").unwrap_or("");
let bars = get_bars(ap.signal);
let desc = format!("{bars} {}{star}", ap.ssid);
let exec = format!("{exe} connect {:?}", ap.ssid);
let entry = Entry::new(&desc, &exec, None);
println!("{}", serde_json::to_string(&entry).unwrap());
}
Ok(())
}
/// Attempt to Connect to the Specified SSID (If Connection Already Exists)
async fn try_connect_ap(ssid: String, timeout: Option<u32>) -> Result<bool> {
// spawn manager and complete scan (if nessesary)
let mut manager = network::Manager::new().await?;
if let Some(timeout) = timeout {
manager = manager.with_timeout(timeout)
}
if !manager.scanned_recently() {
log::info!("Scanning for Access-Points...");
manager.scan_wifi().await?;
}
// attempt to find access-point
let access_point = manager
.access_points()
.into_iter()
.find(|ap| ap.ssid == ssid)
.ok_or_else(|| anyhow!("Unable to find Access-Point: {ssid:?}"))?;
// if connection already exists, try to connect
if access_point.connection.is_some() {
log::info!("Attempting Connection to {ssid:?} w/o Password");
match manager.connect(&access_point, None).await {
Err(err) => log::info!("Connection Failed: {err:?}"),
Ok(_) => {
log::info!("Connection Successful!");
return Ok(true);
}
};
}
Ok(false)
}
fn main() -> Result<()> {
env_logger::init();
let cli = Cli::parse();
let context = glib::MainContext::default();
let command = cli.command.unwrap_or(Commands::ListAccessPoints);
match command {
Commands::ListAccessPoints => context.block_on(list_aps())?,
Commands::Connect { ssid, timeout } => {
let connected = context.block_on(try_connect_ap(ssid.clone(), timeout))?;
if !connected {
log::info!("Spawning GUI to complete AP Login");
gui::run_app(&ssid, timeout.unwrap_or(30));
}
}
}
Ok(())
}

View File

@ -0,0 +1,309 @@
use glib::translate::FromGlib;
use async_std::task;
use futures_channel::oneshot;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use std::time::{Duration, SystemTime};
use anyhow::{anyhow, Context, Result};
use nm::*;
static SCAN_INTERVAL_MS: u64 = 500;
static SCAN_TOTAL_WAIT: u64 = 3;
static SCAN_BETWEEN: i64 = 30;
/// NetworkManager Simplified API
#[derive(Debug)]
pub struct Manager {
client: Client,
wifi: DeviceWifi,
timeout: Duration,
}
/// AccessPoint Information
#[derive(Debug)]
pub struct AccessPoint {
pub in_use: bool,
pub ssid: String,
pub rate: u32,
pub signal: u8,
pub security: String,
pub is_active: bool,
pub connection: Option<Connection>,
}
// SETTING_WIRELESS_MODE
// SETTING_IP4_CONFIG_METHOD_AUTO
/// Generate a NEW Connection to Use AccessPoint
fn new_conn(ap: &AccessPoint, password: Option<&str>) -> Result<SimpleConnection> {
let connection = SimpleConnection::new();
// configure generate connection settings
let s_connection = SettingConnection::new();
s_connection.set_type(Some(&SETTING_WIRELESS_SETTING_NAME));
s_connection.set_id(Some(&ap.ssid));
s_connection.set_autoconnect(false);
// s_connection.set_interface_name(interface);
connection.add_setting(s_connection);
// configure wireless settings
let s_wireless = SettingWireless::new();
s_wireless.set_ssid(Some(&(ap.ssid.as_bytes().into())));
// s_wireless.set_band(Some("bg"));
// s_wireless.set_hidden(false);
// s_wireless.set_mode(Some(&SETTING_WIRELESS_MODE));
connection.add_setting(s_wireless);
// configure login settings
if let Some(password) = password {
//TODO: potentially determine key-mgmt based on ap-security
let s_wireless_security = SettingWirelessSecurity::new();
s_wireless_security.set_key_mgmt(Some("wpa-psk"));
s_wireless_security.set_psk(Some(password));
connection.add_setting(s_wireless_security);
}
// assume DHCP Assignment
let s_ip4 = SettingIP4Config::new();
s_ip4.set_method(Some(&SETTING_IP4_CONFIG_METHOD_AUTO));
connection.add_setting(s_ip4);
Ok(connection)
}
/// Exit Security Section to Specify New Password
fn edit_conn(conn: &Connection, password: Option<&str>) -> Result<()> {
let s_wireless_security = conn
.setting_wireless_security()
.unwrap_or_else(|| SettingWirelessSecurity::new());
s_wireless_security.set_key_mgmt(Some("wpa-psk"));
s_wireless_security.set_psk(password);
conn.add_setting(s_wireless_security);
Ok(())
}
/// Wait for Connection to Fail or Complete
async fn wait_conn(active: &ActiveConnection, timeout: Duration) -> Result<()> {
// spawn communication channels
let (sender, receiver) = oneshot::channel::<Result<()>>();
let sender = Rc::new(RefCell::new(Some(sender)));
// spawn state-callback
active.connect_state_changed(move |active_connection, state, _| {
let sender = sender.clone();
let active_connection = active_connection.clone();
glib::MainContext::ref_thread_default().spawn_local(async move {
let state = unsafe { ActiveConnectionState::from_glib(state as _) };
log::debug!("[Connect] Active connection state: {:?}", state);
// generate send function
let send = move |result| {
let sender = sender.borrow_mut().take();
if let Some(sender) = sender {
sender.send(result).expect("Sender Dropped");
}
};
// handle connection state-changes
match state {
ActiveConnectionState::Activated => {
log::debug!("[Connect] Successfully activated");
return send(Ok(()));
}
ActiveConnectionState::Deactivated => {
log::debug!("[Connect] Connection deactivated");
match active_connection.connection() {
Some(remote_connection) => {
let result = remote_connection
.delete_future()
.await
.context("Failed to delete connection");
if result.is_err() {
return send(result);
}
return send(Err(anyhow!("Connection Failed (Deactivated)")));
}
None => {
return send(Err(anyhow!(
"Failed to get remote connection from active connection"
)))
}
}
}
_ => {}
};
});
});
// wait until state notification is done
let result = async_std::future::timeout(timeout, receiver).await;
match result {
Ok(result) => match result {
Ok(res) => res,
Err(err) => Err(anyhow!("Connection Cancelled: {err:?}")),
},
Err(err) => Err(anyhow!("Timeout Reached: {err:?}")),
}
}
impl Manager {
/// Spawn new Wifi Manager Instance
pub async fn new() -> Result<Self> {
// get network-manager client
let client = Client::new_future()
.await
.context("Failed to create NM Client")?;
// get wifi device if any are available
let device = client
.devices()
.into_iter()
.filter(|d| d.device_type() == DeviceType::Wifi)
.next()
.ok_or_else(|| anyhow!("Cannot find a Wi-Fi device"))?;
// access inner wifi-device object
let wifi: DeviceWifi = device
.downcast()
.map_err(|_| anyhow!("Failed to Access Wi-Fi Device"))?;
log::debug!("NetworkManager Connection Established");
Ok(Self {
client,
wifi,
timeout: Duration::from_secs(30),
})
}
/// Update Manager Timeout and Return Self
pub fn with_timeout(mut self, secs: u32) -> Self {
self.timeout = Duration::from_secs(secs.into());
self
}
/// Check if NetworkManager already scanned recently
pub fn scanned_recently(&self) -> bool {
let last_ms = self.wifi.last_scan();
let now_ms = utils_get_timestamp_msec();
let elapsed = (now_ms - last_ms) / 1000;
last_ms > 0 && elapsed < SCAN_BETWEEN
}
/// Complete General Wifi-Scan
pub async fn scan_wifi(&self) -> Result<()> {
// request wifi-scan
self.wifi
.request_scan_future()
.await
.context("Failed to Request Wi-Fi Scan")?;
// wait until access-points are collected
let mut then = SystemTime::now();
let mut current = self.wifi.access_points().len();
loop {
// wait interval for more access-points
task::sleep(Duration::from_millis(SCAN_INTERVAL_MS)).await;
// check if time has elapsed
let now = SystemTime::now();
let elapsed = now.duration_since(then)?;
if elapsed.as_secs() > SCAN_TOTAL_WAIT {
break;
}
// check if more access-points were discovered
let found = self.wifi.access_points().len();
if found > current {
then = now;
current = found;
}
}
Ok(())
}
/// Retrieve Access-Point Information
pub fn access_points(&self) -> Vec<AccessPoint> {
let conns: Vec<Connection> = self
.client
.connections()
.into_iter()
.map(|c| c.upcast())
.collect();
let mut access: BTreeMap<String, AccessPoint> = BTreeMap::new();
let active = self.wifi.active_access_point();
for a in self.wifi.access_points() {
// retrieve access-point information
let rate = a.max_bitrate() / 1000;
let signal = a.strength();
let ssid = a
.ssid()
.map(|b| b.escape_ascii().to_string())
.unwrap_or_else(|| "--".to_owned());
// determine if connection-map should be updated
let is_active = active
.as_ref()
.map(|b| a.bssid() == b.bssid())
.unwrap_or(false);
if !is_active {
if let Some(point) = access.get(&ssid) {
if point.rate > rate {
continue;
}
}
}
// build security-string
let mut security = vec![];
let wpa_flags = a.wpa_flags();
let wpa2_flags = a.rsn_flags();
if !wpa_flags.is_empty() {
security.push("WPA1");
}
if !wpa2_flags.is_empty() {
security.push("WPA2");
}
if wpa2_flags.intersects(_80211ApSecurityFlags::KEY_MGMT_802_1X) {
security.push("802.1X");
}
if security.is_empty() {
security.push("--");
}
// insert access-point
access.insert(
ssid.to_owned(),
AccessPoint {
in_use: is_active,
ssid,
rate,
signal,
is_active,
security: security.join(" ").to_owned(),
connection: a.filter_connections(&conns).get(0).cloned(),
},
);
}
// move map values into vector and sort by signal-strength
let mut points: Vec<AccessPoint> = access.into_values().collect();
points.sort_by_key(|a| a.signal);
points.reverse();
points
}
/// Attempt to Authenticate and Activate Access-Point Connection
pub async fn connect(&self, ap: &AccessPoint, password: Option<&str>) -> Result<()> {
let device = self.wifi.clone().upcast::<Device>();
match &ap.connection {
Some(conn) => {
edit_conn(conn, password)?;
let active_conn = self
.client
.activate_connection_future(Some(conn), Some(&device), None)
.await
.context("Failed to activate existing connection")?;
wait_conn(&active_conn, self.timeout).await?;
}
None => {
let conn = new_conn(ap, password)?;
let active_conn = self
.client
.add_and_activate_connection_future(Some(&conn), Some(&device), None)
.await
.context("Failed to add and activate connection")?;
wait_conn(&active_conn, self.timeout).await?;
}
}
Ok(())
}
}

View File

@ -1,3 +1,4 @@
# Configure RMenu to Spawn Floating in the Center of the Screen
for_window [app_id="rmenu"] floating enable
for_window [app_id="rmenu-network"] floating enable

View File

@ -29,6 +29,10 @@ plugins:
exec: ["~/.config/rmenu/audio"]
cache: false
placeholder: "Select an Audio Sink"
network:
exec: ["~/.config/rmenu/network"]
cache: false
placeholder: "Connect to the Specified Wi-Fi"
# custom keybindings
keybinds: