Compare commits

...

8 Commits

9 changed files with 566 additions and 72 deletions

View File

@@ -2,14 +2,14 @@ import subprocess
import shutil
class ActionDispatcher:
def dispatch(self, service_type, 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":
@@ -17,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 = [
@@ -47,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",
@@ -69,7 +75,6 @@ class ActionDispatcher:
rdp_bin,
f"/v:{config['target']}",
f"/u:{config['user']}",
f"/p:{config['password']}",
"/auth-pkg-list:!kerberos,!u2u",
"/sound",
"/audio-mode:0",
@@ -79,9 +84,17 @@ class ActionDispatcher:
"/f"
]
if config.get('password', '') != "":
cmd.append(f"/p:{config['password']}")
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):

View File

@@ -1,24 +1,74 @@
# checks.py
import socket
import threading
import urllib.request
import urllib.error
class SystemChecks:
def __init__(self, network_targets, ziti_target):
self.network_targets = network_targets
self.ziti_target = ziti_target
self.network_online = None
self.ziti_online = None
self._stop_event = threading.Event()
self._thread = None
@property
def ready(self):
"""Return True when network & ziti are both available"""
return (
self.network_online is True
and self.ziti_online is True
)
def start(self):
"""Start background status polling"""
if self._thread is not None:
return
self._thread = threading.Thread(
target=self._poll,
daemon=True
)
self._thread.start()
def stop(self):
"""Stop background status polling"""
self._stop_event.set()
def _poll(self):
"""Continuously check system status"""
while not self._stop_event.is_set():
self.network_online = self.check_network_status()
self.ziti_online = self.check_ziti_status()
# print(
# f"System status => "
# f"Network: {self.network_online}, "
# f"Ziti: {self.ziti_online}"
# )
if self.ready:
interval = 30
else:
interval = 3
self._stop_event.wait(interval)
def check_network_status(self):
"""Loops through configured network targets and returns True if any succeed."""
"""Loops through configured network targets and return True if any respond"""
for host, port in self.network_targets:
if self._tcp_ping(host, int(port), timeout=1.5):
return True
return False
def check_ziti_status(self):
"""Makes an HTTP request to the configured address."""
"""Makes HTTP request to Ziti endpoint"""
if not self.ziti_target:
return False

View File

@@ -6,7 +6,7 @@ DEFAULT_CONFIG = {
"app_title": "FJLA Gateway",
"checks": {
"network_targets": [["1.1.1.1", 53], ["8.8.8.8", 53]],
"ziti_target": ["127.0.0.1", 3020],
"ziti_target": "http://test.ziti",
},
"services": [
{

View File

@@ -15,7 +15,9 @@ class LauncherApp(Gtk.Application):
super().__init__(application_id="com.local.Launcher")
self.config = load_launcher_config()
check_cfg = self.config.get("checks", {})
self.checks = SystemChecks(
network_targets=check_cfg.get("network_targets"),
ziti_target=check_cfg.get("ziti_target"),
@@ -23,35 +25,24 @@ class LauncherApp(Gtk.Application):
self.dispatcher = ActionDispatcher()
self.ui = UIController(
status_provider_callback=lambda: {
"net": self.checks.check_network_status(),
"ziti": self.checks.check_ziti_status(),
},
power_action_callback=self.dispatcher.dispatch,
system_checks=self.checks,
power_action_callback=self.dispatcher.power_actions,
service_action_callback=self.dispatcher.dispatch,
services=self.config.get("services", []),
)
def do_activate(self):
self.checks.start()
self.ui.set_app_title(self.config.get("app_title", "Launcher"))
for service in self.config.get("services", []):
title = service.get("title", "Service")
desc = service.get("desc", "")
bg_img = service.get("bg_image", "")
self.ui.add_card(
title,
desc,
bg_img,
lambda svc=service: self.dispatcher.dispatch(
svc.get("type", "command"),
svc,
),
)
self.ui.setup_exit_shortcut(self.quit)
self.ui.present(self)
def do_shutdown(self):
self.checks.stop()
Gtk.Application.do_shutdown(self)
if __name__ == "__main__":
app = LauncherApp()

View File

@@ -5,11 +5,27 @@ window {
font-family: sans-serif;
}
/* Startup Messages */
.startup-message {
padding: 32px;
}
.startup-message-title {
font-size: 55px;
font-weight: 700;
}
.startup-message-description {
font-size: 28px;
opacity: 0.7;
}
/* Large, prominent clock */
label.date-display {
font-size: 18px;
font-size: 32px;
opacity: 0.75;
margin-right: 16px;
color: #fafafa;
}
label.clock-display {
font-size: 32px;
@@ -100,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;
@@ -111,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;
@@ -119,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,
@@ -184,3 +204,105 @@ 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;
}
.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;
}

View File

@@ -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
@@ -12,47 +14,80 @@ class UIController:
self,
ui_file="window.ui",
css_file="style.css",
status_provider_callback=None,
power_action_callback=None
system_checks=None,
power_action_callback=None,
service_action_callback=None,
services = None
):
self.builder = Gtk.Builder.new_from_file(ui_file)
self.window = self.builder.get_object("main_window")
self._load_styles(css_file)
self._setup_clock()
self._initialist_power_options()
self._initialise_usb_options()
self._initialise_power_options()
self.power_action_callback = power_action_callback
self.system_checks = system_checks
self.service_action_callback = service_action_callback
self.services = services or []
if status_provider_callback:
self.setup_status_poller(status_provider_callback)
self._cards_created = False
self._services_visible = False
self.usb_devices = []
def setup_status_poller(self, status_provider_callback, interval_seconds=20):
""" Accepts any function that returns a dictionary of statuses,
currently: {'net':True, 'ziti':True}
"""
def poll():
try:
statuses = status_provider_callback()
print(f"Status result {statuses}")
if self.system_checks:
self._setup_system_status()
# UPDATE UI
if not self._cards_created:
self._create_cards()
self._cards_created = True
def _setup_system_status(self):
"""Monitor SystemChecks state from GTK Main thread"""
self._update_system_status()
GLib.timeout_add_seconds(
1,
self._update_system_status
)
def _update_system_status(self):
"""Update Status display and create cards when system is ready"""
network = self.system_checks.network_online
ziti = self.system_checks.ziti_online
if network is None:
self._update_label(
"net_status",
"Net: Connected" if statuses.get("net") else "Net: Disconnected",
statuses.get("net"),
"Net: Checking",
None,
)
else:
self._update_label(
"net_status",
"Net: Connected" if network else "Net: Disconnected",
network,
)
if ziti is None:
self._update_label(
"ziti_status",
"Ziti: Connected" if statuses.get("ziti") else "Ziti: Disconnected",
statuses.get("ziti")
"Ziti: Checking",
None,
)
except Exception as e:
print(f"Error polling status: {e}")
import traceback
traceback.print_exc()
else :
self._update_label(
"ziti_status",
"Ziti: Connected" if ziti else "Ziti: Disconnected",
ziti,
)
if self.system_checks.ready and not self._services_visible:
self.builder.get_object("startup_revealer").set_reveal_child(False)
self.builder.get_object("cards_revealer").set_reveal_child(True)
self._services_visible = True
return True
poll()
GLib.timeout_add_seconds(interval_seconds, poll)
def _update_label(self, widget_id, text, is_healthy):
label = self.builder.get_object(widget_id)
@@ -65,6 +100,21 @@ class UIController:
label.remove_css_class("ok-label")
label.add_css_class("error-label")
def _create_cards(self):
"""Create Service Cards"""
for service in self.services:
title = service.get("title", "Service")
desc = service.get("desc", "")
bg_img = service.get("bg_image", "")
self.add_card(
title,
desc,
bg_img,
lambda svc=service: self.service_action_callback(svc, self.usb_devices),
)
def _load_styles(self, css_file):
css_provider = Gtk.CssProvider()
css_provider.load_from_path(css_file)
@@ -87,18 +137,27 @@ class UIController:
update()
GLib.timeout_add_seconds(10, update)
def _initialist_power_options(self):
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):
@@ -168,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__":

5
launcher/updater.py Normal file
View File

@@ -0,0 +1,5 @@
# updater.py
"""
Updates the application during runtime, ensuring the latest version will be available after the next restart.
It does not replace the currently running instance with the new version.
"""

61
launcher/usbdev.py Normal file
View File

@@ -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)

View File

@@ -49,11 +49,24 @@
</object>
</child>
<!-- Middle: Cards Grid/Scroller -->
<child>
<object class="GtkScrolledWindow">
<!-- Middle: Status / Cards -->
<child>
<object class="GtkOverlay" id="content_area">
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<!-- Cards -->
<child>
<object class="GtkRevealer" id="cards_revealer">
<property name="transition-type">crossfade</property>
<property name="transition-duration">500</property>
<property name="reveal-child">false</property>
<child>
<object class="GtkScrolledWindow" id="cards_scroller">
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<child>
<object class="GtkFlowBox" id="cards_flow">
<property name="halign">3</property>
@@ -68,6 +81,55 @@
</object>
</child>
</object>
</child>
<!-- Startup message -->
<child type="overlay">
<object class="GtkRevealer" id="startup_revealer">
<property name="transition-type">crossfade</property>
<property name="transition-duration">500</property>
<property name="reveal-child">true</property>
<property name="halign">3</property>
<property name="valign">3</property>
<child>
<object class="GtkBox" id="startup_message">
<style>
<class name="startup-message"/>
</style>
<property name="orientation">1</property>
<property name="spacing">8</property>
<property name="halign">3</property>
<property name="valign">3</property>
<child>
<object class="GtkLabel">
<property name="label">Connecting...</property>
<style>
<class name="startup-message-title"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="label">Waiting for network connectivity before enabling services.</property>
<property name="justify">2</property>
<style>
<class name="startup-message-description"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<!-- Bottom: Status Bar -->
<child>
<object class="GtkBox" id="status_bar">
@@ -102,6 +164,16 @@
</object>
</child>
<!-- Power Menu Button with Popover -->
<child>
<object class="GtkButton" id="usb_menu_button">
<property name="icon-name">drive-removable-media-symbolic</property>
<property name="tooltip-text">USB Devices</property>
<style>
<class name="flat"/>
<class name="status-bar-icon"/>
</style>
</object>
</child>
<child>
<object class="GtkMenuButton" id="power_menu_button">
<property name="icon-name">system-shutdown-symbolic</property>