Compare commits

..

25 Commits

Author SHA1 Message Date
jgitta b7866000b4 feat: add custom Debian ISO build for automated silent installation 2026-05-23 12:37:31 +00:00
jgitta 38f7ae467a refactor: use config.json with MVC architecture 2026-05-23 02:52:34 +00:00
jgitta c8534c967c Document Gitea layout from homelab-configs (Caddy, port 2222 SSH).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:51:54 +00:00
jgitta 8553705e47 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>
2026-05-22 21:39:07 +00:00
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
15 changed files with 1151 additions and 182 deletions
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.env .env
credentials.md
+130
View File
@@ -0,0 +1,130 @@
# 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 |
| `home-screen-index.html` | Home screen source (deploy copies to `/home/jgitta/kiosk-home/index.html`) |
| `deploy.sh` | One-command deploy |
| `/home/jgitta/kiosk-home/index.html` | Senior home screen (live copy, outside repo) |
| `~/.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 (see also `jgitta/homelab-configs`)
Source of truth for infra: **https://gitea.jgitta.com/jgitta/homelab-configs**
| Piece | Detail |
|--------|--------|
| Gitea app | Docker on **siklos** `192.168.88.27` — HTTP **3002**, SSH **2222** (not 22) |
| Public URL | `gitea.jgitta.com`**Caddy** `192.168.88.110` → proxies to Gitea :3002 |
| DNS | MikroTik `192.168.88.1` / siklos; public DNS may resolve to WAN IP (still reaches Caddy :443) |
**This repo (senior-kiosk):**
- **HTTPS (usual — what worked before):** `https://gitea.jgitta.com/jgitta/senior-kiosk.git` — through Caddy. Auth via Gitea username + access token (`git config credential.helper store`), or token in URL (legacy on kiosk-dev).
- **SSH:** Gitea SSH is on port **2222**, not 22. Plain `git@gitea.jgitta.com:...` hits port **22 on the WAN** and will **not** accept your Gitea password. Use either:
- `ssh://git@192.168.88.27:2222/jgitta/senior-kiosk.git` (LAN), or
- `~/.ssh/config`: `Host gitea.jgitta.com``Port 2222` (requires WAN/LAN port-forward 2222→siklos:2222 if using the hostname)
- **Branch:** `master`
- **Deploy:** `bash deploy.sh "message"` — push, sync home screen, `kiosk-restart`
- **Secrets:** `credentials.md` gitignored
## 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 — terminates HTTPS for `*.jgitta.com` (including `gitea.jgitta.com` → Gitea :3002)
- **Gitea**: behind Caddy at `gitea.jgitta.com`; app listens on port 3002 on the Gitea host
- **Headscale**: self-hosted Tailscale on siklos
+99
View File
@@ -0,0 +1,99 @@
# SeniorNet Kiosk — Custom Debian ISO
Build a bootable ISO that installs and auto-configures the kiosk app on any machine.
## On Your Workstation (192.168.88.41)
### 1. Clone the repo (if not already done)
```bash
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git
cd senior-kiosk
```
### 2. Build the ISO
```bash
sudo ./build-iso.sh
```
This creates: `iso-output/seniornet-kiosk.iso` (~1-2 GB, takes 5-10 min)
## Testing on Production Kiosk (192.168.88.44)
### 1. Write ISO to bootable USB
On your workstation with USB connected:
```bash
# List devices to find your USB
lsblk
# Write ISO (replace sdX with your USB device, e.g., sdb)
sudo dd if=iso-output/seniornet-kiosk.iso of=/dev/sdX bs=4M status=progress && sync
```
### 2. Boot from USB
- Plug USB into production kiosk
- Power on and select USB boot (usually F12, ESC, or DEL during startup)
- Installation starts silently (no prompts)
### 3. Wait for installation
- Silent Debian installation: ~5-10 minutes
- Clones repo, installs dependencies
- System reboots automatically
### 4. Verify kiosk is running
After reboot, you should see the kiosk home screen with buttons.
To check status via SSH:
```bash
ssh jgitta@192.168.88.44
journalctl -u kiosk.service -f
```
## What the ISO Contains
- **Debian 13 Trixie** (minimal base)
- **Chromium** (system package, not snap)
- **Python 3, PyQt5, websocket-client**
- **Openbox** (window manager)
- **Xvfb** (virtual display)
- **Your kiosk app** (cloned from Gitea at install time)
- **Auto-boot to kiosk mode** (no desktop login)
## Files
- `preseed.cfg` — Debian unattended installer config
- `install-kiosk.sh` — Post-install script (runs after Debian install)
- `build-iso.sh` — Build script (creates the ISO)
## Troubleshooting
**If installation hangs:**
- Check network connectivity during install
- Gitea must be reachable (or install will use git clone fallback)
**If kiosk doesn't start:**
```bash
ssh jgitta@192.168.88.44
journalctl -u kiosk.service -20 # View last 20 lines of logs
sudo systemctl restart kiosk.service
```
**To access terminal:**
- Press Alt+F2 during boot
- Log in as `root` (password: `kiosk`)
- Run: `/usr/local/bin/kiosk-restart` to restart
## Customization
To modify the ISO:
1. Edit `preseed.cfg` (installer config)
2. Edit `install-kiosk.sh` (post-install)
3. Edit `build-iso.sh` (ISO build options)
4. Rebuild: `sudo ./build-iso.sh`
## Distribution
Once tested, you can:
- Copy `seniornet-kiosk.iso` to USB drives for deployment
- Host on web server for download
- Share with end users
- Any machine can boot and auto-install (no Proxmox needed)
+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 - **Display**: X11 + LightDM (auto-login) + Openbox WM
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar) - **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall - **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
+2 -2
View File
@@ -1,7 +1,7 @@
[ [
{ {
"name": "Gmail", "name": "Email",
"url": "https://mail.google.com/mail/u/0/#inbox" "url": "https://mail.jgitta.com/roundcube/"
}, },
{ {
"name": "Facebook", "name": "Facebook",
+397 -139
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3 #!/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. 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, websocket, pathlib, shutil, sqlite3
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QEvent
from PyQt5.QtGui import QFont, QColor, QPalette from PyQt5.QtGui import QFont, QColor, QPalette, QCursor
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout, QApplication, QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit, QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
@@ -16,22 +17,26 @@ from PyQt5.QtWidgets import (
HOME_URL = "file:///home/jgitta/kiosk-home/index.html" HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json" BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
CDP_PORT = 9222 CDP_PORT = 9222
TOOLBAR_H = 82 TOOLBAR_H = 72 # single row — was 142 (82 + 60)
BM_BAR_H = 60 TOP_H = TOOLBAR_H
TOP_H = TOOLBAR_H + BM_BAR_H # 142 px overlay height # Screen size is detected dynamically in main() after QApplication starts
SCREEN_W = 1920 SCREEN_W = 1920 # overwritten at runtime
SCREEN_H = 1080 SCREEN_H = 1080 # overwritten at runtime
# ── Colour palette ──────────────────────────────────────────────── # ── Colour palette ────────────────────────────────────────────────
TOOLBAR_COLOR = "#1a3a5c" TOOLBAR_COLOR = "#1a3a5c"
BAR_COLOR = "#162f4a" BTN_COLOR = "#2e6da4"
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc" BTN_HOVER = "#3a87cc"
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030" GOLD = "#e8a020"
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c" GOLD_HOVER = "#f0b030"
RED = "#c62828"; RED_HOVER = "#d32f2f" GREEN = "#2e7d32"
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20" GREEN_HOVER = "#388e3c"
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa" RED = "#c62828"
FOLDER_BG = "#3a4a6a"; FOLDER_HOVER = "#4a5a7a" RED_HOVER = "#d32f2f"
AMBER = "#7a5a10"
AMBER_HOVER = "#9a7a20"
PURPLE = "#5a5a8a"
PURPLE_HOVER = "#7a7aaa"
FONT_SIZE = 18 FONT_SIZE = 18
MENU_STYLE = """ MENU_STYLE = """
@@ -39,7 +44,7 @@ MENU_STYLE = """
background: #1e3a5c; color: white; background: #1e3a5c; color: white;
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px; 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::item:selected { background: #3a6da4; color: white; }
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; } QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
""" """
@@ -59,10 +64,26 @@ def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
""") """)
return b 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(): def load_bm():
try: try:
with open(BOOKMARKS_FILE) as f: return json.load(f) with open(BOOKMARKS_FILE) as f:
except: return [] return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_bm(data): def save_bm(data):
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2) with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
@@ -100,12 +121,11 @@ class CDP:
def _send(self, method, params=None): def _send(self, method, params=None):
if not self.ready: if not self.ready:
return return
import websocket
with self._lock: with self._lock:
try: try:
ws = websocket.create_connection(self._ws_url(), timeout=3) ws = websocket.create_connection(self._ws_url(), timeout=3)
ws.send(json.dumps({"id": 1, "method": method, ws.send(json.dumps({"id": 1, "method": method,
**({"params": params} if params else {})})) **( {"params": params} if params else {})}))
ws.recv() ws.recv()
ws.close() ws.close()
except Exception as e: except Exception as e:
@@ -115,6 +135,28 @@ class CDP:
threading.Thread(target=self._send, args=(method, params), threading.Thread(target=self._send, args=(method, params),
daemon=True).start() 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
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): def navigate(self, url):
self._async("Page.navigate", {"url": url}) self._async("Page.navigate", {"url": url})
@@ -341,102 +383,213 @@ class ManagerDialog(QDialog):
self.tree.setCurrentItem(sel); self._commit() self.tree.setCurrentItem(sel); self._commit()
# ── Bookmarks Bar ───────────────────────────────────────────────── # ── X11 helpers (clicks/focus on Chromium never reach Qt) ───────────
class BookmarksBar(QWidget): def _x11_env():
def __init__(self, nav_cb): 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 ───────────────────────────────────────
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.
"""
_OPEN_GRACE_SEC = 0.35
def __init__(self, cdp, overlay):
super().__init__() super().__init__()
self.nav_cb = nav_cb self.cdp = cdp
self.setFixedHeight(BM_BAR_H) self.overlay = overlay
self.setStyleSheet(f"background:{BAR_COLOR};") self._shown_at = 0.0
self._focus_at_open = 0
self._our_window_ids = set()
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) self._dismiss_timer = QTimer()
outer.setContentsMargins(6, 5, 6, 5); outer.setSpacing(4) self._dismiss_timer.timeout.connect(self._check_dismiss)
self.left_btn = self._arrow("") def rebuild(self, items):
self.left_btn.clicked.connect(lambda: self._scroll(-240)) # Remove old layout
outer.addWidget(self.left_btn) old = self.layout()
if old:
while old.count():
w = old.takeAt(0).widget()
if w:
w.deleteLater()
QWidget().setLayout(old)
self.scroll_area = QScrollArea() scroll_content = QWidget()
self.scroll_area.setWidgetResizable(True) scroll_content.setStyleSheet("background: #1e3a5c;")
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) vbox = QVBoxLayout(scroll_content)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) vbox.setContentsMargins(6, 6, 6, 6)
self.scroll_area.setFrameShape(QFrame.NoFrame) vbox.setSpacing(1)
self.scroll_area.setStyleSheet(f"background:{BAR_COLOR}; border:none;")
self.inner = QWidget() self._add_items(vbox, items, depth=0)
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.right_btn = self._arrow("") sep = QFrame()
self.right_btn.clicked.connect(lambda: self._scroll(240)) sep.setFrameShape(QFrame.HLine)
outer.addWidget(self.right_btn) 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): scroll = QScrollArea()
b = QPushButton(label) scroll.setWidgetResizable(True)
b.setFixedSize(38, 42); b.setFont(QFont("Arial", 14, QFont.Bold)) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
b.setCursor(Qt.PointingHandCursor) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
b.setStyleSheet("QPushButton { background:#2a4a6c; color:white; border:none;" scroll.setStyleSheet("background: transparent; border: none;")
" border-radius:8px; } QPushButton:hover { background:#3a6da4; }") scroll.setWidget(scroll_content)
return b
def reload(self): outer = QVBoxLayout(self)
while self.row.count(): outer.setContentsMargins(2, 2, 2, 2)
w = self.row.takeAt(0).widget() outer.setSpacing(0)
if w: w.deleteLater() outer.addWidget(scroll)
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()
def _bm_btn(self, name, url): # Size: cap height at 80 % of screen below toolbar
b = QPushButton(name) scroll_content.adjustSize()
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(100) ph = scroll_content.sizeHint().height() + 16
b.setCursor(Qt.PointingHandCursor); b.setToolTip(url) pw = max(scroll_content.sizeHint().width() + 16, 340)
b.setStyleSheet("QPushButton { background:#2e5c8a; color:white; border:none;" max_h = SCREEN_H - TOOLBAR_H - 20
" border-radius:8px; padding:4px 12px; }" self.resize(pw, min(ph, max_h))
"QPushButton:hover { background:#3a7abf; }" self.move(SCREEN_W - pw - 4, TOOLBAR_H)
"QPushButton:pressed { background:#1a3a5c; }")
b.clicked.connect(lambda _, u=url: self.nav_cb(u))
return b
def _folder_btn(self, folder): def _refresh_our_window_ids(self):
b = QPushButton(f"+ {folder['name']}") ids = set()
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(110) for w in (self, self.overlay):
b.setCursor(Qt.PointingHandCursor) wid = int(w.winId())
b.setStyleSheet(f"QPushButton {{ background:{FOLDER_BG}; color:white; border:none;" if wid:
f" border-radius:8px; padding:4px 14px; }}" ids.add(wid)
f"QPushButton:hover {{ background:{FOLDER_HOVER}; }}" self._our_window_ids = ids
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): def _burger_global_rect(self):
self._build_menu(folder["children"]).exec_( btn = self.overlay.burger_btn
button.mapToGlobal(button.rect().bottomLeft())) origin = btn.mapToGlobal(QPoint(0, 0))
return QRect(origin, btn.size())
def _build_menu(self, items): def _add_items(self, vbox, items, depth):
menu = QMenu(); menu.setFont(QFont("Arial", 16)); menu.setStyleSheet(MENU_STYLE) indent = " " * depth
for item in items: for item in items:
if is_folder(item): if is_folder(item):
sub = self._build_menu(item["children"]) lbl = QLabel(f"{indent}{item['name']}")
sub.setTitle(f" > {item['name']}") lbl.setFont(QFont("Arial", 14, QFont.Bold))
sub.setFont(QFont("Arial", 16)); sub.setStyleSheet(MENU_STYLE) vbox.addWidget(lbl)
menu.addMenu(sub) self._add_items(vbox, item["children"], depth + 1)
else: 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"] url = item["url"]
act.triggered.connect(lambda _, u=url: self.nav_cb(u)) btn.clicked.connect(lambda _, u=url: self._nav(u))
menu.addAction(act) vbox.addWidget(btn)
return menu
def _scroll(self, dx): def _nav(self, url):
sb = self.scroll_area.horizontalScrollBar() self.hide()
sb.setValue(sb.value() + dx) self.cdp.navigate(url)
def _on_manage(self):
self.hide()
self.overlay.open_manager()
def hide(self):
self._dismiss_timer.stop()
super().hide()
def setVisible(self, visible):
super().setVisible(visible)
if visible:
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:
self._dismiss_timer.stop()
def _cursor_outside_panel(self):
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():
self._dismiss_timer.stop()
return
if time.time() - self._shown_at < self._OPEN_GRACE_SEC:
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()
def eventFilter(self, obj, event):
if event.type() == QEvent.MouseButtonPress and self.isVisible():
if not self.frameGeometry().contains(event.globalPos()):
self.hide()
return False
# ── Floating toolbar overlay ────────────────────────────────────── # ── Floating toolbar overlay ──────────────────────────────────────
@@ -457,58 +610,136 @@ class KioskOverlay(QWidget):
vbox = QVBoxLayout(self) vbox = QVBoxLayout(self)
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0) vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
vbox.addWidget(self._build_toolbar()) vbox.addWidget(self._build_toolbar())
self.bm_bar = BookmarksBar(cdp.navigate) self.bm_panel = BookmarkPanel(cdp, self)
vbox.addWidget(self.bm_bar)
self.show() 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)
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
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."""
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): def _build_toolbar(self):
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H) bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};") 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 = QLabel("SeniorNet")
brand.setFont(QFont("Arial", 24, QFont.Bold)) brand.setFont(QFont("Arial", 22, QFont.Bold))
brand.setStyleSheet("color:white;") brand.setStyleSheet("color:white;")
tl.addWidget(brand); tl.addStretch() 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)
# ◀ Back
back_btn = mk_icon_btn("", BTN_COLOR, BTN_HOVER)
back_btn.setToolTip("Go Back")
back_btn.clicked.connect(self.cdp.back) back_btn.clicked.connect(self.cdp.back)
fwd_btn.clicked.connect(self.cdp.forward) tl.addWidget(back_btn)
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
mgr_btn.clicked.connect(self.open_manager) # ▶ 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 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.setVisible(True)
def open_manager(self): def open_manager(self):
d = ManagerDialog(self); d.exec_() d = ManagerDialog(self)
self.bm_bar.reload() d.exec_()
def keyPressEvent(self, e): def keyPressEvent(self, e):
# Home key -> home screen
if e.key() == Qt.Key_Home: if e.key() == Qt.Key_Home:
self.cdp.navigate(HOME_URL) self.cdp.navigate(HOME_URL)
# Block all other dangerous keys silently
e.accept() e.accept()
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.KeyPress: if event.type() == QEvent.KeyPress:
key = event.key() key = event.key()
mods = event.modifiers() 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, 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_Tab, Qt.Key_F11, Qt.Key_Escape,
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3, Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10} Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
if mods & (Qt.AltModifier | Qt.ControlModifier): if mods & (Qt.AltModifier | Qt.ControlModifier):
if key in blocked_keys: if key in blocked_keys:
return True # swallow the event return True
# Also block bare Escape and F-keys
if key in {Qt.Key_Escape, Qt.Key_F11}: if key in {Qt.Key_Escape, Qt.Key_F11}:
return True return True
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -516,16 +747,15 @@ class KioskOverlay(QWidget):
# ── Chrome launcher ─────────────────────────────────────────────── # ── Chrome launcher ───────────────────────────────────────────────
def launch_chrome(): def launch_chrome():
import pathlib, json as _json, shutil profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
profile_dir = pathlib.Path("/home/jgitta/.config/google-chrome-kiosk")
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
# Remove stale singleton locks
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"): for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try: (profile_dir / name).unlink() try:
except FileNotFoundError: pass (profile_dir / name).unlink()
except FileNotFoundError:
pass
# Wipe GPU/ML caches that corrupt across restarts
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache", for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel", "OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
"optimization_guide_model_store", "optimization_guide_model_store",
@@ -535,36 +765,42 @@ def launch_chrome():
if p.exists(): if p.exists():
shutil.rmtree(p, ignore_errors=True) shutil.rmtree(p, ignore_errors=True)
# Patch Local State
ls_path = profile_dir / "Local State" ls_path = profile_dir / "Local State"
if ls_path.exists(): if ls_path.exists():
try: try:
d = _json.loads(ls_path.read_text()) d = json.loads(ls_path.read_text())
d.setdefault("profile", {})["exit_type"] = "Normal" d.setdefault("profile", {})["exit_type"] = "Normal"
d["hardware_acceleration_mode_previous"] = False d["hardware_acceleration_mode_previous"] = False
ls_path.write_text(_json.dumps(d)) ls_path.write_text(json.dumps(d))
except Exception: except Exception:
pass pass
cookies_db = profile_dir / "Default" / "Cookies"
if cookies_db.exists():
try:
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 chrome_h = SCREEN_H - TOP_H
cmd = [ cmd = [
"google-chrome", "chromium",
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
"--disable-translate",
"--disable-infobars",
"--disable-session-crashed-bubble",
"--disable-save-password-bubble",
"--disable-restore-session-state", "--disable-restore-session-state",
"--disable-default-browser-check",
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading", "--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
"--disable-component-update", "--disable-component-update",
"--disable-background-networking", "--disable-background-networking",
"--disable-extensions", "--disable-extensions",
"--disable-gpu", "--disable-gpu",
"--no-sandbox",
"--test-type",
"--disable-dev-shm-usage", "--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}", f"--remote-debugging-port={CDP_PORT}",
"--remote-allow-origins=*", "--remote-allow-origins=*",
f"--window-position=0,{TOP_H}", f"--window-position=0,{TOP_H}",
@@ -577,6 +813,9 @@ def launch_chrome():
# ── Main ────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
app = QApplication(sys.argv) 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") app.setApplicationName("SeniorNet")
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p) p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
@@ -585,9 +824,28 @@ if __name__ == "__main__":
chrome_proc = launch_chrome() 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(): def init_cdp():
if cdp.wait_ready(timeout=30): if cdp.wait_ready(timeout=30):
print("CDP ready — kiosk running") print("CDP ready — kiosk running")
dismiss_chrome_dialogs()
threading.Thread(target=init_cdp, daemon=True).start() threading.Thread(target=init_cdp, daemon=True).start()
sys.exit(app.exec_()) sys.exit(app.exec_())
Executable
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
set -e
# Build SeniorNet Kiosk ISO
# Run on workstation: ./build-iso.sh
# Requires: debian-live-build, sudo
echo "==> SeniorNet Kiosk ISO Builder"
echo
if [ "$EUID" -ne 0 ]; then
echo "ERROR: Run as root (sudo ./build-iso.sh)"
exit 1
fi
# Check dependencies
if ! command -v lb &>/dev/null; then
echo "==> Installing debian-live-build..."
apt-get update
apt-get install -y debian-live-build
fi
BUILD_DIR="/tmp/kiosk-build-$$"
OUTPUT_DIR="$(pwd)/iso-output"
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$BUILD_DIR" "$OUTPUT_DIR"
cd "$BUILD_DIR"
echo "==> Initializing live-build..."
lb config \
--distribution trixie \
--architectures amd64 \
--image-type iso-hybrid \
--bootappend-live "boot=live components autologin username=root quiet splash" \
--bootappend-install "preseed/file=/preseed.cfg auto=true priority=critical" \
--linux-packages "linux-image-amd64" \
--parent-mirror-bootstrap "https://deb.debian.org/debian" \
--parent-mirror-binary "https://deb.debian.org/debian" \
--parent-mirror-binary-security "https://security.debian.org/debian-security" \
--parent-distribution-binary-security trixie-security
echo "==> Adding packages..."
mkdir -p config/package-lists
cat > config/package-lists/kiosk.list.chroot << 'PKGLIST'
# Base system
openssh-server
build-essential
git
curl
wget
sudo
# Display and window manager
xserver-xorg
xserver-xorg-video-dummy
xinit
openbox
xvfb
xdotool
xprop
# Chromium browser
chromium
# Python and kiosk app
python3
python3-pip
sqlite3
PKGLIST
echo "==> Adding preseed file..."
mkdir -p config/includes.installer
cp "$REPO_DIR/preseed.cfg" config/includes.installer/
echo "==> Adding hook script..."
mkdir -p config/hooks/normal
cat > config/hooks/normal/9999-kiosk-postinstall.chroot << 'HOOK'
#!/bin/bash
set -e
echo "==> Running kiosk post-install hook..."
# Install Python packages
pip3 install --break-system-packages PyQt5 websocket-client
# Clone repo
if [ ! -d /home/jgitta/kiosk-browser ]; then
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git /home/jgitta/kiosk-browser
cd /home/jgitta/kiosk-browser
bash install-kiosk.sh
fi
echo "==> Kiosk post-install complete"
HOOK
chmod +x config/hooks/normal/9999-kiosk-postinstall.chroot
echo "==> Building ISO (this may take 5-10 minutes)..."
lb build 2>&1 | tail -20
ISO_FILE="live-image-amd64.hybrid.iso"
if [ -f "$ISO_FILE" ]; then
cp "$ISO_FILE" "$OUTPUT_DIR/seniornet-kiosk.iso"
echo
echo "==> ✓ ISO build complete!"
echo " Output: $OUTPUT_DIR/seniornet-kiosk.iso"
echo
echo "Next steps:"
echo " 1. Write to USB:"
echo " sudo dd if=$OUTPUT_DIR/seniornet-kiosk.iso of=/dev/sdX bs=4M status=progress && sync"
echo
echo " 2. Boot from USB and wait ~10 minutes for silent installation"
echo
echo " 3. Machine will reboot into kiosk mode automatically"
echo
else
echo "ERROR: ISO build failed"
exit 1
fi
+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"
}
}
+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.
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/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)"
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")"
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"
fi
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
# 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."
fi
echo "==> Deploy complete!"
+40 -38
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">
@@ -94,46 +95,45 @@
<div id="clock">12:00 PM</div> <div id="clock">12:00 PM</div>
<div id="date">Wednesday, January 1</div> <div id="date">Wednesday, January 1</div>
<div class="grid"> <div class="grid" id="buttonGrid"></div>
<a class="btn btn-gmail" href="https://mail.google.com">
<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>
<script> <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() { function updateClock() {
const now = new Date(); const now = new Date();
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true }; const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
@@ -141,6 +141,8 @@
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts); document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts); document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
} }
loadButtons();
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
</script> </script>
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
set -e
# Post-install script for SeniorNet Kiosk ISO
# Runs after Debian installation completes
echo "==> SeniorNet Kiosk Post-Install"
echo
REPO_DIR="/home/jgitta/kiosk-browser"
HOME_DIR="/home/jgitta/kiosk-home"
CHROMIUM_PROFILE="/home/jgitta/chromium-kiosk"
# Ensure jgitta user exists
if ! id "jgitta" &>/dev/null; then
useradd -m -s /bin/bash jgitta
fi
# Install packages
echo "==> Installing dependencies..."
apt-get update
apt-get install -y python3 python3-pip chromium openbox xserver-xorg xserver-xorg-video-dummy xinit xdotool xprop git sqlite3 xvfb 2>&1 | grep -v "^Reading"
# Install Python packages
echo "==> Installing Python packages..."
pip3 install --break-system-packages PyQt5 websocket-client 2>&1 | tail -5
# Clone repo
echo "==> Cloning repository..."
if [ ! -d "$REPO_DIR" ]; then
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git "$REPO_DIR"
fi
cd "$REPO_DIR"
# Deploy home screen with config
echo "==> Deploying home screen..."
mkdir -p "$HOME_DIR"
if [ -f config.json ] && [ -f home-screen-index.html ]; then
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_DIR/index.html"
chown jgitta:jgitta "$HOME_DIR/index.html"
fi
# Create Chromium profile directory
mkdir -p "$CHROMIUM_PROFILE"
chown -R jgitta:jgitta "$CHROMIUM_PROFILE"
# Systemd units
echo "==> Installing systemd units..."
cat > /etc/systemd/system/kiosk-display.service << 'UNIT1'
[Unit]
Description=SeniorNet Kiosk Display
Before=kiosk.service
After=network.target
[Service]
Type=forking
User=jgitta
ExecStart=/bin/bash -c 'Xvfb :0 -screen 0 1920x1080x24 -ac &'
ExecStartPost=/bin/sleep 2
ExecStartPost=/bin/bash -c 'DISPLAY=:0 su - jgitta -c "openbox &"'
ExecStartPost=/bin/sleep 1
[Install]
WantedBy=multi-user.target
UNIT1
cat > /etc/systemd/system/kiosk.service << 'UNIT2'
[Unit]
Description=SeniorNet Kiosk
After=network.target kiosk-display.service
Wants=kiosk-display.service
[Service]
Type=simple
User=jgitta
Environment="DISPLAY=:0"
WorkingDirectory=/home/jgitta/kiosk-browser
ExecStart=/usr/bin/python3 /home/jgitta/kiosk-browser/browser.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT2
systemctl daemon-reload
systemctl enable kiosk-display.service kiosk.service
# kiosk-restart helper
cat > /usr/local/bin/kiosk-restart << 'HELPER'
#!/bin/bash
pkill -f "remote-debugging-port=9222" || true
pkill -f "python3.*browser" || true
sleep 1
systemctl restart kiosk.service
HELPER
chmod +x /usr/local/bin/kiosk-restart
# Openbox config
echo "==> Configuring Openbox..."
mkdir -p /home/jgitta/.config/openbox
cat > /home/jgitta/.config/openbox/rc.xml << 'OPENBOX'
<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc">
<startup/>
</openbox_config>
OPENBOX
cat > /home/jgitta/.xinitrc << 'XINITRC'
#!/bin/bash
exec openbox
XINITRC
chmod +x /home/jgitta/.xinitrc
# X11 hardening
echo "==> Configuring X11..."
cat > /etc/X11/xorg.conf.d/50-kiosk.conf << 'XORG'
Section "Device"
Identifier "Dummy0"
Driver "dummy"
EndSection
Section "Monitor"
Identifier "Monitor0"
HorizSync 30-80
VertRefresh 30-75
EndSection
Section "Screen"
Identifier "Screen0"
Device "Dummy0"
Monitor "Monitor0"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "1920x1080"
EndSubSection
EndSection
Section "ServerFlags"
Option "DontVTSwitch" "true"
Option "BlankTime" "0"
Option "StandbyTime" "0"
EndSection
XORG
# Permissions
echo "==> Setting permissions..."
chown -R jgitta:jgitta /home/jgitta/.config /home/jgitta/.xinitrc "$REPO_DIR" "$HOME_DIR"
# Enable getty on ttys for troubleshooting (optional, comment out for full lockdown)
systemctl set-default multi-user.target
echo
echo "==> Post-install complete"
echo " Machine will auto-start kiosk on next boot"
echo
+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
+58
View File
@@ -0,0 +1,58 @@
# Debian Preseed — SeniorNet Kiosk
# Silent/unattended installer configuration
# Locale and keyboard
d-i debian-installer/locale string en_US.UTF-8
d-i keyboard-configuration/xkb-keymap select us
# Network
d-i netcfg/choose_interface select auto
d-i netcfg/get_hostname string kiosk
d-i netcfg/get_domain string local
d-i netcfg/hostname string kiosk
# Mirror
d-i mirror/country string manual
d-i mirror/http/hostname string deb.debian.org
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string
# Partitioning — use entire disk
d-i partman-auto/method string lvm
d-i partman-lvm/device_remove_lvm boolean true
d-i partman-auto/purge_lvm_confirmation boolean true
d-i partman-auto/disk string /dev/sda
d-i partman-auto/choose_recipe select atomic
d-i partman/confirm_write_new_label boolean true
d-i partman/confirm boolean true
# Clock/timezone
d-i time/zone string UTC
d-i clock-setup/utc boolean true
d-i clock-setup/ntp boolean true
# Account setup — root with no password
d-i passwd/root-login boolean true
d-i passwd/make-user boolean false
d-i passwd/root-password password kiosk
d-i passwd/root-password-again password kiosk
# APT
d-i apt-setup/services-select multiselect security, updates
d-i apt-setup/security_host string security.debian.org
# Package selection
tasksel tasksel/first multiselect standard, ssh-server
d-i pkgsel/include string openssh-server build-essential git curl wget
# Grub bootloader
d-i grub-installer/only boolean true
d-i grub-installer/bootdev string default
# Finish installation
d-i finish-install/reboot_in_background boolean true
# Run post-install script
d-i preseed/late_command string \
in-target bash -c 'curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/master/install-kiosk.sh | bash' || \
in-target bash -c 'cd /root && git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git && bash senior-kiosk/install-kiosk.sh'
+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;
}
}