diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 4ecf0a8..0000000 --- a/.cursorrules +++ /dev/null @@ -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 _NET_WM_STATE` to check `_NET_WM_STATE_HIDDEN`, then `xdotool windowmap ` - -### 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 diff --git a/README.md b/README.md index 66e0b3c..c50dced 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/browser.py b/browser.py index e6e2aaf..dd1a038 100755 --- a/browser.py +++ b/browser.py @@ -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%'") diff --git a/config.json b/config.json new file mode 100644 index 0000000..4d9d6f9 --- /dev/null +++ b/config.json @@ -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" + } +} diff --git a/deploy.sh b/deploy.sh index 20fc9d2..166f524 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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." diff --git a/home-screen-index.html b/home-screen-index.html index 1fcd9cb..5301411 100644 --- a/home-screen-index.html +++ b/home-screen-index.html @@ -95,46 +95,45 @@
12:00 PM
Wednesday, January 1
- +