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:
+6
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
|
credentials.md
|
||||||
|
|||||||
@@ -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 machine’s 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
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user