Compare commits

...

21 Commits

Author SHA1 Message Date
jgitta b99f058bfb add .cursorrules for Cursor AI auto-context 2026-05-22 20:56:23 +00:00
jgitta 2ea316ffd8 add CLAUDE.md — full project context for AI assistants 2026-05-22 20:48:02 +00:00
jgitta ea2b5c30c7 deploy.sh: always restart kiosk-dev; production is optional best-effort 2026-05-22 20:44:13 +00:00
jgitta 8e105aa0af v0.11: fix infobar flag, hamburger click-outside via polling timer 2026-05-22 20:34:29 +00:00
jgitta 237c81ace2 v0.10: remove deprecated flags that cause unsupported-flag infobar 2026-05-22 20:27:21 +00:00
jgitta 686395f2d5 v0.09: remove --disable-blink-features=AutomationControlled (causes infobar) 2026-05-22 20:25:38 +00:00
jgitta ff58ec0fd4 v0.08: prevent minimize — toolbar self-restores, Chromium watchdog 2026-05-22 20:22:03 +00:00
jgitta d4041582f0 fix: ignore tiny resize events from minimize/RDP disconnect 2026-05-22 13:10:55 +00:00
jgitta ba2d5c2f17 fix: responsive toolbar and Chromium resize on screen geometry change 2026-05-22 13:05:41 +00:00
jgitta e34ea8f7c8 fix: remove --no-sandbox, dynamic screen resolution detection 2026-05-22 13:03:11 +00:00
jgitta a61f66e57a add deploy.sh: one-command push to production kiosk 2026-05-22 12:54:51 +00:00
jgitta aac4605e66 v0.07: switch to Chromium snap, remove Google Chrome
- Replace google-chrome with /snap/bin/chromium
- Remove --test-type flag (was triggering Google bot detection/CAPTCHA)
- Add --disable-blink-features=AutomationControlled (hides webdriver flag)
- Move profile dir to /home/jgitta/chromium-kiosk (snap can't write to .config)
- Uninstall google-chrome-stable and its apt repo
2026-05-22 12:28:52 +00:00
jgitta e574403853 v0.06: replace QMenu with custom BookmarkPanel widget
QMenu has z-order and event-grab issues under Openbox/X11 — the menu was
being created successfully but either appeared behind Chrome or was
dismissed instantly by the mouse-release event.

Replace with BookmarkPanel: a QWidget using Qt.Tool | WindowStaysOnTopHint
| FramelessWindowHint (same flags as the toolbar itself). Since the toolbar
already stays above Chrome, the panel does too. Features:
- Scrollable list of all bookmarks with folder headers
- Closes when clicking anywhere outside
- Manage Bookmarks entry at the bottom
- Toggle open/close on hamburger button
2026-05-21 20:39:59 -05:00
jgitta 2ca1da4f8e v0.05: fix Roundcube autologin + hamburger menu focus
Roundcube autologin fix:
- Added missing session->regenerate_id(false) and session->set_auth_cookie()
  calls after login, matching Roundcube's normal login flow in index.php.
  Without these, the roundcube_sessauth cookie was never set, causing every
  second request to fail with 'session is invalid or expired'.

Hamburger menu fix:
- Added activateWindow() + raise_() + processEvents() before menu.exec_()
  to ensure the toolbar window is active before the QMenu event loop starts.
  Without this, WA_ShowWithoutActivating prevents focus from being grabbed
  and the menu vanishes immediately.
- Added try/except to surface any silent errors.
2026-05-21 20:29:46 -05:00
jgitta dfba158f3c v0.04: fix hamburger menu position + clear stale Roundcube cookie
- Hamburger menu: calculate explicit QPoint position (right-aligned below
  toolbar) instead of relying on Qt overflow logic with bottomRight() anchor.
  sizeHint() used for width with 360px fallback.
- Roundcube login: clear mail.jgitta.com session cookie from Chrome's
  SQLite Cookies DB on every startup so autologin always fires cleanly
  instead of showing 'session is invalid or expired'.
2026-05-21 20:14:50 -05:00
jgitta dc4b129986 v0.03: fix hamburger menu clipping off right edge of screen
Change menu anchor from bottomLeft() to bottomRight() so Qt's built-in
overflow logic flips the menu leftward when the button is near the right
screen edge. All bookmarks now visible.
2026-05-21 20:09:44 -05:00
jgitta 18bb48b874 Auto-dismiss Welcome to Chrome dialog; add suppress flags for first-run popups 2026-05-22 00:56:08 +00:00
jgitta 08f296ceb3 Fix SeniorNet label clipping; auto-dismiss Chrome profile error dialog 2026-05-22 00:52:18 +00:00
jgitta a1a4b2e10c Redesign toolbar: single row with icon buttons and hamburger bookmark menu 2026-05-22 00:50:47 +00:00
jgitta 02b44e3972 Replace Gmail with Roundcube webmail 2026-05-21 23:25:51 +00:00
jgitta ed57ead6af Add Gmail App Password for Roundcube 2026-05-21 12:04:05 +00:00
7 changed files with 629 additions and 127 deletions
+113
View File
@@ -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
+113
View File
@@ -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
View File
@@ -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",
+304 -124
View File
@@ -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)
@@ -105,7 +118,7 @@ class CDP:
try: try:
ws = websocket.create_connection(self._ws_url(), timeout=3) ws = websocket.create_connection(self._ws_url(), timeout=3)
ws.send(json.dumps({"id": 1, "method": method, ws.send(json.dumps({"id": 1, "method": method,
**({"params": params} if params else {})})) **( {"params": params} if params else {})}))
ws.recv() ws.recv()
ws.close() ws.close()
except Exception as e: except Exception as e:
@@ -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_())
+11
View File
@@ -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`
Executable
+41
View File
@@ -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!"
+44
View File
@@ -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;
}
}