Initial release v0.01 — SeniorNet kiosk browser

- PyQt5 floating toolbar (Back, Forward, HOME, Bookmarks) over Google Chrome
- Scrollable bookmarks bar with nested folder dropdown menus
- Chrome DevTools Protocol (CDP) for navigation control
- Home screen with large senior-friendly buttons (Gmail, Google, Weather, News, Facebook, Messenger, YouTube)
- Automatic GPU/ML cache cleanup on every Chrome launch
- Kiosk hardening: TTY switching blocked, keyboard shortcuts intercepted, auto-restart on crash
- System config files and install script included
This commit is contained in:
2026-05-21 11:39:10 +00:00
commit 6c756e3adc
9 changed files with 1086 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.env
+121
View File
@@ -0,0 +1,121 @@
# SeniorNet Kiosk — v0.01
A fully locked-down, zero-maintenance browser kiosk for senior users running on Ubuntu 24.04 LTS Server.
## Overview
The kiosk presents a single Chrome browser window with a custom floating toolbar. Seniors see only large, friendly navigation buttons and bookmarks — no browser UI, no address bar, no settings. Everything is managed remotely over SSH or Cockpit.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ SeniorNet Toolbar (PyQt5 overlay — always on top) │
│ [ < Back ] [ Forward > ] [ HOME ] [ Bookmarks ] │
├─────────────────────────────────────────────────────┤
│ Bookmarks Bar (scrollable, nested folder menus) │
├─────────────────────────────────────────────────────┤
│ │
│ Google Chrome (--app mode) │
│ No browser UI — content only │
│ │
└─────────────────────────────────────────────────────┘
```
- **OS**: Ubuntu 24.04 LTS Server (minimal, headless)
- **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
- **Browser control**: Chrome DevTools Protocol (CDP) over WebSocket on port 9222
- **Remote management**: SSH + Cockpit (port 9091)
- **Remote access**: Headscale (self-hosted Tailscale)
## Files
| File | Purpose |
|------|---------|
| `browser.py` | Main application — PyQt5 overlay + CDP Chrome controller |
| `bookmarks.json` | Nested bookmarks (folders + links) shown in the bookmarks bar |
| `/home/jgitta/kiosk-home/index.html` | Home screen with large buttons and live clock |
## System Configuration Files
| Path | Purpose |
|------|---------|
| `/etc/lightdm/lightdm.conf.d/50-kiosk.conf` | Auto-login as jgitta into Openbox session |
| `~/.config/openbox/autostart` | Launches browser.py at session start, auto-restarts on crash |
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching and Ctrl+Alt+Backspace |
| `/etc/apt/apt.conf.d/20auto-upgrades` | Automatic unattended security updates |
## Chrome Launch Flags
Chrome is launched with these key flags:
```
--app=<HOME_URL> # No browser UI, content only
--window-position=0,142 # Below the 142px overlay
--window-size=1920,938 # Full width, remaining height
--remote-debugging-port=9222 # CDP for toolbar control
--disable-gpu # Required on server hardware (no GPU drivers)
--disable-dev-shm-usage # Prevents shared memory crash
--test-type # Suppresses unsupported-flag warning banner
--disable-features=OptimizationHints,... # Prevents ML model cache corruption
--disable-component-update # No background component downloads
--user-data-dir=~/.config/google-chrome-kiosk # Dedicated kiosk profile
```
## Hardening
- **TTY switching blocked**: `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` sets `DontVTSwitch`
- **X kill blocked**: `DontZap` prevents Ctrl+Alt+Backspace
- **tty2tty6 disabled**: `systemctl disable getty@tty2..6`
- **Keyboard shortcuts blocked**: Overlay intercepts Alt+F4, Ctrl+W, Ctrl+T, Ctrl+N, F11, Escape
- **Auto-restart**: Openbox autostart restarts browser.py within 5 seconds if it crashes
- **Profile cleanup**: GPU/ML caches wiped on every Chrome launch to prevent corruption
## Bookmarks
Bookmarks are stored in `bookmarks.json` as nested JSON. Top-level items appear as buttons in the bookmarks bar. Items with a `children` array become folder dropdown menus.
```json
[
{"name": "Gmail", "url": "https://mail.google.com"},
{"name": "News", "children": [
{"name": "BBC News", "url": "https://www.bbc.com/news"}
]}
]
```
Edit bookmarks live through the **Bookmarks** button in the toolbar, or edit `bookmarks.json` directly via SSH.
## Home Screen Buttons
Defined in `/home/jgitta/kiosk-home/index.html`:
- Email (Gmail)
- Internet (Google)
- Weather (AccuWeather)
- News
- Facebook
- Facebook Messenger
- YouTube
## Remote Management
- **SSH**: `ssh jgitta@192.168.88.44` (local) or `ssh jgitta@kiosk-remote` (via Tailscale)
- **Cockpit**: `https://192.168.88.44:9091`
## Installation Summary
1. Install Ubuntu 24.04 LTS Server (minimal)
2. Install packages: `python3-pyqt5 google-chrome-stable unclutter xorg openbox lightdm fonts-noto-color-emoji websocket-client`
3. Configure LightDM for auto-login
4. Copy `kiosk-browser/` and `kiosk-home/` to `/home/jgitta/`
5. Place system config files (see table above)
6. Reboot
## Version History
| Version | Date | Notes |
|---------|------|-------|
| 0.01 | 2026-05-21 | Initial release — working kiosk with toolbar, bookmarks, hardening |
+148
View File
@@ -0,0 +1,148 @@
[
{
"name": "Gmail",
"url": "https://mail.google.com/mail/u/0/#inbox"
},
{
"name": "Facebook",
"url": "https://www.facebook.com/"
},
{
"name": "Facebook Messenger",
"url": "https://www.messenger.com/t/100000537461369/"
},
{
"name": "YouTube",
"url": "https://www.youtube.com/"
},
{
"name": "AccuWeather",
"url": "https://www.accuweather.com/"
},
{
"name": "News",
"children": [
{
"name": "CNN",
"url": "https://www.cnn.com/"
},
{
"name": "AP News",
"url": "https://apnews.com/"
},
{
"name": "1440 News",
"url": "https://join1440.com/"
},
{
"name": "Reuters",
"url": "https://www.reuters.com/"
},
{
"name": "Newser",
"url": "https://www.newser.com/"
},
{
"name": "Origo HU",
"url": "https://www.origo.hu/"
}
]
},
{
"name": "More Email",
"children": [
{
"name": "Outlook",
"url": "https://outlook.live.com/mail/0/"
},
{
"name": "Yahoo Mail",
"url": "https://mail.yahoo.com/"
},
{
"name": "FreeMail HU",
"url": "http://freemail.hu/mail/index.fm#fm"
}
]
},
{
"name": "Google",
"children": [
{
"name": "Google Photos",
"url": "https://photos.google.com/?pli=1"
},
{
"name": "Google Drive",
"url": "https://drive.google.com/"
},
{
"name": "Google Maps",
"url": "https://www.google.com/maps"
},
{
"name": "Google Calendar",
"url": "https://calendar.google.com/calendar/u/0/r"
},
{
"name": "Google Docs",
"url": "https://docs.google.com/document/u/0/"
}
]
},
{
"name": "Games",
"children": [
{
"name": "PCH",
"url": "https://www.pch.com/"
},
{
"name": "PCH Sweeps",
"url": "https://mpo.pch.com/path/ROBJulyTV24?experience=ROBJulyTV24&theme=FrstPy&tid=36b7d78c-1980-402b-9348-275ba91458a9"
},
{
"name": "Cash Sweeps",
"url": "https://www.cashkingsweeps.com/sweeps/cks_500k"
},
{
"name": "PrizeCastle",
"url": "https://www.prizecastle.com/sweeps/"
},
{
"name": "Best Day Sweeps",
"url": "https://bestdayeversweeps.com/"
}
]
},
{
"name": "Shopping",
"children": [
{
"name": "Amazon",
"url": "https://www.amazon.com/"
},
{
"name": "Walmart",
"url": "https://www.walmart.com/"
},
{
"name": "PayPal",
"url": "https://www.paypal.com/myaccount"
}
]
},
{
"name": "Banking",
"children": [
{
"name": "SoFi Bank",
"url": "https://www.sofi.com/login/"
},
{
"name": "OTP Bank",
"url": "https://www.otpbank.hu/portal/hu/OTPdirekt/Belepes"
}
]
}
]
Executable
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""
SeniorNet Kiosk — PyQt5 floating toolbar + bookmarks bar over Google Chrome.
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
from PyQt5.QtGui import QFont, QColor, QPalette
from PyQt5.QtWidgets import (
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
QMessageBox, QFrame, QMenu, QAction,
QTreeWidget, QTreeWidgetItem, QAbstractItemView
)
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
CDP_PORT = 9222
TOOLBAR_H = 82
BM_BAR_H = 60
TOP_H = TOOLBAR_H + BM_BAR_H # 142 px overlay height
SCREEN_W = 1920
SCREEN_H = 1080
# ── Colour palette ────────────────────────────────────────────────
TOOLBAR_COLOR = "#1a3a5c"
BAR_COLOR = "#162f4a"
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"
FOLDER_BG = "#3a4a6a"; FOLDER_HOVER = "#4a5a7a"
FONT_SIZE = 18
MENU_STYLE = """
QMenu {
background: #1e3a5c; color: white;
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
}
QMenu::item { padding: 12px 32px 12px 14px; border-radius: 6px; font-size: 16px; }
QMenu::item:selected { background: #3a6da4; color: white; }
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
"""
# ── Helpers ───────────────────────────────────────────────────────
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
b = QPushButton(label)
b.setFont(QFont("Arial", fs, QFont.Bold))
b.setMinimumSize(min_w, min_h)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(f"""
QPushButton {{ background:{color}; color:white; border:none;
border-radius:10px; padding:6px 14px; }}
QPushButton:hover {{ background:{hover}; }}
QPushButton:pressed {{ background:#222; }}
QPushButton:disabled {{ background:#444; color:#888; }}
""")
return b
def load_bm():
try:
with open(BOOKMARKS_FILE) as f: return json.load(f)
except: return []
def save_bm(data):
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
def is_folder(item): return "children" in item
# ── CDP controller ────────────────────────────────────────────────
class CDP:
"""Thin Chrome DevTools Protocol client — navigate, back, forward."""
def __init__(self):
self.ready = False
self._lock = threading.Lock()
def wait_ready(self, timeout=30):
for _ in range(timeout * 2):
try:
urllib.request.urlopen(
f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
self.ready = True
print("CDP ready")
return True
except Exception:
time.sleep(0.5)
print("CDP not available after timeout")
return False
def _ws_url(self):
raw = urllib.request.urlopen(
f"http://127.0.0.1:{CDP_PORT}/json", timeout=2).read()
tabs = json.loads(raw)
page = next((t for t in tabs if t.get("type") == "page"), None)
return page["webSocketDebuggerUrl"].replace("localhost", "127.0.0.1") if page else None
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)
ws.send(json.dumps({"id": 1, "method": method,
**({"params": params} if params else {})}))
ws.recv()
ws.close()
except Exception as e:
print(f"CDP error ({method}): {e}")
def _async(self, method, params=None):
threading.Thread(target=self._send, args=(method, params),
daemon=True).start()
def navigate(self, url):
self._async("Page.navigate", {"url": url})
def back(self):
self._async("Runtime.evaluate",
{"expression": "window.history.back()"})
def forward(self):
self._async("Runtime.evaluate",
{"expression": "window.history.forward()"})
# ── Folder name dialog ────────────────────────────────────────────
def ask_name(parent, title="Name", current=""):
d = QDialog(parent)
d.setWindowTitle(title)
d.setMinimumWidth(480)
d.setStyleSheet("background:#1e2a3a; color:white;")
lay = QVBoxLayout(d)
lay.setContentsMargins(28, 28, 28, 28)
lay.setSpacing(16)
lbl = QLabel("Folder name:")
lbl.setFont(QFont("Arial", 16))
lbl.setStyleSheet("color:#aac8e8;")
lay.addWidget(lbl)
edit = QLineEdit(current)
edit.setFont(QFont("Arial", 18))
edit.setMinimumHeight(50)
edit.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
"border-radius:8px;padding:6px;")
lay.addWidget(edit)
row = QHBoxLayout()
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 150, 56, 16)
can = mk_btn("✖ Cancel", RED, RED_HOVER, 150, 56, 16)
ok.clicked.connect(d.accept); can.clicked.connect(d.reject)
row.addStretch(); row.addWidget(ok); row.addWidget(can)
lay.addLayout(row)
return edit.text().strip() if d.exec_() == QDialog.Accepted else None
# ── Bookmark add / edit dialog ────────────────────────────────────
class BookmarkDialog(QDialog):
def __init__(self, parent=None, name="", url=""):
super().__init__(parent)
self.setWindowTitle("Bookmark")
self.setMinimumWidth(560)
self.setStyleSheet("background:#1e2a3a; color:white;")
lay = QVBoxLayout(self)
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
for lbl_text, attr, val in [("Name:", "name_edit", name),
("URL:", "url_edit", url)]:
l = QLabel(lbl_text)
l.setFont(QFont("Arial", 16)); l.setStyleSheet("color:#aac8e8;")
lay.addWidget(l)
e = QLineEdit(val)
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
e.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
"border-radius:8px;padding:6px;")
lay.addWidget(e); setattr(self, attr, e)
row = QHBoxLayout()
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 160, 58, 16)
can = mk_btn("✖ Cancel", RED, RED_HOVER, 160, 58, 16)
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
row.addStretch(); row.addWidget(ok); row.addWidget(can)
lay.addLayout(row)
def values(self):
return self.name_edit.text().strip(), self.url_edit.text().strip()
# ── Bookmark Manager ──────────────────────────────────────────────
class ManagerDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Manage Bookmarks")
self.setMinimumSize(860, 640)
self.setStyleSheet("background:#1e2a3a; color:white;")
lay = QVBoxLayout(self)
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
hdr = QLabel("📋 Bookmarks")
hdr.setFont(QFont("Arial", 22, QFont.Bold))
hdr.setStyleSheet("color:white;")
lay.addWidget(hdr)
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
"▲ ▼ move items within the same folder.")
hint.setFont(QFont("Arial", 13))
hint.setStyleSheet("color:#88aacc;"); hint.setWordWrap(True)
lay.addWidget(hint)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setFont(QFont("Arial", 15))
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
self.tree.setStyleSheet("""
QTreeWidget { background:#2a3f55; color:white;
border:2px solid #3a6da4; border-radius:8px; }
QTreeWidget::item { padding:8px 4px; border-bottom:1px solid #1a2f45; }
QTreeWidget::item:selected { background:#3a6da4; }
QTreeWidget::branch { background:#2a3f55; }
""")
self.tree.setIndentation(30)
self._reload_tree()
lay.addWidget(self.tree)
row = QHBoxLayout(); row.setSpacing(8)
btns = [mk_btn(" Bookmark", GREEN, GREEN_HOVER, 155, 54, 14),
mk_btn("📁 Folder", AMBER, AMBER_HOVER, 130, 54, 14),
mk_btn("✏️ Edit", BTN_COLOR,BTN_HOVER, 120, 54, 14),
mk_btn("🗑 Delete", RED, RED_HOVER, 120, 54, 14),
mk_btn("▲ Up", PURPLE, PURPLE_HOVER, 90, 54, 14),
mk_btn("▼ Down", PURPLE, PURPLE_HOVER, 90, 54, 14),
mk_btn("✖ Close", "#555", "#777", 120, 54, 14)]
acts = [self.add_bookmark, self.add_folder, self.edit_item,
self.delete_item, lambda: self.move_item(-1),
lambda: self.move_item(1), self.accept]
for b, a in zip(btns, acts):
b.clicked.connect(a); row.addWidget(b)
lay.addLayout(row)
def _reload_tree(self):
self.tree.clear()
self._build_nodes(load_bm(), self.tree.invisibleRootItem())
self.tree.expandAll()
def _build_nodes(self, items, parent):
for item in items:
node = QTreeWidgetItem(parent)
if is_folder(item):
node.setText(0, f"[+] {item['name']}")
node.setData(0, Qt.UserRole, {"name": item["name"], "_folder": True})
self._build_nodes(item["children"], node)
else:
node.setText(0, f" {item['name']} - {item['url']}")
node.setData(0, Qt.UserRole, {"name": item["name"], "url": item["url"]})
def _to_list(self, parent=None):
if parent is None: parent = self.tree.invisibleRootItem()
result = []
for i in range(parent.childCount()):
n = parent.child(i); d = n.data(0, Qt.UserRole)
if "_folder" in d:
result.append({"name": d["name"], "children": self._to_list(n)})
else:
result.append({"name": d["name"], "url": d["url"]})
return result
def _sel(self):
items = self.tree.selectedItems()
return items[0] if items else None
def _commit(self): save_bm(self._to_list())
def _insert(self, node):
sel = self._sel()
if sel:
d = sel.data(0, Qt.UserRole)
if "_folder" in d:
sel.addChild(node); sel.setExpanded(True); return
par = sel.parent() or self.tree.invisibleRootItem()
par.insertChild(par.indexOfChild(sel) + 1, node)
else:
self.tree.invisibleRootItem().addChild(node)
def add_bookmark(self):
dlg = BookmarkDialog(self)
if dlg.exec_() != QDialog.Accepted: return
name, url = dlg.values()
if not name or not url: return
node = QTreeWidgetItem()
node.setText(0, f" {name} - {url}")
node.setData(0, Qt.UserRole, {"name": name, "url": url})
self._insert(node); self._commit()
def add_folder(self):
name = ask_name(self, "New Folder")
if not name: return
node = QTreeWidgetItem()
node.setText(0, f"[+] {name}")
node.setData(0, Qt.UserRole, {"name": name, "_folder": True})
self._insert(node); self._commit()
def edit_item(self):
sel = self._sel()
if not sel: return
d = sel.data(0, Qt.UserRole)
if "_folder" in d:
name = ask_name(self, "Edit Folder", d["name"])
if not name: return
sel.setText(0, f"[+] {name}")
sel.setData(0, Qt.UserRole, {"name": name, "_folder": True})
else:
dlg = BookmarkDialog(self, d["name"], d["url"])
if dlg.exec_() != QDialog.Accepted: return
name, url = dlg.values()
if not name or not url: return
sel.setText(0, f" {name} - {url}")
sel.setData(0, Qt.UserRole, {"name": name, "url": url})
self._commit()
def delete_item(self):
sel = self._sel()
if not sel: return
d = sel.data(0, Qt.UserRole)
fld = "_folder" in d
msg = (f"Delete folder '{d['name']}' and ALL its bookmarks?"
if fld else f"Delete '{d['name']}'?")
box = QMessageBox(self)
box.setWindowTitle("Delete"); box.setText(msg)
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
box.setStyleSheet("background:#1e2a3a; color:white; font-size:17px;")
if box.exec_() == QMessageBox.Yes:
par = sel.parent() or self.tree.invisibleRootItem()
par.removeChild(sel); self._commit()
def move_item(self, direction):
sel = self._sel()
if not sel: return
par = sel.parent() or self.tree.invisibleRootItem()
idx = par.indexOfChild(sel); ni = idx + direction
if 0 <= ni < par.childCount():
par.takeChild(idx); par.insertChild(ni, sel)
self.tree.setCurrentItem(sel); self._commit()
# ── Bookmarks Bar ─────────────────────────────────────────────────
class BookmarksBar(QWidget):
def __init__(self, nav_cb):
super().__init__()
self.nav_cb = nav_cb
self.setFixedHeight(BM_BAR_H)
self.setStyleSheet(f"background:{BAR_COLOR};")
outer = QHBoxLayout(self)
outer.setContentsMargins(6, 5, 6, 5); outer.setSpacing(4)
self.left_btn = self._arrow("")
self.left_btn.clicked.connect(lambda: self._scroll(-240))
outer.addWidget(self.left_btn)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setStyleSheet(f"background:{BAR_COLOR}; border:none;")
self.inner = QWidget()
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("")
self.right_btn.clicked.connect(lambda: self._scroll(240))
outer.addWidget(self.right_btn)
self.reload()
def _arrow(self, label):
b = QPushButton(label)
b.setFixedSize(38, 42); b.setFont(QFont("Arial", 14, QFont.Bold))
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet("QPushButton { background:#2a4a6c; color:white; border:none;"
" border-radius:8px; } QPushButton:hover { background:#3a6da4; }")
return b
def reload(self):
while self.row.count():
w = self.row.takeAt(0).widget()
if w: w.deleteLater()
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):
b = QPushButton(name)
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(100)
b.setCursor(Qt.PointingHandCursor); b.setToolTip(url)
b.setStyleSheet("QPushButton { background:#2e5c8a; color:white; border:none;"
" border-radius:8px; padding:4px 12px; }"
"QPushButton:hover { background:#3a7abf; }"
"QPushButton:pressed { background:#1a3a5c; }")
b.clicked.connect(lambda _, u=url: self.nav_cb(u))
return b
def _folder_btn(self, folder):
b = QPushButton(f"+ {folder['name']}")
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(110)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(f"QPushButton {{ background:{FOLDER_BG}; color:white; border:none;"
f" border-radius:8px; padding:4px 14px; }}"
f"QPushButton:hover {{ background:{FOLDER_HOVER}; }}"
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):
self._build_menu(folder["children"]).exec_(
button.mapToGlobal(button.rect().bottomLeft()))
def _build_menu(self, items):
menu = QMenu(); menu.setFont(QFont("Arial", 16)); menu.setStyleSheet(MENU_STYLE)
for item in items:
if is_folder(item):
sub = self._build_menu(item["children"])
sub.setTitle(f" > {item['name']}")
sub.setFont(QFont("Arial", 16)); sub.setStyleSheet(MENU_STYLE)
menu.addMenu(sub)
else:
act = QAction(f" {item['name']}", menu)
url = item["url"]
act.triggered.connect(lambda _, u=url: self.nav_cb(u))
menu.addAction(act)
return menu
def _scroll(self, dx):
sb = self.scroll_area.horizontalScrollBar()
sb.setValue(sb.value() + dx)
# ── Floating toolbar overlay ──────────────────────────────────────
class KioskOverlay(QWidget):
def __init__(self, cdp):
super().__init__()
self.cdp = cdp
self.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool
)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setGeometry(0, 0, SCREEN_W, TOP_H)
self.setStyleSheet("background:transparent;")
QApplication.instance().installEventFilter(self)
vbox = QVBoxLayout(self)
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
vbox.addWidget(self._build_toolbar())
self.bm_bar = BookmarksBar(cdp.navigate)
vbox.addWidget(self.bm_bar)
self.show()
def _build_toolbar(self):
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
tl = QHBoxLayout(bar); tl.setContentsMargins(16, 8, 16, 8); tl.setSpacing(12)
brand = QLabel("SeniorNet")
brand.setFont(QFont("Arial", 24, QFont.Bold))
brand.setStyleSheet("color:white;")
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_btn.clicked.connect(self.cdp.back)
fwd_btn.clicked.connect(self.cdp.forward)
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
mgr_btn.clicked.connect(self.open_manager)
for b in [back_btn, fwd_btn, home_btn, mgr_btn]: tl.addWidget(b)
return bar
def open_manager(self):
d = ManagerDialog(self); d.exec_()
self.bm_bar.reload()
def keyPressEvent(self, e):
# Home key -> home screen
if e.key() == Qt.Key_Home:
self.cdp.navigate(HOME_URL)
# Block all other dangerous keys silently
e.accept()
def eventFilter(self, obj, event):
from PyQt5.QtCore import QEvent
if event.type() == QEvent.KeyPress:
key = event.key()
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,
Qt.Key_Tab, Qt.Key_F11, Qt.Key_Escape,
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
if mods & (Qt.AltModifier | Qt.ControlModifier):
if key in blocked_keys:
return True # swallow the event
# Also block bare Escape and F-keys
if key in {Qt.Key_Escape, Qt.Key_F11}:
return True
return super().eventFilter(obj, event)
# ── Chrome launcher ───────────────────────────────────────────────
def launch_chrome():
import pathlib, json as _json, shutil
profile_dir = pathlib.Path("/home/jgitta/.config/google-chrome-kiosk")
profile_dir.mkdir(parents=True, exist_ok=True)
# Remove stale singleton locks
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
try: (profile_dir / name).unlink()
except FileNotFoundError: pass
# Wipe GPU/ML caches that corrupt across restarts
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
"optimization_guide_model_store",
"Default/GPUCache", "Default/DawnWebGPUCache",
"Default/DawnGraphiteCache", "Default/Segmentation Platform"):
p = profile_dir / rel
if p.exists():
shutil.rmtree(p, ignore_errors=True)
# Patch Local State
ls_path = profile_dir / "Local State"
if ls_path.exists():
try:
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))
except Exception:
pass
chrome_h = SCREEN_H - TOP_H
cmd = [
"google-chrome",
"--no-first-run",
"--no-default-browser-check",
"--disable-translate",
"--disable-infobars",
"--disable-session-crashed-bubble",
"--disable-save-password-bubble",
"--disable-restore-session-state",
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
"--disable-component-update",
"--disable-background-networking",
"--disable-extensions",
"--disable-gpu",
"--no-sandbox",
"--test-type",
"--disable-dev-shm-usage",
"--user-data-dir=/home/jgitta/.config/google-chrome-kiosk",
f"--remote-debugging-port={CDP_PORT}",
"--remote-allow-origins=*",
f"--window-position=0,{TOP_H}",
f"--window-size={SCREEN_W},{chrome_h}",
f"--app={HOME_URL}",
]
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setApplicationName("SeniorNet")
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
cdp = CDP()
overlay = KioskOverlay(cdp)
chrome_proc = launch_chrome()
def init_cdp():
if cdp.wait_ready(timeout=30):
print("CDP ready — kiosk running")
threading.Thread(target=init_cdp, daemon=True).start()
sys.exit(app.exec_())
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #f0f4f8;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px;
}
#clock {
font-size: 52px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 6px;
letter-spacing: 2px;
}
#date {
font-size: 28px;
color: #555;
margin-bottom: 40px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
max-width: 1100px;
width: 100%;
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
border-radius: 24px;
text-decoration: none;
color: white;
font-size: 26px;
font-weight: bold;
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
transition: transform 0.1s, box-shadow 0.1s;
cursor: pointer;
border: none;
min-height: 180px;
}
.btn:hover {
transform: translateY(-4px);
box-shadow: 0 10px 28px rgba(0,0,0,0.22);
}
.btn:active {
transform: translateY(0px);
}
.btn img {
width: 72px;
height: 72px;
margin-bottom: 16px;
border-radius: 16px;
}
.btn-gmail { background: linear-gradient(135deg, #ea4335, #c5221f); }
.btn-browse { background: linear-gradient(135deg, #4285f4, #1a73e8); }
.btn-weather { background: linear-gradient(135deg, #00bcd4, #0097a7); }
.btn-news { background: linear-gradient(135deg, #ff7043, #e64a19); }
.btn-facebook { background: linear-gradient(135deg, #1877f2, #0d5dbf); }
.btn-messenger { background: linear-gradient(135deg, #00b2ff, #0078d4); }
.btn-youtube { background: linear-gradient(135deg, #ff0000, #cc0000); }
.icon {
font-size: 64px;
margin-bottom: 14px;
line-height: 1;
}
</style>
</head>
<body>
<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>
<script>
function updateClock() {
const now = new Date();
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
const dateOpts = { weekday: 'long', month: 'long', day: 'numeric' };
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
}
updateClock();
setInterval(updateClock, 1000);
</script>
</body>
</html>
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# SeniorNet Kiosk — install script
# Run as: sudo bash install.sh
set -e
echo "=== Installing packages ==="
apt-get update
apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \
fonts-noto-color-emoji python3-pip
# Install websocket-client
pip3 install websocket-client --break-system-packages
# Install Google Chrome
if ! command -v google-chrome &>/dev/null; then
echo "=== Installing Google Chrome ==="
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt-get install -y ./google-chrome-stable_current_amd64.deb
rm google-chrome-stable_current_amd64.deb
fi
echo "=== Copying config files ==="
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# LightDM auto-login
mkdir -p /etc/lightdm/lightdm.conf.d
cp "$SCRIPT_DIR/system-config/lightdm-50-kiosk.conf" \
/etc/lightdm/lightdm.conf.d/50-kiosk.conf
# Openbox autostart
mkdir -p /home/jgitta/.config/openbox
cp "$SCRIPT_DIR/system-config/openbox-autostart" \
/home/jgitta/.config/openbox/autostart
chown jgitta:jgitta /home/jgitta/.config/openbox/autostart
# Xorg hardening
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
# Disable unused virtual consoles
for i in 2 3 4 5 6; do
systemctl disable getty@tty$i 2>/dev/null || true
done
echo "=== Setting ownership ==="
chown -R jgitta:jgitta /home/jgitta/kiosk-browser /home/jgitta/kiosk-home
echo "=== Done! Reboot to start the kiosk. ==="
+4
View File
@@ -0,0 +1,4 @@
[Seat:*]
autologin-user=jgitta
autologin-user-timeout=0
user-session=openbox
+14
View File
@@ -0,0 +1,14 @@
# Hide mouse cursor after 3 seconds of inactivity
unclutter -idle 3 -root &
# Disable screen blanking and power saving
xset s off &
xset s noblank &
xset -dpms &
# Launch SeniorNet browser — auto-restart if it ever crashes
(while true; do
DISPLAY=:0 python3 /home/jgitta/kiosk-browser/browser.py
echo "browser.py exited — restarting in 5s..."
sleep 5
done) &
@@ -0,0 +1,4 @@
Section "ServerFlags"
Option "DontVTSwitch" "true"
Option "DontZap" "true"
EndSection