Compare commits

..

21 Commits

Author SHA1 Message Date
jgitta b99f058bfb add .cursorrules for Cursor AI auto-context 2026-05-22 20:56:23 +00:00
jgitta 2ea316ffd8 add CLAUDE.md — full project context for AI assistants 2026-05-22 20:48:02 +00:00
jgitta ea2b5c30c7 deploy.sh: always restart kiosk-dev; production is optional best-effort 2026-05-22 20:44:13 +00:00
jgitta 8e105aa0af v0.11: fix infobar flag, hamburger click-outside via polling timer 2026-05-22 20:34:29 +00:00
jgitta 237c81ace2 v0.10: remove deprecated flags that cause unsupported-flag infobar 2026-05-22 20:27:21 +00:00
jgitta 686395f2d5 v0.09: remove --disable-blink-features=AutomationControlled (causes infobar) 2026-05-22 20:25:38 +00:00
jgitta ff58ec0fd4 v0.08: prevent minimize — toolbar self-restores, Chromium watchdog 2026-05-22 20:22:03 +00:00
jgitta d4041582f0 fix: ignore tiny resize events from minimize/RDP disconnect 2026-05-22 13:10:55 +00:00
jgitta ba2d5c2f17 fix: responsive toolbar and Chromium resize on screen geometry change 2026-05-22 13:05:41 +00:00
jgitta e34ea8f7c8 fix: remove --no-sandbox, dynamic screen resolution detection 2026-05-22 13:03:11 +00:00
jgitta a61f66e57a add deploy.sh: one-command push to production kiosk 2026-05-22 12:54:51 +00:00
jgitta aac4605e66 v0.07: switch to Chromium snap, remove Google Chrome
- Replace google-chrome with /snap/bin/chromium
- Remove --test-type flag (was triggering Google bot detection/CAPTCHA)
- Add --disable-blink-features=AutomationControlled (hides webdriver flag)
- Move profile dir to /home/jgitta/chromium-kiosk (snap can't write to .config)
- Uninstall google-chrome-stable and its apt repo
2026-05-22 12:28:52 +00:00
jgitta e574403853 v0.06: replace QMenu with custom BookmarkPanel widget
QMenu has z-order and event-grab issues under Openbox/X11 — the menu was
being created successfully but either appeared behind Chrome or was
dismissed instantly by the mouse-release event.

Replace with BookmarkPanel: a QWidget using Qt.Tool | WindowStaysOnTopHint
| FramelessWindowHint (same flags as the toolbar itself). Since the toolbar
already stays above Chrome, the panel does too. Features:
- Scrollable list of all bookmarks with folder headers
- Closes when clicking anywhere outside
- Manage Bookmarks entry at the bottom
- Toggle open/close on hamburger button
2026-05-21 20:39:59 -05:00
jgitta 2ca1da4f8e v0.05: fix Roundcube autologin + hamburger menu focus
Roundcube autologin fix:
- Added missing session->regenerate_id(false) and session->set_auth_cookie()
  calls after login, matching Roundcube's normal login flow in index.php.
  Without these, the roundcube_sessauth cookie was never set, causing every
  second request to fail with 'session is invalid or expired'.

Hamburger menu fix:
- Added activateWindow() + raise_() + processEvents() before menu.exec_()
  to ensure the toolbar window is active before the QMenu event loop starts.
  Without this, WA_ShowWithoutActivating prevents focus from being grabbed
  and the menu vanishes immediately.
- Added try/except to surface any silent errors.
2026-05-21 20:29:46 -05:00
jgitta dfba158f3c v0.04: fix hamburger menu position + clear stale Roundcube cookie
- Hamburger menu: calculate explicit QPoint position (right-aligned below
  toolbar) instead of relying on Qt overflow logic with bottomRight() anchor.
  sizeHint() used for width with 360px fallback.
- Roundcube login: clear mail.jgitta.com session cookie from Chrome's
  SQLite Cookies DB on every startup so autologin always fires cleanly
  instead of showing 'session is invalid or expired'.
2026-05-21 20:14:50 -05:00
jgitta dc4b129986 v0.03: fix hamburger menu clipping off right edge of screen
Change menu anchor from bottomLeft() to bottomRight() so Qt's built-in
overflow logic flips the menu leftward when the button is near the right
screen edge. All bookmarks now visible.
2026-05-21 20:09:44 -05:00
jgitta 18bb48b874 Auto-dismiss Welcome to Chrome dialog; add suppress flags for first-run popups 2026-05-22 00:56:08 +00:00
jgitta 08f296ceb3 Fix SeniorNet label clipping; auto-dismiss Chrome profile error dialog 2026-05-22 00:52:18 +00:00
jgitta a1a4b2e10c Redesign toolbar: single row with icon buttons and hamburger bookmark menu 2026-05-22 00:50:47 +00:00
jgitta 02b44e3972 Replace Gmail with Roundcube webmail 2026-05-21 23:25:51 +00:00
jgitta ed57ead6af Add Gmail App Password for Roundcube 2026-05-21 12:04:05 +00:00
7 changed files with 629 additions and 127 deletions
+113
View File
@@ -0,0 +1,113 @@
# 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
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
## 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
+113
View File
@@ -0,0 +1,113 @@
# 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
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
## 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
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"name": "Gmail",
"url": "https://mail.google.com/mail/u/0/#inbox"
"name": "Email",
"url": "https://mail.jgitta.com/roundcube/"
},
{
"name": "Facebook",
+303 -123
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""
SeniorNet Kiosk — PyQt5 floating toolbar + bookmarks bar over Google Chrome.
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
from PyQt5.QtCore import Qt, QTimer, QPoint
from PyQt5.QtGui import QFont, QColor, QPalette
from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
@@ -16,22 +17,20 @@ from PyQt5.QtWidgets import (
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
CDP_PORT = 9222
TOOLBAR_H = 82
BM_BAR_H = 60
TOP_H = TOOLBAR_H + BM_BAR_H # 142 px overlay height
SCREEN_W = 1920
SCREEN_H = 1080
TOOLBAR_H = 72 # single row — was 142 (82 + 60)
TOP_H = TOOLBAR_H
# Screen size is detected dynamically in main() after QApplication starts
SCREEN_W = 1920 # overwritten at runtime
SCREEN_H = 1080 # overwritten at runtime
# ── Colour palette ────────────────────────────────────────────────
TOOLBAR_COLOR = "#1a3a5c"
BAR_COLOR = "#162f4a"
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"
FOLDER_BG = "#3a4a6a"; FOLDER_HOVER = "#4a5a7a"
FONT_SIZE = 18
MENU_STYLE = """
@@ -39,7 +38,7 @@ MENU_STYLE = """
background: #1e3a5c; color: white;
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
}
QMenu::item { padding: 12px 32px 12px 14px; border-radius: 6px; font-size: 16px; }
QMenu::item { padding: 14px 36px 14px 16px; border-radius: 6px; font-size: 17px; }
QMenu::item:selected { background: #3a6da4; color: white; }
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
"""
@@ -59,6 +58,20 @@ def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
""")
return b
def mk_icon_btn(icon, color, hover, size=58, fs=26):
"""Square icon button for Back / Forward / Home / Hamburger."""
b = QPushButton(icon)
b.setFont(QFont("Arial", fs))
b.setFixedSize(size, size)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(f"""
QPushButton {{ background:{color}; color:white; border:none;
border-radius:12px; }}
QPushButton:hover {{ background:{hover}; }}
QPushButton:pressed {{ background:#222; }}
""")
return b
def load_bm():
try:
with open(BOOKMARKS_FILE) as f: return json.load(f)
@@ -115,6 +128,29 @@ class CDP:
threading.Thread(target=self._send, args=(method, params),
daemon=True).start()
def resize_window(self, x, y, w, h):
"""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)
ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
resp = json.loads(ws.recv())
wid = resp.get("result", {}).get("windowId")
if wid:
ws.send(json.dumps({"id": 2,
"method": "Browser.setWindowBounds",
"params": {"windowId": wid,
"bounds": {"left": x, "top": y,
"width": w, "height": h,
"windowState": "normal"}}}))
ws.recv()
ws.close()
except Exception as e:
print(f"CDP resize error: {e}")
def navigate(self, url):
self._async("Page.navigate", {"url": url})
@@ -341,102 +377,137 @@ class ManagerDialog(QDialog):
self.tree.setCurrentItem(sel); self._commit()
# ── Bookmarks Bar ─────────────────────────────────────────────────
class BookmarksBar(QWidget):
def __init__(self, nav_cb):
# ── Bookmark dropdown panel ───────────────────────────────────────
class BookmarkPanel(QWidget):
"""
Custom dropdown that replaces QMenu for bookmarks.
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
always appears above Chrome, unlike QMenu which has z-order issues.
"""
def __init__(self, cdp, overlay):
super().__init__()
self.nav_cb = nav_cb
self.setFixedHeight(BM_BAR_H)
self.setStyleSheet(f"background:{BAR_COLOR};")
self.cdp = cdp
self.overlay = overlay
self.setWindowFlags(
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setStyleSheet(f"""
QWidget {{ background: #1e3a5c; }}
QPushButton {{ background: transparent; color: white; border: none;
text-align: left; padding: 8px 20px;
font-size: 17px; border-radius: 6px; }}
QPushButton:hover {{ background: #3a6da4; }}
QPushButton:pressed {{ background: #222; }}
QLabel {{ color: #aac8e8; padding: 6px 20px 2px 20px;
font-size: 14px; font-weight: bold; }}
""")
QApplication.instance().installEventFilter(self)
outer = QHBoxLayout(self)
outer.setContentsMargins(6, 5, 6, 5); outer.setSpacing(4)
# Timer: hide panel when user clicks anywhere outside it
# (clicks on Chromium never reach Qt's eventFilter)
self._click_guard = QTimer()
self._click_guard.timeout.connect(self._check_outside_click)
self.left_btn = self._arrow("")
self.left_btn.clicked.connect(lambda: self._scroll(-240))
outer.addWidget(self.left_btn)
def rebuild(self, items):
# Remove old layout
old = self.layout()
if old:
while old.count():
w = old.takeAt(0).widget()
if w:
w.deleteLater()
QWidget().setLayout(old)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setStyleSheet(f"background:{BAR_COLOR}; border:none;")
scroll_content = QWidget()
scroll_content.setStyleSheet("background: #1e3a5c;")
vbox = QVBoxLayout(scroll_content)
vbox.setContentsMargins(6, 6, 6, 6)
vbox.setSpacing(1)
self.inner = QWidget()
self.inner.setStyleSheet(f"background:{BAR_COLOR};")
self.row = QHBoxLayout(self.inner)
self.row.setContentsMargins(4, 0, 4, 0); self.row.setSpacing(6)
self.scroll_area.setWidget(self.inner)
outer.addWidget(self.scroll_area)
self._add_items(vbox, items, depth=0)
self.right_btn = self._arrow("")
self.right_btn.clicked.connect(lambda: self._scroll(240))
outer.addWidget(self.right_btn)
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;")
sep.setFixedHeight(1)
vbox.addWidget(sep)
self.reload()
mgr = QPushButton("⚙ Manage Bookmarks…")
mgr.setFont(QFont("Arial", 16))
mgr.setMinimumHeight(48)
mgr.setCursor(Qt.PointingHandCursor)
mgr.clicked.connect(self._on_manage)
vbox.addWidget(mgr)
def _arrow(self, label):
b = QPushButton(label)
b.setFixedSize(38, 42); b.setFont(QFont("Arial", 14, QFont.Bold))
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet("QPushButton { background:#2a4a6c; color:white; border:none;"
" border-radius:8px; } QPushButton:hover { background:#3a6da4; }")
return b
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setStyleSheet("background: transparent; border: none;")
scroll.setWidget(scroll_content)
def reload(self):
while self.row.count():
w = self.row.takeAt(0).widget()
if w: w.deleteLater()
for item in load_bm():
b = self._folder_btn(item) if is_folder(item) else self._bm_btn(item["name"], item["url"])
self.row.addWidget(b)
self.row.addStretch()
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
outer.setSpacing(0)
outer.addWidget(scroll)
def _bm_btn(self, name, url):
b = QPushButton(name)
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(100)
b.setCursor(Qt.PointingHandCursor); b.setToolTip(url)
b.setStyleSheet("QPushButton { background:#2e5c8a; color:white; border:none;"
" border-radius:8px; padding:4px 12px; }"
"QPushButton:hover { background:#3a7abf; }"
"QPushButton:pressed { background:#1a3a5c; }")
b.clicked.connect(lambda _, u=url: self.nav_cb(u))
return b
# Size: cap height at 80 % of screen below toolbar
scroll_content.adjustSize()
ph = scroll_content.sizeHint().height() + 16
pw = max(scroll_content.sizeHint().width() + 16, 340)
max_h = SCREEN_H - TOOLBAR_H - 20
self.resize(pw, min(ph, max_h))
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
def _folder_btn(self, folder):
b = QPushButton(f"+ {folder['name']}")
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(110)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(f"QPushButton {{ background:{FOLDER_BG}; color:white; border:none;"
f" border-radius:8px; padding:4px 14px; }}"
f"QPushButton:hover {{ background:{FOLDER_HOVER}; }}"
f"QPushButton:pressed {{ background:#2a3a5a; }}")
b.clicked.connect(lambda _, f=folder, bb=b: self._show_menu(f, bb))
return b
def _show_menu(self, folder, button):
self._build_menu(folder["children"]).exec_(
button.mapToGlobal(button.rect().bottomLeft()))
def _build_menu(self, items):
menu = QMenu(); menu.setFont(QFont("Arial", 16)); menu.setStyleSheet(MENU_STYLE)
def _add_items(self, vbox, items, depth):
indent = " " * depth
for item in items:
if is_folder(item):
sub = self._build_menu(item["children"])
sub.setTitle(f" > {item['name']}")
sub.setFont(QFont("Arial", 16)); sub.setStyleSheet(MENU_STYLE)
menu.addMenu(sub)
lbl = QLabel(f"{indent}{item['name']}")
lbl.setFont(QFont("Arial", 14, QFont.Bold))
vbox.addWidget(lbl)
self._add_items(vbox, item["children"], depth + 1)
else:
act = QAction(f" {item['name']}", menu)
btn = QPushButton(f"{indent} {item['name']}")
btn.setFont(QFont("Arial", 17))
btn.setMinimumHeight(46)
btn.setCursor(Qt.PointingHandCursor)
url = item["url"]
act.triggered.connect(lambda _, u=url: self.nav_cb(u))
menu.addAction(act)
return menu
btn.clicked.connect(lambda _, u=url: self._nav(u))
vbox.addWidget(btn)
def _scroll(self, dx):
sb = self.scroll_area.horizontalScrollBar()
sb.setValue(sb.value() + dx)
def _nav(self, url):
self.hide()
self.cdp.navigate(url)
def _on_manage(self):
self.hide()
self.overlay.open_manager()
def setVisible(self, visible):
super().setVisible(visible)
if visible:
self._click_guard.start(100)
else:
self._click_guard.stop()
def _check_outside_click(self):
"""Hide panel when a click lands outside it — even on the Chromium window."""
from PyQt5.QtGui import QCursor
if not self.isVisible():
self._click_guard.stop()
return
if QApplication.mouseButtons() & Qt.LeftButton:
if not self.geometry().contains(QCursor.pos()):
self.hide()
def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.MouseButtonPress and self.isVisible():
gp = event.globalPos()
if not self.geometry().contains(gp):
self.hide()
return False
# ── Floating toolbar overlay ──────────────────────────────────────
@@ -457,42 +528,122 @@ class KioskOverlay(QWidget):
vbox = QVBoxLayout(self)
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
vbox.addWidget(self._build_toolbar())
self.bm_bar = BookmarksBar(cdp.navigate)
vbox.addWidget(self.bm_bar)
self.bm_panel = BookmarkPanel(cdp, self)
self.show()
# Reflow toolbar and Chrome when screen/RDP size changes
screen = QApplication.instance().primaryScreen()
screen.geometryChanged.connect(self._on_screen_resize)
# Watchdog: if Chromium ever gets minimised, map it back every 2 s.
self._watchdog = QTimer()
self._watchdog.timeout.connect(self._restore_chromium)
self._watchdog.start(2000)
def _on_screen_resize(self, rect):
global SCREEN_W, SCREEN_H
w, h = rect.width(), rect.height()
# Ignore bogus sizes from minimize / RDP disconnect events
if w < 400 or h < 200:
return
SCREEN_W, SCREEN_H = w, h
self.setGeometry(0, 0, SCREEN_W, TOP_H)
self.resize(SCREEN_W, TOP_H)
# Resize Chrome after brief delay so X has settled
QTimer.singleShot(500, self._resize_chrome)
def _resize_chrome(self):
self.cdp.resize_window(0, TOP_H, SCREEN_W, SCREEN_H - TOP_H)
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)
super().changeEvent(event)
def _self_restore(self):
self.setWindowState(Qt.WindowNoState)
self.show()
self.raise_()
def _restore_chromium(self):
"""Watchdog: restore Chromium if it was minimised (_NET_WM_STATE_HIDDEN)."""
try:
result = subprocess.run(
["xdotool", "search", "--class", "chromium"],
capture_output=True, text=True, timeout=2
)
for wid in result.stdout.strip().split():
if not wid:
continue
state = subprocess.run(
["xprop", "-id", wid, "_NET_WM_STATE"],
capture_output=True, text=True, timeout=1
)
if "_NET_WM_STATE_HIDDEN" in state.stdout:
subprocess.run(
["xdotool", "windowmap", wid],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
pass
def _build_toolbar(self):
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
tl = QHBoxLayout(bar); tl.setContentsMargins(16, 8, 16, 8); tl.setSpacing(12)
tl = QHBoxLayout(bar)
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
# Brand label
brand = QLabel("SeniorNet")
brand.setFont(QFont("Arial", 24, QFont.Bold))
brand.setFont(QFont("Arial", 22, QFont.Bold))
brand.setStyleSheet("color:white;")
tl.addWidget(brand); tl.addStretch()
back_btn = mk_btn("< Back", BTN_COLOR, BTN_HOVER, 120, 62)
fwd_btn = mk_btn("Forward >", BTN_COLOR, BTN_HOVER, 140, 62)
home_btn = mk_btn("HOME", GOLD, GOLD_HOVER, 120, 62)
mgr_btn = mk_btn("Bookmarks", PURPLE, PURPLE_HOVER, 150, 62)
tl.addWidget(brand)
tl.addStretch()
# ◀ Back
back_btn = mk_icon_btn("", BTN_COLOR, BTN_HOVER)
back_btn.setToolTip("Go Back")
back_btn.clicked.connect(self.cdp.back)
fwd_btn.clicked.connect(self.cdp.forward)
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
mgr_btn.clicked.connect(self.open_manager)
tl.addWidget(back_btn)
# ▶ Forward
fwd_btn = mk_icon_btn("", BTN_COLOR, BTN_HOVER)
fwd_btn.setToolTip("Go Forward")
fwd_btn.clicked.connect(self.cdp.forward)
tl.addWidget(fwd_btn)
# ⌂ Home
home_btn = mk_icon_btn("", GOLD, GOLD_HOVER)
home_btn.setToolTip("Go Home")
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
tl.addWidget(home_btn)
# ☰ Hamburger — bookmarks menu
self.burger_btn = mk_icon_btn("", PURPLE, PURPLE_HOVER)
self.burger_btn.setToolTip("Bookmarks")
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
tl.addWidget(self.burger_btn)
for b in [back_btn, fwd_btn, home_btn, mgr_btn]: tl.addWidget(b)
return bar
def _show_bookmarks_menu(self):
if self.bm_panel.isVisible():
self.bm_panel.hide()
else:
self.bm_panel.rebuild(load_bm())
self.bm_panel.show()
self.bm_panel.raise_()
def open_manager(self):
d = ManagerDialog(self); d.exec_()
self.bm_bar.reload()
d = ManagerDialog(self)
d.exec_()
def keyPressEvent(self, e):
# Home key -> home screen
if e.key() == Qt.Key_Home:
self.cdp.navigate(HOME_URL)
# Block all other dangerous keys silently
e.accept()
def eventFilter(self, obj, event):
@@ -500,15 +651,13 @@ class KioskOverlay(QWidget):
if event.type() == QEvent.KeyPress:
key = event.key()
mods = event.modifiers()
# Block Alt+F4, Ctrl+W, Ctrl+T, Ctrl+N, Ctrl+Tab, F11, Escape
blocked_keys = {Qt.Key_F4, Qt.Key_W, Qt.Key_T, Qt.Key_N,
Qt.Key_Tab, Qt.Key_F11, Qt.Key_Escape,
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
if mods & (Qt.AltModifier | Qt.ControlModifier):
if key in blocked_keys:
return True # swallow the event
# Also block bare Escape and F-keys
return True
if key in {Qt.Key_Escape, Qt.Key_F11}:
return True
return super().eventFilter(obj, event)
@@ -517,15 +666,13 @@ class KioskOverlay(QWidget):
# ── Chrome launcher ───────────────────────────────────────────────
def launch_chrome():
import pathlib, json as _json, shutil
profile_dir = pathlib.Path("/home/jgitta/.config/google-chrome-kiosk")
profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
profile_dir.mkdir(parents=True, exist_ok=True)
# Remove stale singleton locks
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try: (profile_dir / name).unlink()
except FileNotFoundError: pass
# Wipe GPU/ML caches that corrupt across restarts
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
"optimization_guide_model_store",
@@ -535,7 +682,6 @@ def launch_chrome():
if p.exists():
shutil.rmtree(p, ignore_errors=True)
# Patch Local State
ls_path = profile_dir / "Local State"
if ls_path.exists():
try:
@@ -546,25 +692,37 @@ def launch_chrome():
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%'")
conn.commit()
conn.close()
print("Cleared stale Roundcube session cookie")
except Exception as e:
print(f"Cookie clear warning: {e}")
chrome_h = SCREEN_H - TOP_H
cmd = [
"google-chrome",
"chromium",
"--no-first-run",
"--no-default-browser-check",
"--disable-translate",
"--disable-infobars",
"--disable-session-crashed-bubble",
"--disable-save-password-bubble",
"--disable-restore-session-state",
"--disable-default-browser-check",
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
"--disable-component-update",
"--disable-background-networking",
"--disable-extensions",
"--disable-gpu",
"--no-sandbox",
"--test-type",
"--disable-dev-shm-usage",
"--user-data-dir=/home/jgitta/.config/google-chrome-kiosk",
"--user-data-dir=/home/jgitta/chromium-kiosk",
f"--remote-debugging-port={CDP_PORT}",
"--remote-allow-origins=*",
f"--window-position=0,{TOP_H}",
@@ -577,6 +735,9 @@ def launch_chrome():
# ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
app = QApplication(sys.argv)
# Detect actual screen resolution at runtime
_geo = app.primaryScreen().geometry()
SCREEN_W, SCREEN_H = _geo.width(), _geo.height()
app.setApplicationName("SeniorNet")
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
@@ -585,9 +746,28 @@ if __name__ == "__main__":
chrome_proc = launch_chrome()
def dismiss_chrome_dialogs():
"""
Chrome shows startup dialogs (first-run welcome, profile errors) on a
fresh or corrupted profile. Dismiss them all automatically so the
senior never sees them. Runs for ~15 seconds after startup.
"""
dialogs = [
"Welcome to Google Chrome",
"Profile error occurred",
]
for _ in range(15):
time.sleep(1)
for title in dialogs:
subprocess.run(
["xdotool", "search", "--name", title, "key", "Return"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
def init_cdp():
if cdp.wait_ready(timeout=30):
print("CDP ready — kiosk running")
dismiss_chrome_dialogs()
threading.Thread(target=init_cdp, daemon=True).start()
sys.exit(app.exec_())
+11
View File
@@ -0,0 +1,11 @@
# Senior Kiosk — Credentials
> **Keep this file private.** Do not share publicly.
## Gmail App Password (for Roundcube IMAP)
- **Account**: Gmail account used on the kiosk
- **Purpose**: Roundcube webmail IMAP/SMTP access
- **App Password**: `zrhe ncfw zvgb qqfs`
- **Created**: 2026-05-21
- **Note**: Spaces are part of the display only — enter without spaces when configuring Roundcube: `zrhencfwzvgbqqfs`
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Deploy latest kiosk code.
# Always commits to Gitea and restarts kiosk-dev (dev/test machine).
# If the production kiosk is reachable, deploys there too.
# Usage: ./deploy.sh ["commit message"]
KIOSK_LOCAL="jgitta@192.168.88.44"
KIOSK_REMOTE="jgitta@100.64.0.1"
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
echo "==> Committing and pushing to Gitea..."
git -C "$REPO_DIR" add -A
git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
git -C "$REPO_DIR" push origin master || echo " (nothing new to push)"
echo "==> Restarting kiosk-dev..."
/usr/local/bin/kiosk-restart
echo "==> Trying production kiosk..."
KIOSK=""
if ssh -o ConnectTimeout=5 -o BatchMode=yes $KIOSK_LOCAL "echo ok" &>/dev/null; then
KIOSK="$KIOSK_LOCAL"
echo " Reaching production via local network"
elif ssh -o ConnectTimeout=8 -o BatchMode=yes $KIOSK_REMOTE "echo ok" &>/dev/null; then
KIOSK="$KIOSK_REMOTE"
echo " Reaching production via Tailscale"
else
echo " Production kiosk unreachable — skipping (will deploy when online)"
fi
if [ -n "$KIOSK" ]; then
ssh "$KIOSK" "
cd /home/jgitta/kiosk-browser
git pull
/usr/local/bin/kiosk-restart
"
echo " Production updated."
fi
echo "==> Deploy complete!"
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Kiosk Auto-Login Plugin
* Automatically logs in — senior user never sees the login screen.
* Mirrors the exact session setup from index.php's normal login flow:
* login() -> session->remove('temp') -> session->regenerate_id(false)
* -> session->set_auth_cookie() -> redirect
*/
class autologin extends rcube_plugin
{
public $task = 'login';
function init()
{
$this->add_hook('startup', array($this, 'startup'));
}
function startup($args)
{
$rcmail = rcmail::get_instance();
if ($args['task'] == 'login' && empty($_SESSION['user_id'])) {
$username = 'rudbelik@gmail.com';
$password = 'zrhencfwzvgbqqfs';
$host = 'ssl://imap.gmail.com:993';
$loggedin = $rcmail->login($username, $password, $host, false);
rcube::write_log('autologin', 'Login attempt result: ' . ($loggedin ? 'SUCCESS' : 'FAILED'));
if ($loggedin) {
// Replicate exact post-login session setup from index.php
$rcmail->session->remove('temp');
$rcmail->session->regenerate_id(false);
$rcmail->session->set_auth_cookie();
header('Location: ' . $rcmail->url(array('_task' => 'mail')));
exit;
}
}
return $args;
}
}