diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..c3b40fc --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,148 @@ +//! This module handles rendering parsed data to a window + +use std::{collections::HashMap, sync::mpsc::Receiver}; + +use eframe::{egui, epi}; + +use crate::ipc::{MdnsPacket, Service}; + +/// Defines a single host record +#[derive(Debug)] +struct HostRecord { + pub name: String, + pub services: HashMap>, +} + +/// This is the GUI, and everything it needs to keep state +pub struct BrowseApp { + /// The data stream coming from the subprocess + packet_stream: Receiver, + + /// The currently rendered hosts + current_hosts: HashMap>, +} + +impl BrowseApp { + pub fn new(packet_stream: Receiver) -> Self { + Self { + packet_stream, + current_hosts: HashMap::new(), + } + } +} + +impl epi::App for BrowseApp { + fn update(&mut self, ctx: &eframe::egui::CtxRef, frame: &mut eframe::epi::Frame<'_>) { + // Poll the packet stream for new data, and update the hosts list accordingly + for packet in self.packet_stream.try_iter() { + match packet.mode { + crate::ipc::PacketMode::New => { + // Create the interface if not already existing + if !self.current_hosts.contains_key(&packet.interface_name) { + self.current_hosts + .insert(packet.interface_name.clone(), HashMap::new()); + } + + // Add the host to the interface + self.current_hosts + .get_mut(&packet.interface_name) + .unwrap() + .insert( + packet.hostname.clone(), + HostRecord { + name: packet.hostname.clone(), + services: match packet.service { + Some(service) => { + let mut services = HashMap::new(); + services.insert(service.name.clone(), vec![service]); + services + } + None => HashMap::new(), + }, + }, + ); + } + crate::ipc::PacketMode::Update => { + // Get the interface + let interface = self.current_hosts.get_mut(&packet.interface_name).unwrap(); + + // Get the host + let host = interface.get_mut(&packet.hostname).unwrap(); + + // Only update the host if the new service is real + if let Some(service) = packet.service { + // If the service name does not exist, create the service on the host + if !host.services.contains_key(&service.name) { + host.services.insert(service.name.clone(), vec![service]); + } else { + // Update an existing service if the address is not already used + let services = host.services.get_mut(&service.name).unwrap(); + if !services.iter().any(|s| { + format!("{} {}:{}", s.hostname, s.ip, s.port) + == format!( + "{} {}:{}", + service.hostname, service.ip, service.port + ) + }) { + services.push(service); + } + } + } + } + crate::ipc::PacketMode::Remove => { + // TODO: I don't want to actually remove data from the view. Maybe this can get implemented in the future? + } + } + } + + // Create a menu bar with quit button + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + egui::menu::menu(ui, "File", |ui| { + if ui.button("Quit").clicked() { + frame.quit(); + } + }); + }); + }); + + // Create a scrollable area to display the packet data + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + // Split the GUI into interfaces + for (interface, hosts) in &self.current_hosts { + // Create a header for the interface + ui.add(egui::Label::new(interface).heading()); + ui.separator(); + + // Render each host + for host in hosts.values() { + // Render the packet data + egui::CollapsingHeader::new(&host.name).show(ui, |ui| { + // Render every service name as a dropdown + for service_name in host.services.keys() { + egui::CollapsingHeader::new(service_name).show(ui, |ui| { + // Render provider for the service as its own dropdown, containing optional metadata + for service in host.services.get(service_name).unwrap() { + egui::CollapsingHeader::new(format!( + "{} (IP: {} Port: {})", + service.hostname, service.ip, service.port + )) + .show(ui, |ui| { + // Render the metadata + ui.label(service.data.join("\n")); + }); + } + }); + } + }); + } + } + }); + }); + } + + fn name(&self) -> &str { + "NetBrowse" + } +} diff --git a/src/ipc.rs b/src/ipc.rs index e6f580b..6395091 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,7 +3,9 @@ use std::{ convert::TryFrom, io::BufRead, + net::IpAddr, process::{Child, Stdio}, + str::FromStr, sync::mpsc::{self, Receiver, Sender}, thread::{self, JoinHandle}, }; @@ -19,6 +21,10 @@ pub enum PacketParseError { IpTypeParseError(String), #[error("Ran out of arguments")] NotEnoughArgsError, + #[error(transparent)] + AddrParseError(#[from] std::net::AddrParseError), + #[error(transparent)] + IntParseError(#[from] std::num::ParseIntError), } /// Tries to unwrap a string from an arg iter, or just returns an error @@ -71,6 +77,21 @@ impl TryFrom for IpType { } } +/// The metadat for a single service +#[derive(Debug)] +pub struct Service { + /// The name of the service + pub name: String, + /// The mDNS hostname of the service + pub hostname: String, + /// The IP address serving the service + pub ip: IpAddr, + /// The port the service is listening on + pub port: u16, + /// All additional data + pub data: Vec, +} + /// An information packet parsed from a `avahi-browse` subprocess #[derive(Debug)] pub struct MdnsPacket { @@ -86,8 +107,8 @@ pub struct MdnsPacket { pub service_type: String, /// The domain of the service pub domain: String, - /// Additional service metadata - pub metadata: Vec, + /// The service defined in this packet + pub service: Option, } impl TryFrom for MdnsPacket { @@ -98,26 +119,52 @@ impl TryFrom for MdnsPacket { // Convert the incoming data into an iter over its components let mut iter = s.split(';'); + // Load the data before constructing the structure + // Grabs the first char of the first arg, and reads it as a mode + let mode = PacketMode::try_from( + try_unwrap_arg(iter.next())? + .chars() + .next() + .ok_or(PacketParseError::NotEnoughArgsError)?, + )?; + // Grabs the second arg, and reads it as an interface name + let interface_name = try_unwrap_arg(iter.next())?.to_string(); + // Grabs the third arg, and reads it as an internet protocol type + let internet_protocol = IpType::try_from(try_unwrap_arg(iter.next())?.to_string())?; + // Grabs the fourth arg, and reads it as a hostname + let hostname = try_unwrap_arg(iter.next())? + .to_string() + .replace("\\.", ".") + .replace("\\0", " "); + // Grabs the fifth arg, and reads it as a service type + let service_type = try_unwrap_arg(iter.next())?.to_string(); + // Grabs the sixth arg, and reads it as a domain + let domain = try_unwrap_arg(iter.next())?.to_string(); + Ok(Self { - // Grabs the first char of the first arg, and reads it as a mode - mode: PacketMode::try_from( - try_unwrap_arg(iter.next())? - .chars() - .next() - .ok_or(PacketParseError::NotEnoughArgsError)?, - )?, - // Grabs the second arg, and reads it as an interface name - interface_name: try_unwrap_arg(iter.next())?.to_string(), - // Grabs the third arg, and reads it as an internet protocol type - internet_protocol: IpType::try_from(try_unwrap_arg(iter.next())?.to_string())?, - // Grabs the fourth arg, and reads it as a hostname - hostname: try_unwrap_arg(iter.next())?.to_string(), - // Grabs the fifth arg, and reads it as a service type - service_type: try_unwrap_arg(iter.next())?.to_string(), - // Grabs the sixth arg, and reads it as a domain - domain: try_unwrap_arg(iter.next())?.to_string(), - // Grabs the remaining args, and reads them as metadata - metadata: iter.map(|s| s.to_string()).collect(), + mode, + interface_name, + internet_protocol, + hostname, + service_type: service_type.clone(), + domain, + // Reads the remaining args as service data + // We do this by assuming there is a service, and turning any "no args" errors into a `None` value + service: match (|| -> Result { + Ok(Service { + name: service_type, + hostname: try_unwrap_arg(iter.next())?.to_string(), + ip: IpAddr::from_str(try_unwrap_arg(iter.next())?)?, + port: u16::from_str(try_unwrap_arg(iter.next())?)?, + data: iter.map(|s| s.to_string()).collect(), + }) + })() { + Ok(s) => Some(s), + Err(e) => { + // eprintln!("{}", e); + None + } + }, }) } } @@ -161,7 +208,7 @@ impl AvahiSubprocess { match MdnsPacket::try_from(line.to_string()) { Ok(packet) => { // Send the packet to the main thread - println!("{:?}", packet); + // println!("{:?}", packet); packet_sender .send(packet) .expect("Failed to send packet to main thread"); diff --git a/src/main.rs b/src/main.rs index 1dc722f..ce6d69f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,10 +65,12 @@ )] use colored::Colorize; +use eframe::{egui::Vec2, NativeOptions}; +use gui::BrowseApp; +mod gui; mod ipc; fn main() { - // Check for avahi-browse on this system if let Err(_) = which::which("avahi-browse") { eprintln!("{}", "`avahi-browse` was not found in system $PATH".red()); @@ -78,6 +80,9 @@ fn main() { // Spawn the subprocess let subprocess = ipc::AvahiSubprocess::spawn(); - loop{} - -} \ No newline at end of file + // Build the egui app and run it + let app = BrowseApp::new(subprocess.packet_stream); + let mut options = NativeOptions::default(); + options.initial_window_size = Some(Vec2::new(500.0, 800.0)); + eframe::run_native(Box::new(app), options); +}