initial commit

This commit is contained in:
2026-08-25 14:27:22 +01:00
commit 7c9723fd95
14 changed files with 902 additions and 0 deletions

176
.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
Copyright (c) 2026 fred.boniface
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

61
launcher/actions.py Normal file
View File

@@ -0,0 +1,61 @@
import subprocess
import shutil
class ActionDispatcher:
def dispatch(self, service_type, target):
print(f"Executing action -> Type: {service_type}, Target: {target}")
if service_type == "browser":
self._launch_app_browser(target)
elif service_type == "rdp":
## Refactor required to pass 'config' through to this level
self._launch_xfreerdp(config)
elif service_type == "command":
self._run_command(target.split())
elif service_type == "power":
self._handle_power_action(target)
else:
print(f"Service type: {service_type}, not known")
def _launch_app_browser(self, url):
browser_candidates = [
"chromium",
"chromium-browser",
"brave-browser",
"google-chrome",
"microsoft-edge",
"vivaldi"
]
browser_bin = None
for candidate in browser_candidates:
if shutil.which(candidate):
browser_bin = candidate
break
if not browser_bin:
print("Error: No supported chromium based browser found in PATH,")
return
cmd = [browser_bin, f"--app={url}", "--start-maximized", "--incognito"]
print(f"Launching browser command: {' '.join(cmd)}")
self._run_command(cmd)
def _launch_xfreerdp(self, config):
# Read config and build xfreerdp command before passing to _run_command
return
def _handle_power_action(self, action):
if action == "shutdown":
print("Initiating system shutdown")
self._run_command(["systemctl", "poweroff"])
elif action == "reboot":
print("Initiating system reboot...")
self._run_command(["systemctl", "reboot"])
else:
print(f"Unknown power action: {action}")
def _run_command(self, cmd):
try:
subprocess.Popen(cmd)
except Exception as e:
print(f"Failed to execute cmd: {cmd}: {e}")

BIN
launcher/assets/cctv.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
launcher/assets/gaming.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
launcher/assets/linux.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
launcher/assets/media.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
launcher/assets/windows.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

30
launcher/checks.py Normal file
View File

@@ -0,0 +1,30 @@
# checks.py
import socket
class SystemChecks:
def __init__(self, network_targets, ziti_target):
self.network_targets = network_targets
self.ziti_target = ziti_target
def check_network_status(self):
"""Loops through configured network targets and returns True if any succeed."""
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):
"""Probes the configured Ziti target endpoint."""
if not self.ziti_target:
return False
host, port = self.ziti_target
return self._tcp_ping(host, int(port), timeout=1.0)
def _tcp_ping(self, host, port, timeout=1):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False

53
launcher/config.py Normal file
View File

@@ -0,0 +1,53 @@
import json
import os
from pathlib import Path
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],
},
"services": [
{
"id": "helloworld",
"title": "Test Service",
"desc": "Test Service",
"type": "browser",
"target": "https://www.example.com",
},
],
}
def get_config_path():
"""Determines standard user config path: ~/.config/launcher/config.json"""
config_dir = Path.home() / ".config" / "launcher"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir / "config.json"
def load_launcher_config(config_path=None):
path = Path(config_path) if config_path else get_config_path()
if not path.exists():
save_default_config(path)
return DEFAULT_CONFIG
try:
with open(path, "r", encoding="utf-8") as f:
user_config = json.load(f)
merged = DEFAULT_CONFIG.copy()
merged.update(user_config)
return merged
except Exception as e:
print(
f"Error loading config from {path}: {e}. Running with default config"
)
return DEFAULT_CONFIG
def save_default_config(path):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=4)
print(f"Created default configuration at: {path}")
except Exception as e:
print(f"Failed to save default config: {e}")

57
launcher/main.py Normal file
View File

@@ -0,0 +1,57 @@
# main.py
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk
from actions import ActionDispatcher
from checks import SystemChecks
from config import load_launcher_config
from ui import UIController
class LauncherApp(Gtk.Application):
def __init__(self):
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"),
)
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,
)
def do_activate(self):
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", "")
service_type = service.get("type", "command")
target = service.get("target", "")
bg_img = service.get("bg_image", "")
self.ui.add_card(
title,
desc,
bg_img,
lambda t=service_type, tgt=target: self.dispatcher.dispatch(t, tgt),
)
self.ui.setup_exit_shortcut(self.quit)
self.ui.present(self)
if __name__ == "__main__":
app = LauncherApp()
sys.exit(app.run(sys.argv))

181
launcher/style.css Normal file
View File

@@ -0,0 +1,181 @@
/* Dull grey window background */
window {
background-color: #18181b;
color: #f4f4f5;
font-family: sans-serif;
}
/* Large, prominent clock */
label.clock-display {
font-size: 32px;
font-weight: 700;
color: #fafafa;
}
/* Bottom status bar pill container */
box.status-bar {
background-color: rgba(24, 24, 27, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 10px 18px;
}
label.error-label {
color: #f87171;
font-weight: 600;
}
label.ok-label {
color: #4ade80;
font-weight: 600;
}
/* Card Container */
/* Card Container */
button.image-card {
padding: 0px;
background-color: transparent;
background-image: none;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 300px;
min-height: 160px;
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Explicitly round the inner picture/overlay container */
button.image-card overlay,
button.image-card picture,
button.image-card box {
border-radius: 15px;
}
button.image-card:focus,
button.image-card:hover,
button.image-card:active {
background-color: transparent;
background-image: none;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5), 0 0 0 2px #6366f1;
border-color: #6366f1;
transform: translateY(-2px);
}
/* Fallback background if asset path is missing */
.card-fallback-bg {
background-color: #27272a;
}
/* Dark gradient tint over the image for high text contrast */
.card-tint-overlay {
background: linear-gradient(
to top,
rgba(24, 24, 27, 0.95) 0%,
rgba(24, 24, 27, 0.5) 60%,
rgba(24, 24, 27, 0.15) 100%
);
}
.card-content-box {
padding: 16px;
}
/* Shadowed Typography for Image Backgrounds */
.card-title-shadowed {
font-size: 18px;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.card-desc-shadowed {
font-size: 13px;
font-weight: 500;
color: #d4d4d8;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
/* Power Menu Button in the Status Bar - Pure Icon Only */
menubutton.flat {
background: transparent;
background-color: transparent;
background-image: none;
border: none;
box-shadow: none;
padding: 0;
}
menubutton.flat > button {
background: transparent;
background-color: transparent;
background-image: none;
color: #a1a1aa;
border: none;
box-shadow: none;
border-radius: 8px;
padding: 6px 12px; /* Expands the clickable hit target across the full box */
transition: all 200ms ease;
}
menubutton.flat:hover > button,
menubutton.flat > button:hover {
background-color: #27272a;
color: #ffffff;
border: none;
box-shadow: none;
}
/* When the popover menu is open (:checked or :active) */
menubutton.flat:checked > button,
menubutton.flat:active > button,
menubutton.flat > button:checked,
menubutton.flat > button:active {
background-color: #3f3f46;
color: #ffffff;
border: none;
box-shadow: none;
}
/* Popover Container Styling */
popover > contents {
background-color: #18181b;
border: 1px solid #3f3f46;
border-radius: 12px;
padding: 4px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
}
/* Popover Action Buttons */
popover button {
background-color: transparent;
background-image: none;
color: #f4f4f5;
border: none;
box-shadow: none;
border-radius: 8px;
padding: 8px 16px;
font-weight: 500;
text-shadow: none;
transition: background-color 150ms ease;
}
popover button:hover {
background-color: #27272a;
color: #ffffff;
border: none;
}
popover button:active {
background-color: #3f3f46;
}
/* Shutdown Action Button */
popover button.error-label {
color: #f87171; /* Coral red for destructive shutdown action */
}
popover button.error-label:hover {
background-color: rgba(248, 113, 113, 0.15);
color: #fca5a5;
}

194
launcher/ui.py Normal file
View File

@@ -0,0 +1,194 @@
from datetime import datetime
import sys
import gi
import os
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GLib, Gtk
class UIController:
def __init__(
self,
ui_file="window.ui",
css_file="style.css",
status_provider_callback=None,
power_action_callback=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.power_action_callback = power_action_callback
if status_provider_callback:
self.setup_status_poller(status_provider_callback)
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()
# UPDATE UI
self._update_label(
"net_status",
"Net: Connected" if statuses.get("net") else "Net: Disconnected",
statuses.get("net"),
)
self._update_label(
"ziti_status",
"Ziti: Connected" if statuses.get("ziti") else "Ziti: Disconnected",
statuses.get("ziti")
)
except Exception as e:
print(f"Error polling status: {e}")
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)
if label:
label.set_text(text)
if is_healthy:
label.remove_css_class("error-label")
label.add_css_class("ok-label")
else:
label.remove_css_class("ok-label")
label.add_css_class("error-label")
def _load_styles(self, css_file):
css_provider = Gtk.CssProvider()
css_provider.load_from_path(css_file)
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def _setup_clock(self):
clock_label = self.builder.get_object("clock_label")
def update():
clock_label.set_text(datetime.now().strftime("%H:%M"))
return True
update()
GLib.timeout_add_seconds(30, update)
def _initialist_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")
)
if btn_shutdown:
btn_shutdown.connect(
"clicked", lambda _: self.power_action_callback("power", "shutdown")
)
def set_app_title(self, title):
title_label = self.builder.get_object("app_title_label")
title_label.set_text(title)
def add_card(self, title_text, desc_text, bg_path, on_click_callback):
container = self.builder.get_object("cards_flow")
button = Gtk.Button()
button.add_css_class("image-card") # Switched to our new image-card class
button.connect("clicked", lambda *_: on_click_callback())
# Main overlay container
overlay = Gtk.Overlay()
# 1. Background Image Layer
if bg_path and os.path.exists(bg_path):
picture = Gtk.Picture.new_for_filename(bg_path)
picture.set_content_fit(Gtk.ContentFit.COVER)
else:
picture = Gtk.Box()
picture.add_css_class("card-fallback-bg")
overlay.set_child(picture)
# 2. Dark Gradient Tint Layer for legibility
tint_box = Gtk.Box()
tint_box.add_css_class("card-tint-overlay")
overlay.add_overlay(tint_box)
# 3. Text Content Layer (anchored to the bottom)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
box.add_css_class("card-content-box")
box.set_valign(Gtk.Align.END)
title = Gtk.Label(label=title_text)
title.set_xalign(0.0)
title.add_css_class("card-title-shadowed")
desc = Gtk.Label(label=desc_text)
desc.set_xalign(0.0)
desc.set_wrap(True)
desc.add_css_class("card-desc-shadowed")
box.append(title)
box.append(desc)
overlay.add_overlay(box)
button.set_child(overlay)
container.append(button)
def setup_exit_shortcut(self, quit_callback):
controller = Gtk.EventControllerKey.new()
def on_key_pressed(_, keyval, __, ___):
if keyval == Gdk.KEY_Escape:
quit_callback()
return True
return False
controller.connect("key-pressed", on_key_pressed)
self.window.add_controller(controller)
def present(self, app):
self.window.set_application(app)
self.window.present()
# Allow direct execution for isolated UI testing
if __name__ == "__main__":
class TestApp(Gtk.Application):
def __init__(self):
super().__init__(application_id="com.local.UITest")
self.ui = None
def do_activate(self):
self.ui = UIController()
self.ui.set_app_title("UI Test Mode")
# Add mock test cards
self.ui.add_card(
"Test Card 1",
"Verifying layout rendering",
lambda: print("Clicked Test 1"),
)
self.ui.add_card(
"Test Card 2",
"Checking flowbox constraints",
lambda: print("Clicked Test 2"),
)
self.ui.setup_exit_shortcut(self.quit)
self.ui.present(self)
app = TestApp()
sys.exit(app.run(sys.argv))

141
launcher/window.ui Normal file
View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkWindow" id="main_window">
<property name="decorated">false</property>
<property name="fullscreened">true</property>
<child>
<object class="GtkBox">
<property name="orientation">1</property>
<property name="spacing">16</property>
<property name="margin-top">32</property>
<property name="margin-bottom">24</property>
<property name="margin-start">32</property>
<property name="margin-end">32</property>
<!-- Top Bar: Title & Clock -->
<child>
<object class="GtkBox">
<property name="orientation">0</property>
<property name="valign">3</property>
<child>
<object class="GtkLabel" id="app_title_label">
<property name="label">Launcher Dashboard</property>
<style>
<class name="title-1"/>
</style>
<property name="hexpand">true</property>
<property name="xalign">0</property>
</object>
</child>
<child>
<object class="GtkLabel" id="clock_label">
<property name="label">00:00</property>
<style>
<class name="clock-display"/>
</style>
<property name="xalign">1</property>
</object>
</child>
</object>
</child>
<!-- Middle: Cards Grid/Scroller -->
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<child>
<object class="GtkFlowBox" id="cards_flow">
<property name="halign">3</property>
<property name="valign">1</property>
<property name="max-children-per-line">3</property>
<property name="min-children-per-line">1</property>
<property name="row-spacing">16</property>
<property name="column-spacing">16</property>
<property name="selection-mode">0</property>
</object>
</child>
</object>
</child>
<!-- Bottom: Status Bar -->
<child>
<object class="GtkBox" id="status_bar">
<property name="orientation">0</property>
<property name="spacing">24</property>
<property name="valign">3</property>
<style>
<class name="status-bar"/>
</style>
<!-- Net Status -->
<child>
<object class="GtkLabel" id="net_status">
<property name="label">Net: Unknown</property>
<style>
<class name="error-label"/>
</style>
</object>
</child>
<!-- Ziti Status -->
<child>
<object class="GtkLabel" id="ziti_status">
<property name="label">Ziti: Unknown</property>
<style>
<class name="error-label"/>
</style>
</object>
</child>
<!-- Spacer -->
<child>
<object class="GtkBox">
<property name="hexpand">true</property>
</object>
</child>
<!-- Power Menu Button with Popover -->
<child>
<object class="GtkMenuButton" id="power_menu_button">
<property name="icon-name">system-shutdown-symbolic</property>
<property name="tooltip-text">Power Options</property>
<style>
<class name="flat"/>
</style>
<property name="popover">
<object class="GtkPopover">
<child>
<object class="GtkBox">
<property name="orientation">1</property>
<property name="spacing">6</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkButton" id="btn_reboot">
<property name="label">Restart</property>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="btn_shutdown">
<property name="label">Shutdown</property>
<style>
<class name="error-label"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>