initial commit
This commit is contained in:
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal 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
9
LICENSE
Normal 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
61
launcher/actions.py
Normal 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
BIN
launcher/assets/cctv.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
launcher/assets/gaming.jpg
Normal file
BIN
launcher/assets/gaming.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
launcher/assets/linux.jpg
Normal file
BIN
launcher/assets/linux.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
launcher/assets/media.jpg
Normal file
BIN
launcher/assets/media.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
launcher/assets/windows.jpg
Normal file
BIN
launcher/assets/windows.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
30
launcher/checks.py
Normal file
30
launcher/checks.py
Normal 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
53
launcher/config.py
Normal 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
57
launcher/main.py
Normal 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
181
launcher/style.css
Normal 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
194
launcher/ui.py
Normal 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
141
launcher/window.ui
Normal 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>
|
||||||
Reference in New Issue
Block a user