874 lines
34 KiB
Python
Executable File
874 lines
34 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
|
||
SCREEN_W = 1920 # Dynamically updated in main
|
||
SCREEN_H = 1080 # Dynamically updated in main
|
||
|
||
# ── Styling and Themes (DRY Style System) ─────────────────────────
|
||
THEME = {
|
||
"colors": {
|
||
"toolbar": "#1a3a5c",
|
||
"btn": "#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",
|
||
"bg_dark": "#1e2a3a",
|
||
"bg_input": "#2a3f55",
|
||
"border": "#3a6da4",
|
||
"text_secondary": "#aac8e8",
|
||
},
|
||
"font_size": 18
|
||
}
|
||
|
||
STYLES = {
|
||
"button": """
|
||
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; }}
|
||
""",
|
||
"icon_button": """
|
||
QPushButton {{
|
||
background: {color}; color: white; border: none;
|
||
border-radius: 12px;
|
||
}}
|
||
QPushButton:hover {{ background: {hover}; }}
|
||
QPushButton:pressed {{ background: #222; }}
|
||
""",
|
||
"line_edit": """
|
||
QLineEdit {{
|
||
background: {bg_input}; color: white;
|
||
border: 2px solid {border}; border-radius: 8px;
|
||
padding: 6px;
|
||
}}
|
||
""",
|
||
"dialog": """
|
||
QDialog {{
|
||
background: {bg_dark}; color: white;
|
||
}}
|
||
""",
|
||
"tree_widget": """
|
||
QTreeWidget {{
|
||
background: {bg_input}; color: white;
|
||
border: 2px solid {border}; border-radius: 8px;
|
||
}}
|
||
QTreeWidget::item {{
|
||
padding: 8px 4px; border-bottom: 1px solid #1a2f45;
|
||
}}
|
||
QTreeWidget::item:selected {{
|
||
background: {border};
|
||
}}
|
||
QTreeWidget::branch {{
|
||
background: {bg_input};
|
||
}}
|
||
""",
|
||
"bookmark_panel": """
|
||
QWidget {{
|
||
background: {toolbar};
|
||
}}
|
||
QPushButton {{
|
||
background: transparent; color: white; border: none;
|
||
text-align: left; padding: 8px 20px;
|
||
font-size: 17px; border-radius: 6px;
|
||
}}
|
||
QPushButton:hover {{ background: {border}; }}
|
||
QPushButton:pressed {{ background: #222; }}
|
||
QLabel {{
|
||
color: {text_secondary}; padding: 6px 20px 2px 20px;
|
||
font-size: 14px; font-weight: bold;
|
||
}}
|
||
"""
|
||
}
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────
|
||
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=THEME["font_size"]):
|
||
b = QPushButton(label)
|
||
b.setFont(QFont("Arial", fs, QFont.Bold))
|
||
b.setMinimumSize(min_w, min_h)
|
||
b.setCursor(Qt.PointingHandCursor)
|
||
b.setStyleSheet(STYLES["button"].format(color=color, hover=hover))
|
||
return b
|
||
|
||
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
||
b = QPushButton(icon)
|
||
b.setFont(QFont("Arial", fs))
|
||
b.setFixedSize(size, size)
|
||
b.setCursor(Qt.PointingHandCursor)
|
||
b.setStyleSheet(STYLES["icon_button"].format(color=color, hover=hover))
|
||
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 (Persistent WebSockets) ────────────────────────
|
||
class CDP:
|
||
"""Thin, persistent Chrome DevTools Protocol client."""
|
||
def __init__(self):
|
||
self.ready = False
|
||
self._lock = threading.Lock()
|
||
self.ws = None
|
||
self.ws_url = None
|
||
|
||
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 — pre-connecting WebSocket")
|
||
self._connect()
|
||
return True
|
||
except Exception:
|
||
time.sleep(0.5)
|
||
print("CDP not available after timeout")
|
||
return False
|
||
|
||
def _connect(self):
|
||
"""Internal helper to connect or reconnect the persistent WS client."""
|
||
with self._lock:
|
||
if self.ws:
|
||
try:
|
||
self.ws.ping()
|
||
return True
|
||
except Exception:
|
||
self._close_ws()
|
||
|
||
try:
|
||
# Fetch WebSocket endpoint once, or re-fetch on disconnect
|
||
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)
|
||
if page:
|
||
self.ws_url = page["webSocketDebuggerUrl"].replace("localhost", "127.0.0.1")
|
||
self.ws = websocket.create_connection(self.ws_url, timeout=3)
|
||
print(f"Connected persistent WebSocket to: {self.ws_url}")
|
||
return True
|
||
except Exception as e:
|
||
print(f"CDP connection failed: {e}")
|
||
self._close_ws()
|
||
return False
|
||
|
||
def _close_ws(self):
|
||
if self.ws:
|
||
try:
|
||
self.ws.close()
|
||
except Exception:
|
||
pass
|
||
self.ws = None
|
||
|
||
def _send(self, method, params=None):
|
||
if not self.ready:
|
||
return
|
||
if not self.ws and not self._connect():
|
||
return
|
||
with self._lock:
|
||
try:
|
||
self.ws.send(json.dumps({"id": 1, "method": method,
|
||
**( {"params": params} if params else {})}))
|
||
self.ws.recv()
|
||
except Exception as e:
|
||
print(f"CDP send error ({method}): {e}")
|
||
self._close_ws()
|
||
|
||
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
|
||
if not self.ws and not self._connect():
|
||
return
|
||
with self._lock:
|
||
try:
|
||
self.ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
||
resp = json.loads(self.ws.recv())
|
||
wid = resp.get("result", {}).get("windowId")
|
||
if wid:
|
||
self.ws.send(json.dumps({"id": 2,
|
||
"method": "Browser.setWindowBounds",
|
||
"params": {"windowId": wid,
|
||
"bounds": {"left": x, "top": y,
|
||
"width": w, "height": h,
|
||
"windowState": "normal"}}}))
|
||
self.ws.recv()
|
||
except Exception as e:
|
||
print(f"CDP resize error: {e}")
|
||
self._close_ws()
|
||
|
||
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(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||
lay = QVBoxLayout(d)
|
||
lay.setContentsMargins(28, 28, 28, 28)
|
||
lay.setSpacing(16)
|
||
|
||
lbl = QLabel("Folder name:")
|
||
lbl.setFont(QFont("Arial", 16))
|
||
lbl.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
|
||
lay.addWidget(lbl)
|
||
|
||
edit = QLineEdit(current)
|
||
edit.setFont(QFont("Arial", 18))
|
||
edit.setMinimumHeight(50)
|
||
edit.setStyleSheet(STYLES["line_edit"].format(
|
||
bg_input=THEME["colors"]["bg_input"],
|
||
border=THEME["colors"]["border"]
|
||
))
|
||
lay.addWidget(edit)
|
||
|
||
row = QHBoxLayout()
|
||
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 150, 56, 16)
|
||
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["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(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||
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(f"color:{THEME['colors']['text_secondary']};")
|
||
lay.addWidget(l)
|
||
|
||
e = QLineEdit(val)
|
||
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
|
||
e.setStyleSheet(STYLES["line_edit"].format(
|
||
bg_input=THEME["colors"]["bg_input"],
|
||
border=THEME["colors"]["border"]
|
||
))
|
||
lay.addWidget(e)
|
||
setattr(self, attr, e)
|
||
|
||
row = QHBoxLayout()
|
||
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 160, 58, 16)
|
||
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["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(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||
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(f"color:{THEME['colors']['text_secondary']};"); 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(STYLES["tree_widget"].format(
|
||
bg_input=THEME["colors"]["bg_input"],
|
||
border=THEME["colors"]["border"]
|
||
))
|
||
self.tree.setIndentation(30)
|
||
self._reload_tree()
|
||
lay.addWidget(self.tree)
|
||
|
||
row = QHBoxLayout(); row.setSpacing(8)
|
||
btns = [
|
||
mk_btn("➕ Bookmark", THEME["colors"]["green"], THEME["colors"]["green_hover"], 155, 54, 14),
|
||
mk_btn("📁 Folder", THEME["colors"]["amber"], THEME["colors"]["amber_hover"], 130, 54, 14),
|
||
mk_btn("✏️ Edit", THEME["colors"]["btn"], THEME["colors"]["btn_hover"], 120, 54, 14),
|
||
mk_btn("🗑 Delete", THEME["colors"]["red"], THEME["colors"]["red_hover"], 120, 54, 14),
|
||
mk_btn("▲ Up", THEME["colors"]["purple"], THEME["colors"]["purple_hover"], 90, 54, 14),
|
||
mk_btn("▼ Down", THEME["colors"]["purple"], THEME["colors"]["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(f"background:{THEME['colors']['bg_dark']}; 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 (Native Focus-Loss Dismissal) ─────────
|
||
class BookmarkPanel(QWidget):
|
||
"""
|
||
Custom dropdown that replaces QMenu for bookmarks.
|
||
Uses Qt.Popup flag for native focus-out dismissal on outside click (0% CPU).
|
||
"""
|
||
def __init__(self, cdp, overlay):
|
||
super().__init__()
|
||
self.cdp = cdp
|
||
self.overlay = overlay
|
||
self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||
self.setStyleSheet(STYLES["bookmark_panel"].format(
|
||
toolbar=THEME["colors"]["toolbar"],
|
||
border=THEME["colors"]["border"],
|
||
text_secondary=THEME["colors"]["text_secondary"]
|
||
))
|
||
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(f"background: {THEME['colors']['toolbar']};")
|
||
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(f"background: {THEME['colors']['border']}; 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 _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 changeEvent(self, event):
|
||
"""Native X11 focus loss detector (when clicking Chromium outside the Qt app)."""
|
||
if event.type() == QEvent.ActivationChange:
|
||
if not self.isActiveWindow() and not self.overlay.isActiveWindow():
|
||
self.hide()
|
||
super().changeEvent(event)
|
||
|
||
def eventFilter(self, obj, event):
|
||
"""Native local clicks inside toolbar/panel dismissal logic."""
|
||
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||
if not self.geometry().contains(event.globalPos()) and not self._burger_global_rect().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()
|
||
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)
|
||
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:{THEME['colors']['toolbar']};")
|
||
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("◀", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||
back_btn.setToolTip("Go Back")
|
||
back_btn.clicked.connect(self.cdp.back)
|
||
tl.addWidget(back_btn)
|
||
|
||
# ▶ Forward
|
||
fwd_btn = mk_icon_btn("▶", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||
fwd_btn.setToolTip("Go Forward")
|
||
fwd_btn.clicked.connect(self.cdp.forward)
|
||
tl.addWidget(fwd_btn)
|
||
|
||
# ⌂ Home
|
||
home_btn = mk_icon_btn("⌂", THEME["colors"]["gold"], THEME["colors"]["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("☰", THEME["colors"]["purple"], THEME["colors"]["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"
|
||
|
||
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=Translate,TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
||
"--disable-component-update",
|
||
"--disable-background-networking",
|
||
"--disable-extensions",
|
||
"--disable-gpu",
|
||
"--disable-dev-shm-usage",
|
||
"--allow-file-access-from-files",
|
||
f"--user-data-dir={profile_dir}",
|
||
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)
|
||
_geo = app.primaryScreen().geometry()
|
||
SCREEN_W, SCREEN_H = _geo.width(), _geo.height()
|
||
app.setApplicationName("SeniorNet")
|
||
p = app.palette(); p.setColor(QPalette.Window, QColor(THEME["colors"]["toolbar"])); app.setPalette(p)
|
||
|
||
cdp = CDP()
|
||
overlay = KioskOverlay(cdp)
|
||
chrome_proc = launch_chrome()
|
||
|
||
def update_weather_cache():
|
||
"""Fetch weather data in background via Python and cache as local JSON to bypass CORS securely."""
|
||
cache_file = _HOME / "kiosk-home" / "weather-data.json"
|
||
while True:
|
||
try:
|
||
# 1. Geolocation lookup (using highly accurate ip-api.com)
|
||
req = urllib.request.Request(
|
||
"http://ip-api.com/json/",
|
||
headers={"User-Agent": "Mozilla/5.0"}
|
||
)
|
||
geo_raw = urllib.request.urlopen(req, timeout=8).read()
|
||
geo = json.loads(geo_raw)
|
||
|
||
loc_name = "Your Area"
|
||
if geo.get("city") and geo.get("regionName"):
|
||
loc_name = f"{geo['city']}, {geo['regionName']}"
|
||
elif geo.get("city"):
|
||
loc_name = geo["city"]
|
||
|
||
lat, lon = geo.get("lat"), geo.get("lon")
|
||
if lat and lon:
|
||
# 2. Weather forecast lookup
|
||
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=auto"
|
||
fc_raw = urllib.request.urlopen(url, timeout=8).read()
|
||
fc = json.loads(fc_raw)
|
||
|
||
# 3. Save combined cache
|
||
payload = {"location": loc_name, "forecast": fc}
|
||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||
cache_file.write_text(json.dumps(payload, indent=2))
|
||
print("Updated local weather-data.json cache successfully")
|
||
except Exception as e:
|
||
print(f"Weather cache background update failed: {e}")
|
||
|
||
# Update cache every 15 minutes
|
||
time.sleep(900)
|
||
|
||
def init_cdp():
|
||
if cdp.wait_ready(timeout=30):
|
||
print("CDP ready — persistent WebSocket connected successfully")
|
||
|
||
threading.Thread(target=update_weather_cache, daemon=True).start()
|
||
threading.Thread(target=init_cdp, daemon=True).start()
|
||
|
||
sys.exit(app.exec_())
|