Initial release v0.01 — SeniorNet kiosk browser
- PyQt5 floating toolbar (Back, Forward, HOME, Bookmarks) over Google Chrome - Scrollable bookmarks bar with nested folder dropdown menus - Chrome DevTools Protocol (CDP) for navigation control - Home screen with large senior-friendly buttons (Gmail, Google, Weather, News, Facebook, Messenger, YouTube) - Automatic GPU/ML cache cleanup on every Chrome launch - Kiosk hardening: TTY switching blocked, keyboard shortcuts intercepted, auto-restart on crash - System config files and install script included
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# SeniorNet Kiosk — v0.01
|
||||||
|
|
||||||
|
A fully locked-down, zero-maintenance browser kiosk for senior users running on Ubuntu 24.04 LTS Server.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The kiosk presents a single Chrome browser window with a custom floating toolbar. Seniors see only large, friendly navigation buttons and bookmarks — no browser UI, no address bar, no settings. Everything is managed remotely over SSH or Cockpit.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ SeniorNet Toolbar (PyQt5 overlay — always on top) │
|
||||||
|
│ [ < Back ] [ Forward > ] [ HOME ] [ Bookmarks ] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ Bookmarks Bar (scrollable, nested folder menus) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Google Chrome (--app mode) │
|
||||||
|
│ No browser UI — content only │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **OS**: Ubuntu 24.04 LTS Server (minimal, headless)
|
||||||
|
- **Display**: X11 + LightDM (auto-login) + Openbox WM
|
||||||
|
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
|
||||||
|
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
|
||||||
|
- **Browser control**: Chrome DevTools Protocol (CDP) over WebSocket on port 9222
|
||||||
|
- **Remote management**: SSH + Cockpit (port 9091)
|
||||||
|
- **Remote access**: Headscale (self-hosted Tailscale)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `browser.py` | Main application — PyQt5 overlay + CDP Chrome controller |
|
||||||
|
| `bookmarks.json` | Nested bookmarks (folders + links) shown in the bookmarks bar |
|
||||||
|
| `/home/jgitta/kiosk-home/index.html` | Home screen with large buttons and live clock |
|
||||||
|
|
||||||
|
## System Configuration Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `/etc/lightdm/lightdm.conf.d/50-kiosk.conf` | Auto-login as jgitta into Openbox session |
|
||||||
|
| `~/.config/openbox/autostart` | Launches browser.py at session start, auto-restarts on crash |
|
||||||
|
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching and Ctrl+Alt+Backspace |
|
||||||
|
| `/etc/apt/apt.conf.d/20auto-upgrades` | Automatic unattended security updates |
|
||||||
|
|
||||||
|
## Chrome Launch Flags
|
||||||
|
|
||||||
|
Chrome is launched with these key flags:
|
||||||
|
|
||||||
|
```
|
||||||
|
--app=<HOME_URL> # No browser UI, content only
|
||||||
|
--window-position=0,142 # Below the 142px overlay
|
||||||
|
--window-size=1920,938 # Full width, remaining height
|
||||||
|
--remote-debugging-port=9222 # CDP for toolbar control
|
||||||
|
--disable-gpu # Required on server hardware (no GPU drivers)
|
||||||
|
--disable-dev-shm-usage # Prevents shared memory crash
|
||||||
|
--test-type # Suppresses unsupported-flag warning banner
|
||||||
|
--disable-features=OptimizationHints,... # Prevents ML model cache corruption
|
||||||
|
--disable-component-update # No background component downloads
|
||||||
|
--user-data-dir=~/.config/google-chrome-kiosk # Dedicated kiosk profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hardening
|
||||||
|
|
||||||
|
- **TTY switching blocked**: `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` sets `DontVTSwitch`
|
||||||
|
- **X kill blocked**: `DontZap` prevents Ctrl+Alt+Backspace
|
||||||
|
- **tty2–tty6 disabled**: `systemctl disable getty@tty2..6`
|
||||||
|
- **Keyboard shortcuts blocked**: Overlay intercepts Alt+F4, Ctrl+W, Ctrl+T, Ctrl+N, F11, Escape
|
||||||
|
- **Auto-restart**: Openbox autostart restarts browser.py within 5 seconds if it crashes
|
||||||
|
- **Profile cleanup**: GPU/ML caches wiped on every Chrome launch to prevent corruption
|
||||||
|
|
||||||
|
## Bookmarks
|
||||||
|
|
||||||
|
Bookmarks are stored in `bookmarks.json` as nested JSON. Top-level items appear as buttons in the bookmarks bar. Items with a `children` array become folder dropdown menus.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"name": "Gmail", "url": "https://mail.google.com"},
|
||||||
|
{"name": "News", "children": [
|
||||||
|
{"name": "BBC News", "url": "https://www.bbc.com/news"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit bookmarks live through the **Bookmarks** button in the toolbar, or edit `bookmarks.json` directly via SSH.
|
||||||
|
|
||||||
|
## Home Screen Buttons
|
||||||
|
|
||||||
|
Defined in `/home/jgitta/kiosk-home/index.html`:
|
||||||
|
|
||||||
|
- Email (Gmail)
|
||||||
|
- Internet (Google)
|
||||||
|
- Weather (AccuWeather)
|
||||||
|
- News
|
||||||
|
- Facebook
|
||||||
|
- Facebook Messenger
|
||||||
|
- YouTube
|
||||||
|
|
||||||
|
## Remote Management
|
||||||
|
|
||||||
|
- **SSH**: `ssh jgitta@192.168.88.44` (local) or `ssh jgitta@kiosk-remote` (via Tailscale)
|
||||||
|
- **Cockpit**: `https://192.168.88.44:9091`
|
||||||
|
|
||||||
|
## Installation Summary
|
||||||
|
|
||||||
|
1. Install Ubuntu 24.04 LTS Server (minimal)
|
||||||
|
2. Install packages: `python3-pyqt5 google-chrome-stable unclutter xorg openbox lightdm fonts-noto-color-emoji websocket-client`
|
||||||
|
3. Configure LightDM for auto-login
|
||||||
|
4. Copy `kiosk-browser/` and `kiosk-home/` to `/home/jgitta/`
|
||||||
|
5. Place system config files (see table above)
|
||||||
|
6. Reboot
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
| Version | Date | Notes |
|
||||||
|
|---------|------|-------|
|
||||||
|
| 0.01 | 2026-05-21 | Initial release — working kiosk with toolbar, bookmarks, hardening |
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Gmail",
|
||||||
|
"url": "https://mail.google.com/mail/u/0/#inbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Facebook",
|
||||||
|
"url": "https://www.facebook.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Facebook Messenger",
|
||||||
|
"url": "https://www.messenger.com/t/100000537461369/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "YouTube",
|
||||||
|
"url": "https://www.youtube.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AccuWeather",
|
||||||
|
"url": "https://www.accuweather.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "News",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "CNN",
|
||||||
|
"url": "https://www.cnn.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AP News",
|
||||||
|
"url": "https://apnews.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1440 News",
|
||||||
|
"url": "https://join1440.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reuters",
|
||||||
|
"url": "https://www.reuters.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Newser",
|
||||||
|
"url": "https://www.newser.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Origo HU",
|
||||||
|
"url": "https://www.origo.hu/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "More Email",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Outlook",
|
||||||
|
"url": "https://outlook.live.com/mail/0/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Yahoo Mail",
|
||||||
|
"url": "https://mail.yahoo.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FreeMail HU",
|
||||||
|
"url": "http://freemail.hu/mail/index.fm#fm"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Google",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Google Photos",
|
||||||
|
"url": "https://photos.google.com/?pli=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Google Drive",
|
||||||
|
"url": "https://drive.google.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Google Maps",
|
||||||
|
"url": "https://www.google.com/maps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Google Calendar",
|
||||||
|
"url": "https://calendar.google.com/calendar/u/0/r"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Google Docs",
|
||||||
|
"url": "https://docs.google.com/document/u/0/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Games",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "PCH",
|
||||||
|
"url": "https://www.pch.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PCH Sweeps",
|
||||||
|
"url": "https://mpo.pch.com/path/ROBJulyTV24?experience=ROBJulyTV24&theme=FrstPy&tid=36b7d78c-1980-402b-9348-275ba91458a9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cash Sweeps",
|
||||||
|
"url": "https://www.cashkingsweeps.com/sweeps/cks_500k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PrizeCastle",
|
||||||
|
"url": "https://www.prizecastle.com/sweeps/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Best Day Sweeps",
|
||||||
|
"url": "https://bestdayeversweeps.com/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shopping",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Amazon",
|
||||||
|
"url": "https://www.amazon.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Walmart",
|
||||||
|
"url": "https://www.walmart.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayPal",
|
||||||
|
"url": "https://www.paypal.com/myaccount"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Banking",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "SoFi Bank",
|
||||||
|
"url": "https://www.sofi.com/login/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OTP Bank",
|
||||||
|
"url": "https://www.otpbank.hu/portal/hu/OTPdirekt/Belepes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Executable
+593
@@ -0,0 +1,593 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SeniorNet Kiosk — PyQt5 floating toolbar + bookmarks bar over Google Chrome.
|
||||||
|
Chrome is controlled via Chrome DevTools Protocol (CDP) over WebSocket.
|
||||||
|
"""
|
||||||
|
import sys, json, os, subprocess, time, threading, urllib.request
|
||||||
|
from PyQt5.QtCore import Qt, QTimer
|
||||||
|
from PyQt5.QtGui import QFont, QColor, QPalette
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
||||||
|
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
|
||||||
|
QMessageBox, QFrame, QMenu, QAction,
|
||||||
|
QTreeWidget, QTreeWidgetItem, QAbstractItemView
|
||||||
|
)
|
||||||
|
|
||||||
|
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
|
||||||
|
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
|
||||||
|
CDP_PORT = 9222
|
||||||
|
TOOLBAR_H = 82
|
||||||
|
BM_BAR_H = 60
|
||||||
|
TOP_H = TOOLBAR_H + BM_BAR_H # 142 px overlay height
|
||||||
|
SCREEN_W = 1920
|
||||||
|
SCREEN_H = 1080
|
||||||
|
|
||||||
|
# ── Colour palette ────────────────────────────────────────────────
|
||||||
|
TOOLBAR_COLOR = "#1a3a5c"
|
||||||
|
BAR_COLOR = "#162f4a"
|
||||||
|
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"
|
||||||
|
FOLDER_BG = "#3a4a6a"; FOLDER_HOVER = "#4a5a7a"
|
||||||
|
FONT_SIZE = 18
|
||||||
|
|
||||||
|
MENU_STYLE = """
|
||||||
|
QMenu {
|
||||||
|
background: #1e3a5c; color: white;
|
||||||
|
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
|
||||||
|
}
|
||||||
|
QMenu::item { padding: 12px 32px 12px 14px; border-radius: 6px; font-size: 16px; }
|
||||||
|
QMenu::item:selected { background: #3a6da4; color: white; }
|
||||||
|
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=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; }}
|
||||||
|
""")
|
||||||
|
return b
|
||||||
|
|
||||||
|
def load_bm():
|
||||||
|
try:
|
||||||
|
with open(BOOKMARKS_FILE) as f: return json.load(f)
|
||||||
|
except: return []
|
||||||
|
|
||||||
|
def save_bm(data):
|
||||||
|
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
def is_folder(item): return "children" in item
|
||||||
|
|
||||||
|
|
||||||
|
# ── CDP controller ────────────────────────────────────────────────
|
||||||
|
class CDP:
|
||||||
|
"""Thin Chrome DevTools Protocol client — navigate, back, forward."""
|
||||||
|
def __init__(self):
|
||||||
|
self.ready = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def wait_ready(self, timeout=30):
|
||||||
|
for _ in range(timeout * 2):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(
|
||||||
|
f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
|
||||||
|
self.ready = True
|
||||||
|
print("CDP ready")
|
||||||
|
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 _send(self, method, params=None):
|
||||||
|
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": method,
|
||||||
|
**({"params": params} if params else {})}))
|
||||||
|
ws.recv()
|
||||||
|
ws.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CDP error ({method}): {e}")
|
||||||
|
|
||||||
|
def _async(self, method, params=None):
|
||||||
|
threading.Thread(target=self._send, args=(method, params),
|
||||||
|
daemon=True).start()
|
||||||
|
|
||||||
|
def navigate(self, url):
|
||||||
|
self._async("Page.navigate", {"url": url})
|
||||||
|
|
||||||
|
def back(self):
|
||||||
|
self._async("Runtime.evaluate",
|
||||||
|
{"expression": "window.history.back()"})
|
||||||
|
|
||||||
|
def forward(self):
|
||||||
|
self._async("Runtime.evaluate",
|
||||||
|
{"expression": "window.history.forward()"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Folder name dialog ────────────────────────────────────────────
|
||||||
|
def ask_name(parent, title="Name", current=""):
|
||||||
|
d = QDialog(parent)
|
||||||
|
d.setWindowTitle(title)
|
||||||
|
d.setMinimumWidth(480)
|
||||||
|
d.setStyleSheet("background:#1e2a3a; color:white;")
|
||||||
|
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;")
|
||||||
|
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;")
|
||||||
|
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.clicked.connect(d.accept); can.clicked.connect(d.reject)
|
||||||
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
|
lay.addLayout(row)
|
||||||
|
return edit.text().strip() if d.exec_() == QDialog.Accepted else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bookmark add / edit dialog ────────────────────────────────────
|
||||||
|
class BookmarkDialog(QDialog):
|
||||||
|
def __init__(self, parent=None, name="", url=""):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Bookmark")
|
||||||
|
self.setMinimumWidth(560)
|
||||||
|
self.setStyleSheet("background:#1e2a3a; color:white;")
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
|
||||||
|
for lbl_text, attr, val in [("Name:", "name_edit", name),
|
||||||
|
("URL:", "url_edit", url)]:
|
||||||
|
l = QLabel(lbl_text)
|
||||||
|
l.setFont(QFont("Arial", 16)); l.setStyleSheet("color:#aac8e8;")
|
||||||
|
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)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 160, 58, 16)
|
||||||
|
can = mk_btn("✖ Cancel", RED, RED_HOVER, 160, 58, 16)
|
||||||
|
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
|
||||||
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
|
lay.addLayout(row)
|
||||||
|
|
||||||
|
def values(self):
|
||||||
|
return self.name_edit.text().strip(), self.url_edit.text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bookmark Manager ──────────────────────────────────────────────
|
||||||
|
class ManagerDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Manage Bookmarks")
|
||||||
|
self.setMinimumSize(860, 640)
|
||||||
|
self.setStyleSheet("background:#1e2a3a; color:white;")
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
|
||||||
|
|
||||||
|
hdr = QLabel("📋 Bookmarks")
|
||||||
|
hdr.setFont(QFont("Arial", 22, QFont.Bold))
|
||||||
|
hdr.setStyleSheet("color:white;")
|
||||||
|
lay.addWidget(hdr)
|
||||||
|
|
||||||
|
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
|
||||||
|
"▲ ▼ move items within the same folder.")
|
||||||
|
hint.setFont(QFont("Arial", 13))
|
||||||
|
hint.setStyleSheet("color:#88aacc;"); 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.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]
|
||||||
|
for b, a in zip(btns, acts):
|
||||||
|
b.clicked.connect(a); row.addWidget(b)
|
||||||
|
lay.addLayout(row)
|
||||||
|
|
||||||
|
def _reload_tree(self):
|
||||||
|
self.tree.clear()
|
||||||
|
self._build_nodes(load_bm(), self.tree.invisibleRootItem())
|
||||||
|
self.tree.expandAll()
|
||||||
|
|
||||||
|
def _build_nodes(self, items, parent):
|
||||||
|
for item in items:
|
||||||
|
node = QTreeWidgetItem(parent)
|
||||||
|
if is_folder(item):
|
||||||
|
node.setText(0, f"[+] {item['name']}")
|
||||||
|
node.setData(0, Qt.UserRole, {"name": item["name"], "_folder": True})
|
||||||
|
self._build_nodes(item["children"], node)
|
||||||
|
else:
|
||||||
|
node.setText(0, f" {item['name']} - {item['url']}")
|
||||||
|
node.setData(0, Qt.UserRole, {"name": item["name"], "url": item["url"]})
|
||||||
|
|
||||||
|
def _to_list(self, parent=None):
|
||||||
|
if parent is None: parent = self.tree.invisibleRootItem()
|
||||||
|
result = []
|
||||||
|
for i in range(parent.childCount()):
|
||||||
|
n = parent.child(i); d = n.data(0, Qt.UserRole)
|
||||||
|
if "_folder" in d:
|
||||||
|
result.append({"name": d["name"], "children": self._to_list(n)})
|
||||||
|
else:
|
||||||
|
result.append({"name": d["name"], "url": d["url"]})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _sel(self):
|
||||||
|
items = self.tree.selectedItems()
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
def _commit(self): save_bm(self._to_list())
|
||||||
|
|
||||||
|
def _insert(self, node):
|
||||||
|
sel = self._sel()
|
||||||
|
if sel:
|
||||||
|
d = sel.data(0, Qt.UserRole)
|
||||||
|
if "_folder" in d:
|
||||||
|
sel.addChild(node); sel.setExpanded(True); return
|
||||||
|
par = sel.parent() or self.tree.invisibleRootItem()
|
||||||
|
par.insertChild(par.indexOfChild(sel) + 1, node)
|
||||||
|
else:
|
||||||
|
self.tree.invisibleRootItem().addChild(node)
|
||||||
|
|
||||||
|
def add_bookmark(self):
|
||||||
|
dlg = BookmarkDialog(self)
|
||||||
|
if dlg.exec_() != QDialog.Accepted: return
|
||||||
|
name, url = dlg.values()
|
||||||
|
if not name or not url: return
|
||||||
|
node = QTreeWidgetItem()
|
||||||
|
node.setText(0, f" {name} - {url}")
|
||||||
|
node.setData(0, Qt.UserRole, {"name": name, "url": url})
|
||||||
|
self._insert(node); self._commit()
|
||||||
|
|
||||||
|
def add_folder(self):
|
||||||
|
name = ask_name(self, "New Folder")
|
||||||
|
if not name: return
|
||||||
|
node = QTreeWidgetItem()
|
||||||
|
node.setText(0, f"[+] {name}")
|
||||||
|
node.setData(0, Qt.UserRole, {"name": name, "_folder": True})
|
||||||
|
self._insert(node); self._commit()
|
||||||
|
|
||||||
|
def edit_item(self):
|
||||||
|
sel = self._sel()
|
||||||
|
if not sel: return
|
||||||
|
d = sel.data(0, Qt.UserRole)
|
||||||
|
if "_folder" in d:
|
||||||
|
name = ask_name(self, "Edit Folder", d["name"])
|
||||||
|
if not name: return
|
||||||
|
sel.setText(0, f"[+] {name}")
|
||||||
|
sel.setData(0, Qt.UserRole, {"name": name, "_folder": True})
|
||||||
|
else:
|
||||||
|
dlg = BookmarkDialog(self, d["name"], d["url"])
|
||||||
|
if dlg.exec_() != QDialog.Accepted: return
|
||||||
|
name, url = dlg.values()
|
||||||
|
if not name or not url: return
|
||||||
|
sel.setText(0, f" {name} - {url}")
|
||||||
|
sel.setData(0, Qt.UserRole, {"name": name, "url": url})
|
||||||
|
self._commit()
|
||||||
|
|
||||||
|
def delete_item(self):
|
||||||
|
sel = self._sel()
|
||||||
|
if not sel: return
|
||||||
|
d = sel.data(0, Qt.UserRole)
|
||||||
|
fld = "_folder" in d
|
||||||
|
msg = (f"Delete folder '{d['name']}' and ALL its bookmarks?"
|
||||||
|
if fld else f"Delete '{d['name']}'?")
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("Delete"); box.setText(msg)
|
||||||
|
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||||
|
box.setStyleSheet("background:#1e2a3a; color:white; font-size:17px;")
|
||||||
|
if box.exec_() == QMessageBox.Yes:
|
||||||
|
par = sel.parent() or self.tree.invisibleRootItem()
|
||||||
|
par.removeChild(sel); self._commit()
|
||||||
|
|
||||||
|
def move_item(self, direction):
|
||||||
|
sel = self._sel()
|
||||||
|
if not sel: return
|
||||||
|
par = sel.parent() or self.tree.invisibleRootItem()
|
||||||
|
idx = par.indexOfChild(sel); ni = idx + direction
|
||||||
|
if 0 <= ni < par.childCount():
|
||||||
|
par.takeChild(idx); par.insertChild(ni, sel)
|
||||||
|
self.tree.setCurrentItem(sel); self._commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bookmarks Bar ─────────────────────────────────────────────────
|
||||||
|
class BookmarksBar(QWidget):
|
||||||
|
def __init__(self, nav_cb):
|
||||||
|
super().__init__()
|
||||||
|
self.nav_cb = nav_cb
|
||||||
|
self.setFixedHeight(BM_BAR_H)
|
||||||
|
self.setStyleSheet(f"background:{BAR_COLOR};")
|
||||||
|
|
||||||
|
outer = QHBoxLayout(self)
|
||||||
|
outer.setContentsMargins(6, 5, 6, 5); outer.setSpacing(4)
|
||||||
|
|
||||||
|
self.left_btn = self._arrow("◀")
|
||||||
|
self.left_btn.clicked.connect(lambda: self._scroll(-240))
|
||||||
|
outer.addWidget(self.left_btn)
|
||||||
|
|
||||||
|
self.scroll_area = QScrollArea()
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.scroll_area.setFrameShape(QFrame.NoFrame)
|
||||||
|
self.scroll_area.setStyleSheet(f"background:{BAR_COLOR}; border:none;")
|
||||||
|
|
||||||
|
self.inner = QWidget()
|
||||||
|
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("▶")
|
||||||
|
self.right_btn.clicked.connect(lambda: self._scroll(240))
|
||||||
|
outer.addWidget(self.right_btn)
|
||||||
|
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def _arrow(self, label):
|
||||||
|
b = QPushButton(label)
|
||||||
|
b.setFixedSize(38, 42); b.setFont(QFont("Arial", 14, QFont.Bold))
|
||||||
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
|
b.setStyleSheet("QPushButton { background:#2a4a6c; color:white; border:none;"
|
||||||
|
" border-radius:8px; } QPushButton:hover { background:#3a6da4; }")
|
||||||
|
return b
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
while self.row.count():
|
||||||
|
w = self.row.takeAt(0).widget()
|
||||||
|
if w: w.deleteLater()
|
||||||
|
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):
|
||||||
|
b = QPushButton(name)
|
||||||
|
b.setFont(QFont("Arial", 15)); b.setMinimumHeight(44); b.setMinimumWidth(100)
|
||||||
|
b.setCursor(Qt.PointingHandCursor); b.setToolTip(url)
|
||||||
|
b.setStyleSheet("QPushButton { background:#2e5c8a; color:white; border:none;"
|
||||||
|
" border-radius:8px; padding:4px 12px; }"
|
||||||
|
"QPushButton:hover { background:#3a7abf; }"
|
||||||
|
"QPushButton:pressed { background:#1a3a5c; }")
|
||||||
|
b.clicked.connect(lambda _, u=url: self.nav_cb(u))
|
||||||
|
return b
|
||||||
|
|
||||||
|
def _folder_btn(self, folder):
|
||||||
|
b = QPushButton(f"+ {folder['name']}")
|
||||||
|
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:
|
||||||
|
if is_folder(item):
|
||||||
|
sub = self._build_menu(item["children"])
|
||||||
|
sub.setTitle(f" > {item['name']}")
|
||||||
|
sub.setFont(QFont("Arial", 16)); sub.setStyleSheet(MENU_STYLE)
|
||||||
|
menu.addMenu(sub)
|
||||||
|
else:
|
||||||
|
act = QAction(f" {item['name']}", menu)
|
||||||
|
url = item["url"]
|
||||||
|
act.triggered.connect(lambda _, u=url: self.nav_cb(u))
|
||||||
|
menu.addAction(act)
|
||||||
|
return menu
|
||||||
|
|
||||||
|
def _scroll(self, dx):
|
||||||
|
sb = self.scroll_area.horizontalScrollBar()
|
||||||
|
sb.setValue(sb.value() + dx)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Floating toolbar overlay ──────────────────────────────────────
|
||||||
|
class KioskOverlay(QWidget):
|
||||||
|
def __init__(self, cdp):
|
||||||
|
super().__init__()
|
||||||
|
self.cdp = cdp
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.FramelessWindowHint |
|
||||||
|
Qt.WindowStaysOnTopHint |
|
||||||
|
Qt.Tool
|
||||||
|
)
|
||||||
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
|
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
||||||
|
self.setStyleSheet("background:transparent;")
|
||||||
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
|
vbox = QVBoxLayout(self)
|
||||||
|
vbox.setContentsMargins(0, 0, 0, 0); vbox.setSpacing(0)
|
||||||
|
vbox.addWidget(self._build_toolbar())
|
||||||
|
self.bm_bar = BookmarksBar(cdp.navigate)
|
||||||
|
vbox.addWidget(self.bm_bar)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def _build_toolbar(self):
|
||||||
|
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
|
||||||
|
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
|
||||||
|
tl = QHBoxLayout(bar); tl.setContentsMargins(16, 8, 16, 8); tl.setSpacing(12)
|
||||||
|
|
||||||
|
brand = QLabel("SeniorNet")
|
||||||
|
brand.setFont(QFont("Arial", 24, QFont.Bold))
|
||||||
|
brand.setStyleSheet("color:white;")
|
||||||
|
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_btn.clicked.connect(self.cdp.back)
|
||||||
|
fwd_btn.clicked.connect(self.cdp.forward)
|
||||||
|
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
||||||
|
mgr_btn.clicked.connect(self.open_manager)
|
||||||
|
|
||||||
|
for b in [back_btn, fwd_btn, home_btn, mgr_btn]: tl.addWidget(b)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def open_manager(self):
|
||||||
|
d = ManagerDialog(self); d.exec_()
|
||||||
|
self.bm_bar.reload()
|
||||||
|
|
||||||
|
def keyPressEvent(self, e):
|
||||||
|
# Home key -> home screen
|
||||||
|
if e.key() == Qt.Key_Home:
|
||||||
|
self.cdp.navigate(HOME_URL)
|
||||||
|
# Block all other dangerous keys silently
|
||||||
|
e.accept()
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
from PyQt5.QtCore import QEvent
|
||||||
|
if event.type() == QEvent.KeyPress:
|
||||||
|
key = event.key()
|
||||||
|
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,
|
||||||
|
Qt.Key_Tab, Qt.Key_F11, Qt.Key_Escape,
|
||||||
|
Qt.Key_F1, Qt.Key_F2, Qt.Key_F3,
|
||||||
|
Qt.Key_F5, Qt.Key_F6, Qt.Key_F10}
|
||||||
|
if mods & (Qt.AltModifier | Qt.ControlModifier):
|
||||||
|
if key in blocked_keys:
|
||||||
|
return True # swallow the event
|
||||||
|
# Also block bare Escape and F-keys
|
||||||
|
if key in {Qt.Key_Escape, Qt.Key_F11}:
|
||||||
|
return True
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chrome launcher ───────────────────────────────────────────────
|
||||||
|
def launch_chrome():
|
||||||
|
import pathlib, json as _json, shutil
|
||||||
|
profile_dir = pathlib.Path("/home/jgitta/.config/google-chrome-kiosk")
|
||||||
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Remove stale singleton locks
|
||||||
|
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||||
|
try: (profile_dir / name).unlink()
|
||||||
|
except FileNotFoundError: pass
|
||||||
|
|
||||||
|
# Wipe GPU/ML caches that corrupt across restarts
|
||||||
|
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
||||||
|
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
||||||
|
"optimization_guide_model_store",
|
||||||
|
"Default/GPUCache", "Default/DawnWebGPUCache",
|
||||||
|
"Default/DawnGraphiteCache", "Default/Segmentation Platform"):
|
||||||
|
p = profile_dir / rel
|
||||||
|
if p.exists():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
|
||||||
|
# Patch Local State
|
||||||
|
ls_path = profile_dir / "Local State"
|
||||||
|
if ls_path.exists():
|
||||||
|
try:
|
||||||
|
d = _json.loads(ls_path.read_text())
|
||||||
|
d.setdefault("profile", {})["exit_type"] = "Normal"
|
||||||
|
d["hardware_acceleration_mode_previous"] = False
|
||||||
|
ls_path.write_text(_json.dumps(d))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chrome_h = SCREEN_H - TOP_H
|
||||||
|
cmd = [
|
||||||
|
"google-chrome",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-translate",
|
||||||
|
"--disable-infobars",
|
||||||
|
"--disable-session-crashed-bubble",
|
||||||
|
"--disable-save-password-bubble",
|
||||||
|
"--disable-restore-session-state",
|
||||||
|
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--test-type",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--user-data-dir=/home/jgitta/.config/google-chrome-kiosk",
|
||||||
|
f"--remote-debugging-port={CDP_PORT}",
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
f"--window-position=0,{TOP_H}",
|
||||||
|
f"--window-size={SCREEN_W},{chrome_h}",
|
||||||
|
f"--app={HOME_URL}",
|
||||||
|
]
|
||||||
|
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("SeniorNet")
|
||||||
|
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
|
||||||
|
|
||||||
|
cdp = CDP()
|
||||||
|
overlay = KioskOverlay(cdp)
|
||||||
|
|
||||||
|
chrome_proc = launch_chrome()
|
||||||
|
|
||||||
|
def init_cdp():
|
||||||
|
if cdp.wait_ready(timeout=30):
|
||||||
|
print("CDP ready — kiosk running")
|
||||||
|
threading.Thread(target=init_cdp, daemon=True).start()
|
||||||
|
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Home</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f0f4f8;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clock {
|
||||||
|
font-size: 52px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#date {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 1100px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 10px 28px rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gmail { background: linear-gradient(135deg, #ea4335, #c5221f); }
|
||||||
|
.btn-browse { background: linear-gradient(135deg, #4285f4, #1a73e8); }
|
||||||
|
.btn-weather { background: linear-gradient(135deg, #00bcd4, #0097a7); }
|
||||||
|
.btn-news { background: linear-gradient(135deg, #ff7043, #e64a19); }
|
||||||
|
.btn-facebook { background: linear-gradient(135deg, #1877f2, #0d5dbf); }
|
||||||
|
.btn-messenger { background: linear-gradient(135deg, #00b2ff, #0078d4); }
|
||||||
|
.btn-youtube { background: linear-gradient(135deg, #ff0000, #cc0000); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="clock">12:00 PM</div>
|
||||||
|
<div id="date">Wednesday, January 1</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<a class="btn btn-gmail" href="https://mail.google.com">
|
||||||
|
<div class="icon">✉️</div>
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-browse" href="https://www.google.com">
|
||||||
|
<div class="icon">🌐</div>
|
||||||
|
Internet
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-weather" href="https://weather.com">
|
||||||
|
<div class="icon">🌤️</div>
|
||||||
|
Weather
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-news" href="https://news.google.com">
|
||||||
|
<div class="icon">📰</div>
|
||||||
|
News
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-facebook" href="https://www.facebook.com">
|
||||||
|
<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="54" height="54" fill="white"><path d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z"/></svg></div>
|
||||||
|
Facebook
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-messenger" href="https://www.messenger.com">
|
||||||
|
<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="54" height="54" fill="white"><path d="M256.55 8C116.52 8 8 110.34 8 248.57c0 72.3 29.71 134.78 78.07 177.94 8.35 7.51 6.63 11.86 8.05 58.23A19.92 19.92 0 0 0 122 502.31c52.91-23.3 53.59-25.14 62.56-22.7C337.85 521.8 504 423.7 504 248.57 504 110.34 396.59 8 256.55 8zm149.24 185.13l-73 115.57a37.37 37.37 0 0 1-53.91 9.93l-58.08-43.47a15 15 0 0 0-18 0l-78.37 59.44c-10.46 7.93-24.16-4.6-17.11-15.67l73-115.57a37.36 37.36 0 0 1 53.91-9.93l58.06 43.46a15 15 0 0 0 18 0l78.34-59.44c10.48-7.93 24.17 4.6 17.12 15.67z"/></svg></div>
|
||||||
|
Messenger
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-youtube" href="https://www.youtube.com">
|
||||||
|
<div class="icon">▶️</div>
|
||||||
|
YouTube
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
|
||||||
|
const dateOpts = { weekday: 'long', month: 'long', day: 'numeric' };
|
||||||
|
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
|
||||||
|
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
|
||||||
|
}
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SeniorNet Kiosk — install script
|
||||||
|
# Run as: sudo bash install.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Installing packages ==="
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \
|
||||||
|
fonts-noto-color-emoji python3-pip
|
||||||
|
|
||||||
|
# Install websocket-client
|
||||||
|
pip3 install websocket-client --break-system-packages
|
||||||
|
|
||||||
|
# Install Google Chrome
|
||||||
|
if ! command -v google-chrome &>/dev/null; then
|
||||||
|
echo "=== Installing Google Chrome ==="
|
||||||
|
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||||
|
rm google-chrome-stable_current_amd64.deb
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Copying config files ==="
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# LightDM auto-login
|
||||||
|
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
|
cp "$SCRIPT_DIR/system-config/lightdm-50-kiosk.conf" \
|
||||||
|
/etc/lightdm/lightdm.conf.d/50-kiosk.conf
|
||||||
|
|
||||||
|
# Openbox autostart
|
||||||
|
mkdir -p /home/jgitta/.config/openbox
|
||||||
|
cp "$SCRIPT_DIR/system-config/openbox-autostart" \
|
||||||
|
/home/jgitta/.config/openbox/autostart
|
||||||
|
chown jgitta:jgitta /home/jgitta/.config/openbox/autostart
|
||||||
|
|
||||||
|
# Xorg hardening
|
||||||
|
mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
cp "$SCRIPT_DIR/system-config/xorg-50-kiosk-hardening.conf" \
|
||||||
|
/etc/X11/xorg.conf.d/50-kiosk-hardening.conf
|
||||||
|
|
||||||
|
# Disable unused virtual consoles
|
||||||
|
for i in 2 3 4 5 6; do
|
||||||
|
systemctl disable getty@tty$i 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== Setting ownership ==="
|
||||||
|
chown -R jgitta:jgitta /home/jgitta/kiosk-browser /home/jgitta/kiosk-home
|
||||||
|
|
||||||
|
echo "=== Done! Reboot to start the kiosk. ==="
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Seat:*]
|
||||||
|
autologin-user=jgitta
|
||||||
|
autologin-user-timeout=0
|
||||||
|
user-session=openbox
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Hide mouse cursor after 3 seconds of inactivity
|
||||||
|
unclutter -idle 3 -root &
|
||||||
|
|
||||||
|
# Disable screen blanking and power saving
|
||||||
|
xset s off &
|
||||||
|
xset s noblank &
|
||||||
|
xset -dpms &
|
||||||
|
|
||||||
|
# Launch SeniorNet browser — auto-restart if it ever crashes
|
||||||
|
(while true; do
|
||||||
|
DISPLAY=:0 python3 /home/jgitta/kiosk-browser/browser.py
|
||||||
|
echo "browser.py exited — restarting in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done) &
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Section "ServerFlags"
|
||||||
|
Option "DontVTSwitch" "true"
|
||||||
|
Option "DontZap" "true"
|
||||||
|
EndSection
|
||||||
Reference in New Issue
Block a user