Files
senior-kiosk/browser.py
T

1007 lines
41 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, random
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}&current=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 update_news_cache():
"""Fetch top RSS news feeds, scrape full article text & images in background, and cache locally with randomized sources."""
import xml.etree.ElementTree as ET
import re
cache_file = _HOME / "kiosk-home" / "news-data.json"
# Refined catalog of highly reliable, 100% paywall-free, direct-link news sources
world_catalog = [
("http://feeds.bbci.co.uk/news/world/rss.xml", "BBC News"),
("https://www.aljazeera.com/xml/rss/all.xml", "Al Jazeera")
]
national_catalog = [
("https://feeds.npr.org/1001/rss.xml", "NPR News"),
("http://rss.cnn.com/rss/cnn_topstories.rss", "CNN News"),
("https://search.cnbc.com/rs/search/all/view.xml?partnerId=2000", "CNBC News")
]
def scrape_article(url):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
# 1. Extract og:image
img_url = ""
og_img_match = re.search(r'<meta[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']', html)
if not og_img_match:
og_img_match = re.search(r'<meta[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']', html)
if og_img_match:
img_url = og_img_match.group(1)
# 2. Extract main paragraphs
paragraphs = []
p_matches = re.findall(r'<p[^>]*>(.*?)</p>', html, re.DOTALL)
for p in p_matches:
p_clean = re.sub('<[^<]+?>', '', p)
p_clean = p_clean.strip()
# Decode basic HTML entities
p_clean = p_clean.replace("&nbsp;", " ").replace("&amp;", "&").replace("&quot;", '"').replace("&apos;", "'").replace("&#39;", "'").replace("&ndash;", "-").replace("&mdash;", "-")
# Filter out short noise paragraphs (ads, nav links)
if len(p_clean) > 85 and not p_clean.startswith("Follow ") and not p_clean.startswith("Read more") and not "click here" in p_clean.lower() and not "twitter" in p_clean.lower():
# Standard library blocklist to discard site global header/footer navigation items
ignore_terms = [
"British Broadcasting Corporation", "Watch Live", "HomeNewsSport",
"Special Series", "Up First", "All Up First Stories", "hide caption",
"LISTEN & FOLLOW", "Morning Edition", "Morning news brief", "Podcast",
"New York Times", "NYT", "The Times", "Al Jazeera", "BBC", "CNN"
]
if any(term in p_clean for term in ignore_terms):
continue
paragraphs.append(p_clean)
# Keep top 8 substantial paragraphs
full_text = "\n\n".join(paragraphs[:8])
return full_text, img_url
except Exception as e:
print(f"Scraping error for {url}: {e}")
return "", ""
while True:
# Randomly select 2 World feeds and 2 National feeds to harvest (safely capped by catalog size)
selected_world = random.sample(world_catalog, min(len(world_catalog), 2))
selected_national = random.sample(national_catalog, min(len(national_catalog), 2))
feeds = {
"World News": selected_world,
"National News": selected_national
}
payload = {}
for category, source_list in feeds.items():
category_stories = []
for url, source_name in source_list:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
xml_raw = urllib.request.urlopen(req, timeout=12).read()
root = ET.fromstring(xml_raw)
channel = root.find("channel")
if channel is not None:
# Fetch top 3 stories per selected feed to compile a rich, diverse set
items_harvested = 0
for item in channel.findall("item"):
if items_harvested >= 3:
break
title = item.find("title")
link = item.find("link")
pub_date = item.find("pubDate")
t_str = title.text.strip() if title is not None and title.text else ""
l_str = link.text.strip() if link is not None and link.text else ""
p_str = ""
if pub_date is not None and pub_date.text:
try:
p_str = " ".join(pub_date.text.split()[:4])
except Exception:
p_str = pub_date.text
# Scrape rich data
full_story, img_url = "", ""
if l_str:
full_story, img_url = scrape_article(l_str)
category_stories.append({
"title": t_str,
"summary": full_story if full_story else "Click to read the story details.",
"image": img_url,
"source": source_name,
"date": p_str
})
items_harvested += 1
print(f"Harvested {items_harvested} stories from {source_name} for {category}")
except Exception as e:
print(f"Failed to harvest RSS feed {source_name} ({url}): {e}")
# Fully randomize/shuffle the compiled stories list so sources are mixed nicely!
random.shuffle(category_stories)
payload[category] = category_stories
if payload:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(payload, indent=2))
print("Updated local news-data.json cache with randomized shuffled stories successfully")
except Exception as e:
print(f"Failed to write news cache file: {e}")
# Update news every 30 minutes
time.sleep(1800)
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=update_news_cache, daemon=True).start()
threading.Thread(target=init_cdp, daemon=True).start()
sys.exit(app.exec_())