From 6c756e3adc74efbf68bea7d7e0054910af868ce7 Mon Sep 17 00:00:00 2001 From: jgitta Date: Thu, 21 May 2026 11:39:10 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20release=20v0.01=20=E2=80=94=20SeniorN?= =?UTF-8?q?et=20kiosk=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 + README.md | 121 +++++ bookmarks.json | 148 +++++ browser.py | 593 +++++++++++++++++++++ home-screen-index.html | 149 ++++++ install.sh | 49 ++ system-config/lightdm-50-kiosk.conf | 4 + system-config/openbox-autostart | 14 + system-config/xorg-50-kiosk-hardening.conf | 4 + 9 files changed, 1086 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bookmarks.json create mode 100755 browser.py create mode 100644 home-screen-index.html create mode 100755 install.sh create mode 100644 system-config/lightdm-50-kiosk.conf create mode 100644 system-config/openbox-autostart create mode 100644 system-config/xorg-50-kiosk-hardening.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9460c99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..66e0b3c --- /dev/null +++ b/README.md @@ -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= # 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 +- **tty2–tty6 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 | diff --git a/bookmarks.json b/bookmarks.json new file mode 100644 index 0000000..98c9339 --- /dev/null +++ b/bookmarks.json @@ -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" + } + ] + } +] \ No newline at end of file diff --git a/browser.py b/browser.py new file mode 100755 index 0000000..95c43fb --- /dev/null +++ b/browser.py @@ -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_()) diff --git a/home-screen-index.html b/home-screen-index.html new file mode 100644 index 0000000..d5c3432 --- /dev/null +++ b/home-screen-index.html @@ -0,0 +1,149 @@ + + + + + + Home + + + + +
12:00 PM
+
Wednesday, January 1
+ + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..653468a --- /dev/null +++ b/install.sh @@ -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. ===" diff --git a/system-config/lightdm-50-kiosk.conf b/system-config/lightdm-50-kiosk.conf new file mode 100644 index 0000000..625345f --- /dev/null +++ b/system-config/lightdm-50-kiosk.conf @@ -0,0 +1,4 @@ +[Seat:*] +autologin-user=jgitta +autologin-user-timeout=0 +user-session=openbox diff --git a/system-config/openbox-autostart b/system-config/openbox-autostart new file mode 100644 index 0000000..0eb854f --- /dev/null +++ b/system-config/openbox-autostart @@ -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) & diff --git a/system-config/xorg-50-kiosk-hardening.conf b/system-config/xorg-50-kiosk-hardening.conf new file mode 100644 index 0000000..ce7289f --- /dev/null +++ b/system-config/xorg-50-kiosk-hardening.conf @@ -0,0 +1,4 @@ +Section "ServerFlags" + Option "DontVTSwitch" "true" + Option "DontZap" "true" +EndSection