diff --git a/Cargo.toml b/Cargo.toml index 03923ab..18c2829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "plugin-run", "plugin-desktop", "plugin-audio", + "plugin-network", ] diff --git a/Makefile b/Makefile index 410e468..bb7601a 100644 --- a/Makefile +++ b/Makefile @@ -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} diff --git a/plugin-network/Cargo.toml b/plugin-network/Cargo.toml new file mode 100644 index 0000000..9c0fc04 --- /dev/null +++ b/plugin-network/Cargo.toml @@ -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" diff --git a/plugin-network/public/default.css b/plugin-network/public/default.css new file mode 100644 index 0000000..abbf203 --- /dev/null +++ b/plugin-network/public/default.css @@ -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; +} diff --git a/plugin-network/public/spinner.css b/plugin-network/public/spinner.css new file mode 100644 index 0000000..c19270e --- /dev/null +++ b/plugin-network/public/spinner.css @@ -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; + } +} diff --git a/plugin-network/public/spinner.html b/plugin-network/public/spinner.html new file mode 100644 index 0000000..92980e7 --- /dev/null +++ b/plugin-network/public/spinner.html @@ -0,0 +1,14 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugin-network/src/gui.rs b/plugin-network/src/gui.rs new file mode 100644 index 0000000..307a7c0 --- /dev/null +++ b/plugin-network/src/gui.rs @@ -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 { + 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(cx: Scope) { + 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) -> 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}" + } + } + } + }) +} diff --git a/plugin-network/src/main.rs b/plugin-network/src/main.rs new file mode 100644 index 0000000..8ec59ae --- /dev/null +++ b/plugin-network/src/main.rs @@ -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 }, +} + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Cli { + #[clap(subcommand)] + command: Option, +} + +/// 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) -> Result { + // 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(()) +} diff --git a/plugin-network/src/network.rs b/plugin-network/src/network.rs new file mode 100644 index 0000000..2b127c4 --- /dev/null +++ b/plugin-network/src/network.rs @@ -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, +} + +// SETTING_WIRELESS_MODE +// SETTING_IP4_CONFIG_METHOD_AUTO + +/// Generate a NEW Connection to Use AccessPoint +fn new_conn(ap: &AccessPoint, password: Option<&str>) -> Result { + 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::>(); + 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 { + // 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 { + let conns: Vec = self + .client + .connections() + .into_iter() + .map(|c| c.upcast()) + .collect(); + let mut access: BTreeMap = 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 = 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::(); + 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(()) + } +} diff --git a/rmenu/public/99-rmenu-sway.conf b/rmenu/public/99-rmenu-sway.conf index 3163368..af400ef 100644 --- a/rmenu/public/99-rmenu-sway.conf +++ b/rmenu/public/99-rmenu-sway.conf @@ -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 diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml index 3fadeb6..b0cc52e 100644 --- a/rmenu/public/config.yaml +++ b/rmenu/public/config.yaml @@ -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: