Compare commits
21 Commits
v0.01
..
b99f058bfb
| Author | SHA1 | Date | |
|---|---|---|---|
| b99f058bfb | |||
| 2ea316ffd8 | |||
| ea2b5c30c7 | |||
| 8e105aa0af | |||
| 237c81ace2 | |||
| 686395f2d5 | |||
| ff58ec0fd4 | |||
| d4041582f0 | |||
| ba2d5c2f17 | |||
| e34ea8f7c8 | |||
| a61f66e57a | |||
| aac4605e66 | |||
| e574403853 | |||
| 2ca1da4f8e | |||
| dfba158f3c | |||
| dc4b129986 | |||
| 18bb48b874 | |||
| 08f296ceb3 | |||
| a1a4b2e10c | |||
| 02b44e3972 | |||
| ed57ead6af |
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# SeniorNet Kiosk — Project Context for AI Assistants
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A locked-down, zero-maintenance browser workstation for elderly users. The senior never sees a desktop — only a full-screen Chromium window with a slim toolbar on top. No configuration, no maintenance required from the user.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ PyQt5 toolbar (72px, always-on-top, FramelessHint) │
|
||||||
|
│ [SeniorNet] [◀] [▶] [⌂] [☰ bookmarks] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Chromium --app mode │
|
||||||
|
│ (CDP port 9222 for navigation) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `browser.py` — main app. Launches both the PyQt5 toolbar and Chromium as a subprocess.
|
||||||
|
- Toolbar talks to Chromium via Chrome DevTools Protocol (CDP) over WebSocket on port 9222.
|
||||||
|
- Bookmarks stored in `bookmarks.json` (nested JSON, supports folders).
|
||||||
|
- Home screen: `/home/jgitta/kiosk-home/index.html`
|
||||||
|
|
||||||
|
## Machines
|
||||||
|
|
||||||
|
### kiosk-dev — 192.168.88.48 (SSH alias: `kiosk-dev`) ← ACTIVE DEV MACHINE
|
||||||
|
- Debian 13 Trixie, Proxmox VM 130 on jg-hud
|
||||||
|
- System Chromium: `/usr/lib/chromium/chromium` — NOT snap
|
||||||
|
- Repo at: `/home/jgitta/kiosk-browser/`
|
||||||
|
- This is the machine being actively developed and tested on
|
||||||
|
|
||||||
|
### Production kiosk — 192.168.88.44 (SSH alias: `kiosk`)
|
||||||
|
- Ubuntu 24.04 + snap Chromium — needs to be migrated to Debian to match kiosk-dev
|
||||||
|
- Currently offline / pending migration
|
||||||
|
|
||||||
|
## Deploy workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /home/jgitta/kiosk-browser/deploy.sh "describe your change"
|
||||||
|
```
|
||||||
|
|
||||||
|
This: commits + pushes to Gitea, restarts kiosk-dev immediately, and deploys to production if reachable.
|
||||||
|
|
||||||
|
**CRITICAL**: `kiosk-restart` must kill Chromium (`pkill -f "remote-debugging-port=9222"`) BEFORE killing browser.py. If only Python is killed, Chromium holds the SingletonLock and the new instance silently exits — deploys appear to work but nothing changes on screen.
|
||||||
|
|
||||||
|
`/usr/local/bin/kiosk-restart` on kiosk-dev:
|
||||||
|
```bash
|
||||||
|
pkill -f "remote-debugging-port=9222" || true
|
||||||
|
pkill -f "python3.*browser" || true
|
||||||
|
sleep 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
||||||
|
| `bookmarks.json` | Nested bookmark structure |
|
||||||
|
| `deploy.sh` | One-command deploy |
|
||||||
|
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
|
||||||
|
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
||||||
|
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
||||||
|
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
||||||
|
|
||||||
|
## Gitea repo
|
||||||
|
|
||||||
|
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
|
||||||
|
|
||||||
|
## Roundcube webmail
|
||||||
|
|
||||||
|
- URL: https://mail.jgitta.com/roundcube/ (proxied via Caddy at 192.168.88.110)
|
||||||
|
- LXC container 203 on jg-hud, IP: 192.168.88.45
|
||||||
|
- Ubuntu 24.04, Apache2, PHP, MariaDB, Roundcube 1.6.6
|
||||||
|
- Gmail IMAP: `ssl://imap.gmail.com:993`
|
||||||
|
- Autologin plugin: `/var/lib/roundcube/plugins/autologin/autologin.php`
|
||||||
|
- Logs: `/var/lib/roundcube/logs/autologin.log` and `errors.log`
|
||||||
|
- Run commands on container: `ssh jg-hud "pct exec 203 -- bash -c '...'"` (jg-hud is the Proxmox host)
|
||||||
|
- browser.py clears the Roundcube session cookie on every startup (forces fresh autologin)
|
||||||
|
|
||||||
|
## Known hard-won lessons
|
||||||
|
|
||||||
|
### Chromium
|
||||||
|
- `--no-sandbox` causes "unsupported flag" infobar — never use it on Debian system Chromium
|
||||||
|
- `--test-type` triggers Google CAPTCHA bot detection — never use it
|
||||||
|
- `--disable-blink-features=AutomationControlled` causes "unsupported flag" infobar — removed
|
||||||
|
- `--disable-infobars`, `--disable-session-crashed-bubble`, `--disable-save-password-bubble`, `--suppress-message-center-popups`, `--disable-translate` are all deprecated/removed in current Chromium — do not use
|
||||||
|
- Profile dir: `/home/jgitta/chromium-kiosk` (not hidden — snap couldn't write hidden dirs; kept this convention)
|
||||||
|
- Chromium adds its own flags via the Debian wrapper (`--show-component-extension-options` etc.) — ignore these, they're harmless
|
||||||
|
|
||||||
|
### PyQt5 / Openbox
|
||||||
|
- `QMenu` does NOT work under Openbox/X11 with `Qt.Tool | WindowStaysOnTopHint` parent — use a custom `QWidget` with the same flags instead (BookmarkPanel)
|
||||||
|
- Qt event filters only see clicks on Qt widgets — clicks that land on Chromium (separate X11 process) are invisible. Use a `QTimer` polling `QApplication.mouseButtons()` + `QCursor.pos()` to detect outside clicks.
|
||||||
|
- To prevent toolbar minimize: override `changeEvent`, detect `Qt.WindowMinimized`, call `QTimer.singleShot(50, self._self_restore)`
|
||||||
|
- To restore minimized Chromium: periodic `QTimer` + `xprop -id <wid> _NET_WM_STATE` to check `_NET_WM_STATE_HIDDEN`, then `xdotool windowmap <wid>`
|
||||||
|
|
||||||
|
### Roundcube autologin
|
||||||
|
- Plugin MUST call both `session->regenerate_id(false)` AND `session->set_auth_cookie()` after login
|
||||||
|
- Logs are in `/var/lib/roundcube/logs/` NOT `/var/log/roundcube/`
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- `pkill -f browser.py` in SSH kills its own session (shell cmdline contains "browser.py") — use `kiosk-restart`
|
||||||
|
- Chromium must be killed before Python or SingletonLock prevents new instance from starting
|
||||||
|
|
||||||
|
## Infrastructure overview
|
||||||
|
|
||||||
|
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
||||||
|
- **TrueNAS**: 192.168.88.24
|
||||||
|
- **Thinkstation** (dev desktop): 192.168.88.41
|
||||||
|
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
|
||||||
|
- **Gitea**: gitea.jgitta.com (port 3002 internal)
|
||||||
|
- **Headscale**: self-hosted Tailscale on siklos
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# SeniorNet Kiosk — Project Context for AI Assistants
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A locked-down, zero-maintenance browser workstation for elderly users. The senior never sees a desktop — only a full-screen Chromium window with a slim toolbar on top. No configuration, no maintenance required from the user.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ PyQt5 toolbar (72px, always-on-top, FramelessHint) │
|
||||||
|
│ [SeniorNet] [◀] [▶] [⌂] [☰ bookmarks] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Chromium --app mode │
|
||||||
|
│ (CDP port 9222 for navigation) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `browser.py` — main app. Launches both the PyQt5 toolbar and Chromium as a subprocess.
|
||||||
|
- Toolbar talks to Chromium via Chrome DevTools Protocol (CDP) over WebSocket on port 9222.
|
||||||
|
- Bookmarks stored in `bookmarks.json` (nested JSON, supports folders).
|
||||||
|
- Home screen: `/home/jgitta/kiosk-home/index.html`
|
||||||
|
|
||||||
|
## Machines
|
||||||
|
|
||||||
|
### kiosk-dev — 192.168.88.48 (SSH alias: `kiosk-dev`) ← ACTIVE DEV MACHINE
|
||||||
|
- Debian 13 Trixie, Proxmox VM 130 on jg-hud
|
||||||
|
- System Chromium: `/usr/lib/chromium/chromium` — NOT snap
|
||||||
|
- Repo at: `/home/jgitta/kiosk-browser/`
|
||||||
|
- This is the machine being actively developed and tested on
|
||||||
|
|
||||||
|
### Production kiosk — 192.168.88.44 (SSH alias: `kiosk`)
|
||||||
|
- Ubuntu 24.04 + snap Chromium — needs to be migrated to Debian to match kiosk-dev
|
||||||
|
- Currently offline / pending migration
|
||||||
|
|
||||||
|
## Deploy workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /home/jgitta/kiosk-browser/deploy.sh "describe your change"
|
||||||
|
```
|
||||||
|
|
||||||
|
This: commits + pushes to Gitea, restarts kiosk-dev immediately, and deploys to production if reachable.
|
||||||
|
|
||||||
|
**CRITICAL**: `kiosk-restart` must kill Chromium (`pkill -f "remote-debugging-port=9222"`) BEFORE killing browser.py. If only Python is killed, Chromium holds the SingletonLock and the new instance silently exits — deploys appear to work but nothing changes on screen.
|
||||||
|
|
||||||
|
`/usr/local/bin/kiosk-restart` on kiosk-dev:
|
||||||
|
```bash
|
||||||
|
pkill -f "remote-debugging-port=9222" || true
|
||||||
|
pkill -f "python3.*browser" || true
|
||||||
|
sleep 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
||||||
|
| `bookmarks.json` | Nested bookmark structure |
|
||||||
|
| `deploy.sh` | One-command deploy |
|
||||||
|
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
|
||||||
|
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
||||||
|
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
||||||
|
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
||||||
|
|
||||||
|
## Gitea repo
|
||||||
|
|
||||||
|
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
|
||||||
|
|
||||||
|
## Roundcube webmail
|
||||||
|
|
||||||
|
- URL: https://mail.jgitta.com/roundcube/ (proxied via Caddy at 192.168.88.110)
|
||||||
|
- LXC container 203 on jg-hud, IP: 192.168.88.45
|
||||||
|
- Ubuntu 24.04, Apache2, PHP, MariaDB, Roundcube 1.6.6
|
||||||
|
- Gmail IMAP: `ssl://imap.gmail.com:993`
|
||||||
|
- Autologin plugin: `/var/lib/roundcube/plugins/autologin/autologin.php`
|
||||||
|
- Logs: `/var/lib/roundcube/logs/autologin.log` and `errors.log`
|
||||||
|
- Run commands on container: `ssh jg-hud "pct exec 203 -- bash -c '...'"` (jg-hud is the Proxmox host)
|
||||||
|
- browser.py clears the Roundcube session cookie on every startup (forces fresh autologin)
|
||||||
|
|
||||||
|
## Known hard-won lessons
|
||||||
|
|
||||||
|
### Chromium
|
||||||
|
- `--no-sandbox` causes "unsupported flag" infobar — never use it on Debian system Chromium
|
||||||
|
- `--test-type` triggers Google CAPTCHA bot detection — never use it
|
||||||
|
- `--disable-blink-features=AutomationControlled` causes "unsupported flag" infobar — removed
|
||||||
|
- `--disable-infobars`, `--disable-session-crashed-bubble`, `--disable-save-password-bubble`, `--suppress-message-center-popups`, `--disable-translate` are all deprecated/removed in current Chromium — do not use
|
||||||
|
- Profile dir: `/home/jgitta/chromium-kiosk` (not hidden — snap couldn't write hidden dirs; kept this convention)
|
||||||
|
- Chromium adds its own flags via the Debian wrapper (`--show-component-extension-options` etc.) — ignore these, they're harmless
|
||||||
|
|
||||||
|
### PyQt5 / Openbox
|
||||||
|
- `QMenu` does NOT work under Openbox/X11 with `Qt.Tool | WindowStaysOnTopHint` parent — use a custom `QWidget` with the same flags instead (BookmarkPanel)
|
||||||
|
- Qt event filters only see clicks on Qt widgets — clicks that land on Chromium (separate X11 process) are invisible. Use a `QTimer` polling `QApplication.mouseButtons()` + `QCursor.pos()` to detect outside clicks.
|
||||||
|
- To prevent toolbar minimize: override `changeEvent`, detect `Qt.WindowMinimized`, call `QTimer.singleShot(50, self._self_restore)`
|
||||||
|
- To restore minimized Chromium: periodic `QTimer` + `xprop -id <wid> _NET_WM_STATE` to check `_NET_WM_STATE_HIDDEN`, then `xdotool windowmap <wid>`
|
||||||
|
|
||||||
|
### Roundcube autologin
|
||||||
|
- Plugin MUST call both `session->regenerate_id(false)` AND `session->set_auth_cookie()` after login
|
||||||
|
- Logs are in `/var/lib/roundcube/logs/` NOT `/var/log/roundcube/`
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- `pkill -f browser.py` in SSH kills its own session (shell cmdline contains "browser.py") — use `kiosk-restart`
|
||||||
|
- Chromium must be killed before Python or SingletonLock prevents new instance from starting
|
||||||
|
|
||||||
|
## Infrastructure overview
|
||||||
|
|
||||||
|
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
||||||
|
- **TrueNAS**: 192.168.88.24
|
||||||
|
- **Thinkstation** (dev desktop): 192.168.88.41
|
||||||
|
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
|
||||||
|
- **Gitea**: gitea.jgitta.com (port 3002 internal)
|
||||||
|
- **Headscale**: self-hosted Tailscale on siklos
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Gmail",
|
"name": "Email",
|
||||||
"url": "https://mail.google.com/mail/u/0/#inbox"
|
"url": "https://mail.jgitta.com/roundcube/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Facebook",
|
"name": "Facebook",
|
||||||
|
|||||||
+303
-123
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
SeniorNet Kiosk — PyQt5 floating toolbar + bookmarks bar over Google Chrome.
|
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.
|
Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket.
|
||||||
"""
|
"""
|
||||||
import sys, json, os, subprocess, time, threading, urllib.request
|
import sys, json, os, subprocess, time, threading, urllib.request
|
||||||
from PyQt5.QtCore import Qt, QTimer
|
from PyQt5.QtCore import Qt, QTimer, QPoint
|
||||||
from PyQt5.QtGui import QFont, QColor, QPalette
|
from PyQt5.QtGui import QFont, QColor, QPalette
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
||||||
@@ -16,22 +17,20 @@ from PyQt5.QtWidgets import (
|
|||||||
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
|
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
|
||||||
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
|
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
|
||||||
CDP_PORT = 9222
|
CDP_PORT = 9222
|
||||||
TOOLBAR_H = 82
|
TOOLBAR_H = 72 # single row — was 142 (82 + 60)
|
||||||
BM_BAR_H = 60
|
TOP_H = TOOLBAR_H
|
||||||
TOP_H = TOOLBAR_H + BM_BAR_H # 142 px overlay height
|
# Screen size is detected dynamically in main() after QApplication starts
|
||||||
SCREEN_W = 1920
|
SCREEN_W = 1920 # overwritten at runtime
|
||||||
SCREEN_H = 1080
|
SCREEN_H = 1080 # overwritten at runtime
|
||||||
|
|
||||||
# ── Colour palette ────────────────────────────────────────────────
|
# ── Colour palette ────────────────────────────────────────────────
|
||||||
TOOLBAR_COLOR = "#1a3a5c"
|
TOOLBAR_COLOR = "#1a3a5c"
|
||||||
BAR_COLOR = "#162f4a"
|
|
||||||
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
|
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
|
||||||
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
|
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
|
||||||
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
|
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
|
||||||
RED = "#c62828"; RED_HOVER = "#d32f2f"
|
RED = "#c62828"; RED_HOVER = "#d32f2f"
|
||||||
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
|
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
|
||||||
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
|
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
|
||||||
FOLDER_BG = "#3a4a6a"; FOLDER_HOVER = "#4a5a7a"
|
|
||||||
FONT_SIZE = 18
|
FONT_SIZE = 18
|
||||||
|
|
||||||
MENU_STYLE = """
|
MENU_STYLE = """
|
||||||
@@ -39,7 +38,7 @@ MENU_STYLE = """
|
|||||||
background: #1e3a5c; color: white;
|
background: #1e3a5c; color: white;
|
||||||
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
|
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
|
||||||
}
|
}
|
||||||
QMenu::item { padding: 12px 32px 12px 14px; border-radius: 6px; font-size: 16px; }
|
QMenu::item { padding: 14px 36px 14px 16px; border-radius: 6px; font-size: 17px; }
|
||||||
QMenu::item:selected { background: #3a6da4; color: white; }
|
QMenu::item:selected { background: #3a6da4; color: white; }
|
||||||
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
|
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
|
||||||
"""
|
"""
|
||||||
@@ -59,6 +58,20 @@ def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
|
|||||||
""")
|
""")
|
||||||
return b
|
return b
|
||||||
|
|
||||||
|
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
||||||
|
"""Square icon button for Back / Forward / Home / Hamburger."""
|
||||||
|
b = QPushButton(icon)
|
||||||
|
b.setFont(QFont("Arial", fs))
|
||||||
|
b.setFixedSize(size, size)
|
||||||
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
|
b.setStyleSheet(f"""
|
||||||
|
QPushButton {{ background:{color}; color:white; border:none;
|
||||||
|
border-radius:12px; }}
|
||||||
|
QPushButton:hover {{ background:{hover}; }}
|
||||||
|
QPushButton:pressed {{ background:#222; }}
|
||||||
|
""")
|
||||||
|
return b
|
||||||
|
|
||||||
def load_bm():
|
def load_bm():
|
||||||
try:
|
try:
|
||||||
with open(BOOKMARKS_FILE) as f: return json.load(f)
|
with open(BOOKMARKS_FILE) as f: return json.load(f)
|
||||||
@@ -115,6 +128,29 @@ class CDP:
|
|||||||
threading.Thread(target=self._send, args=(method, params),
|
threading.Thread(target=self._send, args=(method, params),
|
||||||
daemon=True).start()
|
daemon=True).start()
|
||||||
|
|
||||||
|
def resize_window(self, x, y, w, h):
|
||||||
|
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
||||||
|
if not self.ready:
|
||||||
|
return
|
||||||
|
import websocket
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
ws = websocket.create_connection(self._ws_url(), timeout=3)
|
||||||
|
ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
||||||
|
resp = json.loads(ws.recv())
|
||||||
|
wid = resp.get("result", {}).get("windowId")
|
||||||
|
if wid:
|
||||||
|
ws.send(json.dumps({"id": 2,
|
||||||
|
"method": "Browser.setWindowBounds",
|
||||||
|
"params": {"windowId": wid,
|
||||||
|
"bounds": {"left": x, "top": y,
|
||||||
|
"width": w, "height": h,
|
||||||
|
"windowState": "normal"}}}))
|
||||||
|
ws.recv()
|
||||||
|
ws.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CDP resize error: {e}")
|
||||||
|
|
||||||
def navigate(self, url):
|
def navigate(self, url):
|
||||||
self._async("Page.navigate", {"url": url})
|
self._async("Page.navigate", {"url": url})
|
||||||
|
|
||||||
@@ -341,102 +377,137 @@ class ManagerDialog(QDialog):
|
|||||||
self.tree.setCurrentItem(sel); self._commit()
|
self.tree.setCurrentItem(sel); self._commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmarks Bar ─────────────────────────────────────────────────
|
# ── Bookmark dropdown panel ───────────────────────────────────────
|
||||||
class BookmarksBar(QWidget):
|
class BookmarkPanel(QWidget):
|
||||||
def __init__(self, nav_cb):
|
"""
|
||||||
|
Custom dropdown that replaces QMenu for bookmarks.
|
||||||
|
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
|
||||||
|
always appears above Chrome, unlike QMenu which has z-order issues.
|
||||||
|
"""
|
||||||
|
def __init__(self, cdp, overlay):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.nav_cb = nav_cb
|
self.cdp = cdp
|
||||||
self.setFixedHeight(BM_BAR_H)
|
self.overlay = overlay
|
||||||
self.setStyleSheet(f"background:{BAR_COLOR};")
|
self.setWindowFlags(
|
||||||
|
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
|
||||||
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QWidget {{ background: #1e3a5c; }}
|
||||||
|
QPushButton {{ background: transparent; color: white; border: none;
|
||||||
|
text-align: left; padding: 8px 20px;
|
||||||
|
font-size: 17px; border-radius: 6px; }}
|
||||||
|
QPushButton:hover {{ background: #3a6da4; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
QLabel {{ color: #aac8e8; padding: 6px 20px 2px 20px;
|
||||||
|
font-size: 14px; font-weight: bold; }}
|
||||||
|
""")
|
||||||
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
outer = QHBoxLayout(self)
|
# Timer: hide panel when user clicks anywhere outside it
|
||||||
outer.setContentsMargins(6, 5, 6, 5); outer.setSpacing(4)
|
# (clicks on Chromium never reach Qt's eventFilter)
|
||||||
|
self._click_guard = QTimer()
|
||||||
|
self._click_guard.timeout.connect(self._check_outside_click)
|
||||||
|
|
||||||
self.left_btn = self._arrow("◀")
|
def rebuild(self, items):
|
||||||
self.left_btn.clicked.connect(lambda: self._scroll(-240))
|
# Remove old layout
|
||||||
outer.addWidget(self.left_btn)
|
old = self.layout()
|
||||||
|
if old:
|
||||||
|
while old.count():
|
||||||
|
w = old.takeAt(0).widget()
|
||||||
|
if w:
|
||||||
|
w.deleteLater()
|
||||||
|
QWidget().setLayout(old)
|
||||||
|
|
||||||
self.scroll_area = QScrollArea()
|
scroll_content = QWidget()
|
||||||
self.scroll_area.setWidgetResizable(True)
|
scroll_content.setStyleSheet("background: #1e3a5c;")
|
||||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
vbox = QVBoxLayout(scroll_content)
|
||||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
vbox.setContentsMargins(6, 6, 6, 6)
|
||||||
self.scroll_area.setFrameShape(QFrame.NoFrame)
|
vbox.setSpacing(1)
|
||||||
self.scroll_area.setStyleSheet(f"background:{BAR_COLOR}; border:none;")
|
|
||||||
|
|
||||||
self.inner = QWidget()
|
self._add_items(vbox, items, depth=0)
|
||||||
self.inner.setStyleSheet(f"background:{BAR_COLOR};")
|
|
||||||
self.row = QHBoxLayout(self.inner)
|
|
||||||
self.row.setContentsMargins(4, 0, 4, 0); self.row.setSpacing(6)
|
|
||||||
self.scroll_area.setWidget(self.inner)
|
|
||||||
outer.addWidget(self.scroll_area)
|
|
||||||
|
|
||||||
self.right_btn = self._arrow("▶")
|
sep = QFrame()
|
||||||
self.right_btn.clicked.connect(lambda: self._scroll(240))
|
sep.setFrameShape(QFrame.HLine)
|
||||||
outer.addWidget(self.right_btn)
|
sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;")
|
||||||
|
sep.setFixedHeight(1)
|
||||||
|
vbox.addWidget(sep)
|
||||||
|
|
||||||
self.reload()
|
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)
|
||||||
|
|
||||||
def _arrow(self, label):
|
scroll = QScrollArea()
|
||||||
b = QPushButton(label)
|
scroll.setWidgetResizable(True)
|
||||||
b.setFixedSize(38, 42); b.setFont(QFont("Arial", 14, QFont.Bold))
|
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||||
b.setStyleSheet("QPushButton { background:#2a4a6c; color:white; border:none;"
|
scroll.setStyleSheet("background: transparent; border: none;")
|
||||||
" border-radius:8px; } QPushButton:hover { background:#3a6da4; }")
|
scroll.setWidget(scroll_content)
|
||||||
return b
|
|
||||||
|
|
||||||
def reload(self):
|
outer = QVBoxLayout(self)
|
||||||
while self.row.count():
|
outer.setContentsMargins(2, 2, 2, 2)
|
||||||
w = self.row.takeAt(0).widget()
|
outer.setSpacing(0)
|
||||||
if w: w.deleteLater()
|
outer.addWidget(scroll)
|
||||||
for item in load_bm():
|
|
||||||
b = self._folder_btn(item) if is_folder(item) else self._bm_btn(item["name"], item["url"])
|
|
||||||
self.row.addWidget(b)
|
|
||||||
self.row.addStretch()
|
|
||||||
|
|
||||||
def _bm_btn(self, name, url):
|
# Size: cap height at 80 % of screen below toolbar
|
||||||
b = QPushButton(name)
|
scroll_content.adjustSize()
|
||||||
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(100)
|
ph = scroll_content.sizeHint().height() + 16
|
||||||
b.setCursor(Qt.PointingHandCursor); b.setToolTip(url)
|
pw = max(scroll_content.sizeHint().width() + 16, 340)
|
||||||
b.setStyleSheet("QPushButton { background:#2e5c8a; color:white; border:none;"
|
max_h = SCREEN_H - TOOLBAR_H - 20
|
||||||
" border-radius:8px; padding:4px 12px; }"
|
self.resize(pw, min(ph, max_h))
|
||||||
"QPushButton:hover { background:#3a7abf; }"
|
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
"QPushButton:pressed { background:#1a3a5c; }")
|
|
||||||
b.clicked.connect(lambda _, u=url: self.nav_cb(u))
|
|
||||||
return b
|
|
||||||
|
|
||||||
def _folder_btn(self, folder):
|
def _add_items(self, vbox, items, depth):
|
||||||
b = QPushButton(f"+ {folder['name']}")
|
indent = " " * depth
|
||||||
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(110)
|
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
|
||||||
b.setStyleSheet(f"QPushButton {{ background:{FOLDER_BG}; color:white; border:none;"
|
|
||||||
f" border-radius:8px; padding:4px 14px; }}"
|
|
||||||
f"QPushButton:hover {{ background:{FOLDER_HOVER}; }}"
|
|
||||||
f"QPushButton:pressed {{ background:#2a3a5a; }}")
|
|
||||||
b.clicked.connect(lambda _, f=folder, bb=b: self._show_menu(f, bb))
|
|
||||||
return b
|
|
||||||
|
|
||||||
def _show_menu(self, folder, button):
|
|
||||||
self._build_menu(folder["children"]).exec_(
|
|
||||||
button.mapToGlobal(button.rect().bottomLeft()))
|
|
||||||
|
|
||||||
def _build_menu(self, items):
|
|
||||||
menu = QMenu(); menu.setFont(QFont("Arial", 16)); menu.setStyleSheet(MENU_STYLE)
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if is_folder(item):
|
if is_folder(item):
|
||||||
sub = self._build_menu(item["children"])
|
lbl = QLabel(f"{indent}▸ {item['name']}")
|
||||||
sub.setTitle(f" > {item['name']}")
|
lbl.setFont(QFont("Arial", 14, QFont.Bold))
|
||||||
sub.setFont(QFont("Arial", 16)); sub.setStyleSheet(MENU_STYLE)
|
vbox.addWidget(lbl)
|
||||||
menu.addMenu(sub)
|
self._add_items(vbox, item["children"], depth + 1)
|
||||||
else:
|
else:
|
||||||
act = QAction(f" {item['name']}", menu)
|
btn = QPushButton(f"{indent} {item['name']}")
|
||||||
|
btn.setFont(QFont("Arial", 17))
|
||||||
|
btn.setMinimumHeight(46)
|
||||||
|
btn.setCursor(Qt.PointingHandCursor)
|
||||||
url = item["url"]
|
url = item["url"]
|
||||||
act.triggered.connect(lambda _, u=url: self.nav_cb(u))
|
btn.clicked.connect(lambda _, u=url: self._nav(u))
|
||||||
menu.addAction(act)
|
vbox.addWidget(btn)
|
||||||
return menu
|
|
||||||
|
|
||||||
def _scroll(self, dx):
|
def _nav(self, url):
|
||||||
sb = self.scroll_area.horizontalScrollBar()
|
self.hide()
|
||||||
sb.setValue(sb.value() + dx)
|
self.cdp.navigate(url)
|
||||||
|
|
||||||
|
def _on_manage(self):
|
||||||
|
self.hide()
|
||||||
|
self.overlay.open_manager()
|
||||||
|
|
||||||
|
def setVisible(self, visible):
|
||||||
|
super().setVisible(visible)
|
||||||
|
if visible:
|
||||||
|
self._click_guard.start(100)
|
||||||
|
else:
|
||||||
|
self._click_guard.stop()
|
||||||
|
|
||||||
|
def _check_outside_click(self):
|
||||||
|
"""Hide panel when a click lands outside it — even on the Chromium window."""
|
||||||
|
from PyQt5.QtGui import QCursor
|
||||||
|
if not self.isVisible():
|
||||||
|
self._click_guard.stop()
|
||||||
|
return
|
||||||
|
if QApplication.mouseButtons() & Qt.LeftButton:
|
||||||
|
if not self.geometry().contains(QCursor.pos()):
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
from PyQt5.QtCore import QEvent
|
||||||
|
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||||||
|
gp = event.globalPos()
|
||||||
|
if not self.geometry().contains(gp):
|
||||||
|
self.hide()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Floating toolbar overlay ──────────────────────────────────────
|
# ── Floating toolbar overlay ──────────────────────────────────────
|
||||||
@@ -457,42 +528,122 @@ class KioskOverlay(QWidget):
|
|||||||
vbox = QVBoxLayout(self)
|
vbox = QVBoxLayout(self)
|
||||||
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
|
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
|
||||||
vbox.addWidget(self._build_toolbar())
|
vbox.addWidget(self._build_toolbar())
|
||||||
self.bm_bar = BookmarksBar(cdp.navigate)
|
self.bm_panel = BookmarkPanel(cdp, self)
|
||||||
vbox.addWidget(self.bm_bar)
|
|
||||||
self.show()
|
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()
|
||||||
|
# Ignore bogus sizes from minimize / RDP disconnect events
|
||||||
|
if w < 400 or h < 200:
|
||||||
|
return
|
||||||
|
SCREEN_W, SCREEN_H = w, h
|
||||||
|
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
||||||
|
self.resize(SCREEN_W, TOP_H)
|
||||||
|
# Resize Chrome after brief delay so X has settled
|
||||||
|
QTimer.singleShot(500, self._resize_chrome)
|
||||||
|
|
||||||
|
def _resize_chrome(self):
|
||||||
|
self.cdp.resize_window(0, TOP_H, SCREEN_W, SCREEN_H - TOP_H)
|
||||||
|
|
||||||
|
def changeEvent(self, event):
|
||||||
|
"""Un-minimise the toolbar the instant it gets iconified."""
|
||||||
|
from PyQt5.QtCore import QEvent
|
||||||
|
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):
|
def _build_toolbar(self):
|
||||||
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
|
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
|
||||||
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
|
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
|
||||||
tl = QHBoxLayout(bar); tl.setContentsMargins(16, 8, 16, 8); tl.setSpacing(12)
|
tl = QHBoxLayout(bar)
|
||||||
|
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
|
||||||
|
|
||||||
|
# Brand label
|
||||||
brand = QLabel("SeniorNet")
|
brand = QLabel("SeniorNet")
|
||||||
brand.setFont(QFont("Arial", 24, QFont.Bold))
|
brand.setFont(QFont("Arial", 22, QFont.Bold))
|
||||||
brand.setStyleSheet("color:white;")
|
brand.setStyleSheet("color:white;")
|
||||||
tl.addWidget(brand); tl.addStretch()
|
tl.addWidget(brand)
|
||||||
|
tl.addStretch()
|
||||||
back_btn = mk_btn("< Back", BTN_COLOR, BTN_HOVER, 120, 62)
|
|
||||||
fwd_btn = mk_btn("Forward >", BTN_COLOR, BTN_HOVER, 140, 62)
|
|
||||||
home_btn = mk_btn("HOME", GOLD, GOLD_HOVER, 120, 62)
|
|
||||||
mgr_btn = mk_btn("Bookmarks", PURPLE, PURPLE_HOVER, 150, 62)
|
|
||||||
|
|
||||||
|
# ◀ Back
|
||||||
|
back_btn = mk_icon_btn("◀", BTN_COLOR, BTN_HOVER)
|
||||||
|
back_btn.setToolTip("Go Back")
|
||||||
back_btn.clicked.connect(self.cdp.back)
|
back_btn.clicked.connect(self.cdp.back)
|
||||||
fwd_btn.clicked.connect(self.cdp.forward)
|
tl.addWidget(back_btn)
|
||||||
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
|
||||||
mgr_btn.clicked.connect(self.open_manager)
|
# ▶ Forward
|
||||||
|
fwd_btn = mk_icon_btn("▶", BTN_COLOR, BTN_HOVER)
|
||||||
|
fwd_btn.setToolTip("Go Forward")
|
||||||
|
fwd_btn.clicked.connect(self.cdp.forward)
|
||||||
|
tl.addWidget(fwd_btn)
|
||||||
|
|
||||||
|
# ⌂ Home
|
||||||
|
home_btn = mk_icon_btn("⌂", GOLD, GOLD_HOVER)
|
||||||
|
home_btn.setToolTip("Go Home")
|
||||||
|
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
||||||
|
tl.addWidget(home_btn)
|
||||||
|
|
||||||
|
# ☰ Hamburger — bookmarks menu
|
||||||
|
self.burger_btn = mk_icon_btn("☰", PURPLE, PURPLE_HOVER)
|
||||||
|
self.burger_btn.setToolTip("Bookmarks")
|
||||||
|
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
|
||||||
|
tl.addWidget(self.burger_btn)
|
||||||
|
|
||||||
for b in [back_btn, fwd_btn, home_btn, mgr_btn]: tl.addWidget(b)
|
|
||||||
return bar
|
return bar
|
||||||
|
|
||||||
|
def _show_bookmarks_menu(self):
|
||||||
|
if self.bm_panel.isVisible():
|
||||||
|
self.bm_panel.hide()
|
||||||
|
else:
|
||||||
|
self.bm_panel.rebuild(load_bm())
|
||||||
|
self.bm_panel.show()
|
||||||
|
self.bm_panel.raise_()
|
||||||
|
|
||||||
def open_manager(self):
|
def open_manager(self):
|
||||||
d = ManagerDialog(self); d.exec_()
|
d = ManagerDialog(self)
|
||||||
self.bm_bar.reload()
|
d.exec_()
|
||||||
|
|
||||||
def keyPressEvent(self, e):
|
def keyPressEvent(self, e):
|
||||||
# Home key -> home screen
|
|
||||||
if e.key() == Qt.Key_Home:
|
if e.key() == Qt.Key_Home:
|
||||||
self.cdp.navigate(HOME_URL)
|
self.cdp.navigate(HOME_URL)
|
||||||
# Block all other dangerous keys silently
|
|
||||||
e.accept()
|
e.accept()
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
@@ -500,15 +651,13 @@ class KioskOverlay(QWidget):
|
|||||||
if event.type() == QEvent.KeyPress:
|
if event.type() == QEvent.KeyPress:
|
||||||
key = event.key()
|
key = event.key()
|
||||||
mods = event.modifiers()
|
mods = event.modifiers()
|
||||||
# Block Alt+F4, Ctrl+W, Ctrl+T, Ctrl+N, Ctrl+Tab, F11, Escape
|
|
||||||
blocked_keys = {Qt.Key_F4, Qt.Key_W, Qt.Key_T, Qt.Key_N,
|
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_Tab, Qt.Key_F11, Qt.Key_Escape,
|
||||||
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
|
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
|
||||||
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
|
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
|
||||||
if mods & (Qt.AltModifier | Qt.ControlModifier):
|
if mods & (Qt.AltModifier | Qt.ControlModifier):
|
||||||
if key in blocked_keys:
|
if key in blocked_keys:
|
||||||
return True # swallow the event
|
return True
|
||||||
# Also block bare Escape and F-keys
|
|
||||||
if key in {Qt.Key_Escape, Qt.Key_F11}:
|
if key in {Qt.Key_Escape, Qt.Key_F11}:
|
||||||
return True
|
return True
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
@@ -517,15 +666,13 @@ class KioskOverlay(QWidget):
|
|||||||
# ── Chrome launcher ───────────────────────────────────────────────
|
# ── Chrome launcher ───────────────────────────────────────────────
|
||||||
def launch_chrome():
|
def launch_chrome():
|
||||||
import pathlib, json as _json, shutil
|
import pathlib, json as _json, shutil
|
||||||
profile_dir = pathlib.Path("/home/jgitta/.config/google-chrome-kiosk")
|
profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Remove stale singleton locks
|
|
||||||
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||||
try: (profile_dir / name).unlink()
|
try: (profile_dir / name).unlink()
|
||||||
except FileNotFoundError: pass
|
except FileNotFoundError: pass
|
||||||
|
|
||||||
# Wipe GPU/ML caches that corrupt across restarts
|
|
||||||
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
||||||
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
||||||
"optimization_guide_model_store",
|
"optimization_guide_model_store",
|
||||||
@@ -535,7 +682,6 @@ def launch_chrome():
|
|||||||
if p.exists():
|
if p.exists():
|
||||||
shutil.rmtree(p, ignore_errors=True)
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
|
||||||
# Patch Local State
|
|
||||||
ls_path = profile_dir / "Local State"
|
ls_path = profile_dir / "Local State"
|
||||||
if ls_path.exists():
|
if ls_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -546,25 +692,37 @@ def launch_chrome():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Clear the stale Roundcube session cookie so the autologin plugin always
|
||||||
|
# fires cleanly. Without this, Chrome sends the old cookie after a reboot,
|
||||||
|
# the PHP session in MariaDB has expired, and Roundcube shows
|
||||||
|
# "session is invalid or expired" instead of auto-logging in.
|
||||||
|
cookies_db = profile_dir / "Default" / "Cookies"
|
||||||
|
if cookies_db.exists():
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(str(cookies_db))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Cleared stale Roundcube session cookie")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Cookie clear warning: {e}")
|
||||||
|
|
||||||
chrome_h = SCREEN_H - TOP_H
|
chrome_h = SCREEN_H - TOP_H
|
||||||
cmd = [
|
cmd = [
|
||||||
"google-chrome",
|
"chromium",
|
||||||
"--no-first-run",
|
"--no-first-run",
|
||||||
"--no-default-browser-check",
|
"--no-default-browser-check",
|
||||||
"--disable-translate",
|
|
||||||
"--disable-infobars",
|
|
||||||
"--disable-session-crashed-bubble",
|
|
||||||
"--disable-save-password-bubble",
|
|
||||||
"--disable-restore-session-state",
|
"--disable-restore-session-state",
|
||||||
|
"--disable-default-browser-check",
|
||||||
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
||||||
"--disable-component-update",
|
"--disable-component-update",
|
||||||
"--disable-background-networking",
|
"--disable-background-networking",
|
||||||
"--disable-extensions",
|
"--disable-extensions",
|
||||||
"--disable-gpu",
|
"--disable-gpu",
|
||||||
"--no-sandbox",
|
|
||||||
"--test-type",
|
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
"--user-data-dir=/home/jgitta/.config/google-chrome-kiosk",
|
"--user-data-dir=/home/jgitta/chromium-kiosk",
|
||||||
f"--remote-debugging-port={CDP_PORT}",
|
f"--remote-debugging-port={CDP_PORT}",
|
||||||
"--remote-allow-origins=*",
|
"--remote-allow-origins=*",
|
||||||
f"--window-position=0,{TOP_H}",
|
f"--window-position=0,{TOP_H}",
|
||||||
@@ -577,6 +735,9 @@ def launch_chrome():
|
|||||||
# ── Main ──────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = QApplication(sys.argv)
|
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")
|
app.setApplicationName("SeniorNet")
|
||||||
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
|
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
|
||||||
|
|
||||||
@@ -585,9 +746,28 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
chrome_proc = launch_chrome()
|
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():
|
def init_cdp():
|
||||||
if cdp.wait_ready(timeout=30):
|
if cdp.wait_ready(timeout=30):
|
||||||
print("CDP ready — kiosk running")
|
print("CDP ready — kiosk running")
|
||||||
|
dismiss_chrome_dialogs()
|
||||||
threading.Thread(target=init_cdp, daemon=True).start()
|
threading.Thread(target=init_cdp, daemon=True).start()
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Senior Kiosk — Credentials
|
||||||
|
|
||||||
|
> **Keep this file private.** Do not share publicly.
|
||||||
|
|
||||||
|
## Gmail App Password (for Roundcube IMAP)
|
||||||
|
|
||||||
|
- **Account**: Gmail account used on the kiosk
|
||||||
|
- **Purpose**: Roundcube webmail IMAP/SMTP access
|
||||||
|
- **App Password**: `zrhe ncfw zvgb qqfs`
|
||||||
|
- **Created**: 2026-05-21
|
||||||
|
- **Note**: Spaces are part of the display only — enter without spaces when configuring Roundcube: `zrhencfwzvgbqqfs`
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy latest kiosk code.
|
||||||
|
# Always commits to Gitea and restarts kiosk-dev (dev/test machine).
|
||||||
|
# If the production kiosk is reachable, deploys there too.
|
||||||
|
# Usage: ./deploy.sh ["commit message"]
|
||||||
|
|
||||||
|
KIOSK_LOCAL="jgitta@192.168.88.44"
|
||||||
|
KIOSK_REMOTE="jgitta@100.64.0.1"
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
|
||||||
|
|
||||||
|
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)"
|
||||||
|
git -C "$REPO_DIR" push origin master || echo " (nothing new to push)"
|
||||||
|
|
||||||
|
echo "==> Restarting kiosk-dev..."
|
||||||
|
/usr/local/bin/kiosk-restart
|
||||||
|
|
||||||
|
echo "==> Trying production kiosk..."
|
||||||
|
KIOSK=""
|
||||||
|
if ssh -o ConnectTimeout=5 -o BatchMode=yes $KIOSK_LOCAL "echo ok" &>/dev/null; then
|
||||||
|
KIOSK="$KIOSK_LOCAL"
|
||||||
|
echo " Reaching production via local network"
|
||||||
|
elif ssh -o ConnectTimeout=8 -o BatchMode=yes $KIOSK_REMOTE "echo ok" &>/dev/null; then
|
||||||
|
KIOSK="$KIOSK_REMOTE"
|
||||||
|
echo " Reaching production via Tailscale"
|
||||||
|
else
|
||||||
|
echo " Production kiosk unreachable — skipping (will deploy when online)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$KIOSK" ]; then
|
||||||
|
ssh "$KIOSK" "
|
||||||
|
cd /home/jgitta/kiosk-browser
|
||||||
|
git pull
|
||||||
|
/usr/local/bin/kiosk-restart
|
||||||
|
"
|
||||||
|
echo " Production updated."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Deploy complete!"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Kiosk Auto-Login Plugin
|
||||||
|
* Automatically logs in — senior user never sees the login screen.
|
||||||
|
* Mirrors the exact session setup from index.php's normal login flow:
|
||||||
|
* login() -> session->remove('temp') -> session->regenerate_id(false)
|
||||||
|
* -> session->set_auth_cookie() -> redirect
|
||||||
|
*/
|
||||||
|
class autologin extends rcube_plugin
|
||||||
|
{
|
||||||
|
public $task = 'login';
|
||||||
|
|
||||||
|
function init()
|
||||||
|
{
|
||||||
|
$this->add_hook('startup', array($this, 'startup'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startup($args)
|
||||||
|
{
|
||||||
|
$rcmail = rcmail::get_instance();
|
||||||
|
|
||||||
|
if ($args['task'] == 'login' && empty($_SESSION['user_id'])) {
|
||||||
|
$username = 'rudbelik@gmail.com';
|
||||||
|
$password = 'zrhencfwzvgbqqfs';
|
||||||
|
$host = 'ssl://imap.gmail.com:993';
|
||||||
|
|
||||||
|
$loggedin = $rcmail->login($username, $password, $host, false);
|
||||||
|
|
||||||
|
rcube::write_log('autologin', 'Login attempt ��� result: ' . ($loggedin ? 'SUCCESS' : 'FAILED'));
|
||||||
|
|
||||||
|
if ($loggedin) {
|
||||||
|
// Replicate exact post-login session setup from index.php
|
||||||
|
$rcmail->session->remove('temp');
|
||||||
|
$rcmail->session->regenerate_id(false);
|
||||||
|
$rcmail->session->set_auth_cookie();
|
||||||
|
|
||||||
|
header('Location: ' . $rcmail->url(array('_task' => 'mail')));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user