716 lines
28 KiB
Python
Executable File
716 lines
28 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
|
||
from PyQt5.QtCore import Qt, QTimer, QPoint
|
||
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 = 72 # single row — was 142 (82 + 60)
|
||
TOP_H = TOOLBAR_H
|
||
# 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
|
||
|
||
MENU_STYLE = """
|
||
QMenu {
|
||
background: #1e3a5c; color: white;
|
||
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
|
||
}
|
||
QMenu::item { padding: 14px 36px 14px 16px; border-radius: 6px; font-size: 17px; }
|
||
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 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: 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 resize_window(self, x, y, w, h):
|
||
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
||
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": "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()
|
||
|
||
|
||
# ── 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.
|
||
"""
|
||
def __init__(self, cdp, overlay):
|
||
super().__init__()
|
||
self.cdp = cdp
|
||
self.overlay = overlay
|
||
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)
|
||
|
||
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 _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 eventFilter(self, obj, event):
|
||
from PyQt5.QtCore import QEvent
|
||
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||
gp = event.globalPos()
|
||
if not self.geometry().contains(gp):
|
||
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, 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_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)
|
||
|
||
def _on_screen_resize(self, rect):
|
||
global SCREEN_W, SCREEN_H
|
||
SCREEN_W, SCREEN_H = rect.width(), rect.height()
|
||
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
||
self.resize(SCREEN_W, TOP_H)
|
||
# Resize Chrome after brief delay so X has settled
|
||
QTimer.singleShot(300, self._resize_chrome)
|
||
|
||
def _resize_chrome(self):
|
||
self.cdp.resize_window(0, TOP_H, SCREEN_W, SCREEN_H - TOP_H)
|
||
|
||
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.show()
|
||
self.bm_panel.raise_()
|
||
|
||
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):
|
||
from PyQt5.QtCore import QEvent
|
||
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():
|
||
import pathlib, json as _json, shutil
|
||
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
|
||
|
||
# Clear the stale Roundcube session cookie so the autologin plugin always
|
||
# fires cleanly. Without this, Chrome sends the old cookie after a reboot,
|
||
# the PHP session in MariaDB has expired, and Roundcube shows
|
||
# "session is invalid or expired" instead of auto-logging in.
|
||
cookies_db = profile_dir / "Default" / "Cookies"
|
||
if cookies_db.exists():
|
||
try:
|
||
import sqlite3
|
||
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 - TOP_H
|
||
cmd = [
|
||
"chromium",
|
||
"--no-first-run",
|
||
"--no-default-browser-check",
|
||
"--disable-translate",
|
||
"--disable-infobars",
|
||
"--disable-session-crashed-bubble",
|
||
"--disable-save-password-bubble",
|
||
"--disable-restore-session-state",
|
||
"--suppress-message-center-popups",
|
||
"--disable-default-browser-check",
|
||
"--no-first-run",
|
||
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
||
"--disable-component-update",
|
||
"--disable-background-networking",
|
||
"--disable-extensions",
|
||
"--disable-gpu",
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--disable-dev-shm-usage",
|
||
"--user-data-dir=/home/jgitta/chromium-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)
|
||
# Detect actual screen resolution at runtime
|
||
global SCREEN_W, SCREEN_H
|
||
_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_())
|