#!/usr/bin/env python3 """ SeniorNet Kiosk — PyQt5 single-row floating toolbar over Google Chrome. Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks. Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket. """ import sys, json, os, subprocess, time, threading, urllib.request, websocket, pathlib, shutil, sqlite3 from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QEvent from PyQt5.QtGui import QFont, QColor, QPalette, QCursor from PyQt5.QtWidgets import ( QApplication, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QScrollArea, QDialog, QLineEdit, QMessageBox, QFrame, 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 = 72 # single row — was 142 (82 + 60) # Screen size is detected dynamically in main() after QApplication starts SCREEN_W = 1920 # overwritten at runtime SCREEN_H = 1080 # overwritten at runtime # ── Colour palette ──────────────────────────────────────────────── TOOLBAR_COLOR = "#1a3a5c" BTN_COLOR = "#2e6da4" BTN_HOVER = "#3a87cc" GOLD = "#e8a020" GOLD_HOVER = "#f0b030" GREEN = "#2e7d32" GREEN_HOVER = "#388e3c" RED = "#c62828" RED_HOVER = "#d32f2f" AMBER = "#7a5a10" AMBER_HOVER = "#9a7a20" PURPLE = "#5a5a8a" PURPLE_HOVER = "#7a7aaa" FONT_SIZE = 18 # ── 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 mk_icon_btn(icon, color, hover, size=58, fs=26): """Square icon button for Back / Forward / Home / Hamburger.""" b = QPushButton(icon) b.setFont(QFont("Arial", fs)) b.setFixedSize(size, size) b.setCursor(Qt.PointingHandCursor) b.setStyleSheet(f""" QPushButton {{ background:{color}; color:white; border:none; border-radius:12px; }} QPushButton:hover {{ background:{hover}; }} QPushButton:pressed {{ background:#222; }} """) return b def load_bm(): try: with open(BOOKMARKS_FILE) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return [] def save_bm(data): with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2) 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 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 resize_window(self, x, y, w, h): """Move and resize the Chrome window via CDP Browser.setWindowBounds.""" if not self.ready: return with self._lock: try: ws = websocket.create_connection(self._ws_url(), timeout=3) ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"})) resp = json.loads(ws.recv()) wid = resp.get("result", {}).get("windowId") if wid: ws.send(json.dumps({"id": 2, "method": "Browser.setWindowBounds", "params": {"windowId": wid, "bounds": {"left": x, "top": y, "width": w, "height": h, "windowState": "normal"}}})) ws.recv() ws.close() except Exception as e: print(f"CDP resize error: {e}") def navigate(self, url): 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() # ── X11 helpers (clicks/focus on Chromium never reach Qt) ─────────── def _x11_env(): env = os.environ.copy() env.setdefault("DISPLAY", ":0") return env def _x11_run(cmd, timeout=0.4): try: r = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, env=_x11_env()) return r.stdout.strip() if r.returncode == 0 else "" except Exception: return "" def _x11_active_window_id(): out = _x11_run(["xdotool", "getactivewindow"]) return int(out) if out.isdigit() else 0 def _x11_left_button_down(): """True when the physical left mouse button is held (works across all apps).""" try: r = subprocess.run( ["bash", "-c", "DISPLAY=:0 dev=$(xinput --list --short 2>/dev/null | " "awk '/slave.*pointer/ {print $2; exit}'); " "[ -n \"$dev\" ] && xinput query-state \"$dev\" 2>/dev/null | " "grep -q 'button\\[1\\]=down'"], timeout=0.3, env=_x11_env()) return r.returncode == 0 except Exception: return False # ── Bookmark dropdown panel ─────────────────────────────────────── class BookmarkPanel(QWidget): """ Custom dropdown that replaces QMenu for bookmarks. Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it always appears above Chrome, unlike QMenu which has z-order issues. """ _OPEN_GRACE_SEC = 0.35 def __init__(self, cdp, overlay): super().__init__() self.cdp = cdp self.overlay = overlay self._shown_at = 0.0 self._focus_at_open = 0 self._our_window_ids = set() self.setWindowFlags( Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_ShowWithoutActivating) self.setStyleSheet(f""" QWidget {{ background: #1e3a5c; }} QPushButton {{ background: transparent; color: white; border: none; text-align: left; padding: 8px 20px; font-size: 17px; border-radius: 6px; }} QPushButton:hover {{ background: #3a6da4; }} QPushButton:pressed {{ background: #222; }} QLabel {{ color: #aac8e8; padding: 6px 20px 2px 20px; font-size: 14px; font-weight: bold; }} """) QApplication.instance().installEventFilter(self) self._dismiss_timer = QTimer() self._dismiss_timer.timeout.connect(self._check_dismiss) def rebuild(self, items): # Remove old layout old = self.layout() if old: while old.count(): w = old.takeAt(0).widget() if w: w.deleteLater() QWidget().setLayout(old) scroll_content = QWidget() scroll_content.setStyleSheet("background: #1e3a5c;") vbox = QVBoxLayout(scroll_content) vbox.setContentsMargins(6, 6, 6, 6) vbox.setSpacing(1) self._add_items(vbox, items, depth=0) sep = QFrame() sep.setFrameShape(QFrame.HLine) sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;") sep.setFixedHeight(1) vbox.addWidget(sep) mgr = QPushButton("⚙ Manage Bookmarks…") mgr.setFont(QFont("Arial", 16)) mgr.setMinimumHeight(48) mgr.setCursor(Qt.PointingHandCursor) mgr.clicked.connect(self._on_manage) vbox.addWidget(mgr) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) scroll.setStyleSheet("background: transparent; border: none;") scroll.setWidget(scroll_content) outer = QVBoxLayout(self) outer.setContentsMargins(2, 2, 2, 2) outer.setSpacing(0) outer.addWidget(scroll) # Size: cap height at 80 % of screen below toolbar scroll_content.adjustSize() ph = scroll_content.sizeHint().height() + 16 pw = max(scroll_content.sizeHint().width() + 16, 340) max_h = SCREEN_H - TOOLBAR_H - 20 self.resize(pw, min(ph, max_h)) self.move(SCREEN_W - pw - 4, TOOLBAR_H) def _refresh_our_window_ids(self): ids = set() for w in (self, self.overlay): wid = int(w.winId()) if wid: ids.add(wid) self._our_window_ids = ids def _burger_global_rect(self): btn = self.overlay.burger_btn origin = btn.mapToGlobal(QPoint(0, 0)) return QRect(origin, btn.size()) def _add_items(self, vbox, items, depth): indent = " " * depth for item in items: if is_folder(item): lbl = QLabel(f"{indent}▸ {item['name']}") lbl.setFont(QFont("Arial", 14, QFont.Bold)) vbox.addWidget(lbl) self._add_items(vbox, item["children"], depth + 1) else: btn = QPushButton(f"{indent} {item['name']}") btn.setFont(QFont("Arial", 17)) btn.setMinimumHeight(46) btn.setCursor(Qt.PointingHandCursor) url = item["url"] btn.clicked.connect(lambda _, u=url: self._nav(u)) vbox.addWidget(btn) def _nav(self, url): self.hide() self.cdp.navigate(url) def _on_manage(self): self.hide() self.overlay.open_manager() def hide(self): self._dismiss_timer.stop() super().hide() def setVisible(self, visible): super().setVisible(visible) if visible: self._shown_at = time.time() self._refresh_our_window_ids() self._focus_at_open = _x11_active_window_id() self.raise_() self.overlay.raise_() self._dismiss_timer.start(100) else: self._dismiss_timer.stop() def _cursor_outside_panel(self): pos = QCursor.pos() if self.frameGeometry().contains(pos): return False if self._burger_global_rect().contains(pos): return False return True def _check_dismiss(self): """Close when focus leaves or user clicks outside the panel.""" if not self.isVisible(): self._dismiss_timer.stop() return if time.time() - self._shown_at < self._OPEN_GRACE_SEC: return # Click outside (X11 — works even when Chromium has focus) if _x11_left_button_down() and self._cursor_outside_panel(): self.hide() return # Focus moved away from toolbar / menu (e.g. clicked the web page) active = _x11_active_window_id() if active and active != self._focus_at_open and active not in self._our_window_ids: self.hide() def eventFilter(self, obj, event): if event.type() == QEvent.MouseButtonPress and self.isVisible(): if not self.frameGeometry().contains(event.globalPos()): self.hide() return False # ── Floating toolbar overlay ────────────────────────────────────── 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, TOOLBAR_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_panel = BookmarkPanel(cdp, self) self.show() # Reflow toolbar and Chrome when screen/RDP size changes screen = QApplication.instance().primaryScreen() screen.geometryChanged.connect(self._on_screen_resize) # Watchdog: if Chromium ever gets minimised, map it back every 2 s. self._watchdog = QTimer() self._watchdog.timeout.connect(self._restore_chromium) self._watchdog.start(2000) def _on_screen_resize(self, rect): global SCREEN_W, SCREEN_H w, h = rect.width(), rect.height() # Ignore bogus sizes from minimize / RDP disconnect events if w < 400 or h < 200: return SCREEN_W, SCREEN_H = w, h self.setGeometry(0, 0, SCREEN_W, TOOLBAR_H) self.resize(SCREEN_W, TOOLBAR_H) if self.bm_panel.isVisible(): pw = self.bm_panel.width() self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H) # Resize Chrome after brief delay so X has settled QTimer.singleShot(500, self._resize_chrome) def _resize_chrome(self): self.cdp.resize_window(0, TOOLBAR_H, SCREEN_W, SCREEN_H - TOOLBAR_H) def changeEvent(self, event): """Un-minimise the toolbar the instant it gets iconified.""" if event.type() == QEvent.WindowStateChange: if self.windowState() & Qt.WindowMinimized: QTimer.singleShot(50, self._self_restore) super().changeEvent(event) def _self_restore(self): self.setWindowState(Qt.WindowNoState) self.show() self.raise_() def _restore_chromium(self): """Watchdog: restore Chromium if it was minimised (_NET_WM_STATE_HIDDEN).""" try: result = subprocess.run( ["xdotool", "search", "--class", "chromium"], capture_output=True, text=True, timeout=2 ) for wid in result.stdout.strip().split(): if not wid: continue state = subprocess.run( ["xprop", "-id", wid, "_NET_WM_STATE"], capture_output=True, text=True, timeout=1 ) if "_NET_WM_STATE_HIDDEN" in state.stdout: subprocess.run( ["xdotool", "windowmap", wid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except Exception: pass def _build_toolbar(self): bar = QWidget(); bar.setFixedHeight(TOOLBAR_H) bar.setStyleSheet(f"background:{TOOLBAR_COLOR};") tl = QHBoxLayout(bar) tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10) # Brand label brand = QLabel("SeniorNet") brand.setFont(QFont("Arial", 22, QFont.Bold)) brand.setStyleSheet("color:white;") tl.addWidget(brand) tl.addStretch() # ◀ Back back_btn = mk_icon_btn("◀", BTN_COLOR, BTN_HOVER) back_btn.setToolTip("Go Back") back_btn.clicked.connect(self.cdp.back) tl.addWidget(back_btn) # ▶ Forward fwd_btn = mk_icon_btn("▶", BTN_COLOR, BTN_HOVER) fwd_btn.setToolTip("Go Forward") fwd_btn.clicked.connect(self.cdp.forward) tl.addWidget(fwd_btn) # ⌂ Home home_btn = mk_icon_btn("⌂", GOLD, GOLD_HOVER) home_btn.setToolTip("Go Home") home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL)) tl.addWidget(home_btn) # ☰ Hamburger — bookmarks menu self.burger_btn = mk_icon_btn("☰", PURPLE, PURPLE_HOVER) self.burger_btn.setToolTip("Bookmarks") self.burger_btn.clicked.connect(self._show_bookmarks_menu) tl.addWidget(self.burger_btn) return bar def _show_bookmarks_menu(self): if self.bm_panel.isVisible(): self.bm_panel.hide() else: self.bm_panel.rebuild(load_bm()) self.bm_panel.setVisible(True) def open_manager(self): d = ManagerDialog(self) d.exec_() def keyPressEvent(self, e): if e.key() == Qt.Key_Home: self.cdp.navigate(HOME_URL) e.accept() def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress: key = event.key() mods = event.modifiers() 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 if key in {Qt.Key_Escape, Qt.Key_F11}: return True return super().eventFilter(obj, event) # ── Chrome launcher ─────────────────────────────────────────────── def launch_chrome(): profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk") profile_dir.mkdir(parents=True, exist_ok=True) for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"): try: (profile_dir / name).unlink() except FileNotFoundError: pass 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) 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 cookies_db = profile_dir / "Default" / "Cookies" if cookies_db.exists(): try: conn = sqlite3.connect(str(cookies_db)) conn.execute( "DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'") conn.commit() conn.close() print("Cleared stale Roundcube session cookie") except Exception as e: print(f"Cookie clear warning: {e}") chrome_h = SCREEN_H - TOOLBAR_H cmd = [ "chromium", "--no-first-run", "--no-default-browser-check", "--disable-restore-session-state", "--disable-default-browser-check", "--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading", "--disable-component-update", "--disable-background-networking", "--disable-extensions", "--disable-gpu", "--disable-dev-shm-usage", "--user-data-dir=/home/jgitta/chromium-kiosk", f"--remote-debugging-port={CDP_PORT}", "--remote-allow-origins=*", f"--window-position=0,{TOOLBAR_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) # Detect actual screen resolution at runtime _geo = app.primaryScreen().geometry() SCREEN_W, SCREEN_H = _geo.width(), _geo.height() 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 dismiss_chrome_dialogs(): """ Chrome shows startup dialogs (first-run welcome, profile errors) on a fresh or corrupted profile. Dismiss them all automatically so the senior never sees them. Runs for ~15 seconds after startup. """ dialogs = [ "Welcome to Google Chrome", "Profile error occurred", ] for _ in range(15): time.sleep(1) for title in dialogs: subprocess.run( ["xdotool", "search", "--name", title, "key", "Return"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) def init_cdp(): if cdp.wait_ready(timeout=30): print("CDP ready — kiosk running") dismiss_chrome_dialogs() threading.Thread(target=init_cdp, daemon=True).start() sys.exit(app.exec_())