diff --git a/VERSION b/VERSION index abd4105..3a4036f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.4 +0.2.5 diff --git a/browser.py b/browser.py index bec0238..47460ae 100755 --- a/browser.py +++ b/browser.py @@ -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_()) diff --git a/config.json b/config.json index 4d9d6f9..168c4a2 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "urls": { "email": "https://mail.jgitta.com/roundcube/", "internet": "https://www.duckduckgo.com", - "weather": "https://weather.com", + "weather": "weather.html", "news": "https://news.google.com", "facebook": "https://www.facebook.com", "messenger": "https://www.messenger.com", diff --git a/deploy.sh b/deploy.sh index 8ed69be..b684cd6 100755 --- a/deploy.sh +++ b/deploy.sh @@ -9,6 +9,8 @@ KIOSK_REMOTE="jgitta@100.64.0.1" REPO_DIR="$(cd "$(dirname "$0")" && pwd)" HOME_SRC="$REPO_DIR/home-screen-index.html" HOME_DST="/home/jgitta/kiosk-home/index.html" +WEATHER_SRC="$REPO_DIR/weather.html" +WEATHER_DST="/home/jgitta/kiosk-home/weather.html" CONFIG_SRC="$REPO_DIR/config.json" # Auto-increment patch version (0.2.0 → 0.2.1 → 0.2.2 ...) VERSION_FILE="$REPO_DIR/VERSION" @@ -54,6 +56,12 @@ else echo " WARNING: $HOME_SRC missing — home screen not updated" fi +if [ -f "$WEATHER_SRC" ]; then + cp "$WEATHER_SRC" "$WEATHER_DST" + chown jgitta:jgitta "$WEATHER_DST" 2>/dev/null || true + echo " Synced weather page" +fi + echo "==> Committing and pushing to Gitea..." git -C "$REPO_DIR" add -A git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)" @@ -61,7 +69,11 @@ git -C "$REPO_DIR" remote set-url origin "http://$GITEA_USER:$GITEA_TOKEN@192.16 git -C "$REPO_DIR" push origin master || echo " (push failed — check network)" echo "==> Restarting kiosk-dev..." -/usr/local/bin/kiosk-restart +if [ -f "/usr/local/bin/kiosk-restart" ]; then + /usr/local/bin/kiosk-restart +else + ssh kiosk-dev "/usr/local/bin/kiosk-restart" +fi echo "==> Trying production kiosk..." KIOSK="" @@ -80,8 +92,9 @@ if [ -n "$KIOSK" ]; then cd /home/jgitta/kiosk-browser git pull # Inject config at deploy time - config_json=\$(cat config.json | sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g' | tr '\n' ' ') - sed \"s|const config = {};|const config = \$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html + config_json=\\\$(cat config.json | sed 's/\\\\\\\\/\\\\\\\\\\\\\\\\/g' | sed 's/\\\"/\\\\\\\"/g' | tr '\\\n' ' ') + sed \"s|const config = {};|const config = \\\$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html + cp weather.html /home/jgitta/kiosk-home/weather.html /usr/local/bin/kiosk-restart " echo " Production updated." diff --git a/weather.html b/weather.html new file mode 100644 index 0000000..ecc64ca --- /dev/null +++ b/weather.html @@ -0,0 +1,348 @@ + + + + + + Weather + + + + +
+
+
🔍 Determining your location...
+
+ + + + ⌂ Go Home +
+ + + +