diff --git a/app/ethernet_service.py b/app/ethernet_service.py new file mode 100644 index 0000000..cb8bb1c --- /dev/null +++ b/app/ethernet_service.py @@ -0,0 +1,153 @@ +import nmcli +from typing import Literal, overload +from nmcli.data.device import DeviceDetails + + +class EthernetDetails: + device: str + hwaddr: str + name: str + ipaddr: str + gateway_addr: str + state: Literal["connected", "disconnected", "unavailable"] + + def __init__(self): + # pass device object to parser + details = EthernetService.get_device_details() + if details != None: + self._parse_data(details) + + def _parse_data(self, eth_dev: DeviceDetails) -> None: + """parses the information from eth_dev and add to properties""" + self.device = eth_dev.get("GENERAL.DEVICE") + self.hwaddr = eth_dev.get("GENERAL.HWADDR") + self.name = eth_dev.get("GENERAL.CONNECTION") + self.ipaddr = ( + eth_dev.get("IP4.ADDRESS[1]") + or eth_dev.get("IP6.ADDRESS[1]") + or "unavailable" + ) + self.gateway_addr = ( + eth_dev.get("IP4.GATEWAY") or eth_dev.get("IP6.GATEWAY") or "unavailable" + ) + + # the format of GENERAL.STATE is "int (str)", e.g. "100 (connected)" + state: int = int(eth_dev.get("GENERAL.STATE").split(" ")[0]) + + # check for connected and disconnected, the rest is unavailable + if state == 30: + self.state = "disconnected" + elif state == 100: + self.state = "connected" + else: + self.state = "unavailable" + + +class EthernetService: + @staticmethod + def get_device_details() -> DeviceDetails | None: + """Get the DeviceDetails object of the ethernet device""" + # find device with ethernet type + for dev in nmcli.device.show_all(): + if dev.get("GENERAL.TYPE") == "ethernet": + return dev + + return None + + @staticmethod + def get_device() -> str | None: + """Get the ethernet device's identifier""" + details = EthernetService.get_device_details() + + if details == None: + return None + + return details.get("GENERAL.DEVICE") + + @overload + @staticmethod + def get_ethernet_status() -> bool: + """Get the status of the ethernet, True for connected, if not, False. Will always return False if ethernet is not available.""" + pass + + @overload + @staticmethod + def get_ethernet_status(details: EthernetDetails) -> bool: + """Get the status of the ethernet from the details, True for connected, if not, False. Will always return False if ethernet is not available.""" + pass + + def get_ethernet_status(details: EthernetDetails | None = None) -> bool: + if details == None: + details = EthernetService.get_device_details() + + # no ethernet device available + if details == None: + return False + + state = int(details.get("GENERAL.STATE").split(" ")[0]) + + if state == 100: + return True + + return False + + # if details is an EthernetDetails object + else: + if details.state == "connected": + return True + + return False + + @overload + @staticmethod + def is_ethernet_available() -> bool: + """Check if ethernet is available and able to be connected. This is different from status.""" + pass + + @overload + @staticmethod + def is_ethernet_available(details: EthernetDetails) -> bool: + """Check if ethernet is available and able to be connected from the details. This is different from status.""" + pass + + def is_ethernet_available(details: EthernetDetails | None = None) -> bool: + if details == None: + details = EthernetService.get_device_details() + + # no ethernet device available + if details == None: + return False + + state = int(details.get("GENERAL.STATE").split(" ")[0]) + + # if state is disconnected or connected, then its available + if state == 30 or state == 100: + return True + + return False + + # if details is an EthernetDetails object + else: + if details.state != "unavailable": + return True + + return False + + @staticmethod + def toggle_ethernet(state: bool) -> bool: + """Enable or disable Ethernet""" + try: + current_state = EthernetService.get_ethernet_status() + if current_state == state: + return True + + if state: + nmcli._syscmd.nmcli(["device", "connect", EthernetService.get_device()]) + else: + nmcli._syscmd.nmcli( + ["device", "disconnect", EthernetService.get_device()] + ) + return True + except Exception as e: + print(f"Error toggling Ethernet: {e}") + return False diff --git a/app/main.py b/app/main.py index 1dc7847..4308f02 100755 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -import gi import argparse import sys + +import gi from network_service import NetworkService from ui.main_window import NetworkManagerWindow + gi.require_version("Gtk", "4.0") from gi.repository import Gtk from ui.styles import StyleManager @@ -11,42 +13,41 @@ # Application version __version__ = "1.0.0" + class NetworkManagerApp(Gtk.Application): """Main application class""" - + def __init__(self): super().__init__(application_id="com.network.manager") - + def do_activate(self): """Application activation""" StyleManager.apply_styles() win = NetworkManagerWindow(self) win.present() + def parse_arguments(): """Parse command line arguments""" parser = argparse.ArgumentParser( description="Network Manager - A GTK4 network management application", - prog="network-manager" - ) - - parser.add_argument( - "-v", "--version", - action="version", - version=f"%(prog)s {__version__}" + prog="network-manager", ) - + + parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}") + return parser.parse_args() + if __name__ == "__main__": try: # Parse command line arguments first args = parse_arguments() - + # Check if NetworkManager is available before starting the app if not NetworkService.check_networkmanager(): sys.exit(1) - + # If we get here, NetworkManager is available, so start the app app = NetworkManagerApp() app.run() diff --git a/app/ui/ethernet_details.py b/app/ui/ethernet_details.py new file mode 100644 index 0000000..2a657a8 --- /dev/null +++ b/app/ui/ethernet_details.py @@ -0,0 +1,108 @@ +from typing import Callable +import gi + +from ui.utils import UIUtils +from ethernet_service import EthernetService, EthernetDetails + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + + +class EthernetDetailsWidget(Gtk.Box): + content: Gtk.Box + refresh_label: Gtk.Label + name_label: Gtk.Label + spinner: Gtk.Spinner + switch: Gtk.Switch + + def __init__(self, ethernet_switch: Gtk.Switch): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + # an ethernet_switch parameter is passed to the widget so that we can set the switch's state on update + self.switch = ethernet_switch + + self._create_header() + self.append(self.content) + self._show_content() + + def _create_header(self): + self.refresh_label = Gtk.Label(label="Refresh", name="ethernet-scan-label") + self.refresh_label.set_cursor_from_name("pointer") + self.refresh_label.add_controller(self._create_click_controller(self.update)) + + self.spinner = Gtk.Spinner() + self.spinner.set_size_request(20, 20) + + self.name_label = Gtk.Label( + label=EthernetDetails().name or "Not Connected", + xalign=0, + name="ethernet-name-label", + ) + + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + header_box.append(self.name_label) + header_box.append(Gtk.Box(hexpand=True)) # Spacer + header_box.append(self.spinner) + header_box.append(self.refresh_label) + + self.append(header_box) + + def _create_click_controller(self, callback: Callable): + """Create a click controller for a label""" + controller = Gtk.GestureClick() + controller.set_button(0) + controller.connect("pressed", lambda *args: callback()) + return controller + + def _show_content(self): + if not EthernetService.is_ethernet_available(): + return + + details = EthernetDetails() + + self.content.append( + UIUtils.create_detail_row( + "Network Status", + details.state.title(), + "network-transmit-receive-symbolic", + ) + ) + self.content.append( + UIUtils.create_detail_row( + "IP Address", details.ipaddr, "network-workgroup-symbolic" + ) + ) + self.content.append( + UIUtils.create_detail_row( + "MAC Address", details.hwaddr, "network-wired-symbolic" + ) + ) + + def _clear_content(self): + child = self.content.get_first_child() + while child: + next_child = child.get_next_sibling() + self.content.remove(child) + child = next_child + + def update(self): + self.spinner.start() + self.refresh_label.set_sensitive(False) + self.refresh_label.add_css_class("rescan-in-progress") + + self.name_label.set_label(EthernetDetails().name or "Not Connected") + + if EthernetService.is_ethernet_available(): + self._clear_content() + self._show_content() + self.switch.set_sensitive(True) + else: + self._clear_content() + self.switch.set_active(False) + self.switch.set_sensitive(False) + + self.spinner.stop() + self.refresh_label.set_sensitive(True) + self.refresh_label.remove_css_class("rescan-in-progress") diff --git a/app/ui/main_window.py b/app/ui/main_window.py index c3d8ead..c4e0683 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,24 +1,29 @@ import gi - +from ui.ethernet_details import EthernetDetailsWidget from models import NetworkInfo, WiFiState + from ui.network_list import NetworkListWidget + gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk, GLib from typing import Optional -from network_service import NetworkService +from gi.repository import Gdk, GLib, Gtk +from network_service import NetworkService +from ethernet_service import EthernetService + +from ui.dialogs import PasswordDialog from ui.network_details import NetworkDetailsWidget from ui.wifi_off import WiFiOffWidget -from ui.dialogs import PasswordDialog + class NetworkManagerWindow(Gtk.ApplicationWindow): """Main application window""" - + def __init__(self, app): super().__init__(application=app) self.set_title("Network Manager") self.set_default_size(450, 350) - + self.current_state = WiFiState.OFF self.current_view = "list" # "list" or "details" self._setup_ui() @@ -28,7 +33,7 @@ def __init__(self, app): key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_esc_pressed) self.add_controller(key_controller) - + def _setup_ui(self): """Setup the main UI""" main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) @@ -36,46 +41,83 @@ def _setup_ui(self): main_box.set_margin_bottom(20) main_box.set_margin_start(20) main_box.set_margin_end(20) - + + main_box.append(self._create_ethernet_toggle()) + main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + self.ethernet_details_widget = EthernetDetailsWidget(self.ethernet_switch) + main_box.append(self.ethernet_details_widget) + + main_box.append(Gtk.Box(hexpand=True)) # spacer + main_box.append(self._create_wifi_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True) main_box.append(self.content_box) - + self.set_child(main_box) - + + def _create_ethernet_toggle(self) -> Gtk.Box: + """Create the Ethernet toggle section""" + self.ethernet_switch = Gtk.Switch(valign=Gtk.Align.CENTER) + + self.ethernet_switch.set_sensitive(EthernetService.is_ethernet_available()) + + self.ethernet_switch.set_active(EthernetService.get_ethernet_status()) + self.ethernet_switch.connect("state-set", self._on_ethernet_toggled) + + ethernet_label = Gtk.Label(label="Ethernet", xalign=0, name="ethernet-label") + + toggle_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + toggle_box.append(ethernet_label) + toggle_box.append(Gtk.Box(hexpand=True)) # Spacer + toggle_box.append(self.ethernet_switch) + + return toggle_box + + def _on_ethernet_toggled(self, switch, state): + """Handle Ethernet toggle""" + current_state = EthernetService.get_ethernet_status() + + if current_state == state: + return + + if EthernetService.toggle_ethernet(state): + switch.set_active(state) + + self.ethernet_details_widget.update() + def _create_wifi_toggle(self) -> Gtk.Box: """Create the WiFi toggle section""" self.wifi_switch = Gtk.Switch(valign=Gtk.Align.CENTER) self.wifi_switch.connect("state-set", self._on_wifi_toggled) - + wifi_label = Gtk.Label(label="Wi-Fi", xalign=0, name="wifi-label") - + toggle_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) toggle_box.append(wifi_label) toggle_box.append(Gtk.Box(hexpand=True)) # Spacer toggle_box.append(self.wifi_switch) - + return toggle_box - + def _on_wifi_toggled(self, switch, state): """Handle WiFi toggle""" current_state = NetworkService.get_wifi_status() - + if current_state == state: return - + if NetworkService.toggle_wifi(state): self._update_wifi_state(scan_immediately=state) else: switch.set_active(current_state) - + def _update_wifi_state(self, scan_immediately=False, initial_load=False): """Update the UI based on WiFi state""" wifi_status = NetworkService.get_wifi_status() self.wifi_switch.set_active(wifi_status) - + if wifi_status: self.current_state = WiFiState.ON if self.current_view == "list": @@ -84,36 +126,38 @@ def _update_wifi_state(self, scan_immediately=False, initial_load=False): self.current_state = WiFiState.OFF self.current_view = "list" self._show_wifi_off() - + def _show_network_list(self, scan_immediately=False): """Show the network list widget""" self.current_view = "list" self._clear_content() - - self.network_list = NetworkListWidget(self._on_network_selected, self._on_network_details) + + self.network_list = NetworkListWidget( + self._on_network_selected, self._on_network_details + ) self.content_box.append(self.network_list) - + if scan_immediately: GLib.timeout_add(500, lambda: self.network_list.start_scan()) - + def _show_network_details(self, network: NetworkInfo): """Show the network details widget""" self.current_view = "details" self._clear_content() - + self.network_details = NetworkDetailsWidget(network, self._on_back_to_list) self.content_box.append(self.network_details) - + def _on_back_to_list(self): """Handle back button click from details view""" self._show_network_list(scan_immediately=True) - + def _show_wifi_off(self): """Show the WiFi off widget""" self._clear_content() wifi_off_widget = WiFiOffWidget() self.content_box.append(wifi_off_widget) - + def _clear_content(self): """Clear the content area""" child = self.content_box.get_first_child() @@ -121,39 +165,43 @@ def _clear_content(self): next_child = child.get_next_sibling() self.content_box.remove(child) child = next_child - + def _on_network_selected(self, network: NetworkInfo): """Handle network selection for connection""" if NetworkService.is_wifi_known(network.ssid): self._connect_to_network(network.ssid) elif network.requires_password: - dialog = PasswordDialog(self, network.ssid, - lambda password: self._connect_to_network(network.ssid, password)) + dialog = PasswordDialog( + self, + network.ssid, + lambda password: self._connect_to_network(network.ssid, password), + ) dialog.show() else: self._connect_to_network(network.ssid) - + def _on_network_details(self, network: NetworkInfo): """Handle network details button click""" self._show_network_details(network) - + def _connect_to_network(self, ssid: str, password: Optional[str] = None): """Connect to a network""" self.current_state = WiFiState.CONNECTING - + def connect_thread(): success, message = NetworkService.connect_to_network(ssid, password) GLib.idle_add(lambda: self._connection_complete(ssid, success, message)) - + import threading + thread = threading.Thread(target=connect_thread) thread.daemon = True thread.start() - + def _connection_complete(self, ssid: str, success: bool, message: str): """Handle connection completion""" self.current_state = WiFiState.ON - + dialog = Gtk.AlertDialog() if success: dialog.set_message(f"Connected to {ssid}") @@ -161,13 +209,13 @@ def _connection_complete(self, ssid: str, success: bool, message: str): else: dialog.set_message(f"Failed to connect to {ssid}") dialog.set_detail(f"Error: {message}") - + dialog.set_modal(True) dialog.show(self) - - if self.current_view == "list" and hasattr(self, 'network_list'): + + if self.current_view == "list" and hasattr(self, "network_list"): self.network_list.start_scan() - + return False def _on_esc_pressed(self, controller, keyval, keycode, state): diff --git a/app/ui/styles.py b/app/ui/styles.py index 0a726e8..6283414 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -1,42 +1,46 @@ """CSS styling for the application""" import gi + gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk +import os + +from gi.repository import Gdk, Gtk + class StyleManager: """Handles CSS styling for the application""" - + CSS_STYLES = b""" - #wifi-label { + #wifi-label, #ethernet-label { font-weight: bold; font-size: 16px; - } + } - #wifi-networks-label { + #wifi-networks-label, #ethernet-name-label { font-weight: bold; font-size: 14px; } - #wifi-scan-label { + #wifi-scan-label, #ethernet-scan-label { font-size: 14px; } #wifi-scan-label:hover { color: #4a90d9; } - + #scanning-label { font-style: italic; color: #888888; margin: 10px; } - + #error-label { color: #cc0000; margin: 10px; } - + #no-networks-label { font-style: italic; color: #888888; @@ -50,21 +54,21 @@ class StyleManager: .connected-network { background-color: rgba(74, 144, 217, 0.1); } - + #wifi-off-box { margin: 50px; } - + #wifi-off-label { color: #666666; font-size: 14px; } - + .rescan-in-progress { color: #4a90d9; animation: pulse 1.5s infinite; } - + @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } @@ -75,30 +79,35 @@ class StyleManager: font-size: 1.5rem; font-weight: 500; } - + .title-4 { font-size: 1.1rem; font-weight: 500; margin-bottom: 8px; } - + .dim-label { opacity: 0.7; font-size: 0.9rem; } - + scrollbar slider { min-width: 6px; min-height: 6px; } """ - + @classmethod def apply_styles(cls): """Apply CSS styles to the application""" css_provider = Gtk.CssProvider() - css_provider.load_from_data(cls.CSS_STYLES) - + # Test if the ~/.config/nmgui/style.css exists + home_path = os.path.expanduser("~") + if os.path.exists(f"{home_path}/.config/nmgui/style.css"): + css_provider.load_from_path(f"{home_path}/.config/nmgui/style.css") + else: + css_provider.load_from_data(cls.CSS_STYLES) + display = Gdk.Display.get_default() Gtk.StyleContext.add_provider_for_display( display, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION