feat: add local ad-free weather page

This commit is contained in:
2026-05-27 13:33:44 +00:00
parent fd519a64b0
commit 1b06a29e53
5 changed files with 598 additions and 262 deletions
+232 -257
View File
@@ -10,8 +10,7 @@ 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
QMessageBox, QFrame, QTreeWidget, QTreeWidgetItem, QAbstractItemView
)
_HOME = pathlib.Path.home()
@@ -22,54 +21,112 @@ try:
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
TOOLBAR_H = 72
SCREEN_W = 1920 # Dynamically updated in main
SCREEN_H = 1080 # Dynamically updated in main
# ── 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
# ── 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=FONT_SIZE):
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(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; }}
""")
b.setStyleSheet(STYLES["button"].format(color=color, hover=hover))
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; }}
""")
b.setStyleSheet(STYLES["icon_button"].format(color=color, hover=hover))
return b
def load_bm():
@@ -80,87 +137,116 @@ def load_bm():
return []
def save_bm(data):
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
with open(BOOKMARKS_FILE, "w") as f:
json.dump(data, f, indent=2)
def is_folder(item): return "children" in item
def is_folder(item):
return "children" in item
# ── CDP controller ────────────────────────────────────────────────
# ── CDP controller (Persistent WebSockets) ────────────────────────
class CDP:
"""Thin Chrome DevTools Protocol client — navigate, back, forward."""
"""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)
urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
self.ready = True
print("CDP ready")
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 _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 _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:
ws = websocket.create_connection(self._ws_url(), timeout=3)
ws.send(json.dumps({"id": 1, "method": method,
self.ws.send(json.dumps({"id": 1, "method": method,
**( {"params": params} if params else {})}))
ws.recv()
ws.close()
self.ws.recv()
except Exception as e:
print(f"CDP error ({method}): {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()
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:
ws = websocket.create_connection(self._ws_url(), timeout=3)
ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
resp = json.loads(ws.recv())
self.ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
resp = json.loads(self.ws.recv())
wid = resp.get("result", {}).get("windowId")
if wid:
ws.send(json.dumps({"id": 2,
self.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()
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()"})
self._async("Runtime.evaluate", {"expression": "window.history.back()"})
def forward(self):
self._async("Runtime.evaluate",
{"expression": "window.history.forward()"})
self._async("Runtime.evaluate", {"expression": "window.history.forward()"})
# ── Folder name dialog ────────────────────────────────────────────
@@ -168,23 +254,28 @@ def ask_name(parent, title="Name", current=""):
d = QDialog(parent)
d.setWindowTitle(title)
d.setMinimumWidth(480)
d.setStyleSheet("background:#1e2a3a; color:white;")
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("color:#aac8e8;")
lbl.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
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;")
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", GREEN, GREEN_HOVER, 150, 56, 16)
can = mk_btn("✖ Cancel", RED, RED_HOVER, 150, 56, 16)
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)
@@ -197,22 +288,28 @@ class BookmarkDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("Bookmark")
self.setMinimumWidth(560)
self.setStyleSheet("background:#1e2a3a; color:white;")
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)]:
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;")
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("background:#2a3f55;color:white;border:2px solid #3a6da4;"
"border-radius:8px;padding:6px;")
lay.addWidget(e); setattr(self, attr, e)
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", GREEN, GREEN_HOVER, 160, 58, 16)
can = mk_btn("✖ Cancel", RED, RED_HOVER, 160, 58, 16)
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)
@@ -227,7 +324,7 @@ class ManagerDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("Manage Bookmarks")
self.setMinimumSize(860, 640)
self.setStyleSheet("background:#1e2a3a; color:white;")
self.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
lay = QVBoxLayout(self)
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
@@ -239,35 +336,36 @@ class ManagerDialog(QDialog):
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)
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("""
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.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", 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]
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)
@@ -303,7 +401,8 @@ class ManagerDialog(QDialog):
items = self.tree.selectedItems()
return items[0] if items else None
def _commit(self): save_bm(self._to_list())
def _commit(self):
save_bm(self._to_list())
def _insert(self, node):
sel = self._sel()
@@ -357,12 +456,11 @@ class ManagerDialog(QDialog):
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']}'?")
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;")
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()
@@ -377,77 +475,24 @@ class ManagerDialog(QDialog):
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 ───────────────────────────────────────
# ── Bookmark dropdown panel (Native Focus-Loss Dismissal) ─────────
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.
Uses Qt.Popup flag for native focus-out dismissal on outside click (0% CPU).
"""
_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; }}
""")
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)
self._dismiss_timer = QTimer()
self._dismiss_timer.timeout.connect(self._check_dismiss)
def rebuild(self, items):
# Remove old layout
old = self.layout()
@@ -459,7 +504,7 @@ class BookmarkPanel(QWidget):
QWidget().setLayout(old)
scroll_content = QWidget()
scroll_content.setStyleSheet("background: #1e3a5c;")
scroll_content.setStyleSheet(f"background: {THEME['colors']['toolbar']};")
vbox = QVBoxLayout(scroll_content)
vbox.setContentsMargins(6, 6, 6, 6)
vbox.setSpacing(1)
@@ -468,7 +513,7 @@ class BookmarkPanel(QWidget):
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;")
sep.setStyleSheet(f"background: {THEME['colors']['border']}; margin: 4px 8px;")
sep.setFixedHeight(1)
vbox.addWidget(sep)
@@ -491,7 +536,7 @@ class BookmarkPanel(QWidget):
outer.setSpacing(0)
outer.addWidget(scroll)
# Size: cap height at 80 % of screen below toolbar
# 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)
@@ -499,14 +544,6 @@ class BookmarkPanel(QWidget):
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))
@@ -537,51 +574,17 @@ class BookmarkPanel(QWidget):
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 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.frameGeometry().contains(event.globalPos()):
if not self.geometry().contains(event.globalPos()) and not self._burger_global_rect().contains(event.globalPos()):
self.hide()
return False
@@ -591,11 +594,7 @@ class KioskOverlay(QWidget):
def __init__(self, cdp):
super().__init__()
self.cdp = cdp
self.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool
)
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;")
@@ -619,7 +618,6 @@ class KioskOverlay(QWidget):
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
@@ -628,7 +626,6 @@ class KioskOverlay(QWidget):
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):
@@ -670,7 +667,7 @@ class KioskOverlay(QWidget):
def _build_toolbar(self):
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
bar.setStyleSheet(f"background:{THEME['colors']['toolbar']};")
tl = QHBoxLayout(bar)
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
@@ -692,25 +689,25 @@ class KioskOverlay(QWidget):
tl.addStretch()
# ◀ Back
back_btn = mk_icon_btn("", BTN_COLOR, BTN_HOVER)
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("", BTN_COLOR, BTN_HOVER)
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("", GOLD, GOLD_HOVER)
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("", PURPLE, PURPLE_HOVER)
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)
@@ -786,8 +783,7 @@ def launch_chrome():
_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.execute("DELETE FROM cookies WHERE host_key LIKE ?", (f"%{_email_host}%",))
conn.commit()
print(f"Cleared stale session cookie for {_email_host}")
conn.close()
@@ -801,13 +797,13 @@ def launch_chrome():
"--no-default-browser-check",
"--disable-restore-session-state",
"--disable-default-browser-check",
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
"--disable-features=Translate,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"--user-data-dir={profile_dir}",
f"--remote-debugging-port={CDP_PORT}",
"--remote-allow-origins=*",
f"--window-position=0,{TOOLBAR_H}",
@@ -820,39 +816,18 @@ def launch_chrome():
# ── 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)
p = app.palette(); p.setColor(QPalette.Window, QColor(THEME["colors"]["toolbar"])); 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()
print("CDP ready — persistent WebSocket connected successfully")
threading.Thread(target=init_cdp, daemon=True).start()
sys.exit(app.exec_())