860 lines
33 KiB
Python
Executable File
860 lines
33 KiB
Python
Executable File
#!/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 = pathlib.Path.home()
|
||
HOME_URL = f"file://{_HOME}/kiosk-home/index.html"
|
||
BOOKMARKS_FILE = str(_HOME / "kiosk-browser/bookmarks.json")
|
||
try:
|
||
_VERSION = (pathlib.Path(__file__).parent / "VERSION").read_text().strip()
|
||
except Exception:
|
||
_VERSION = "?"
|
||
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 + version
|
||
brand_wrap = QWidget()
|
||
brand_wrap.setStyleSheet("background:transparent;")
|
||
brand_vbox = QVBoxLayout(brand_wrap)
|
||
brand_vbox.setContentsMargins(0, 0, 0, 0)
|
||
brand_vbox.setSpacing(0)
|
||
brand = QLabel("SeniorNet")
|
||
brand.setFont(QFont("Arial", 22, QFont.Bold))
|
||
brand.setStyleSheet("color:white;")
|
||
ver = QLabel(f"v{_VERSION}")
|
||
ver.setFont(QFont("Arial", 10))
|
||
ver.setStyleSheet("color:#7aafd4;")
|
||
brand_vbox.addWidget(brand)
|
||
brand_vbox.addWidget(ver)
|
||
tl.addWidget(brand_wrap)
|
||
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 = _HOME / "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:
|
||
_cfg = json.loads((_HOME / "kiosk-browser/config.json").read_text())
|
||
_email_url = _cfg.get("urls", {}).get("email", "")
|
||
_email_host = _email_url.split("//")[-1].split("/")[0] if _email_url else ""
|
||
conn = sqlite3.connect(str(cookies_db))
|
||
if _email_host:
|
||
conn.execute("DELETE FROM cookies WHERE host_key LIKE ?",
|
||
(f"%{_email_host}%",))
|
||
conn.commit()
|
||
print(f"Cleared stale session cookie for {_email_host}")
|
||
conn.close()
|
||
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",
|
||
f"--user-data-dir={_HOME / '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_())
|