Compare commits

...

4 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
15 changed files with 679 additions and 212 deletions
-113
View File
@@ -1,113 +0,0 @@
# SeniorNet Kiosk — Project Context for AI Assistants
## What this project is
A locked-down, zero-maintenance browser workstation for elderly users. The senior never sees a desktop — only a full-screen Chromium window with a slim toolbar on top. No configuration, no maintenance required from the user.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ PyQt5 toolbar (72px, always-on-top, FramelessHint) │
│ [SeniorNet] [◀] [▶] [⌂] [☰ bookmarks] │
├─────────────────────────────────────────────────────┤
│ │
│ Chromium --app mode │
│ (CDP port 9222 for navigation) │
│ │
└─────────────────────────────────────────────────────┘
```
- `browser.py` — main app. Launches both the PyQt5 toolbar and Chromium as a subprocess.
- Toolbar talks to Chromium via Chrome DevTools Protocol (CDP) over WebSocket on port 9222.
- Bookmarks stored in `bookmarks.json` (nested JSON, supports folders).
- Home screen: `/home/jgitta/kiosk-home/index.html`
## Machines
### kiosk-dev — 192.168.88.48 (SSH alias: `kiosk-dev`) ← ACTIVE DEV MACHINE
- Debian 13 Trixie, Proxmox VM 130 on jg-hud
- System Chromium: `/usr/lib/chromium/chromium` — NOT snap
- Repo at: `/home/jgitta/kiosk-browser/`
- This is the machine being actively developed and tested on
### Production kiosk — 192.168.88.44 (SSH alias: `kiosk`)
- Ubuntu 24.04 + snap Chromium — needs to be migrated to Debian to match kiosk-dev
- Currently offline / pending migration
## Deploy workflow
```bash
bash /home/jgitta/kiosk-browser/deploy.sh "describe your change"
```
This: commits + pushes to Gitea, restarts kiosk-dev immediately, and deploys to production if reachable.
**CRITICAL**: `kiosk-restart` must kill Chromium (`pkill -f "remote-debugging-port=9222"`) BEFORE killing browser.py. If only Python is killed, Chromium holds the SingletonLock and the new instance silently exits — deploys appear to work but nothing changes on screen.
`/usr/local/bin/kiosk-restart` on kiosk-dev:
```bash
pkill -f "remote-debugging-port=9222" || true
pkill -f "python3.*browser" || true
sleep 1
```
## Key files
| File | Purpose |
|------|---------|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
| `bookmarks.json` | Nested bookmark structure |
| `deploy.sh` | One-command deploy |
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
## Gitea repo
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
## Roundcube webmail
- URL: https://mail.jgitta.com/roundcube/ (proxied via Caddy at 192.168.88.110)
- LXC container 203 on jg-hud, IP: 192.168.88.45
- Ubuntu 24.04, Apache2, PHP, MariaDB, Roundcube 1.6.6
- Gmail IMAP: `ssl://imap.gmail.com:993`
- Autologin plugin: `/var/lib/roundcube/plugins/autologin/autologin.php`
- Logs: `/var/lib/roundcube/logs/autologin.log` and `errors.log`
- Run commands on container: `ssh jg-hud "pct exec 203 -- bash -c '...'"` (jg-hud is the Proxmox host)
- browser.py clears the Roundcube session cookie on every startup (forces fresh autologin)
## Known hard-won lessons
### Chromium
- `--no-sandbox` causes "unsupported flag" infobar — never use it on Debian system Chromium
- `--test-type` triggers Google CAPTCHA bot detection — never use it
- `--disable-blink-features=AutomationControlled` causes "unsupported flag" infobar — removed
- `--disable-infobars`, `--disable-session-crashed-bubble`, `--disable-save-password-bubble`, `--suppress-message-center-popups`, `--disable-translate` are all deprecated/removed in current Chromium — do not use
- Profile dir: `/home/jgitta/chromium-kiosk` (not hidden — snap couldn't write hidden dirs; kept this convention)
- Chromium adds its own flags via the Debian wrapper (`--show-component-extension-options` etc.) — ignore these, they're harmless
### PyQt5 / Openbox
- `QMenu` does NOT work under Openbox/X11 with `Qt.Tool | WindowStaysOnTopHint` parent — use a custom `QWidget` with the same flags instead (BookmarkPanel)
- Qt event filters only see clicks on Qt widgets — clicks that land on Chromium (separate X11 process) are invisible. Use a `QTimer` polling `QApplication.mouseButtons()` + `QCursor.pos()` to detect outside clicks.
- To prevent toolbar minimize: override `changeEvent`, detect `Qt.WindowMinimized`, call `QTimer.singleShot(50, self._self_restore)`
- To restore minimized Chromium: periodic `QTimer` + `xprop -id <wid> _NET_WM_STATE` to check `_NET_WM_STATE_HIDDEN`, then `xdotool windowmap <wid>`
### Roundcube autologin
- Plugin MUST call both `session->regenerate_id(false)` AND `session->set_auth_cookie()` after login
- Logs are in `/var/lib/roundcube/logs/` NOT `/var/log/roundcube/`
### Deployment
- `pkill -f browser.py` in SSH kills its own session (shell cmdline contains "browser.py") — use `kiosk-restart`
- Chromium must be killed before Python or SingletonLock prevents new instance from starting
## Infrastructure overview
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
- **TrueNAS**: 192.168.88.24
- **Thinkstation** (dev desktop): 192.168.88.41
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
- **Gitea**: gitea.jgitta.com (port 3002 internal)
- **Headscale**: self-hosted Tailscale on siklos
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
*.pyo
.env
credentials.md
+22 -5
View File
@@ -58,15 +58,32 @@ sleep 1
|------|---------|
| `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 |
| `/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
## Gitea repo (see also `jgitta/homelab-configs`)
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
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
@@ -108,6 +125,6 @@ sleep 1
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
- **TrueNAS**: 192.168.88.24
- **Thinkstation** (dev desktop): 192.168.88.41
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
- **Gitea**: gitea.jgitta.com (port 3002 internal)
- **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
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
+120 -42
View File
@@ -4,9 +4,9 @@ SeniorNet Kiosk — PyQt5 single-row floating toolbar over Google Chrome.
Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks.
Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket.
"""
import sys, json, os, subprocess, time, threading, urllib.request
from PyQt5.QtCore import Qt, QTimer, QPoint
from PyQt5.QtGui import QFont, QColor, QPalette
import sys, json, os, subprocess, time, threading, urllib.request, websocket, pathlib, shutil, sqlite3
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QEvent
from PyQt5.QtGui import QFont, QColor, QPalette, QCursor
from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
@@ -25,12 +25,18 @@ SCREEN_H = 1080 # overwritten at runtime
# ── Colour palette ────────────────────────────────────────────────
TOOLBAR_COLOR = "#1a3a5c"
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
RED = "#c62828"; RED_HOVER = "#d32f2f"
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
BTN_COLOR = "#2e6da4"
BTN_HOVER = "#3a87cc"
GOLD = "#e8a020"
GOLD_HOVER = "#f0b030"
GREEN = "#2e7d32"
GREEN_HOVER = "#388e3c"
RED = "#c62828"
RED_HOVER = "#d32f2f"
AMBER = "#7a5a10"
AMBER_HOVER = "#9a7a20"
PURPLE = "#5a5a8a"
PURPLE_HOVER = "#7a7aaa"
FONT_SIZE = 18
MENU_STYLE = """
@@ -74,8 +80,10 @@ def mk_icon_btn(icon, color, hover, size=58, fs=26):
def load_bm():
try:
with open(BOOKMARKS_FILE) as f: return json.load(f)
except: return []
with open(BOOKMARKS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_bm(data):
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
@@ -113,7 +121,6 @@ class CDP:
def _send(self, method, params=None):
if not self.ready:
return
import websocket
with self._lock:
try:
ws = websocket.create_connection(self._ws_url(), timeout=3)
@@ -132,7 +139,6 @@ class CDP:
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
if not self.ready:
return
import websocket
with self._lock:
try:
ws = websocket.create_connection(self._ws_url(), timeout=3)
@@ -377,6 +383,43 @@ class ManagerDialog(QDialog):
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 ───────────────────────────────────────
class BookmarkPanel(QWidget):
"""
@@ -384,10 +427,15 @@ class BookmarkPanel(QWidget):
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__()
self.cdp = cdp
self.overlay = overlay
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)
@@ -403,10 +451,8 @@ class BookmarkPanel(QWidget):
""")
QApplication.instance().installEventFilter(self)
# Timer: hide panel when user clicks anywhere outside it
# (clicks on Chromium never reach Qt's eventFilter)
self._click_guard = QTimer()
self._click_guard.timeout.connect(self._check_outside_click)
self._dismiss_timer = QTimer()
self._dismiss_timer.timeout.connect(self._check_dismiss)
def rebuild(self, items):
# Remove old layout
@@ -459,6 +505,19 @@ class BookmarkPanel(QWidget):
self.resize(pw, min(ph, max_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):
indent = " " * depth
for item in items:
@@ -484,28 +543,51 @@ class BookmarkPanel(QWidget):
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._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:
self._click_guard.stop()
self._dismiss_timer.stop()
def _check_outside_click(self):
"""Hide panel when a click lands outside it — even on the Chromium window."""
from PyQt5.QtGui import QCursor
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._click_guard.stop()
self._dismiss_timer.stop()
return
if QApplication.mouseButtons() & Qt.LeftButton:
if not self.geometry().contains(QCursor.pos()):
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):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.MouseButtonPress and self.isVisible():
gp = event.globalPos()
if not self.geometry().contains(gp):
if not self.frameGeometry().contains(event.globalPos()):
self.hide()
return False
@@ -549,6 +631,9 @@ class KioskOverlay(QWidget):
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)
@@ -557,7 +642,6 @@ class KioskOverlay(QWidget):
def changeEvent(self, event):
"""Un-minimise the toolbar the instant it gets iconified."""
from PyQt5.QtCore import QEvent
if event.type() == QEvent.WindowStateChange:
if self.windowState() & Qt.WindowMinimized:
QTimer.singleShot(50, self._self_restore)
@@ -634,8 +718,7 @@ class KioskOverlay(QWidget):
self.bm_panel.hide()
else:
self.bm_panel.rebuild(load_bm())
self.bm_panel.show()
self.bm_panel.raise_()
self.bm_panel.setVisible(True)
def open_manager(self):
d = ManagerDialog(self)
@@ -647,7 +730,6 @@ class KioskOverlay(QWidget):
e.accept()
def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.KeyPress:
key = event.key()
mods = event.modifiers()
@@ -665,13 +747,14 @@ class KioskOverlay(QWidget):
# ── Chrome launcher ───────────────────────────────────────────────
def launch_chrome():
import pathlib, json as _json, shutil
profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
profile_dir.mkdir(parents=True, exist_ok=True)
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try: (profile_dir / name).unlink()
except FileNotFoundError: pass
try:
(profile_dir / name).unlink()
except FileNotFoundError:
pass
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
@@ -685,21 +768,16 @@ def launch_chrome():
ls_path = profile_dir / "Local State"
if ls_path.exists():
try:
d = _json.loads(ls_path.read_text())
d = json.loads(ls_path.read_text())
d.setdefault("profile", {})["exit_type"] = "Normal"
d["hardware_acceleration_mode_previous"] = False
ls_path.write_text(_json.dumps(d))
ls_path.write_text(json.dumps(d))
except Exception:
pass
# Clear the stale Roundcube session cookie so the autologin plugin always
# fires cleanly. Without this, Chrome sends the old cookie after a reboot,
# the PHP session in MariaDB has expired, and Roundcube shows
# "session is invalid or expired" instead of auto-logging in.
cookies_db = profile_dir / "Default" / "Cookies"
if cookies_db.exists():
try:
import sqlite3
conn = sqlite3.connect(str(cookies_db))
conn.execute(
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
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"
}
}
-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.
+30
View File
@@ -7,8 +7,35 @@
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)"
@@ -33,6 +60,9 @@ 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."
+40 -38
View File
@@ -1,4 +1,5 @@
<!DOCTYPE html>
<!-- Source for /home/jgitta/kiosk-home/index.html — copied by deploy.sh and install.sh -->
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -94,46 +95,45 @@
<div id="clock">12:00 PM</div>
<div id="date">Wednesday, January 1</div>
<div class="grid">
<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>
<div class="grid" id="buttonGrid"></div>
<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() {
const now = new Date();
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
@@ -141,6 +141,8 @@
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
}
loadButtons();
updateClock();
setInterval(updateClock, 1000);
</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 ==="
apt-get update
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
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" \
/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
for i in 2 3 4 5 6; do
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'