Compare commits
4 Commits
b99f058bfb
...
b7866000b4
| Author | SHA1 | Date | |
|---|---|---|---|
| b7866000b4 | |||
| 38f7ae467a | |||
| c8534c967c | |||
| 8553705e47 |
-113
@@ -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
|
|
||||||
@@ -2,3 +2,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
|
credentials.md
|
||||||
|
|||||||
@@ -58,15 +58,32 @@ sleep 1
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
||||||
| `bookmarks.json` | Nested bookmark structure |
|
| `bookmarks.json` | Nested bookmark structure |
|
||||||
|
| `home-screen-index.html` | Home screen source (deploy copies to `/home/jgitta/kiosk-home/index.html`) |
|
||||||
| `deploy.sh` | One-command deploy |
|
| `deploy.sh` | One-command deploy |
|
||||||
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
|
| `/home/jgitta/kiosk-home/index.html` | Senior home screen (live copy, outside repo) |
|
||||||
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
||||||
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
||||||
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
||||||
|
|
||||||
## Gitea repo
|
## Gitea repo (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
|
## Roundcube webmail
|
||||||
|
|
||||||
@@ -108,6 +125,6 @@ sleep 1
|
|||||||
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
||||||
- **TrueNAS**: 192.168.88.24
|
- **TrueNAS**: 192.168.88.24
|
||||||
- **Thinkstation** (dev desktop): 192.168.88.41
|
- **Thinkstation** (dev desktop): 192.168.88.41
|
||||||
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
|
- **Caddy reverse proxy**: 192.168.88.110 — terminates HTTPS for `*.jgitta.com` (including `gitea.jgitta.com` → Gitea :3002)
|
||||||
- **Gitea**: gitea.jgitta.com (port 3002 internal)
|
- **Gitea**: behind Caddy at `gitea.jgitta.com`; app listens on port 3002 on the Gitea host
|
||||||
- **Headscale**: self-hosted Tailscale on siklos
|
- **Headscale**: self-hosted Tailscale on siklos
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
+121
-43
@@ -4,9 +4,9 @@ SeniorNet Kiosk — PyQt5 single-row floating toolbar over Google Chrome.
|
|||||||
Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks.
|
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, QPoint
|
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,
|
||||||
@@ -25,12 +25,18 @@ SCREEN_H = 1080 # overwritten at runtime
|
|||||||
|
|
||||||
# ── Colour palette ────────────────────────────────────────────────
|
# ── Colour palette ────────────────────────────────────────────────
|
||||||
TOOLBAR_COLOR = "#1a3a5c"
|
TOOLBAR_COLOR = "#1a3a5c"
|
||||||
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
|
BTN_COLOR = "#2e6da4"
|
||||||
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
|
BTN_HOVER = "#3a87cc"
|
||||||
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
|
GOLD = "#e8a020"
|
||||||
RED = "#c62828"; RED_HOVER = "#d32f2f"
|
GOLD_HOVER = "#f0b030"
|
||||||
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
|
GREEN = "#2e7d32"
|
||||||
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
|
GREEN_HOVER = "#388e3c"
|
||||||
|
RED = "#c62828"
|
||||||
|
RED_HOVER = "#d32f2f"
|
||||||
|
AMBER = "#7a5a10"
|
||||||
|
AMBER_HOVER = "#9a7a20"
|
||||||
|
PURPLE = "#5a5a8a"
|
||||||
|
PURPLE_HOVER = "#7a7aaa"
|
||||||
FONT_SIZE = 18
|
FONT_SIZE = 18
|
||||||
|
|
||||||
MENU_STYLE = """
|
MENU_STYLE = """
|
||||||
@@ -74,8 +80,10 @@ def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
|||||||
|
|
||||||
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)
|
||||||
@@ -113,7 +121,6 @@ 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)
|
||||||
@@ -132,7 +139,6 @@ class CDP:
|
|||||||
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
||||||
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)
|
||||||
@@ -377,6 +383,43 @@ class ManagerDialog(QDialog):
|
|||||||
self.tree.setCurrentItem(sel); self._commit()
|
self.tree.setCurrentItem(sel); self._commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── X11 helpers (clicks/focus on Chromium never reach Qt) ───────────
|
||||||
|
def _x11_env():
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("DISPLAY", ":0")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _x11_run(cmd, timeout=0.4):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True,
|
||||||
|
timeout=timeout, env=_x11_env())
|
||||||
|
return r.stdout.strip() if r.returncode == 0 else ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _x11_active_window_id():
|
||||||
|
out = _x11_run(["xdotool", "getactivewindow"])
|
||||||
|
return int(out) if out.isdigit() else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _x11_left_button_down():
|
||||||
|
"""True when the physical left mouse button is held (works across all apps)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["bash", "-c",
|
||||||
|
"DISPLAY=:0 dev=$(xinput --list --short 2>/dev/null | "
|
||||||
|
"awk '/slave.*pointer/ {print $2; exit}'); "
|
||||||
|
"[ -n \"$dev\" ] && xinput query-state \"$dev\" 2>/dev/null | "
|
||||||
|
"grep -q 'button\\[1\\]=down'"],
|
||||||
|
timeout=0.3, env=_x11_env())
|
||||||
|
return r.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmark dropdown panel ───────────────────────────────────────
|
# ── Bookmark dropdown panel ───────────────────────────────────────
|
||||||
class BookmarkPanel(QWidget):
|
class BookmarkPanel(QWidget):
|
||||||
"""
|
"""
|
||||||
@@ -384,10 +427,15 @@ class BookmarkPanel(QWidget):
|
|||||||
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
|
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
|
||||||
always appears above Chrome, unlike QMenu which has z-order issues.
|
always appears above Chrome, unlike QMenu which has z-order issues.
|
||||||
"""
|
"""
|
||||||
|
_OPEN_GRACE_SEC = 0.35
|
||||||
|
|
||||||
def __init__(self, cdp, overlay):
|
def __init__(self, cdp, overlay):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cdp = cdp
|
self.cdp = cdp
|
||||||
self.overlay = overlay
|
self.overlay = overlay
|
||||||
|
self._shown_at = 0.0
|
||||||
|
self._focus_at_open = 0
|
||||||
|
self._our_window_ids = set()
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(
|
||||||
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
|
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
@@ -403,10 +451,8 @@ class BookmarkPanel(QWidget):
|
|||||||
""")
|
""")
|
||||||
QApplication.instance().installEventFilter(self)
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
# Timer: hide panel when user clicks anywhere outside it
|
self._dismiss_timer = QTimer()
|
||||||
# (clicks on Chromium never reach Qt's eventFilter)
|
self._dismiss_timer.timeout.connect(self._check_dismiss)
|
||||||
self._click_guard = QTimer()
|
|
||||||
self._click_guard.timeout.connect(self._check_outside_click)
|
|
||||||
|
|
||||||
def rebuild(self, items):
|
def rebuild(self, items):
|
||||||
# Remove old layout
|
# Remove old layout
|
||||||
@@ -459,6 +505,19 @@ class BookmarkPanel(QWidget):
|
|||||||
self.resize(pw, min(ph, max_h))
|
self.resize(pw, min(ph, max_h))
|
||||||
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
|
|
||||||
|
def _refresh_our_window_ids(self):
|
||||||
|
ids = set()
|
||||||
|
for w in (self, self.overlay):
|
||||||
|
wid = int(w.winId())
|
||||||
|
if wid:
|
||||||
|
ids.add(wid)
|
||||||
|
self._our_window_ids = ids
|
||||||
|
|
||||||
|
def _burger_global_rect(self):
|
||||||
|
btn = self.overlay.burger_btn
|
||||||
|
origin = btn.mapToGlobal(QPoint(0, 0))
|
||||||
|
return QRect(origin, btn.size())
|
||||||
|
|
||||||
def _add_items(self, vbox, items, depth):
|
def _add_items(self, vbox, items, depth):
|
||||||
indent = " " * depth
|
indent = " " * depth
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -484,28 +543,51 @@ class BookmarkPanel(QWidget):
|
|||||||
self.hide()
|
self.hide()
|
||||||
self.overlay.open_manager()
|
self.overlay.open_manager()
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
self._dismiss_timer.stop()
|
||||||
|
super().hide()
|
||||||
|
|
||||||
def setVisible(self, visible):
|
def setVisible(self, visible):
|
||||||
super().setVisible(visible)
|
super().setVisible(visible)
|
||||||
if visible:
|
if visible:
|
||||||
self._click_guard.start(100)
|
self._shown_at = time.time()
|
||||||
|
self._refresh_our_window_ids()
|
||||||
|
self._focus_at_open = _x11_active_window_id()
|
||||||
|
self.raise_()
|
||||||
|
self.overlay.raise_()
|
||||||
|
self._dismiss_timer.start(100)
|
||||||
else:
|
else:
|
||||||
self._click_guard.stop()
|
self._dismiss_timer.stop()
|
||||||
|
|
||||||
def _check_outside_click(self):
|
def _cursor_outside_panel(self):
|
||||||
"""Hide panel when a click lands outside it — even on the Chromium window."""
|
pos = QCursor.pos()
|
||||||
from PyQt5.QtGui import QCursor
|
if self.frameGeometry().contains(pos):
|
||||||
|
return False
|
||||||
|
if self._burger_global_rect().contains(pos):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _check_dismiss(self):
|
||||||
|
"""Close when focus leaves or user clicks outside the panel."""
|
||||||
if not self.isVisible():
|
if not self.isVisible():
|
||||||
self._click_guard.stop()
|
self._dismiss_timer.stop()
|
||||||
return
|
return
|
||||||
if QApplication.mouseButtons() & Qt.LeftButton:
|
if time.time() - self._shown_at < self._OPEN_GRACE_SEC:
|
||||||
if not self.geometry().contains(QCursor.pos()):
|
return
|
||||||
self.hide()
|
|
||||||
|
# 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):
|
def eventFilter(self, obj, event):
|
||||||
from PyQt5.QtCore import QEvent
|
|
||||||
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||||||
gp = event.globalPos()
|
if not self.frameGeometry().contains(event.globalPos()):
|
||||||
if not self.geometry().contains(gp):
|
|
||||||
self.hide()
|
self.hide()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -549,6 +631,9 @@ class KioskOverlay(QWidget):
|
|||||||
SCREEN_W, SCREEN_H = w, h
|
SCREEN_W, SCREEN_H = w, h
|
||||||
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
||||||
self.resize(SCREEN_W, TOP_H)
|
self.resize(SCREEN_W, TOP_H)
|
||||||
|
if self.bm_panel.isVisible():
|
||||||
|
pw = self.bm_panel.width()
|
||||||
|
self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
# Resize Chrome after brief delay so X has settled
|
# Resize Chrome after brief delay so X has settled
|
||||||
QTimer.singleShot(500, self._resize_chrome)
|
QTimer.singleShot(500, self._resize_chrome)
|
||||||
|
|
||||||
@@ -557,7 +642,6 @@ class KioskOverlay(QWidget):
|
|||||||
|
|
||||||
def changeEvent(self, event):
|
def changeEvent(self, event):
|
||||||
"""Un-minimise the toolbar the instant it gets iconified."""
|
"""Un-minimise the toolbar the instant it gets iconified."""
|
||||||
from PyQt5.QtCore import QEvent
|
|
||||||
if event.type() == QEvent.WindowStateChange:
|
if event.type() == QEvent.WindowStateChange:
|
||||||
if self.windowState() & Qt.WindowMinimized:
|
if self.windowState() & Qt.WindowMinimized:
|
||||||
QTimer.singleShot(50, self._self_restore)
|
QTimer.singleShot(50, self._self_restore)
|
||||||
@@ -634,8 +718,7 @@ class KioskOverlay(QWidget):
|
|||||||
self.bm_panel.hide()
|
self.bm_panel.hide()
|
||||||
else:
|
else:
|
||||||
self.bm_panel.rebuild(load_bm())
|
self.bm_panel.rebuild(load_bm())
|
||||||
self.bm_panel.show()
|
self.bm_panel.setVisible(True)
|
||||||
self.bm_panel.raise_()
|
|
||||||
|
|
||||||
def open_manager(self):
|
def open_manager(self):
|
||||||
d = ManagerDialog(self)
|
d = ManagerDialog(self)
|
||||||
@@ -647,7 +730,6 @@ class KioskOverlay(QWidget):
|
|||||||
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()
|
||||||
@@ -665,13 +747,14 @@ 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/chromium-kiosk")
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
||||||
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
||||||
@@ -685,21 +768,16 @@ def launch_chrome():
|
|||||||
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
|
||||||
|
|
||||||
# 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"
|
cookies_db = profile_dir / "Default" / "Cookies"
|
||||||
if cookies_db.exists():
|
if cookies_db.exists():
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(cookies_db))
|
conn = sqlite3.connect(str(cookies_db))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
|
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
|
||||||
|
|||||||
Executable
+119
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Senior Kiosk — Credentials
|
|
||||||
|
|
||||||
> **Keep this file private.** Do not share publicly.
|
|
||||||
|
|
||||||
## Gmail App Password (for Roundcube IMAP)
|
|
||||||
|
|
||||||
- **Account**: Gmail account used on the kiosk
|
|
||||||
- **Purpose**: Roundcube webmail IMAP/SMTP access
|
|
||||||
- **App Password**: `zrhe ncfw zvgb qqfs`
|
|
||||||
- **Created**: 2026-05-21
|
|
||||||
- **Note**: Spaces are part of the display only — enter without spaces when configuring Roundcube: `zrhencfwzvgbqqfs`
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Senior Kiosk — Credentials (template)
|
||||||
|
|
||||||
|
> Copy to `credentials.md` (gitignored). Never commit real secrets.
|
||||||
|
|
||||||
|
## Gmail App Password (for Roundcube IMAP)
|
||||||
|
|
||||||
|
- **Account**: your-gmail@example.com
|
||||||
|
- **Purpose**: Roundcube webmail IMAP/SMTP access
|
||||||
|
- **App Password**: (create in Google Account → Security → App passwords)
|
||||||
|
- **Note**: Configure on the Roundcube server only; do not commit to git.
|
||||||
@@ -7,8 +7,35 @@
|
|||||||
KIOSK_LOCAL="jgitta@192.168.88.44"
|
KIOSK_LOCAL="jgitta@192.168.88.44"
|
||||||
KIOSK_REMOTE="jgitta@100.64.0.1"
|
KIOSK_REMOTE="jgitta@100.64.0.1"
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HOME_SRC="$REPO_DIR/home-screen-index.html"
|
||||||
|
HOME_DST="/home/jgitta/kiosk-home/index.html"
|
||||||
|
CONFIG_SRC="$REPO_DIR/config.json"
|
||||||
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
|
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..."
|
echo "==> Committing and pushing to Gitea..."
|
||||||
git -C "$REPO_DIR" add -A
|
git -C "$REPO_DIR" add -A
|
||||||
git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
|
git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
|
||||||
@@ -33,6 +60,9 @@ if [ -n "$KIOSK" ]; then
|
|||||||
ssh "$KIOSK" "
|
ssh "$KIOSK" "
|
||||||
cd /home/jgitta/kiosk-browser
|
cd /home/jgitta/kiosk-browser
|
||||||
git pull
|
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
|
/usr/local/bin/kiosk-restart
|
||||||
"
|
"
|
||||||
echo " Production updated."
|
echo " Production updated."
|
||||||
|
|||||||
+40
-38
@@ -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>
|
||||||
|
|||||||
Executable
+161
@@ -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
@@ -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
@@ -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'
|
||||||
Reference in New Issue
Block a user