Fix bookmarks menu dismiss, home Email URL, and deploy hygiene.

Close the hamburger panel on outside click/focus via X11 (xinput/xdotool)
without a fullscreen overlay; point home Email to Roundcube; sync
home-screen-index.html in deploy/install; remove credentials.md from git
and document private LAN-only Gitea usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-22 21:39:07 +00:00
parent b99f058bfb
commit 8553705e47
9 changed files with 143 additions and 35 deletions
+6 -2
View File
@@ -64,9 +64,13 @@ sleep 1
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) | | `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching | | `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
## Gitea repo ## Gitea repo (private LAN only)
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch - **URL**: `git@gitea.jgitta.com:jgitta/senior-kiosk.git` (SSH) or `https://gitea.jgitta.com/jgitta/senior-kiosk.git` (HTTPS)
- **Branch**: `master`
- **Exposure**: Internal only — LAN/VPN, not public internet. Never embed tokens in `git remote` URLs.
- **Deploy**: `bash deploy.sh "message"` — push to Gitea, sync home screen, `kiosk-restart`.
- **Secrets**: `credentials.md` is gitignored.
## Roundcube webmail ## Roundcube webmail
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.env .env
credentials.md
+9 -3
View File
@@ -58,15 +58,21 @@ sleep 1
|------|---------| |------|---------|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher | | `browser.py` | Main application — toolbar + CDP + Chromium launcher |
| `bookmarks.json` | Nested bookmark structure | | `bookmarks.json` | Nested bookmark structure |
| `home-screen-index.html` | Home screen source (deploy copies to `/home/jgitta/kiosk-home/index.html`) |
| `deploy.sh` | One-command deploy | | `deploy.sh` | One-command deploy |
| `/home/jgitta/kiosk-home/index.html` | Senior home screen | | `/home/jgitta/kiosk-home/index.html` | Senior home screen (live copy, outside repo) |
| `~/.config/openbox/autostart` | Auto-restart loop on crash | | `~/.config/openbox/autostart` | Auto-restart loop on crash |
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) | | `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching | | `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
## Gitea repo ## Gitea repo (private LAN only)
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch - **URL**: `git@gitea.jgitta.com:jgitta/senior-kiosk.git` (SSH) or `https://gitea.jgitta.com/jgitta/senior-kiosk.git` (HTTPS)
- **Branch**: `master`
- **Exposure**: Internal only — `gitea.jgitta.com` resolves on LAN/VPN (Caddy at 192.168.88.110). Not public internet.
- **Auth**: SSH key preferred (`git@gitea.jgitta.com:jgitta/senior-kiosk.git`) once the machines public key is in Gitea → Settings → SSH Keys. Until then use HTTPS without a token in the URL and `git config credential.helper store`. Rotate any token that was ever in a remote URL.
- **Deploy**: `bash deploy.sh "message"` — push to Gitea, sync home screen, `kiosk-restart`; production via SSH on 192.168.88.x / Tailscale.
- **Secrets**: `credentials.md` is gitignored; Roundcube passwords live on the mail server, not in this repo.
## Roundcube webmail ## Roundcube webmail
+96 -16
View File
@@ -5,7 +5,7 @@ Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks.
Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket. Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket.
""" """
import sys, json, os, subprocess, time, threading, urllib.request import sys, json, os, subprocess, time, threading, urllib.request
from PyQt5.QtCore import Qt, QTimer, QPoint from PyQt5.QtCore import Qt, QTimer, QPoint, QRect
from PyQt5.QtGui import QFont, QColor, QPalette from PyQt5.QtGui import QFont, QColor, QPalette
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout, QApplication, QWidget, QHBoxLayout, QVBoxLayout,
@@ -377,6 +377,43 @@ class ManagerDialog(QDialog):
self.tree.setCurrentItem(sel); self._commit() self.tree.setCurrentItem(sel); self._commit()
# ── X11 helpers (clicks/focus on Chromium never reach Qt) ───────────
def _x11_env():
env = os.environ.copy()
env.setdefault("DISPLAY", ":0")
return env
def _x11_run(cmd, timeout=0.4):
try:
r = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, env=_x11_env())
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def _x11_active_window_id():
out = _x11_run(["xdotool", "getactivewindow"])
return int(out) if out.isdigit() else 0
def _x11_left_button_down():
"""True when the physical left mouse button is held (works across all apps)."""
try:
r = subprocess.run(
["bash", "-c",
"DISPLAY=:0 dev=$(xinput --list --short 2>/dev/null | "
"awk '/slave.*pointer/ {print $2; exit}'); "
"[ -n \"$dev\" ] && xinput query-state \"$dev\" 2>/dev/null | "
"grep -q 'button\\[1\\]=down'"],
timeout=0.3, env=_x11_env())
return r.returncode == 0
except Exception:
return False
# ── Bookmark dropdown panel ─────────────────────────────────────── # ── Bookmark dropdown panel ───────────────────────────────────────
class BookmarkPanel(QWidget): class BookmarkPanel(QWidget):
""" """
@@ -384,10 +421,15 @@ class BookmarkPanel(QWidget):
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
always appears above Chrome, unlike QMenu which has z-order issues. always appears above Chrome, unlike QMenu which has z-order issues.
""" """
_OPEN_GRACE_SEC = 0.35
def __init__(self, cdp, overlay): def __init__(self, cdp, overlay):
super().__init__() super().__init__()
self.cdp = cdp self.cdp = cdp
self.overlay = overlay self.overlay = overlay
self._shown_at = 0.0
self._focus_at_open = 0
self._our_window_ids = set()
self.setWindowFlags( self.setWindowFlags(
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_ShowWithoutActivating) self.setAttribute(Qt.WA_ShowWithoutActivating)
@@ -403,10 +445,8 @@ class BookmarkPanel(QWidget):
""") """)
QApplication.instance().installEventFilter(self) QApplication.instance().installEventFilter(self)
# Timer: hide panel when user clicks anywhere outside it self._dismiss_timer = QTimer()
# (clicks on Chromium never reach Qt's eventFilter) self._dismiss_timer.timeout.connect(self._check_dismiss)
self._click_guard = QTimer()
self._click_guard.timeout.connect(self._check_outside_click)
def rebuild(self, items): def rebuild(self, items):
# Remove old layout # Remove old layout
@@ -459,6 +499,19 @@ class BookmarkPanel(QWidget):
self.resize(pw, min(ph, max_h)) self.resize(pw, min(ph, max_h))
self.move(SCREEN_W - pw - 4, TOOLBAR_H) self.move(SCREEN_W - pw - 4, TOOLBAR_H)
def _refresh_our_window_ids(self):
ids = set()
for w in (self, self.overlay):
wid = int(w.winId())
if wid:
ids.add(wid)
self._our_window_ids = ids
def _burger_global_rect(self):
btn = self.overlay.burger_btn
origin = btn.mapToGlobal(QPoint(0, 0))
return QRect(origin, btn.size())
def _add_items(self, vbox, items, depth): def _add_items(self, vbox, items, depth):
indent = " " * depth indent = " " * depth
for item in items: for item in items:
@@ -484,28 +537,53 @@ class BookmarkPanel(QWidget):
self.hide() self.hide()
self.overlay.open_manager() self.overlay.open_manager()
def hide(self):
self._dismiss_timer.stop()
super().hide()
def setVisible(self, visible): def setVisible(self, visible):
super().setVisible(visible) super().setVisible(visible)
if visible: if visible:
self._click_guard.start(100) self._shown_at = time.time()
self._refresh_our_window_ids()
self._focus_at_open = _x11_active_window_id()
self.raise_()
self.overlay.raise_()
self._dismiss_timer.start(100)
else: else:
self._click_guard.stop() self._dismiss_timer.stop()
def _check_outside_click(self): def _cursor_outside_panel(self):
"""Hide panel when a click lands outside it — even on the Chromium window."""
from PyQt5.QtGui import QCursor from PyQt5.QtGui import QCursor
pos = QCursor.pos()
if self.frameGeometry().contains(pos):
return False
if self._burger_global_rect().contains(pos):
return False
return True
def _check_dismiss(self):
"""Close when focus leaves or user clicks outside the panel."""
if not self.isVisible(): if not self.isVisible():
self._click_guard.stop() self._dismiss_timer.stop()
return return
if QApplication.mouseButtons() & Qt.LeftButton: if time.time() - self._shown_at < self._OPEN_GRACE_SEC:
if not self.geometry().contains(QCursor.pos()): return
# Click outside (X11 — works even when Chromium has focus)
if _x11_left_button_down() and self._cursor_outside_panel():
self.hide()
return
# Focus moved away from toolbar / menu (e.g. clicked the web page)
active = _x11_active_window_id()
if active and active != self._focus_at_open and active not in self._our_window_ids:
self.hide() self.hide()
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent from PyQt5.QtCore import QEvent
if event.type() == QEvent.MouseButtonPress and self.isVisible(): if event.type() == QEvent.MouseButtonPress and self.isVisible():
gp = event.globalPos() if not self.frameGeometry().contains(event.globalPos()):
if not self.geometry().contains(gp):
self.hide() self.hide()
return False return False
@@ -549,6 +627,9 @@ class KioskOverlay(QWidget):
SCREEN_W, SCREEN_H = w, h SCREEN_W, SCREEN_H = w, h
self.setGeometry(0, 0, SCREEN_W, TOP_H) self.setGeometry(0, 0, SCREEN_W, TOP_H)
self.resize(SCREEN_W, TOP_H) self.resize(SCREEN_W, TOP_H)
if self.bm_panel.isVisible():
pw = self.bm_panel.width()
self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H)
# Resize Chrome after brief delay so X has settled # Resize Chrome after brief delay so X has settled
QTimer.singleShot(500, self._resize_chrome) QTimer.singleShot(500, self._resize_chrome)
@@ -634,8 +715,7 @@ class KioskOverlay(QWidget):
self.bm_panel.hide() self.bm_panel.hide()
else: else:
self.bm_panel.rebuild(load_bm()) self.bm_panel.rebuild(load_bm())
self.bm_panel.show() self.bm_panel.setVisible(True)
self.bm_panel.raise_()
def open_manager(self): def open_manager(self):
d = ManagerDialog(self) d = ManagerDialog(self)
-11
View File
@@ -1,11 +0,0 @@
# 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`
+10
View File
@@ -0,0 +1,10 @@
# Senior Kiosk — Credentials (template)
> Copy to `credentials.md` (gitignored). Never commit real secrets.
## Gmail App Password (for Roundcube IMAP)
- **Account**: your-gmail@example.com
- **Purpose**: Roundcube webmail IMAP/SMTP access
- **App Password**: (create in Google Account → Security → App passwords)
- **Note**: Configure on the Roundcube server only; do not commit to git.
+12
View File
@@ -7,8 +7,19 @@
KIOSK_LOCAL="jgitta@192.168.88.44" KIOSK_LOCAL="jgitta@192.168.88.44"
KIOSK_REMOTE="jgitta@100.64.0.1" KIOSK_REMOTE="jgitta@100.64.0.1"
REPO_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
HOME_SRC="$REPO_DIR/home-screen-index.html"
HOME_DST="/home/jgitta/kiosk-home/index.html"
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}" COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
echo "==> Syncing home screen to $HOME_DST..."
if [ -f "$HOME_SRC" ]; then
mkdir -p "$(dirname "$HOME_DST")"
cp "$HOME_SRC" "$HOME_DST"
chown jgitta:jgitta "$HOME_DST" 2>/dev/null || true
else
echo " WARNING: $HOME_SRC missing — home screen not updated"
fi
echo "==> Committing and pushing to Gitea..." echo "==> Committing and pushing to Gitea..."
git -C "$REPO_DIR" add -A 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" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
@@ -33,6 +44,7 @@ if [ -n "$KIOSK" ]; then
ssh "$KIOSK" " ssh "$KIOSK" "
cd /home/jgitta/kiosk-browser cd /home/jgitta/kiosk-browser
git pull git pull
cp home-screen-index.html /home/jgitta/kiosk-home/index.html
/usr/local/bin/kiosk-restart /usr/local/bin/kiosk-restart
" "
echo " Production updated." echo " Production updated."
+2 -1
View File
@@ -1,4 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- Source for /home/jgitta/kiosk-home/index.html — copied by deploy.sh and install.sh -->
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -96,7 +97,7 @@
<div class="grid"> <div class="grid">
<a class="btn btn-gmail" href="https://mail.google.com"> <a class="btn btn-gmail" href="https://mail.jgitta.com/roundcube/">
<div class="icon">✉️</div> <div class="icon">✉️</div>
Email Email
</a> </a>
+6 -1
View File
@@ -6,7 +6,7 @@ set -e
echo "=== Installing packages ===" echo "=== Installing packages ==="
apt-get update apt-get update
apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \ apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \
fonts-noto-color-emoji python3-pip fonts-noto-color-emoji python3-pip xinput xdotool
# Install websocket-client # Install websocket-client
pip3 install websocket-client --break-system-packages pip3 install websocket-client --break-system-packages
@@ -38,6 +38,11 @@ mkdir -p /etc/X11/xorg.conf.d
cp "$SCRIPT_DIR/system-config/xorg-50-kiosk-hardening.conf" \ cp "$SCRIPT_DIR/system-config/xorg-50-kiosk-hardening.conf" \
/etc/X11/xorg.conf.d/50-kiosk-hardening.conf /etc/X11/xorg.conf.d/50-kiosk-hardening.conf
# Home screen (source in repo → served outside repo at file:///home/jgitta/kiosk-home/)
mkdir -p /home/jgitta/kiosk-home
cp "$SCRIPT_DIR/home-screen-index.html" /home/jgitta/kiosk-home/index.html
chown jgitta:jgitta /home/jgitta/kiosk-home/index.html
# Disable unused virtual consoles # Disable unused virtual consoles
for i in 2 3 4 5 6; do for i in 2 3 4 5 6; do
systemctl disable getty@tty$i 2>/dev/null || true systemctl disable getty@tty$i 2>/dev/null || true