refactor: use config.json with MVC architecture

This commit is contained in:
2026-05-23 02:52:34 +00:00
parent c8534c967c
commit 38f7ae467a
6 changed files with 96 additions and 183 deletions
-115
View File
@@ -1,115 +0,0 @@
# SeniorNet Kiosk — Project Context for AI Assistants
## What this project is
A locked-down, zero-maintenance browser workstation for elderly users. The senior never sees a desktop — only a full-screen Chromium window with a slim toolbar on top. No configuration, no maintenance required from the user.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ PyQt5 toolbar (72px, always-on-top, FramelessHint) │
│ [SeniorNet] [◀] [▶] [⌂] [☰ bookmarks] │
├─────────────────────────────────────────────────────┤
│ │
│ Chromium --app mode │
│ (CDP port 9222 for navigation) │
│ │
└─────────────────────────────────────────────────────┘
```
- `browser.py` — main app. Launches both the PyQt5 toolbar and Chromium as a subprocess.
- Toolbar talks to Chromium via Chrome DevTools Protocol (CDP) over WebSocket on port 9222.
- Bookmarks stored in `bookmarks.json` (nested JSON, supports folders).
- Home screen: `/home/jgitta/kiosk-home/index.html`
## Machines
### kiosk-dev — 192.168.88.48 (SSH alias: `kiosk-dev`) ← ACTIVE DEV MACHINE
- Debian 13 Trixie, Proxmox VM 130 on jg-hud
- System Chromium: `/usr/lib/chromium/chromium` — NOT snap
- Repo at: `/home/jgitta/kiosk-browser/`
- This is the machine being actively developed and tested on
### Production kiosk — 192.168.88.44 (SSH alias: `kiosk`)
- Ubuntu 24.04 + snap Chromium — needs to be migrated to Debian to match kiosk-dev
- Currently offline / pending migration
## Deploy workflow
```bash
bash /home/jgitta/kiosk-browser/deploy.sh "describe your change"
```
This: commits + pushes to Gitea, restarts kiosk-dev immediately, and deploys to production if reachable.
**CRITICAL**: `kiosk-restart` must kill Chromium (`pkill -f "remote-debugging-port=9222"`) BEFORE killing browser.py. If only Python is killed, Chromium holds the SingletonLock and the new instance silently exits — deploys appear to work but nothing changes on screen.
`/usr/local/bin/kiosk-restart` on kiosk-dev:
```bash
pkill -f "remote-debugging-port=9222" || true
pkill -f "python3.*browser" || true
sleep 1
```
## Key files
| File | Purpose |
|------|---------|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
| `bookmarks.json` | Nested bookmark structure |
| `deploy.sh` | One-command deploy |
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
## Gitea repo (homelab-configs is source of truth)
- Gitea on **siklos:3002**, SSH on **port 2222** (not 22). HTTPS via Caddy `192.168.88.110`.
- **Push senior-kiosk:** `https://gitea.jgitta.com/jgitta/senior-kiosk.git` (usual). Do not use `git@host:repo` without port 2222.
- Infra docs: `jgitta/homelab-configs` on Gitea.
## Roundcube webmail
- URL: https://mail.jgitta.com/roundcube/ (proxied via Caddy at 192.168.88.110)
- LXC container 203 on jg-hud, IP: 192.168.88.45
- Ubuntu 24.04, Apache2, PHP, MariaDB, Roundcube 1.6.6
- Gmail IMAP: `ssl://imap.gmail.com:993`
- Autologin plugin: `/var/lib/roundcube/plugins/autologin/autologin.php`
- Logs: `/var/lib/roundcube/logs/autologin.log` and `errors.log`
- Run commands on container: `ssh jg-hud "pct exec 203 -- bash -c '...'"` (jg-hud is the Proxmox host)
- browser.py clears the Roundcube session cookie on every startup (forces fresh autologin)
## Known hard-won lessons
### Chromium
- `--no-sandbox` causes "unsupported flag" infobar — never use it on Debian system Chromium
- `--test-type` triggers Google CAPTCHA bot detection — never use it
- `--disable-blink-features=AutomationControlled` causes "unsupported flag" infobar — removed
- `--disable-infobars`, `--disable-session-crashed-bubble`, `--disable-save-password-bubble`, `--suppress-message-center-popups`, `--disable-translate` are all deprecated/removed in current Chromium — do not use
- Profile dir: `/home/jgitta/chromium-kiosk` (not hidden — snap couldn't write hidden dirs; kept this convention)
- Chromium adds its own flags via the Debian wrapper (`--show-component-extension-options` etc.) — ignore these, they're harmless
### PyQt5 / Openbox
- `QMenu` does NOT work under Openbox/X11 with `Qt.Tool | WindowStaysOnTopHint` parent — use a custom `QWidget` with the same flags instead (BookmarkPanel)
- Qt event filters only see clicks on Qt widgets — clicks that land on Chromium (separate X11 process) are invisible. Use a `QTimer` polling `QApplication.mouseButtons()` + `QCursor.pos()` to detect outside clicks.
- To prevent toolbar minimize: override `changeEvent`, detect `Qt.WindowMinimized`, call `QTimer.singleShot(50, self._self_restore)`
- To restore minimized Chromium: periodic `QTimer` + `xprop -id <wid> _NET_WM_STATE` to check `_NET_WM_STATE_HIDDEN`, then `xdotool windowmap <wid>`
### Roundcube autologin
- Plugin MUST call both `session->regenerate_id(false)` AND `session->set_auth_cookie()` after login
- Logs are in `/var/lib/roundcube/logs/` NOT `/var/log/roundcube/`
### Deployment
- `pkill -f browser.py` in SSH kills its own session (shell cmdline contains "browser.py") — use `kiosk-restart`
- Chromium must be killed before Python or SingletonLock prevents new instance from starting
## Infrastructure overview
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
- **TrueNAS**: 192.168.88.24
- **Thinkstation** (dev desktop): 192.168.88.41
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
- **Gitea**: gitea.jgitta.com (port 3002 internal)
- **Headscale**: self-hosted Tailscale on siklos
+1 -1
View File
@@ -22,7 +22,7 @@ The kiosk presents a single Chrome browser window with a custom floating toolbar
└─────────────────────────────────────────────────────┘
```
- **OS**: Ubuntu 24.04 LTS Server (minimal, headless)
- **OS**: Debian Trixie
- **Display**: X11 + LightDM (auto-login) + Openbox WM
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
+25 -27
View File
@@ -4,9 +4,9 @@ SeniorNet Kiosk — PyQt5 single-row floating toolbar over Google Chrome.
Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks.
Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket.
"""
import sys, json, os, subprocess, time, threading, urllib.request
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect
from PyQt5.QtGui import QFont, QColor, QPalette
import sys, json, os, subprocess, time, threading, urllib.request, websocket, pathlib, shutil, sqlite3
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QEvent
from PyQt5.QtGui import QFont, QColor, QPalette, QCursor
from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
@@ -25,12 +25,18 @@ SCREEN_H = 1080 # overwritten at runtime
# ── Colour palette ────────────────────────────────────────────────
TOOLBAR_COLOR = "#1a3a5c"
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
RED = "#c62828"; RED_HOVER = "#d32f2f"
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
BTN_COLOR = "#2e6da4"
BTN_HOVER = "#3a87cc"
GOLD = "#e8a020"
GOLD_HOVER = "#f0b030"
GREEN = "#2e7d32"
GREEN_HOVER = "#388e3c"
RED = "#c62828"
RED_HOVER = "#d32f2f"
AMBER = "#7a5a10"
AMBER_HOVER = "#9a7a20"
PURPLE = "#5a5a8a"
PURPLE_HOVER = "#7a7aaa"
FONT_SIZE = 18
MENU_STYLE = """
@@ -74,8 +80,10 @@ def mk_icon_btn(icon, color, hover, size=58, fs=26):
def load_bm():
try:
with open(BOOKMARKS_FILE) as f: return json.load(f)
except: return []
with open(BOOKMARKS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_bm(data):
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
@@ -113,7 +121,6 @@ class CDP:
def _send(self, method, params=None):
if not self.ready:
return
import websocket
with self._lock:
try:
ws = websocket.create_connection(self._ws_url(), timeout=3)
@@ -132,7 +139,6 @@ class CDP:
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
if not self.ready:
return
import websocket
with self._lock:
try:
ws = websocket.create_connection(self._ws_url(), timeout=3)
@@ -554,7 +560,6 @@ class BookmarkPanel(QWidget):
self._dismiss_timer.stop()
def _cursor_outside_panel(self):
from PyQt5.QtGui import QCursor
pos = QCursor.pos()
if self.frameGeometry().contains(pos):
return False
@@ -581,7 +586,6 @@ class BookmarkPanel(QWidget):
self.hide()
def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.MouseButtonPress and self.isVisible():
if not self.frameGeometry().contains(event.globalPos()):
self.hide()
@@ -638,7 +642,6 @@ class KioskOverlay(QWidget):
def changeEvent(self, event):
"""Un-minimise the toolbar the instant it gets iconified."""
from PyQt5.QtCore import QEvent
if event.type() == QEvent.WindowStateChange:
if self.windowState() & Qt.WindowMinimized:
QTimer.singleShot(50, self._self_restore)
@@ -727,7 +730,6 @@ class KioskOverlay(QWidget):
e.accept()
def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.KeyPress:
key = event.key()
mods = event.modifiers()
@@ -745,13 +747,14 @@ class KioskOverlay(QWidget):
# ── Chrome launcher ───────────────────────────────────────────────
def launch_chrome():
import pathlib, json as _json, shutil
profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
profile_dir.mkdir(parents=True, exist_ok=True)
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try: (profile_dir / name).unlink()
except FileNotFoundError: pass
try:
(profile_dir / name).unlink()
except FileNotFoundError:
pass
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
@@ -765,21 +768,16 @@ def launch_chrome():
ls_path = profile_dir / "Local State"
if ls_path.exists():
try:
d = _json.loads(ls_path.read_text())
d = json.loads(ls_path.read_text())
d.setdefault("profile", {})["exit_type"] = "Normal"
d["hardware_acceleration_mode_previous"] = False
ls_path.write_text(_json.dumps(d))
ls_path.write_text(json.dumps(d))
except Exception:
pass
# Clear the stale Roundcube session cookie so the autologin plugin always
# fires cleanly. Without this, Chrome sends the old cookie after a reboot,
# the PHP session in MariaDB has expired, and Roundcube shows
# "session is invalid or expired" instead of auto-logging in.
cookies_db = profile_dir / "Default" / "Cookies"
if cookies_db.exists():
try:
import sqlite3
conn = sqlite3.connect(str(cookies_db))
conn.execute(
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
+11
View File
@@ -0,0 +1,11 @@
{
"urls": {
"email": "https://mail.jgitta.com/roundcube/",
"internet": "https://www.duckduckgo.com",
"weather": "https://weather.com",
"news": "https://news.google.com",
"facebook": "https://www.facebook.com",
"messenger": "https://www.messenger.com",
"youtube": "https://www.youtube.com"
}
}
+20 -2
View File
@@ -9,12 +9,28 @@ KIOSK_REMOTE="jgitta@100.64.0.1"
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
HOME_SRC="$REPO_DIR/home-screen-index.html"
HOME_DST="/home/jgitta/kiosk-home/index.html"
CONFIG_SRC="$REPO_DIR/config.json"
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
inject_config() {
local src="$1"
local dst="$2"
local config="$3"
if [ -f "$config" ]; then
local config_json=$(cat "$config" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | tr '\n' ' ')
sed "s|const config = {.*};|const config = $config_json;|" "$src" > "$dst"
echo " Injected config.json into home screen"
else
cp "$src" "$dst"
echo " WARNING: config.json missing — deploying without config injection"
fi
}
echo "==> Syncing home screen to $HOME_DST..."
if [ -f "$HOME_SRC" ]; then
mkdir -p "$(dirname "$HOME_DST")"
cp "$HOME_SRC" "$HOME_DST"
inject_config "$HOME_SRC" "$HOME_DST" "$CONFIG_SRC"
chown jgitta:jgitta "$HOME_DST" 2>/dev/null || true
else
echo " WARNING: $HOME_SRC missing — home screen not updated"
@@ -44,7 +60,9 @@ if [ -n "$KIOSK" ]; then
ssh "$KIOSK" "
cd /home/jgitta/kiosk-browser
git pull
cp home-screen-index.html /home/jgitta/kiosk-home/index.html
# Inject config at deploy time
config_json=\$(cat config.json | sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g' | tr '\n' ' ')
sed \"s|const config = {.*};|const config = \$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html
/usr/local/bin/kiosk-restart
"
echo " Production updated."
+39 -38
View File
@@ -95,46 +95,45 @@
<div id="clock">12:00 PM</div>
<div id="date">Wednesday, January 1</div>
<div class="grid">
<a class="btn btn-gmail" href="https://mail.jgitta.com/roundcube/">
<div class="icon">✉️</div>
Email
</a>
<a class="btn btn-browse" href="https://www.google.com">
<div class="icon">🌐</div>
Internet
</a>
<a class="btn btn-weather" href="https://weather.com">
<div class="icon">🌤️</div>
Weather
</a>
<a class="btn btn-news" href="https://news.google.com">
<div class="icon">📰</div>
News
</a>
<a class="btn btn-facebook" href="https://www.facebook.com">
<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="54" height="54" fill="white"><path d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z"/></svg></div>
Facebook
</a>
<a class="btn btn-messenger" href="https://www.messenger.com">
<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="54" height="54" fill="white"><path d="M256.55 8C116.52 8 8 110.34 8 248.57c0 72.3 29.71 134.78 78.07 177.94 8.35 7.51 6.63 11.86 8.05 58.23A19.92 19.92 0 0 0 122 502.31c52.91-23.3 53.59-25.14 62.56-22.7C337.85 521.8 504 423.7 504 248.57 504 110.34 396.59 8 256.55 8zm149.24 185.13l-73 115.57a37.37 37.37 0 0 1-53.91 9.93l-58.08-43.47a15 15 0 0 0-18 0l-78.37 59.44c-10.46 7.93-24.16-4.6-17.11-15.67l73-115.57a37.36 37.36 0 0 1 53.91-9.93l58.06 43.46a15 15 0 0 0 18 0l78.34-59.44c10.48-7.93 24.17 4.6 17.12 15.67z"/></svg></div>
Messenger
</a>
<a class="btn btn-youtube" href="https://www.youtube.com">
<div class="icon">▶️</div>
YouTube
</a>
</div>
<div class="grid" id="buttonGrid"></div>
<script>
const buttons = [
{ id: 'email', label: 'Email', icon: '✉️', class: 'btn-gmail' },
{ id: 'internet', label: 'Internet', icon: '🌐', class: 'btn-browse' },
{ id: 'weather', label: 'Weather', icon: '🌤️', class: 'btn-weather' },
{ id: 'news', label: 'News', icon: '📰', class: 'btn-news' },
{ id: 'facebook', label: 'Facebook', icon: null, class: 'btn-facebook', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="54" height="54" fill="white"><path d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z"/></svg>' },
{ id: 'messenger', label: 'Messenger', icon: null, class: 'btn-messenger', svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="54" height="54" fill="white"><path d="M256.55 8C116.52 8 8 110.34 8 248.57c0 72.3 29.71 134.78 78.07 177.94 8.35 7.51 6.63 11.86 8.05 58.23A19.92 19.92 0 0 0 122 502.31c52.91-23.3 53.59-25.14 62.56-22.7C337.85 521.8 504 423.7 504 248.57 504 110.34 396.59 8 256.55 8zm149.24 185.13l-73 115.57a37.37 37.37 0 0 1-53.91 9.93l-58.08-43.47a15 15 0 0 0-18 0l-78.37 59.44c-10.46 7.93-24.16-4.6-17.11-15.67l73-115.57a37.36 37.36 0 0 1 53.91-9.93l58.06 43.46a15 15 0 0 0 18 0l78.34-59.44c10.48-7.93 24.17 4.6 17.12 15.67z"/></svg>' },
{ id: 'youtube', label: 'YouTube', icon: '▶️', class: 'btn-youtube' }
];
function loadButtons() {
const config = {};
const grid = document.getElementById('buttonGrid');
buttons.forEach(btn => {
const url = config.urls[btn.id];
const link = document.createElement('a');
link.className = `btn ${btn.class}`;
link.href = url;
const iconDiv = document.createElement('div');
iconDiv.className = 'icon';
if (btn.icon) {
iconDiv.textContent = btn.icon;
} else if (btn.svg) {
iconDiv.innerHTML = btn.svg;
}
link.appendChild(iconDiv);
const label = document.createElement('div');
label.textContent = btn.label;
link.appendChild(label);
grid.appendChild(link);
});
}
function updateClock() {
const now = new Date();
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
@@ -142,6 +141,8 @@
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
}
loadButtons();
updateClock();
setInterval(updateClock, 1000);
</script>