diff --git a/launcher/actions.py b/launcher/actions.py index f18b694..db227df 100644 --- a/launcher/actions.py +++ b/launcher/actions.py @@ -2,15 +2,14 @@ import subprocess import shutil class ActionDispatcher: - def dispatch(self, config): + def dispatch(self, config, usb_devices): service_type = config.get("type", "command") print(f"Executing action -> Type: {service_type}") if service_type == "browser": self._launch_app_browser(config['target']) elif service_type == "rdp": - ## Refactor required to pass 'config' through to this level - self._launch_xfreerdp(config) + self._launch_xfreerdp(config, usb_devices) elif service_type == "command": self._run_command(config['cmd'].split()) elif service_type == "power": @@ -18,6 +17,12 @@ class ActionDispatcher: else: print(f"Service type: {service_type}, not known") + def power_actions(self, action): + if action == "shutdown": + self._run_command(["systemctl", "poweroff"]) + elif action == "reboot": + self._run_command(["systemctl", "reboot"]) + def _launch_app_browser(self, url): print(f"URL: {url}") browser_candidates = [ @@ -48,7 +53,7 @@ class ActionDispatcher: print(f"Launching browser command: {' '.join(cmd)}") self._run_command(cmd) - def _launch_xfreerdp(self, config): + def _launch_xfreerdp(self, config, usb_devices): rdp_candidates = [ "sdl-freerdp3", "sdl-freerdp", @@ -83,6 +88,11 @@ class ActionDispatcher: if config.get('scale_desktop', '') != "": cmd.append(f"/scale-desktop:{config['scale_desktop']}") + for vendor_id, product_id in usb_devices: + if vendor_id and product_id: + print(f"Forwarding USB Device {vendor_id}:{product_id}") + cmd.append(f"/usb:id,dev:{vendor_id}:{product_id}") + self._run_command(cmd) def _handle_power_action(self, action): diff --git a/launcher/main.py b/launcher/main.py index 7be0479..4f01d5a 100644 --- a/launcher/main.py +++ b/launcher/main.py @@ -26,7 +26,7 @@ class LauncherApp(Gtk.Application): self.ui = UIController( system_checks=self.checks, - power_action_callback=self.dispatcher.dispatch, + power_action_callback=self.dispatcher.power_actions, service_action_callback=self.dispatcher.dispatch, services=self.config.get("services", []), ) diff --git a/launcher/style.css b/launcher/style.css index e86d4a5..c4359ef 100644 --- a/launcher/style.css +++ b/launcher/style.css @@ -116,9 +116,10 @@ button.image-card:active { text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); } +/* Status Bar Icon Buttons */ -/* Power Menu Button in the Status Bar - Pure Icon Only */ -menubutton.flat { +menubutton.flat, +button.status-bar-icon { background: transparent; background-color: transparent; background-image: none; @@ -127,7 +128,8 @@ menubutton.flat { padding: 0; } -menubutton.flat > button { +menubutton.flat > button, +button.status-bar-icon { background: transparent; background-color: transparent; background-image: none; @@ -135,19 +137,21 @@ menubutton.flat > button { border: none; box-shadow: none; border-radius: 8px; - padding: 6px 12px; /* Expands the clickable hit target across the full box */ + padding: 6px 12px; transition: all 200ms ease; } menubutton.flat:hover > button, -menubutton.flat > button:hover { +menubutton.flat > button:hover, +button.status-bar-icon:hover { background-color: #27272a; color: #ffffff; border: none; box-shadow: none; } -/* When the popover menu is open (:checked or :active) */ +/* Power menu active state */ + menubutton.flat:checked > button, menubutton.flat:active > button, menubutton.flat > button:checked, @@ -199,4 +203,107 @@ popover button.error-label { popover button.error-label:hover { background-color: rgba(248, 113, 113, 0.15); color: #fca5a5; +} + + +/* USB Selection Dialog */ + +.usb-dialog { + background-color: #18181b; + color: #f4f4f5; + border: 1px solid #3f3f46; + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6); +} + +/* Content */ + +.usb-dialog-content { + padding: 24px; +} + +/* Description */ + +.usb-dialog-description { + font-size: 13px; + color: #a1a1aa; + margin-bottom: 16px; +} + +/* Device list */ + +.usb-device-list { + background-color: #27272a; + border: 1px solid #3f3f46; + border-radius: 10px; +} + +/* Device rows */ + +.usb-device-row { + background-color: transparent; + color: #f4f4f5; + border: none; + border-radius: 8px; + padding: 4px; +} + +.usb-device-row:hover { + background-color: #3f3f46; +} + +.usb-device-row:selected { + background-color: transparent; +} + +/* Device checkboxes */ + +.usb-device-checkbox { + background-color: transparent; + color: #f4f4f5; + font-size: 14px; + padding: 8px 10px; +} + +/* Dialog buttons */ + +.usb-dialog-buttons { + margin-top: 16px; + margin-bottom: 4px; + halign: center; +} + +.usb-dialog-buttons button { + min-width: 90px; + padding: 8px 20px; + border-radius: 8px; + font-weight: 600; + background-image: none; + box-shadow: none; + text-shadow: none; +} + +/* Cancel */ + +.usb-dialog-buttons button.usb-dialog-cancel { + background-color: #27272a; + color: #d4d4d8; + border: 1px solid #3f3f46; +} + +.usb-dialog-buttons button.usb-dialog-cancel:hover { + background-color: #3f3f46; + color: #ffffff; +} + +/* Select */ + +.usb-dialog-buttons button.usb-dialog-select { + background-color: #6366f1; + color: #ffffff; + border: 1px solid #6366f1; +} + +.usb-dialog-buttons button.usb-dialog-select:hover { + background-color: #818cf8; } \ No newline at end of file diff --git a/launcher/ui.py b/launcher/ui.py index dd7e313..7717190 100644 --- a/launcher/ui.py +++ b/launcher/ui.py @@ -3,6 +3,8 @@ import sys import gi import os +from usbdev import USBDevices + gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gdk, GLib, Gtk @@ -21,6 +23,7 @@ class UIController: self.window = self.builder.get_object("main_window") self._load_styles(css_file) self._setup_clock() + self._initialise_usb_options() self._initialise_power_options() self.power_action_callback = power_action_callback self.system_checks = system_checks @@ -29,6 +32,7 @@ class UIController: self._cards_created = False self._services_visible = False + self.usb_devices = [] if self.system_checks: self._setup_system_status() @@ -108,7 +112,7 @@ class UIController: title, desc, bg_img, - lambda svc=service: self.service_action_callback(svc), + lambda svc=service: self.service_action_callback(svc, self.usb_devices), ) def _load_styles(self, css_file): @@ -133,18 +137,27 @@ class UIController: update() GLib.timeout_add_seconds(10, update) + def _initialise_usb_options(self): + btn_usb = self.builder.get_object("usb_menu_button") + + if btn_usb: + btn_usb.connect( + "clicked", + lambda _: self._show_usb_selection() + ) + def _initialise_power_options(self): btn_reboot = self.builder.get_object("btn_reboot") btn_shutdown = self.builder.get_object("btn_shutdown") if btn_reboot: btn_reboot.connect( - "clicked", lambda _: self.power_action_callback("power", "reboot") + "clicked", lambda _: self.power_action_callback("reboot") ) if btn_shutdown: btn_shutdown.connect( - "clicked", lambda _: self.power_action_callback("power", "shutdown") + "clicked", lambda _: self.power_action_callback("shutdown") ) def set_app_title(self, title): @@ -214,6 +227,127 @@ class UIController: self.window.set_application(app) self.window.present() + def _show_usb_selection(self): + usb_devices = USBDevices() + devices = usb_devices.enumerate() + + dialog = USBSelectionDialog( + self.window, + devices, + self.usb_devices, + ) + + def on_response(dialog, response): + if response == Gtk.ResponseType.OK: + self.usb_devices = dialog.get_selected_devices() + + print("Selected USB devices:", self.usb_devices) + + dialog.destroy() + + dialog.connect("response", on_response) + dialog.present() + +class USBSelectionDialog(Gtk.Dialog): + def __init__(self, parent, devices, selected_devices): + super().__init__( + transient_for=parent, + modal=True, + title="", + ) + + self.set_decorated(False) + self.add_css_class("usb-dialog") + + self.devices = devices + self.selected_devices = selected_devices + + self.set_default_size(450, 350) + + self.content = self.get_content_area() + self.content.add_css_class("usb-dialog-content") + + info_label = Gtk.Label( + label="Select the USB devices you wish to make available " + "to Windows RDP sessions." + ) + info_label.set_wrap(True) + info_label.set_xalign(0.0) + info_label.add_css_class("usb-dialog-description") + self.content.append(info_label) + + self.list_box = Gtk.ListBox() + self.list_box.set_vexpand(True) + self.list_box.set_selection_mode(Gtk.SelectionMode.NONE) + self.list_box.add_css_class("usb-device-list") + + self.content.append(self.list_box) + + self._populate_devices() + + ## Button Box + button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + ) + + button_box.set_halign(Gtk.Align.CENTER) + button_box.add_css_class("usb-dialog-buttons") + + cancel_button = Gtk.Button(label="Cancel") + cancel_button.add_css_class("usb-dialog-cancel") + cancel_button.connect( + "clicked", + lambda _: self.response(Gtk.ResponseType.CANCEL), + ) + + select_button = Gtk.Button(label="Select") + select_button.add_css_class("usb-dialog-select") + select_button.connect( + "clicked", + lambda _: self.response(Gtk.ResponseType.OK), + ) + + button_box.append(cancel_button) + button_box.append(select_button) + + self.content.append(button_box) + ## + + def _populate_devices(self): + for device in self.devices: + check_button = Gtk.CheckButton(label=device.name) + check_button.add_css_class("usb-device-checkbox") + + check_button.usb_device = device + + if (device.vendor_id, device.product_id) in self.selected_devices: + check_button.set_active(True) + + self.list_box.append(check_button) + + row = check_button.get_parent() + row.add_css_class("usb-device-row") + + def get_selected_devices(self): + selected_devices = [] + + row = self.list_box.get_first_child() + + while row: + check_button = row.get_child() + + if check_button.get_active(): + device = check_button.usb_device + + selected_devices.append( + (device.vendor_id, device.product_id) + ) + + row = row.get_next_sibling() + + return selected_devices + # Allow direct execution for isolated UI testing if __name__ == "__main__": diff --git a/launcher/usbdev.py b/launcher/usbdev.py new file mode 100644 index 0000000..0c0289c --- /dev/null +++ b/launcher/usbdev.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +import pyudev + +@dataclass +class USBDevice: + vendor_id: str | None + product_id: str | None + vendor: str | None + product: str | None + serial: str | None + + @property + def id(self) -> str | None: + if self.vendor_id is None or self.product_id is None: + return None + + return f"{self.vendor_id}:{self.product_id}" + + @property + def name(self) -> str: + return f"{self.vendor} {self.product}" + +class USBDevices: + def __init__(self): + self.context = pyudev.Context() + + def enumerate(self) -> list[USBDevice]: + devices = [] + + for device in self.context.list_devices( + subsystem="usb", + DEVTYPE="usb_device" + ): + usb_device = USBDevice( + vendor_id=device.get("ID_VENDOR_ID"), + product_id=device.get("ID_MODEL_ID"), + vendor=device.get("ID_VENDOR"), + product=device.get("ID_MODEL"), + serial=device.get("ID_SERIAL_SHORT"), + ) + + if self._is_valid_device(usb_device): + devices.append(usb_device) + + return devices + + @staticmethod + def _is_valid_device(device: USBDevice) -> bool: + # Exclude Linux USB root hubs + if device.vendor_id == "1d6b": + return False + + return True + +if __name__ == "__main__": + devices = USBDevices() + enumerated = devices.enumerate() + #print(devices.enumerate()) + + for device in enumerated: + print(device.id, device.name) diff --git a/launcher/window.ui b/launcher/window.ui index f44e87d..9a0c224 100644 --- a/launcher/window.ui +++ b/launcher/window.ui @@ -164,6 +164,16 @@ + + + drive-removable-media-symbolic + USB Devices + + + system-shutdown-symbolic