Compare commits
43 Commits
b99f058bfb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2969648aa1 | |||
| e727ed35a9 | |||
| 999dda22df | |||
| 4c474c0916 | |||
| 585b7f2639 | |||
| f02ac40b91 | |||
| abe61d1156 | |||
| 79b6d4857d | |||
| 598bd83887 | |||
| 6a4eee9a15 | |||
| 4b1ea2da11 | |||
| 0d31af526c | |||
| 3d8aef5f22 | |||
| ac6ca6d1b3 | |||
| 46cd4f77c9 | |||
| 1b06a29e53 | |||
| fd519a64b0 | |||
| a2c14a4a39 | |||
| ab423ef1a7 | |||
| e9ee8caccf | |||
| 68dccf958e | |||
| 0dcfe060e5 | |||
| 86f6334737 | |||
| 2431c6b6ae | |||
| 5d3bcb679d | |||
| 23779690d6 | |||
| 96838f9f67 | |||
| 05f20355c8 | |||
| 165e77b9b8 | |||
| d3b05f981f | |||
| b73b8fab65 | |||
| 19b02f91e9 | |||
| dcdbedc26e | |||
| 508328b282 | |||
| b3bc19aa44 | |||
| fa329c7e3c | |||
| cb5bf7ff94 | |||
| 312db58775 | |||
| e9207b4315 | |||
| b7866000b4 | |||
| 38f7ae467a | |||
| c8534c967c | |||
| 8553705e47 |
-113
@@ -1,113 +0,0 @@
|
|||||||
# 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,3 +2,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
|
credentials.md
|
||||||
|
iso-output/*.iso
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"claudeCodeChat.permissions.yoloMode": true
|
||||||
|
}
|
||||||
@@ -58,15 +58,32 @@ sleep 1
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
| `browser.py` | Main application — toolbar + CDP + Chromium launcher |
|
||||||
| `bookmarks.json` | Nested bookmark structure |
|
| `bookmarks.json` | Nested bookmark structure |
|
||||||
|
| `home-screen-index.html` | Home screen source (deploy copies to `/home/jgitta/kiosk-home/index.html`) |
|
||||||
| `deploy.sh` | One-command deploy |
|
| `deploy.sh` | One-command deploy |
|
||||||
| `/home/jgitta/kiosk-home/index.html` | Senior home screen |
|
| `/home/jgitta/kiosk-home/index.html` | Senior home screen (live copy, outside repo) |
|
||||||
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
| `~/.config/openbox/autostart` | Auto-restart loop on crash |
|
||||||
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
| `/usr/local/bin/kiosk-restart` | Safe restart (kills Chromium + Python) |
|
||||||
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
| `/etc/X11/xorg.conf.d/50-kiosk-hardening.conf` | Blocks TTY switching |
|
||||||
|
|
||||||
## Gitea repo
|
## Gitea repo (see also `jgitta/homelab-configs`)
|
||||||
|
|
||||||
`https://gitea.jgitta.com/jgitta/senior-kiosk` — master branch
|
Source of truth for infra: **https://gitea.jgitta.com/jgitta/homelab-configs**
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Gitea app | Docker on **siklos** `192.168.88.27` — HTTP **3002**, SSH **2222** (not 22) |
|
||||||
|
| Public URL | `gitea.jgitta.com` → **Caddy** `192.168.88.110` → proxies to Gitea :3002 |
|
||||||
|
| DNS | MikroTik `192.168.88.1` / siklos; public DNS may resolve to WAN IP (still reaches Caddy :443) |
|
||||||
|
|
||||||
|
**This repo (senior-kiosk):**
|
||||||
|
|
||||||
|
- **HTTPS (usual — what worked before):** `https://gitea.jgitta.com/jgitta/senior-kiosk.git` — through Caddy. Auth via Gitea username + access token (`git config credential.helper store`), or token in URL (legacy on kiosk-dev).
|
||||||
|
- **SSH:** Gitea SSH is on port **2222**, not 22. Plain `git@gitea.jgitta.com:...` hits port **22 on the WAN** and will **not** accept your Gitea password. Use either:
|
||||||
|
- `ssh://git@192.168.88.27:2222/jgitta/senior-kiosk.git` (LAN), or
|
||||||
|
- `~/.ssh/config`: `Host gitea.jgitta.com` → `Port 2222` (requires WAN/LAN port-forward 2222→siklos:2222 if using the hostname)
|
||||||
|
- **Branch:** `master`
|
||||||
|
- **Deploy:** `bash deploy.sh "message"` — push, sync home screen, `kiosk-restart`
|
||||||
|
- **Secrets:** `credentials.md` gitignored
|
||||||
|
|
||||||
## Roundcube webmail
|
## Roundcube webmail
|
||||||
|
|
||||||
@@ -108,6 +125,6 @@ sleep 1
|
|||||||
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
- **Proxmox host**: jg-hud (main), siklos (192.168.88.27)
|
||||||
- **TrueNAS**: 192.168.88.24
|
- **TrueNAS**: 192.168.88.24
|
||||||
- **Thinkstation** (dev desktop): 192.168.88.41
|
- **Thinkstation** (dev desktop): 192.168.88.41
|
||||||
- **Caddy reverse proxy**: 192.168.88.110 — handles *.jgitta.com
|
- **Caddy reverse proxy**: 192.168.88.110 — terminates HTTPS for `*.jgitta.com` (including `gitea.jgitta.com` → Gitea :3002)
|
||||||
- **Gitea**: gitea.jgitta.com (port 3002 internal)
|
- **Gitea**: behind Caddy at `gitea.jgitta.com`; app listens on port 3002 on the Gitea host
|
||||||
- **Headscale**: self-hosted Tailscale on siklos
|
- **Headscale**: self-hosted Tailscale on siklos
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# SeniorNet Kiosk — Custom Debian ISO
|
||||||
|
|
||||||
|
Build a bootable ISO that installs and auto-configures the kiosk app on any machine.
|
||||||
|
|
||||||
|
## On Your Workstation (192.168.88.41)
|
||||||
|
|
||||||
|
### 1. Clone the repo (if not already done)
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git
|
||||||
|
cd senior-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the ISO
|
||||||
|
```bash
|
||||||
|
sudo ./build-iso.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates: `iso-output/seniornet-kiosk.iso` (~1-2 GB, takes 5-10 min)
|
||||||
|
|
||||||
|
## Testing on Production Kiosk (192.168.88.44)
|
||||||
|
|
||||||
|
### 1. Write ISO to bootable USB
|
||||||
|
On your workstation with USB connected:
|
||||||
|
```bash
|
||||||
|
# List devices to find your USB
|
||||||
|
lsblk
|
||||||
|
|
||||||
|
# Write ISO (replace sdX with your USB device, e.g., sdb)
|
||||||
|
sudo dd if=iso-output/seniornet-kiosk.iso of=/dev/sdX bs=4M status=progress && sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Boot from USB
|
||||||
|
- Plug USB into production kiosk
|
||||||
|
- Power on and select USB boot (usually F12, ESC, or DEL during startup)
|
||||||
|
- Installation starts silently (no prompts)
|
||||||
|
|
||||||
|
### 3. Wait for installation
|
||||||
|
- Silent Debian installation: ~5-10 minutes
|
||||||
|
- Clones repo, installs dependencies
|
||||||
|
- System reboots automatically
|
||||||
|
|
||||||
|
### 4. Verify kiosk is running
|
||||||
|
After reboot, you should see the kiosk home screen with buttons.
|
||||||
|
|
||||||
|
To check status via SSH:
|
||||||
|
```bash
|
||||||
|
ssh jgitta@192.168.88.44
|
||||||
|
journalctl -u kiosk.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the ISO Contains
|
||||||
|
|
||||||
|
- **Debian 13 Trixie** (minimal base)
|
||||||
|
- **Chromium** (system package, not snap)
|
||||||
|
- **Python 3, PyQt5, websocket-client**
|
||||||
|
- **Openbox** (window manager)
|
||||||
|
- **Xvfb** (virtual display)
|
||||||
|
- **Your kiosk app** (cloned from Gitea at install time)
|
||||||
|
- **Auto-boot to kiosk mode** (no desktop login)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `preseed.cfg` — Debian unattended installer config
|
||||||
|
- `install-kiosk.sh` — Post-install script (runs after Debian install)
|
||||||
|
- `build-iso.sh` — Build script (creates the ISO)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**If installation hangs:**
|
||||||
|
- Check network connectivity during install
|
||||||
|
- Gitea must be reachable (or install will use git clone fallback)
|
||||||
|
|
||||||
|
**If kiosk doesn't start:**
|
||||||
|
```bash
|
||||||
|
ssh jgitta@192.168.88.44
|
||||||
|
journalctl -u kiosk.service -20 # View last 20 lines of logs
|
||||||
|
sudo systemctl restart kiosk.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**To access terminal:**
|
||||||
|
- Press Alt+F2 during boot
|
||||||
|
- Log in as `root` (password: `kiosk`)
|
||||||
|
- Run: `/usr/local/bin/kiosk-restart` to restart
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
To modify the ISO:
|
||||||
|
1. Edit `preseed.cfg` (installer config)
|
||||||
|
2. Edit `install-kiosk.sh` (post-install)
|
||||||
|
3. Edit `build-iso.sh` (ISO build options)
|
||||||
|
4. Rebuild: `sudo ./build-iso.sh`
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
Once tested, you can:
|
||||||
|
- Copy `seniornet-kiosk.iso` to USB drives for deployment
|
||||||
|
- Host on web server for download
|
||||||
|
- Share with end users
|
||||||
|
- Any machine can boot and auto-install (no Proxmox needed)
|
||||||
@@ -22,7 +22,7 @@ The kiosk presents a single Chrome browser window with a custom floating toolbar
|
|||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **OS**: Ubuntu 24.04 LTS Server (minimal, headless)
|
- **OS**: Debian Trixie
|
||||||
- **Display**: X11 + LightDM (auto-login) + Openbox WM
|
- **Display**: X11 + LightDM (auto-login) + Openbox WM
|
||||||
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
|
- **Browser**: Google Chrome in `--app` mode (no tabs/address bar)
|
||||||
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
|
- **Toolbar**: PyQt5 frameless overlay, always-on-top, 142px tall
|
||||||
|
|||||||
+449
-216
@@ -4,163 +4,249 @@ SeniorNet Kiosk — PyQt5 single-row floating toolbar over Google Chrome.
|
|||||||
Navigation: ◀ ▶ ⌂ icon buttons + ☰ hamburger menu for bookmarks.
|
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, websocket, pathlib, shutil, sqlite3, random
|
||||||
from PyQt5.QtCore import Qt, QTimer, QPoint
|
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QEvent
|
||||||
from PyQt5.QtGui import QFont, QColor, QPalette
|
from PyQt5.QtGui import QFont, QColor, QPalette, QCursor
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
||||||
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
|
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
|
||||||
QMessageBox, QFrame, QMenu, QAction,
|
QMessageBox, QFrame, QTreeWidget, QTreeWidgetItem, QAbstractItemView
|
||||||
QTreeWidget, QTreeWidgetItem, QAbstractItemView
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HOME_URL = "file:///home/jgitta/kiosk-home/index.html"
|
_HOME = pathlib.Path.home()
|
||||||
BOOKMARKS_FILE = "/home/jgitta/kiosk-browser/bookmarks.json"
|
HOME_URL = f"file://{_HOME}/kiosk-home/index.html"
|
||||||
|
BOOKMARKS_FILE = str(_HOME / "kiosk-browser/bookmarks.json")
|
||||||
|
try:
|
||||||
|
_VERSION = (pathlib.Path(__file__).parent / "VERSION").read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
_VERSION = "?"
|
||||||
CDP_PORT = 9222
|
CDP_PORT = 9222
|
||||||
TOOLBAR_H = 72 # single row — was 142 (82 + 60)
|
TOOLBAR_H = 72
|
||||||
TOP_H = TOOLBAR_H
|
SCREEN_W = 1920 # Dynamically updated in main
|
||||||
# Screen size is detected dynamically in main() after QApplication starts
|
SCREEN_H = 1080 # Dynamically updated in main
|
||||||
SCREEN_W = 1920 # overwritten at runtime
|
|
||||||
SCREEN_H = 1080 # overwritten at runtime
|
|
||||||
|
|
||||||
# ── Colour palette ────────────────────────────────────────────────
|
# ── Styling and Themes (DRY Style System) ─────────────────────────
|
||||||
TOOLBAR_COLOR = "#1a3a5c"
|
THEME = {
|
||||||
BTN_COLOR = "#2e6da4"; BTN_HOVER = "#3a87cc"
|
"colors": {
|
||||||
GOLD = "#e8a020"; GOLD_HOVER = "#f0b030"
|
"toolbar": "#1a3a5c",
|
||||||
GREEN = "#2e7d32"; GREEN_HOVER = "#388e3c"
|
"btn": "#2e6da4",
|
||||||
RED = "#c62828"; RED_HOVER = "#d32f2f"
|
"btn_hover": "#3a87cc",
|
||||||
AMBER = "#7a5a10"; AMBER_HOVER = "#9a7a20"
|
"gold": "#e8a020",
|
||||||
PURPLE = "#5a5a8a"; PURPLE_HOVER = "#7a7aaa"
|
"gold_hover": "#f0b030",
|
||||||
FONT_SIZE = 18
|
"green": "#2e7d32",
|
||||||
|
"green_hover": "#388e3c",
|
||||||
MENU_STYLE = """
|
"red": "#c62828",
|
||||||
QMenu {
|
"red_hover": "#d32f2f",
|
||||||
background: #1e3a5c; color: white;
|
"amber": "#7a5a10",
|
||||||
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px;
|
"amber_hover": "#9a7a20",
|
||||||
|
"purple": "#5a5a8a",
|
||||||
|
"purple_hover": "#7a7aaa",
|
||||||
|
"bg_dark": "#1e2a3a",
|
||||||
|
"bg_input": "#2a3f55",
|
||||||
|
"border": "#3a6da4",
|
||||||
|
"text_secondary": "#aac8e8",
|
||||||
|
},
|
||||||
|
"font_size": 18
|
||||||
}
|
}
|
||||||
QMenu::item { padding: 14px 36px 14px 16px; border-radius: 6px; font-size: 17px; }
|
|
||||||
QMenu::item:selected { background: #3a6da4; color: white; }
|
STYLES = {
|
||||||
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; }
|
"button": """
|
||||||
|
QPushButton {{
|
||||||
|
background: {color}; color: white; border: none;
|
||||||
|
border-radius: 10px; padding: 6px 14px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {hover}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
QPushButton:disabled {{ background: #444; color: #888; }}
|
||||||
|
""",
|
||||||
|
"icon_button": """
|
||||||
|
QPushButton {{
|
||||||
|
background: {color}; color: white; border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {hover}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
""",
|
||||||
|
"line_edit": """
|
||||||
|
QLineEdit {{
|
||||||
|
background: {bg_input}; color: white;
|
||||||
|
border: 2px solid {border}; border-radius: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"dialog": """
|
||||||
|
QDialog {{
|
||||||
|
background: {bg_dark}; color: white;
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"tree_widget": """
|
||||||
|
QTreeWidget {{
|
||||||
|
background: {bg_input}; color: white;
|
||||||
|
border: 2px solid {border}; border-radius: 8px;
|
||||||
|
}}
|
||||||
|
QTreeWidget::item {{
|
||||||
|
padding: 8px 4px; border-bottom: 1px solid #1a2f45;
|
||||||
|
}}
|
||||||
|
QTreeWidget::item:selected {{
|
||||||
|
background: {border};
|
||||||
|
}}
|
||||||
|
QTreeWidget::branch {{
|
||||||
|
background: {bg_input};
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"bookmark_panel": """
|
||||||
|
QWidget {{
|
||||||
|
background: {toolbar};
|
||||||
|
}}
|
||||||
|
QPushButton {{
|
||||||
|
background: transparent; color: white; border: none;
|
||||||
|
text-align: left; padding: 8px 20px;
|
||||||
|
font-size: 17px; border-radius: 6px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {border}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
QLabel {{
|
||||||
|
color: {text_secondary}; padding: 6px 20px 2px 20px;
|
||||||
|
font-size: 14px; font-weight: bold;
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
|
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=THEME["font_size"]):
|
||||||
b = QPushButton(label)
|
b = QPushButton(label)
|
||||||
b.setFont(QFont("Arial", fs, QFont.Bold))
|
b.setFont(QFont("Arial", fs, QFont.Bold))
|
||||||
b.setMinimumSize(min_w, min_h)
|
b.setMinimumSize(min_w, min_h)
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
b.setStyleSheet(f"""
|
b.setStyleSheet(STYLES["button"].format(color=color, hover=hover))
|
||||||
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
|
return b
|
||||||
|
|
||||||
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
||||||
"""Square icon button for Back / Forward / Home / Hamburger."""
|
|
||||||
b = QPushButton(icon)
|
b = QPushButton(icon)
|
||||||
b.setFont(QFont("Arial", fs))
|
b.setFont(QFont("Arial", fs))
|
||||||
b.setFixedSize(size, size)
|
b.setFixedSize(size, size)
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
b.setStyleSheet(f"""
|
b.setStyleSheet(STYLES["icon_button"].format(color=color, hover=hover))
|
||||||
QPushButton {{ background:{color}; color:white; border:none;
|
|
||||||
border-radius:12px; }}
|
|
||||||
QPushButton:hover {{ background:{hover}; }}
|
|
||||||
QPushButton:pressed {{ background:#222; }}
|
|
||||||
""")
|
|
||||||
return b
|
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:
|
||||||
except: return []
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
def save_bm(data):
|
def save_bm(data):
|
||||||
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
|
with open(BOOKMARKS_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
def is_folder(item): return "children" in item
|
def is_folder(item):
|
||||||
|
return "children" in item
|
||||||
|
|
||||||
|
|
||||||
# ── CDP controller ────────────────────────────────────────────────
|
# ── CDP controller (Persistent WebSockets) ────────────────────────
|
||||||
class CDP:
|
class CDP:
|
||||||
"""Thin Chrome DevTools Protocol client — navigate, back, forward."""
|
"""Thin, persistent Chrome DevTools Protocol client."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.ready = False
|
self.ready = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self.ws = None
|
||||||
|
self.ws_url = None
|
||||||
|
|
||||||
def wait_ready(self, timeout=30):
|
def wait_ready(self, timeout=30):
|
||||||
for _ in range(timeout * 2):
|
for _ in range(timeout * 2):
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(
|
urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
|
||||||
f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
|
|
||||||
self.ready = True
|
self.ready = True
|
||||||
print("CDP ready")
|
print("CDP ready — pre-connecting WebSocket")
|
||||||
|
self._connect()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
print("CDP not available after timeout")
|
print("CDP not available after timeout")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _ws_url(self):
|
def _connect(self):
|
||||||
raw = urllib.request.urlopen(
|
"""Internal helper to connect or reconnect the persistent WS client."""
|
||||||
f"http://127.0.0.1:{CDP_PORT}/json", timeout=2).read()
|
with self._lock:
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
self.ws.ping()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch WebSocket endpoint once, or re-fetch on disconnect
|
||||||
|
raw = urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=2).read()
|
||||||
tabs = json.loads(raw)
|
tabs = json.loads(raw)
|
||||||
page = next((t for t in tabs if t.get("type") == "page"), None)
|
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
|
if page:
|
||||||
|
self.ws_url = page["webSocketDebuggerUrl"].replace("localhost", "127.0.0.1")
|
||||||
|
self.ws = websocket.create_connection(self.ws_url, timeout=3)
|
||||||
|
print(f"Connected persistent WebSocket to: {self.ws_url}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CDP connection failed: {e}")
|
||||||
|
self._close_ws()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _close_ws(self):
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.ws = None
|
||||||
|
|
||||||
def _send(self, method, params=None):
|
def _send(self, method, params=None):
|
||||||
if not self.ready:
|
if not self.ready:
|
||||||
return
|
return
|
||||||
import websocket
|
if not self.ws and not self._connect():
|
||||||
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
ws = websocket.create_connection(self._ws_url(), timeout=3)
|
self.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()
|
self.ws.recv()
|
||||||
ws.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"CDP error ({method}): {e}")
|
print(f"CDP send error ({method}): {e}")
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
def _async(self, method, params=None):
|
def _async(self, method, params=None):
|
||||||
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):
|
def resize_window(self, x, y, w, h):
|
||||||
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
||||||
if not self.ready:
|
if not self.ready:
|
||||||
return
|
return
|
||||||
import websocket
|
if not self.ws and not self._connect():
|
||||||
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
ws = websocket.create_connection(self._ws_url(), timeout=3)
|
self.ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
||||||
ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
resp = json.loads(self.ws.recv())
|
||||||
resp = json.loads(ws.recv())
|
|
||||||
wid = resp.get("result", {}).get("windowId")
|
wid = resp.get("result", {}).get("windowId")
|
||||||
if wid:
|
if wid:
|
||||||
ws.send(json.dumps({"id": 2,
|
self.ws.send(json.dumps({"id": 2,
|
||||||
"method": "Browser.setWindowBounds",
|
"method": "Browser.setWindowBounds",
|
||||||
"params": {"windowId": wid,
|
"params": {"windowId": wid,
|
||||||
"bounds": {"left": x, "top": y,
|
"bounds": {"left": x, "top": y,
|
||||||
"width": w, "height": h,
|
"width": w, "height": h,
|
||||||
"windowState": "normal"}}}))
|
"windowState": "normal"}}}))
|
||||||
ws.recv()
|
self.ws.recv()
|
||||||
ws.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"CDP resize error: {e}")
|
print(f"CDP resize error: {e}")
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
def navigate(self, url):
|
def navigate(self, url):
|
||||||
self._async("Page.navigate", {"url": url})
|
self._async("Page.navigate", {"url": url})
|
||||||
|
|
||||||
def back(self):
|
def back(self):
|
||||||
self._async("Runtime.evaluate",
|
self._async("Runtime.evaluate", {"expression": "window.history.back()"})
|
||||||
{"expression": "window.history.back()"})
|
|
||||||
|
|
||||||
def forward(self):
|
def forward(self):
|
||||||
self._async("Runtime.evaluate",
|
self._async("Runtime.evaluate", {"expression": "window.history.forward()"})
|
||||||
{"expression": "window.history.forward()"})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Folder name dialog ────────────────────────────────────────────
|
# ── Folder name dialog ────────────────────────────────────────────
|
||||||
@@ -168,23 +254,28 @@ def ask_name(parent, title="Name", current=""):
|
|||||||
d = QDialog(parent)
|
d = QDialog(parent)
|
||||||
d.setWindowTitle(title)
|
d.setWindowTitle(title)
|
||||||
d.setMinimumWidth(480)
|
d.setMinimumWidth(480)
|
||||||
d.setStyleSheet("background:#1e2a3a; color:white;")
|
d.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(d)
|
lay = QVBoxLayout(d)
|
||||||
lay.setContentsMargins(28, 28, 28, 28)
|
lay.setContentsMargins(28, 28, 28, 28)
|
||||||
lay.setSpacing(16)
|
lay.setSpacing(16)
|
||||||
|
|
||||||
lbl = QLabel("Folder name:")
|
lbl = QLabel("Folder name:")
|
||||||
lbl.setFont(QFont("Arial", 16))
|
lbl.setFont(QFont("Arial", 16))
|
||||||
lbl.setStyleSheet("color:#aac8e8;")
|
lbl.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
|
||||||
lay.addWidget(lbl)
|
lay.addWidget(lbl)
|
||||||
|
|
||||||
edit = QLineEdit(current)
|
edit = QLineEdit(current)
|
||||||
edit.setFont(QFont("Arial", 18))
|
edit.setFont(QFont("Arial", 18))
|
||||||
edit.setMinimumHeight(50)
|
edit.setMinimumHeight(50)
|
||||||
edit.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
|
edit.setStyleSheet(STYLES["line_edit"].format(
|
||||||
"border-radius:8px;padding:6px;")
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
|
border=THEME["colors"]["border"]
|
||||||
|
))
|
||||||
lay.addWidget(edit)
|
lay.addWidget(edit)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 150, 56, 16)
|
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 150, 56, 16)
|
||||||
can = mk_btn("✖ Cancel", RED, RED_HOVER, 150, 56, 16)
|
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["red_hover"], 150, 56, 16)
|
||||||
ok.clicked.connect(d.accept); can.clicked.connect(d.reject)
|
ok.clicked.connect(d.accept); can.clicked.connect(d.reject)
|
||||||
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -197,22 +288,28 @@ class BookmarkDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Bookmark")
|
self.setWindowTitle("Bookmark")
|
||||||
self.setMinimumWidth(560)
|
self.setMinimumWidth(560)
|
||||||
self.setStyleSheet("background:#1e2a3a; color:white;")
|
self.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
|
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
|
||||||
for lbl_text, attr, val in [("Name:", "name_edit", name),
|
|
||||||
("URL:", "url_edit", url)]:
|
for lbl_text, attr, val in [("Name:", "name_edit", name), ("URL:", "url_edit", url)]:
|
||||||
l = QLabel(lbl_text)
|
l = QLabel(lbl_text)
|
||||||
l.setFont(QFont("Arial", 16)); l.setStyleSheet("color:#aac8e8;")
|
l.setFont(QFont("Arial", 16))
|
||||||
|
l.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
|
||||||
lay.addWidget(l)
|
lay.addWidget(l)
|
||||||
|
|
||||||
e = QLineEdit(val)
|
e = QLineEdit(val)
|
||||||
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
|
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
|
||||||
e.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
|
e.setStyleSheet(STYLES["line_edit"].format(
|
||||||
"border-radius:8px;padding:6px;")
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
lay.addWidget(e); setattr(self, attr, e)
|
border=THEME["colors"]["border"]
|
||||||
|
))
|
||||||
|
lay.addWidget(e)
|
||||||
|
setattr(self, attr, e)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 160, 58, 16)
|
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 160, 58, 16)
|
||||||
can = mk_btn("✖ Cancel", RED, RED_HOVER, 160, 58, 16)
|
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["red_hover"], 160, 58, 16)
|
||||||
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
|
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
|
||||||
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -227,7 +324,7 @@ class ManagerDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Manage Bookmarks")
|
self.setWindowTitle("Manage Bookmarks")
|
||||||
self.setMinimumSize(860, 640)
|
self.setMinimumSize(860, 640)
|
||||||
self.setStyleSheet("background:#1e2a3a; color:white;")
|
self.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
|
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
|
||||||
|
|
||||||
@@ -239,35 +336,36 @@ class ManagerDialog(QDialog):
|
|||||||
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
|
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
|
||||||
"▲ ▼ move items within the same folder.")
|
"▲ ▼ move items within the same folder.")
|
||||||
hint.setFont(QFont("Arial", 13))
|
hint.setFont(QFont("Arial", 13))
|
||||||
hint.setStyleSheet("color:#88aacc;"); hint.setWordWrap(True)
|
hint.setStyleSheet(f"color:{THEME['colors']['text_secondary']};"); hint.setWordWrap(True)
|
||||||
lay.addWidget(hint)
|
lay.addWidget(hint)
|
||||||
|
|
||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
self.tree.setFont(QFont("Arial", 15))
|
self.tree.setFont(QFont("Arial", 15))
|
||||||
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
self.tree.setStyleSheet("""
|
self.tree.setStyleSheet(STYLES["tree_widget"].format(
|
||||||
QTreeWidget { background:#2a3f55; color:white;
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
border:2px solid #3a6da4; border-radius:8px; }
|
border=THEME["colors"]["border"]
|
||||||
QTreeWidget::item { padding:8px 4px; border-bottom:1px solid #1a2f45; }
|
))
|
||||||
QTreeWidget::item:selected { background:#3a6da4; }
|
|
||||||
QTreeWidget::branch { background:#2a3f55; }
|
|
||||||
""")
|
|
||||||
self.tree.setIndentation(30)
|
self.tree.setIndentation(30)
|
||||||
self._reload_tree()
|
self._reload_tree()
|
||||||
lay.addWidget(self.tree)
|
lay.addWidget(self.tree)
|
||||||
|
|
||||||
row = QHBoxLayout(); row.setSpacing(8)
|
row = QHBoxLayout(); row.setSpacing(8)
|
||||||
btns = [mk_btn("➕ Bookmark", GREEN, GREEN_HOVER, 155, 54, 14),
|
btns = [
|
||||||
mk_btn("📁 Folder", AMBER, AMBER_HOVER, 130, 54, 14),
|
mk_btn("➕ Bookmark", THEME["colors"]["green"], THEME["colors"]["green_hover"], 155, 54, 14),
|
||||||
mk_btn("✏️ Edit", BTN_COLOR,BTN_HOVER, 120, 54, 14),
|
mk_btn("📁 Folder", THEME["colors"]["amber"], THEME["colors"]["amber_hover"], 130, 54, 14),
|
||||||
mk_btn("🗑 Delete", RED, RED_HOVER, 120, 54, 14),
|
mk_btn("✏️ Edit", THEME["colors"]["btn"], THEME["colors"]["btn_hover"], 120, 54, 14),
|
||||||
mk_btn("▲ Up", PURPLE, PURPLE_HOVER, 90, 54, 14),
|
mk_btn("🗑 Delete", THEME["colors"]["red"], THEME["colors"]["red_hover"], 120, 54, 14),
|
||||||
mk_btn("▼ Down", PURPLE, PURPLE_HOVER, 90, 54, 14),
|
mk_btn("▲ Up", THEME["colors"]["purple"], THEME["colors"]["purple_hover"], 90, 54, 14),
|
||||||
mk_btn("✖ Close", "#555", "#777", 120, 54, 14)]
|
mk_btn("▼ Down", THEME["colors"]["purple"], THEME["colors"]["purple_hover"], 90, 54, 14),
|
||||||
acts = [self.add_bookmark, self.add_folder, self.edit_item,
|
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),
|
self.delete_item, lambda: self.move_item(-1),
|
||||||
lambda: self.move_item(1), self.accept]
|
lambda: self.move_item(1), self.accept
|
||||||
|
]
|
||||||
for b, a in zip(btns, acts):
|
for b, a in zip(btns, acts):
|
||||||
b.clicked.connect(a); row.addWidget(b)
|
b.clicked.connect(a); row.addWidget(b)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -303,7 +401,8 @@ class ManagerDialog(QDialog):
|
|||||||
items = self.tree.selectedItems()
|
items = self.tree.selectedItems()
|
||||||
return items[0] if items else None
|
return items[0] if items else None
|
||||||
|
|
||||||
def _commit(self): save_bm(self._to_list())
|
def _commit(self):
|
||||||
|
save_bm(self._to_list())
|
||||||
|
|
||||||
def _insert(self, node):
|
def _insert(self, node):
|
||||||
sel = self._sel()
|
sel = self._sel()
|
||||||
@@ -357,12 +456,11 @@ class ManagerDialog(QDialog):
|
|||||||
if not sel: return
|
if not sel: return
|
||||||
d = sel.data(0, Qt.UserRole)
|
d = sel.data(0, Qt.UserRole)
|
||||||
fld = "_folder" in d
|
fld = "_folder" in d
|
||||||
msg = (f"Delete folder '{d['name']}' and ALL its bookmarks?"
|
msg = f"Delete folder '{d['name']}' and ALL its bookmarks?" if fld else f"Delete '{d['name']}'?"
|
||||||
if fld else f"Delete '{d['name']}'?")
|
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setWindowTitle("Delete"); box.setText(msg)
|
box.setWindowTitle("Delete"); box.setText(msg)
|
||||||
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||||
box.setStyleSheet("background:#1e2a3a; color:white; font-size:17px;")
|
box.setStyleSheet(f"background:{THEME['colors']['bg_dark']}; color:white; font-size:17px;")
|
||||||
if box.exec_() == QMessageBox.Yes:
|
if box.exec_() == QMessageBox.Yes:
|
||||||
par = sel.parent() or self.tree.invisibleRootItem()
|
par = sel.parent() or self.tree.invisibleRootItem()
|
||||||
par.removeChild(sel); self._commit()
|
par.removeChild(sel); self._commit()
|
||||||
@@ -377,37 +475,24 @@ class ManagerDialog(QDialog):
|
|||||||
self.tree.setCurrentItem(sel); self._commit()
|
self.tree.setCurrentItem(sel); self._commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmark dropdown panel ───────────────────────────────────────
|
# ── Bookmark dropdown panel (Native Focus-Loss Dismissal) ─────────
|
||||||
class BookmarkPanel(QWidget):
|
class BookmarkPanel(QWidget):
|
||||||
"""
|
"""
|
||||||
Custom dropdown that replaces QMenu for bookmarks.
|
Custom dropdown that replaces QMenu for bookmarks.
|
||||||
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
|
Uses Qt.Popup flag for native focus-out dismissal on outside click (0% CPU).
|
||||||
always appears above Chrome, unlike QMenu which has z-order issues.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, cdp, overlay):
|
def __init__(self, cdp, overlay):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cdp = cdp
|
self.cdp = cdp
|
||||||
self.overlay = overlay
|
self.overlay = overlay
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||||
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
|
self.setStyleSheet(STYLES["bookmark_panel"].format(
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
toolbar=THEME["colors"]["toolbar"],
|
||||||
self.setStyleSheet(f"""
|
border=THEME["colors"]["border"],
|
||||||
QWidget {{ background: #1e3a5c; }}
|
text_secondary=THEME["colors"]["text_secondary"]
|
||||||
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)
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
# Timer: hide panel when user clicks anywhere outside it
|
|
||||||
# (clicks on Chromium never reach Qt's eventFilter)
|
|
||||||
self._click_guard = QTimer()
|
|
||||||
self._click_guard.timeout.connect(self._check_outside_click)
|
|
||||||
|
|
||||||
def rebuild(self, items):
|
def rebuild(self, items):
|
||||||
# Remove old layout
|
# Remove old layout
|
||||||
old = self.layout()
|
old = self.layout()
|
||||||
@@ -419,7 +504,7 @@ class BookmarkPanel(QWidget):
|
|||||||
QWidget().setLayout(old)
|
QWidget().setLayout(old)
|
||||||
|
|
||||||
scroll_content = QWidget()
|
scroll_content = QWidget()
|
||||||
scroll_content.setStyleSheet("background: #1e3a5c;")
|
scroll_content.setStyleSheet(f"background: {THEME['colors']['toolbar']};")
|
||||||
vbox = QVBoxLayout(scroll_content)
|
vbox = QVBoxLayout(scroll_content)
|
||||||
vbox.setContentsMargins(6, 6, 6, 6)
|
vbox.setContentsMargins(6, 6, 6, 6)
|
||||||
vbox.setSpacing(1)
|
vbox.setSpacing(1)
|
||||||
@@ -428,7 +513,7 @@ class BookmarkPanel(QWidget):
|
|||||||
|
|
||||||
sep = QFrame()
|
sep = QFrame()
|
||||||
sep.setFrameShape(QFrame.HLine)
|
sep.setFrameShape(QFrame.HLine)
|
||||||
sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;")
|
sep.setStyleSheet(f"background: {THEME['colors']['border']}; margin: 4px 8px;")
|
||||||
sep.setFixedHeight(1)
|
sep.setFixedHeight(1)
|
||||||
vbox.addWidget(sep)
|
vbox.addWidget(sep)
|
||||||
|
|
||||||
@@ -459,6 +544,11 @@ class BookmarkPanel(QWidget):
|
|||||||
self.resize(pw, min(ph, max_h))
|
self.resize(pw, min(ph, max_h))
|
||||||
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
|
|
||||||
|
def _burger_global_rect(self):
|
||||||
|
btn = self.overlay.burger_btn
|
||||||
|
origin = btn.mapToGlobal(QPoint(0, 0))
|
||||||
|
return QRect(origin, btn.size())
|
||||||
|
|
||||||
def _add_items(self, vbox, items, depth):
|
def _add_items(self, vbox, items, depth):
|
||||||
indent = " " * depth
|
indent = " " * depth
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -484,28 +574,17 @@ class BookmarkPanel(QWidget):
|
|||||||
self.hide()
|
self.hide()
|
||||||
self.overlay.open_manager()
|
self.overlay.open_manager()
|
||||||
|
|
||||||
def setVisible(self, visible):
|
def changeEvent(self, event):
|
||||||
super().setVisible(visible)
|
"""Native X11 focus loss detector (when clicking Chromium outside the Qt app)."""
|
||||||
if visible:
|
if event.type() == QEvent.ActivationChange:
|
||||||
self._click_guard.start(100)
|
if not self.isActiveWindow() and not self.overlay.isActiveWindow():
|
||||||
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()
|
self.hide()
|
||||||
|
super().changeEvent(event)
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
from PyQt5.QtCore import QEvent
|
"""Native local clicks inside toolbar/panel dismissal logic."""
|
||||||
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||||||
gp = event.globalPos()
|
if not self.geometry().contains(event.globalPos()) and not self._burger_global_rect().contains(event.globalPos()):
|
||||||
if not self.geometry().contains(gp):
|
|
||||||
self.hide()
|
self.hide()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -515,13 +594,9 @@ class KioskOverlay(QWidget):
|
|||||||
def __init__(self, cdp):
|
def __init__(self, cdp):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cdp = cdp
|
self.cdp = cdp
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
|
||||||
Qt.FramelessWindowHint |
|
|
||||||
Qt.WindowStaysOnTopHint |
|
|
||||||
Qt.Tool
|
|
||||||
)
|
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
self.setGeometry(0, 0, SCREEN_W, TOOLBAR_H)
|
||||||
self.setStyleSheet("background:transparent;")
|
self.setStyleSheet("background:transparent;")
|
||||||
QApplication.instance().installEventFilter(self)
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
@@ -543,21 +618,21 @@ class KioskOverlay(QWidget):
|
|||||||
def _on_screen_resize(self, rect):
|
def _on_screen_resize(self, rect):
|
||||||
global SCREEN_W, SCREEN_H
|
global SCREEN_W, SCREEN_H
|
||||||
w, h = rect.width(), rect.height()
|
w, h = rect.width(), rect.height()
|
||||||
# Ignore bogus sizes from minimize / RDP disconnect events
|
|
||||||
if w < 400 or h < 200:
|
if w < 400 or h < 200:
|
||||||
return
|
return
|
||||||
SCREEN_W, SCREEN_H = w, h
|
SCREEN_W, SCREEN_H = w, h
|
||||||
self.setGeometry(0, 0, SCREEN_W, TOP_H)
|
self.setGeometry(0, 0, SCREEN_W, TOOLBAR_H)
|
||||||
self.resize(SCREEN_W, TOP_H)
|
self.resize(SCREEN_W, TOOLBAR_H)
|
||||||
# Resize Chrome after brief delay so X has settled
|
if self.bm_panel.isVisible():
|
||||||
|
pw = self.bm_panel.width()
|
||||||
|
self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
QTimer.singleShot(500, self._resize_chrome)
|
QTimer.singleShot(500, self._resize_chrome)
|
||||||
|
|
||||||
def _resize_chrome(self):
|
def _resize_chrome(self):
|
||||||
self.cdp.resize_window(0, TOP_H, SCREEN_W, SCREEN_H - TOP_H)
|
self.cdp.resize_window(0, TOOLBAR_H, SCREEN_W, SCREEN_H - TOOLBAR_H)
|
||||||
|
|
||||||
def changeEvent(self, event):
|
def changeEvent(self, event):
|
||||||
"""Un-minimise the toolbar the instant it gets iconified."""
|
"""Un-minimise the toolbar the instant it gets iconified."""
|
||||||
from PyQt5.QtCore import QEvent
|
|
||||||
if event.type() == QEvent.WindowStateChange:
|
if event.type() == QEvent.WindowStateChange:
|
||||||
if self.windowState() & Qt.WindowMinimized:
|
if self.windowState() & Qt.WindowMinimized:
|
||||||
QTimer.singleShot(50, self._self_restore)
|
QTimer.singleShot(50, self._self_restore)
|
||||||
@@ -592,37 +667,47 @@ class KioskOverlay(QWidget):
|
|||||||
|
|
||||||
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:{THEME['colors']['toolbar']};")
|
||||||
tl = QHBoxLayout(bar)
|
tl = QHBoxLayout(bar)
|
||||||
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
|
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
|
||||||
|
|
||||||
# Brand label
|
# Brand label + version
|
||||||
|
brand_wrap = QWidget()
|
||||||
|
brand_wrap.setStyleSheet("background:transparent;")
|
||||||
|
brand_vbox = QVBoxLayout(brand_wrap)
|
||||||
|
brand_vbox.setContentsMargins(0, 0, 0, 0)
|
||||||
|
brand_vbox.setSpacing(0)
|
||||||
brand = QLabel("SeniorNet")
|
brand = QLabel("SeniorNet")
|
||||||
brand.setFont(QFont("Arial", 22, QFont.Bold))
|
brand.setFont(QFont("Arial", 22, QFont.Bold))
|
||||||
brand.setStyleSheet("color:white;")
|
brand.setStyleSheet("color:white;")
|
||||||
tl.addWidget(brand)
|
ver = QLabel(f"v{_VERSION}")
|
||||||
|
ver.setFont(QFont("Arial", 10))
|
||||||
|
ver.setStyleSheet("color:#7aafd4;")
|
||||||
|
brand_vbox.addWidget(brand)
|
||||||
|
brand_vbox.addWidget(ver)
|
||||||
|
tl.addWidget(brand_wrap)
|
||||||
tl.addStretch()
|
tl.addStretch()
|
||||||
|
|
||||||
# ◀ Back
|
# ◀ Back
|
||||||
back_btn = mk_icon_btn("◀", BTN_COLOR, BTN_HOVER)
|
back_btn = mk_icon_btn("◀", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||||||
back_btn.setToolTip("Go Back")
|
back_btn.setToolTip("Go Back")
|
||||||
back_btn.clicked.connect(self.cdp.back)
|
back_btn.clicked.connect(self.cdp.back)
|
||||||
tl.addWidget(back_btn)
|
tl.addWidget(back_btn)
|
||||||
|
|
||||||
# ▶ Forward
|
# ▶ Forward
|
||||||
fwd_btn = mk_icon_btn("▶", BTN_COLOR, BTN_HOVER)
|
fwd_btn = mk_icon_btn("▶", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||||||
fwd_btn.setToolTip("Go Forward")
|
fwd_btn.setToolTip("Go Forward")
|
||||||
fwd_btn.clicked.connect(self.cdp.forward)
|
fwd_btn.clicked.connect(self.cdp.forward)
|
||||||
tl.addWidget(fwd_btn)
|
tl.addWidget(fwd_btn)
|
||||||
|
|
||||||
# ⌂ Home
|
# ⌂ Home
|
||||||
home_btn = mk_icon_btn("⌂", GOLD, GOLD_HOVER)
|
home_btn = mk_icon_btn("⌂", THEME["colors"]["gold"], THEME["colors"]["gold_hover"])
|
||||||
home_btn.setToolTip("Go Home")
|
home_btn.setToolTip("Go Home")
|
||||||
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
||||||
tl.addWidget(home_btn)
|
tl.addWidget(home_btn)
|
||||||
|
|
||||||
# ☰ Hamburger — bookmarks menu
|
# ☰ Hamburger — bookmarks menu
|
||||||
self.burger_btn = mk_icon_btn("☰", PURPLE, PURPLE_HOVER)
|
self.burger_btn = mk_icon_btn("☰", THEME["colors"]["purple"], THEME["colors"]["purple_hover"])
|
||||||
self.burger_btn.setToolTip("Bookmarks")
|
self.burger_btn.setToolTip("Bookmarks")
|
||||||
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
|
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
|
||||||
tl.addWidget(self.burger_btn)
|
tl.addWidget(self.burger_btn)
|
||||||
@@ -634,8 +719,7 @@ class KioskOverlay(QWidget):
|
|||||||
self.bm_panel.hide()
|
self.bm_panel.hide()
|
||||||
else:
|
else:
|
||||||
self.bm_panel.rebuild(load_bm())
|
self.bm_panel.rebuild(load_bm())
|
||||||
self.bm_panel.show()
|
self.bm_panel.setVisible(True)
|
||||||
self.bm_panel.raise_()
|
|
||||||
|
|
||||||
def open_manager(self):
|
def open_manager(self):
|
||||||
d = ManagerDialog(self)
|
d = ManagerDialog(self)
|
||||||
@@ -647,7 +731,6 @@ class KioskOverlay(QWidget):
|
|||||||
e.accept()
|
e.accept()
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
from PyQt5.QtCore import QEvent
|
|
||||||
if event.type() == QEvent.KeyPress:
|
if event.type() == QEvent.KeyPress:
|
||||||
key = event.key()
|
key = event.key()
|
||||||
mods = event.modifiers()
|
mods = event.modifiers()
|
||||||
@@ -665,13 +748,13 @@ class KioskOverlay(QWidget):
|
|||||||
|
|
||||||
# ── Chrome launcher ───────────────────────────────────────────────
|
# ── Chrome launcher ───────────────────────────────────────────────
|
||||||
def launch_chrome():
|
def launch_chrome():
|
||||||
import pathlib, json as _json, shutil
|
profile_dir = _HOME / "chromium-kiosk"
|
||||||
profile_dir = pathlib.Path("/home/jgitta/chromium-kiosk")
|
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||||
try: (profile_dir / name).unlink()
|
try:
|
||||||
except FileNotFoundError: pass
|
(profile_dir / name).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
for rel in ("GraphiteDawnCache", "GrShaderCache", "ShaderCache",
|
||||||
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
"OptGuideOnDeviceClassifierModel", "OptGuideOnDeviceModel",
|
||||||
@@ -685,47 +768,46 @@ def launch_chrome():
|
|||||||
ls_path = profile_dir / "Local State"
|
ls_path = profile_dir / "Local State"
|
||||||
if ls_path.exists():
|
if ls_path.exists():
|
||||||
try:
|
try:
|
||||||
d = _json.loads(ls_path.read_text())
|
d = json.loads(ls_path.read_text())
|
||||||
d.setdefault("profile", {})["exit_type"] = "Normal"
|
d.setdefault("profile", {})["exit_type"] = "Normal"
|
||||||
d["hardware_acceleration_mode_previous"] = False
|
d["hardware_acceleration_mode_previous"] = False
|
||||||
ls_path.write_text(_json.dumps(d))
|
ls_path.write_text(json.dumps(d))
|
||||||
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"
|
cookies_db = profile_dir / "Default" / "Cookies"
|
||||||
if cookies_db.exists():
|
if cookies_db.exists():
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
_cfg = json.loads((_HOME / "kiosk-browser/config.json").read_text())
|
||||||
|
_email_url = _cfg.get("urls", {}).get("email", "")
|
||||||
|
_email_host = _email_url.split("//")[-1].split("/")[0] if _email_url else ""
|
||||||
conn = sqlite3.connect(str(cookies_db))
|
conn = sqlite3.connect(str(cookies_db))
|
||||||
conn.execute(
|
if _email_host:
|
||||||
"DELETE FROM cookies WHERE host_key LIKE '%mail.jgitta.com%'")
|
conn.execute("DELETE FROM cookies WHERE host_key LIKE ?", (f"%{_email_host}%",))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
print(f"Cleared stale session cookie for {_email_host}")
|
||||||
conn.close()
|
conn.close()
|
||||||
print("Cleared stale Roundcube session cookie")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Cookie clear warning: {e}")
|
print(f"Cookie clear warning: {e}")
|
||||||
|
|
||||||
chrome_h = SCREEN_H - TOP_H
|
chrome_h = SCREEN_H - TOOLBAR_H
|
||||||
cmd = [
|
cmd = [
|
||||||
"chromium",
|
"chromium",
|
||||||
"--no-first-run",
|
"--no-first-run",
|
||||||
"--no-default-browser-check",
|
"--no-default-browser-check",
|
||||||
"--disable-restore-session-state",
|
"--disable-restore-session-state",
|
||||||
"--disable-default-browser-check",
|
"--disable-default-browser-check",
|
||||||
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
"--disable-features=Translate,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",
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
"--user-data-dir=/home/jgitta/chromium-kiosk",
|
"--allow-file-access-from-files",
|
||||||
|
f"--user-data-dir={profile_dir}",
|
||||||
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,{TOOLBAR_H}",
|
||||||
f"--window-size={SCREEN_W},{chrome_h}",
|
f"--window-size={SCREEN_W},{chrome_h}",
|
||||||
f"--app={HOME_URL}",
|
f"--app={HOME_URL}",
|
||||||
]
|
]
|
||||||
@@ -735,39 +817,190 @@ 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()
|
_geo = app.primaryScreen().geometry()
|
||||||
SCREEN_W, SCREEN_H = _geo.width(), _geo.height()
|
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(THEME["colors"]["toolbar"])); app.setPalette(p)
|
||||||
|
|
||||||
cdp = CDP()
|
cdp = CDP()
|
||||||
overlay = KioskOverlay(cdp)
|
overlay = KioskOverlay(cdp)
|
||||||
|
|
||||||
chrome_proc = launch_chrome()
|
chrome_proc = launch_chrome()
|
||||||
|
|
||||||
def dismiss_chrome_dialogs():
|
def update_weather_cache():
|
||||||
"""
|
"""Fetch weather data in background via Python and cache as local JSON to bypass CORS securely."""
|
||||||
Chrome shows startup dialogs (first-run welcome, profile errors) on a
|
cache_file = _HOME / "kiosk-home" / "weather-data.json"
|
||||||
fresh or corrupted profile. Dismiss them all automatically so the
|
while True:
|
||||||
senior never sees them. Runs for ~15 seconds after startup.
|
try:
|
||||||
"""
|
# 1. Geolocation lookup (using highly accurate ip-api.com)
|
||||||
dialogs = [
|
req = urllib.request.Request(
|
||||||
"Welcome to Google Chrome",
|
"http://ip-api.com/json/",
|
||||||
"Profile error occurred",
|
headers={"User-Agent": "Mozilla/5.0"}
|
||||||
]
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
geo_raw = urllib.request.urlopen(req, timeout=8).read()
|
||||||
|
geo = json.loads(geo_raw)
|
||||||
|
|
||||||
|
loc_name = "Your Area"
|
||||||
|
if geo.get("city") and geo.get("regionName"):
|
||||||
|
loc_name = f"{geo['city']}, {geo['regionName']}"
|
||||||
|
elif geo.get("city"):
|
||||||
|
loc_name = geo["city"]
|
||||||
|
|
||||||
|
lat, lon = geo.get("lat"), geo.get("lon")
|
||||||
|
if lat and lon:
|
||||||
|
# 2. Weather forecast lookup
|
||||||
|
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=auto"
|
||||||
|
fc_raw = urllib.request.urlopen(url, timeout=8).read()
|
||||||
|
fc = json.loads(fc_raw)
|
||||||
|
|
||||||
|
# 3. Save combined cache
|
||||||
|
payload = {"location": loc_name, "forecast": fc}
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file.write_text(json.dumps(payload, indent=2))
|
||||||
|
print("Updated local weather-data.json cache successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Weather cache background update failed: {e}")
|
||||||
|
|
||||||
|
# Update cache every 15 minutes
|
||||||
|
time.sleep(900)
|
||||||
|
|
||||||
|
def update_news_cache():
|
||||||
|
"""Fetch top RSS news feeds, scrape full article text & images in background, and cache locally with randomized sources."""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import re
|
||||||
|
cache_file = _HOME / "kiosk-home" / "news-data.json"
|
||||||
|
|
||||||
|
# Refined catalog of highly reliable, 100% paywall-free, direct-link news sources
|
||||||
|
world_catalog = [
|
||||||
|
("http://feeds.bbci.co.uk/news/world/rss.xml", "BBC News"),
|
||||||
|
("https://www.aljazeera.com/xml/rss/all.xml", "Al Jazeera")
|
||||||
|
]
|
||||||
|
|
||||||
|
national_catalog = [
|
||||||
|
("https://feeds.npr.org/1001/rss.xml", "NPR News"),
|
||||||
|
("http://rss.cnn.com/rss/cnn_topstories.rss", "CNN News"),
|
||||||
|
("https://search.cnbc.com/rs/search/all/view.xml?partnerId=2000", "CNBC News")
|
||||||
|
]
|
||||||
|
|
||||||
|
def scrape_article(url):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
# 1. Extract og:image
|
||||||
|
img_url = ""
|
||||||
|
og_img_match = re.search(r'<meta[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']', html)
|
||||||
|
if not og_img_match:
|
||||||
|
og_img_match = re.search(r'<meta[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']', html)
|
||||||
|
if og_img_match:
|
||||||
|
img_url = og_img_match.group(1)
|
||||||
|
|
||||||
|
# 2. Extract main paragraphs
|
||||||
|
paragraphs = []
|
||||||
|
p_matches = re.findall(r'<p[^>]*>(.*?)</p>', html, re.DOTALL)
|
||||||
|
for p in p_matches:
|
||||||
|
p_clean = re.sub('<[^<]+?>', '', p)
|
||||||
|
p_clean = p_clean.strip()
|
||||||
|
# Decode basic HTML entities
|
||||||
|
p_clean = p_clean.replace(" ", " ").replace("&", "&").replace(""", '"').replace("'", "'").replace("'", "'").replace("–", "-").replace("—", "-")
|
||||||
|
|
||||||
|
# Filter out short noise paragraphs (ads, nav links)
|
||||||
|
if len(p_clean) > 85 and not p_clean.startswith("Follow ") and not p_clean.startswith("Read more") and not "click here" in p_clean.lower() and not "twitter" in p_clean.lower():
|
||||||
|
# Standard library blocklist to discard site global header/footer navigation items
|
||||||
|
ignore_terms = [
|
||||||
|
"British Broadcasting Corporation", "Watch Live", "HomeNewsSport",
|
||||||
|
"Special Series", "Up First", "All Up First Stories", "hide caption",
|
||||||
|
"LISTEN & FOLLOW", "Morning Edition", "Morning news brief", "Podcast",
|
||||||
|
"New York Times", "NYT", "The Times", "Al Jazeera", "BBC", "CNN"
|
||||||
|
]
|
||||||
|
if any(term in p_clean for term in ignore_terms):
|
||||||
|
continue
|
||||||
|
paragraphs.append(p_clean)
|
||||||
|
|
||||||
|
# Keep top 8 substantial paragraphs
|
||||||
|
full_text = "\n\n".join(paragraphs[:8])
|
||||||
|
return full_text, img_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Scraping error for {url}: {e}")
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Randomly select 2 World feeds and 2 National feeds to harvest (safely capped by catalog size)
|
||||||
|
selected_world = random.sample(world_catalog, min(len(world_catalog), 2))
|
||||||
|
selected_national = random.sample(national_catalog, min(len(national_catalog), 2))
|
||||||
|
|
||||||
|
feeds = {
|
||||||
|
"World News": selected_world,
|
||||||
|
"National News": selected_national
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
for category, source_list in feeds.items():
|
||||||
|
category_stories = []
|
||||||
|
for url, source_name in source_list:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
xml_raw = urllib.request.urlopen(req, timeout=12).read()
|
||||||
|
root = ET.fromstring(xml_raw)
|
||||||
|
|
||||||
|
channel = root.find("channel")
|
||||||
|
if channel is not None:
|
||||||
|
# Fetch top 3 stories per selected feed to compile a rich, diverse set
|
||||||
|
items_harvested = 0
|
||||||
|
for item in channel.findall("item"):
|
||||||
|
if items_harvested >= 3:
|
||||||
|
break
|
||||||
|
title = item.find("title")
|
||||||
|
link = item.find("link")
|
||||||
|
pub_date = item.find("pubDate")
|
||||||
|
|
||||||
|
t_str = title.text.strip() if title is not None and title.text else ""
|
||||||
|
l_str = link.text.strip() if link is not None and link.text else ""
|
||||||
|
|
||||||
|
p_str = ""
|
||||||
|
if pub_date is not None and pub_date.text:
|
||||||
|
try:
|
||||||
|
p_str = " ".join(pub_date.text.split()[:4])
|
||||||
|
except Exception:
|
||||||
|
p_str = pub_date.text
|
||||||
|
|
||||||
|
# Scrape rich data
|
||||||
|
full_story, img_url = "", ""
|
||||||
|
if l_str:
|
||||||
|
full_story, img_url = scrape_article(l_str)
|
||||||
|
|
||||||
|
category_stories.append({
|
||||||
|
"title": t_str,
|
||||||
|
"summary": full_story if full_story else "Click to read the story details.",
|
||||||
|
"image": img_url,
|
||||||
|
"source": source_name,
|
||||||
|
"date": p_str
|
||||||
|
})
|
||||||
|
items_harvested += 1
|
||||||
|
print(f"Harvested {items_harvested} stories from {source_name} for {category}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to harvest RSS feed {source_name} ({url}): {e}")
|
||||||
|
|
||||||
|
# Fully randomize/shuffle the compiled stories list so sources are mixed nicely!
|
||||||
|
random.shuffle(category_stories)
|
||||||
|
payload[category] = category_stories
|
||||||
|
|
||||||
|
if payload:
|
||||||
|
try:
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file.write_text(json.dumps(payload, indent=2))
|
||||||
|
print("Updated local news-data.json cache with randomized shuffled stories successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to write news cache file: {e}")
|
||||||
|
|
||||||
|
# Update news every 30 minutes
|
||||||
|
time.sleep(1800)
|
||||||
|
|
||||||
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 — persistent WebSocket connected successfully")
|
||||||
dismiss_chrome_dialogs()
|
|
||||||
|
threading.Thread(target=update_weather_cache, daemon=True).start()
|
||||||
|
threading.Thread(target=update_news_cache, daemon=True).start()
|
||||||
threading.Thread(target=init_cdp, daemon=True).start()
|
threading.Thread(target=init_cdp, daemon=True).start()
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SeniorNet Kiosk ISO Builder
|
||||||
|
# Takes the official Debian Trixie netinstall ISO and repacks it with:
|
||||||
|
# - preseed.cfg embedded for silent auto-install
|
||||||
|
# - GRUB + isolinux configured for auto-boot (UEFI + BIOS)
|
||||||
|
#
|
||||||
|
# The kiosk app itself is NOT bundled — it's downloaded from Gitea at
|
||||||
|
# first-boot time. Rebuild the ISO only when changing Debian base or preseed.
|
||||||
|
#
|
||||||
|
# Run: sudo ./build-iso.sh
|
||||||
|
# Output: iso-output/seniornet-kiosk.iso
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "ERROR: Run as root: sudo ./build-iso.sh"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
OUTPUT_DIR="$REPO_DIR/iso-output"
|
||||||
|
WORK_DIR="$(mktemp -d /tmp/kiosk-iso-XXXX)"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
DEBIAN_ISO_URL="https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.5.0-amd64-netinst.iso"
|
||||||
|
ISO_CACHE="$OUTPUT_DIR/debian-netinst.iso"
|
||||||
|
OUTPUT_ISO="$OUTPUT_DIR/seniornet-kiosk.iso"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# ── Dependencies ───────────────────────────────────────────────────
|
||||||
|
echo "==> Checking dependencies..."
|
||||||
|
MISSING=""
|
||||||
|
for pkg in xorriso isolinux curl; do
|
||||||
|
dpkg -l "$pkg" &>/dev/null || MISSING="$MISSING $pkg"
|
||||||
|
done
|
||||||
|
if [ -n "$MISSING" ]; then
|
||||||
|
apt-get install -y $MISSING
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Download Debian netinstall ISO (cached) ────────────────────────
|
||||||
|
if [ -f "$ISO_CACHE" ]; then
|
||||||
|
echo "==> Using cached Debian ISO: $ISO_CACHE"
|
||||||
|
else
|
||||||
|
echo "==> Downloading Debian 13 Trixie stable netinstall (~700 MB)..."
|
||||||
|
curl -L --progress-bar "$DEBIAN_ISO_URL" -o "$ISO_CACHE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Extract ISO ────────────────────────────────────────────────────
|
||||||
|
echo "==> Extracting ISO..."
|
||||||
|
mkdir -p "$WORK_DIR/iso"
|
||||||
|
xorriso -osirrox on -indev "$ISO_CACHE" -extract / "$WORK_DIR/iso" 2>/dev/null
|
||||||
|
chmod -R u+w "$WORK_DIR/iso"
|
||||||
|
|
||||||
|
# ── Add preseed to ISO root ────────────────────────────────────────
|
||||||
|
# Accessible during install as /cdrom/preseed.cfg
|
||||||
|
cp "$REPO_DIR/preseed.cfg" "$WORK_DIR/iso/preseed.cfg"
|
||||||
|
|
||||||
|
# ── Auto-boot: GRUB (UEFI) ────────────────────────────────────────
|
||||||
|
echo "==> Configuring auto-boot..."
|
||||||
|
cat > "$WORK_DIR/iso/boot/grub/grub.cfg" << 'GRUBEOF'
|
||||||
|
set default=0
|
||||||
|
set timeout=5
|
||||||
|
|
||||||
|
menuentry "SeniorNet Kiosk — Automatic Install" {
|
||||||
|
linux /install.amd/vmlinuz preseed/file=/cdrom/preseed.cfg auto=true priority=critical quiet ---
|
||||||
|
initrd /install.amd/initrd.gz
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "SeniorNet Kiosk — Install (verbose)" {
|
||||||
|
linux /install.amd/vmlinuz preseed/file=/cdrom/preseed.cfg auto=true priority=critical ---
|
||||||
|
initrd /install.amd/initrd.gz
|
||||||
|
}
|
||||||
|
GRUBEOF
|
||||||
|
|
||||||
|
# ── Auto-boot: isolinux (BIOS legacy) ─────────────────────────────
|
||||||
|
if [ -f "$WORK_DIR/iso/isolinux/isolinux.cfg" ]; then
|
||||||
|
cat > "$WORK_DIR/iso/isolinux/isolinux.cfg" << 'ISOEOF'
|
||||||
|
default install
|
||||||
|
timeout 50
|
||||||
|
|
||||||
|
label install
|
||||||
|
menu label SeniorNet Kiosk - Automatic Install
|
||||||
|
kernel /install.amd/vmlinuz
|
||||||
|
append initrd=/install.amd/initrd.gz preseed/file=/cdrom/preseed.cfg auto=true priority=critical quiet ---
|
||||||
|
|
||||||
|
label verbose
|
||||||
|
menu label SeniorNet Kiosk - Install (verbose)
|
||||||
|
kernel /install.amd/vmlinuz
|
||||||
|
append initrd=/install.amd/initrd.gz preseed/file=/cdrom/preseed.cfg auto=true priority=critical ---
|
||||||
|
ISOEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Repack ISO (UEFI + BIOS hybrid) ───────────────────────────────
|
||||||
|
echo "==> Building final ISO..."
|
||||||
|
cd "$WORK_DIR/iso"
|
||||||
|
|
||||||
|
xorriso -as mkisofs \
|
||||||
|
-r -J -joliet-long \
|
||||||
|
-V "SeniorNet Kiosk" \
|
||||||
|
-b isolinux/isolinux.bin \
|
||||||
|
-c isolinux/boot.cat \
|
||||||
|
-no-emul-boot -boot-load-size 4 -boot-info-table \
|
||||||
|
-eltorito-alt-boot \
|
||||||
|
-e boot/grub/efi.img \
|
||||||
|
-no-emul-boot \
|
||||||
|
-isohybrid-gpt-basdat \
|
||||||
|
-o "$OUTPUT_ISO" \
|
||||||
|
. 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Make the ISO bootable from USB on both BIOS and UEFI machines
|
||||||
|
isohybrid --uefi "$OUTPUT_ISO"
|
||||||
|
|
||||||
|
echo
|
||||||
|
SIZE=$(du -sh "$OUTPUT_ISO" | cut -f1)
|
||||||
|
echo "==> ✓ ISO complete: $OUTPUT_ISO ($SIZE)"
|
||||||
|
echo
|
||||||
|
echo "Write to USB:"
|
||||||
|
echo " sudo dd if=\"$OUTPUT_ISO\" of=/dev/sdX bs=4M status=progress && sync"
|
||||||
|
echo
|
||||||
|
echo "Boot from USB — Debian installs silently (~10 min) then reboots."
|
||||||
|
echo "On first reboot: setup wizard prompts for username and senior's name."
|
||||||
|
echo "Second reboot: kiosk is running."
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"urls": {
|
||||||
|
"email": "https://mail.jgitta.com/roundcube/",
|
||||||
|
"internet": "https://www.duckduckgo.com",
|
||||||
|
"weather": "weather.html",
|
||||||
|
"news": "news.html",
|
||||||
|
"facebook": "https://www.facebook.com",
|
||||||
|
"messenger": "https://www.messenger.com",
|
||||||
|
"youtube": "https://www.youtube.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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,10 @@
|
|||||||
|
# Senior Kiosk — Credentials (template)
|
||||||
|
|
||||||
|
> Copy to `credentials.md` (gitignored). Never commit real secrets.
|
||||||
|
|
||||||
|
## Gmail App Password (for Roundcube IMAP)
|
||||||
|
|
||||||
|
- **Account**: your-gmail@example.com
|
||||||
|
- **Purpose**: Roundcube webmail IMAP/SMTP access
|
||||||
|
- **App Password**: (create in Google Account → Security → App passwords)
|
||||||
|
- **Note**: Configure on the Roundcube server only; do not commit to git.
|
||||||
@@ -7,15 +7,81 @@
|
|||||||
KIOSK_LOCAL="jgitta@192.168.88.44"
|
KIOSK_LOCAL="jgitta@192.168.88.44"
|
||||||
KIOSK_REMOTE="jgitta@100.64.0.1"
|
KIOSK_REMOTE="jgitta@100.64.0.1"
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
|
HOME_SRC="$REPO_DIR/home-screen-index.html"
|
||||||
|
HOME_DST="/home/jgitta/kiosk-home/index.html"
|
||||||
|
WEATHER_SRC="$REPO_DIR/weather.html"
|
||||||
|
WEATHER_DST="/home/jgitta/kiosk-home/weather.html"
|
||||||
|
NEWS_SRC="$REPO_DIR/news.html"
|
||||||
|
NEWS_DST="/home/jgitta/kiosk-home/news.html"
|
||||||
|
CONFIG_SRC="$REPO_DIR/config.json"
|
||||||
|
# Auto-increment patch version (0.2.0 → 0.2.1 → 0.2.2 ...)
|
||||||
|
VERSION_FILE="$REPO_DIR/VERSION"
|
||||||
|
if [ -f "$VERSION_FILE" ]; then
|
||||||
|
CUR=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||||
|
MAJOR=$(echo "$CUR" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$CUR" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$CUR" | cut -d. -f3)
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
|
||||||
|
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||||
|
echo "==> Version: $CUR → $NEW_VERSION"
|
||||||
|
else
|
||||||
|
NEW_VERSION="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT_MSG="${1:-deploy: v$NEW_VERSION $(date +%Y-%m-%d)}"
|
||||||
|
|
||||||
|
# Gitea credentials (from credentials.md)
|
||||||
|
GITEA_TOKEN="4fe93c6c62490ef0bb4e4aeec58f47da26752ce8"
|
||||||
|
GITEA_USER="jgitta"
|
||||||
|
|
||||||
|
inject_config() {
|
||||||
|
local src="$1"
|
||||||
|
local dst="$2"
|
||||||
|
local config="$3"
|
||||||
|
|
||||||
|
if [ -f "$config" ]; then
|
||||||
|
local config_json=$(cat "$config" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | tr '\n' ' ')
|
||||||
|
sed "s|const config = {};|const config = $config_json;|" "$src" > "$dst"
|
||||||
|
echo " Injected config.json into home screen"
|
||||||
|
else
|
||||||
|
cp "$src" "$dst"
|
||||||
|
echo " WARNING: config.json missing — deploying without config injection"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Syncing home screen to $HOME_DST..."
|
||||||
|
if [ -f "$HOME_SRC" ]; then
|
||||||
|
mkdir -p "$(dirname "$HOME_DST")"
|
||||||
|
inject_config "$HOME_SRC" "$HOME_DST" "$CONFIG_SRC"
|
||||||
|
chown jgitta:jgitta "$HOME_DST" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " WARNING: $HOME_SRC missing — home screen not updated"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$WEATHER_SRC" ]; then
|
||||||
|
cp "$WEATHER_SRC" "$WEATHER_DST"
|
||||||
|
chown jgitta:jgitta "$WEATHER_DST" 2>/dev/null || true
|
||||||
|
echo " Synced weather page"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$NEWS_SRC" ]; then
|
||||||
|
cp "$NEWS_SRC" "$NEWS_DST"
|
||||||
|
chown jgitta:jgitta "$NEWS_DST" 2>/dev/null || true
|
||||||
|
echo " Synced news page"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Committing and pushing to Gitea..."
|
echo "==> Committing and pushing to Gitea..."
|
||||||
git -C "$REPO_DIR" add -A
|
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" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
|
||||||
git -C "$REPO_DIR" push origin master || echo " (nothing new to push)"
|
git -C "$REPO_DIR" remote set-url origin "http://$GITEA_USER:$GITEA_TOKEN@192.168.88.27:3002/jgitta/senior-kiosk.git"
|
||||||
|
git -C "$REPO_DIR" push origin master || echo " (push failed — check network)"
|
||||||
|
|
||||||
echo "==> Restarting kiosk-dev..."
|
echo "==> Restarting kiosk-dev..."
|
||||||
|
if [ -f "/usr/local/bin/kiosk-restart" ]; then
|
||||||
/usr/local/bin/kiosk-restart
|
/usr/local/bin/kiosk-restart
|
||||||
|
else
|
||||||
|
ssh kiosk-dev "/usr/local/bin/kiosk-restart"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Trying production kiosk..."
|
echo "==> Trying production kiosk..."
|
||||||
KIOSK=""
|
KIOSK=""
|
||||||
@@ -33,6 +99,11 @@ if [ -n "$KIOSK" ]; then
|
|||||||
ssh "$KIOSK" "
|
ssh "$KIOSK" "
|
||||||
cd /home/jgitta/kiosk-browser
|
cd /home/jgitta/kiosk-browser
|
||||||
git pull
|
git pull
|
||||||
|
# Inject config at deploy time
|
||||||
|
config_json=\\\$(cat config.json | sed 's/\\\\\\\\/\\\\\\\\\\\\\\\\/g' | sed 's/\\\"/\\\\\\\"/g' | tr '\\\n' ' ')
|
||||||
|
sed \"s|const config = {};|const config = \\\$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html
|
||||||
|
cp weather.html /home/jgitta/kiosk-home/weather.html
|
||||||
|
cp news.html /home/jgitta/kiosk-home/news.html
|
||||||
/usr/local/bin/kiosk-restart
|
/usr/local/bin/kiosk-restart
|
||||||
"
|
"
|
||||||
echo " Production updated."
|
echo " Production updated."
|
||||||
|
|||||||
+42
-38
@@ -1,4 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<!-- Source for /home/jgitta/kiosk-home/index.html — copied by deploy.sh and install.sh -->
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -94,46 +95,47 @@
|
|||||||
<div id="clock">12:00 PM</div>
|
<div id="clock">12:00 PM</div>
|
||||||
<div id="date">Wednesday, January 1</div>
|
<div id="date">Wednesday, January 1</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid" id="buttonGrid"></div>
|
||||||
|
|
||||||
<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>
|
<script>
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{ id: 'email', label: 'Email', icon: '✉️', class: 'btn-gmail' },
|
||||||
|
{ id: 'internet', label: 'Internet', icon: '🌐', class: 'btn-browse' },
|
||||||
|
{ id: 'weather', label: 'Weather', icon: '🌤️', class: 'btn-weather' },
|
||||||
|
{ id: 'news', label: 'News', icon: '📰', class: 'btn-news' },
|
||||||
|
{ id: 'facebook', label: 'Facebook', icon: null, class: 'btn-facebook', svg: '<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>' },
|
||||||
|
{ id: 'messenger', label: 'Messenger', icon: null, class: 'btn-messenger', svg: '<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>' },
|
||||||
|
{ id: 'youtube', label: 'YouTube', icon: '▶️', class: 'btn-youtube' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadButtons() {
|
||||||
|
const grid = document.getElementById('buttonGrid');
|
||||||
|
const urls = config.urls || {};
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
const url = urls[btn.id] || '#';
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = `btn ${btn.class}`;
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
const iconDiv = document.createElement('div');
|
||||||
|
iconDiv.className = 'icon';
|
||||||
|
if (btn.icon) {
|
||||||
|
iconDiv.textContent = btn.icon;
|
||||||
|
} else if (btn.svg) {
|
||||||
|
iconDiv.innerHTML = btn.svg;
|
||||||
|
}
|
||||||
|
link.appendChild(iconDiv);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.textContent = btn.label;
|
||||||
|
link.appendChild(label);
|
||||||
|
|
||||||
|
grid.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateClock() {
|
function updateClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
|
const timeOpts = { hour: 'numeric', minute: '2-digit', hour12: true };
|
||||||
@@ -141,6 +143,8 @@
|
|||||||
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
|
document.getElementById('clock').textContent = now.toLocaleTimeString('en-US', timeOpts);
|
||||||
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
|
document.getElementById('date').textContent = now.toLocaleDateString('en-US', dateOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadButtons();
|
||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SeniorNet Kiosk setup script.
|
||||||
|
# Runs on first boot via kiosk-firstboot.service (which gives it a real TTY).
|
||||||
|
# Can also be run manually: sudo bash install-kiosk.sh
|
||||||
|
|
||||||
|
REPO_BASE="https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " SeniorNet Kiosk — First-Boot Setup"
|
||||||
|
echo "============================================"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── Fixed user details ─────────────────────────────────────────────
|
||||||
|
KIOSK_USER="kiosk"
|
||||||
|
KIOSK_NAME="Kiosk"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── Paths ──────────────────────────────────────────────────────────
|
||||||
|
KIOSK_HOME="/home/$KIOSK_USER"
|
||||||
|
REPO_DIR="$KIOSK_HOME/kiosk-browser"
|
||||||
|
HOME_DIR="$KIOSK_HOME/kiosk-home"
|
||||||
|
CHROMIUM_PROFILE="$KIOSK_HOME/chromium-kiosk"
|
||||||
|
|
||||||
|
# ── User ───────────────────────────────────────────────────────────
|
||||||
|
echo "==> Creating user $KIOSK_USER..."
|
||||||
|
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||||
|
useradd -m -s /bin/bash -c "$KIOSK_NAME" "$KIOSK_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Packages ───────────────────────────────────────────────────────
|
||||||
|
echo "==> Installing packages..."
|
||||||
|
sed -i '/^deb cdrom:/d' /etc/apt/sources.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y \
|
||||||
|
chromium \
|
||||||
|
openbox \
|
||||||
|
lightdm \
|
||||||
|
xserver-xorg \
|
||||||
|
x11-utils \
|
||||||
|
xdotool \
|
||||||
|
python3 \
|
||||||
|
python3-pyqt5 \
|
||||||
|
python3-websocket \
|
||||||
|
sqlite3 \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
openssh-server \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
2>&1 | grep -Ev "^(Reading|Selecting|Preparing|Unpacking|Setting|Processing|Triggers|Get)"
|
||||||
|
|
||||||
|
# ── Kiosk repo ─────────────────────────────────────────────────────
|
||||||
|
echo "==> Cloning kiosk repo..."
|
||||||
|
if [ ! -d "$REPO_DIR" ]; then
|
||||||
|
git clone --depth=1 https://gitea.jgitta.com/jgitta/senior-kiosk.git "$REPO_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Home screen ────────────────────────────────────────────────────
|
||||||
|
echo "==> Deploying home screen..."
|
||||||
|
mkdir -p "$HOME_DIR"
|
||||||
|
config_json=$(cat "$REPO_DIR/config.json" | tr '\n' ' ')
|
||||||
|
sed "s|const config = {};|const config = $config_json;|" \
|
||||||
|
"$REPO_DIR/home-screen-index.html" > "$HOME_DIR/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
# ── LightDM auto-login ─────────────────────────────────────────────
|
||||||
|
echo "==> Configuring LightDM auto-login..."
|
||||||
|
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
|
cat > /etc/lightdm/lightdm.conf.d/50-kiosk.conf << EOF
|
||||||
|
[Seat:*]
|
||||||
|
autologin-user=$KIOSK_USER
|
||||||
|
autologin-user-timeout=0
|
||||||
|
user-session=openbox
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── Openbox ────────────────────────────────────────────────────────
|
||||||
|
echo "==> Configuring Openbox..."
|
||||||
|
mkdir -p "$KIOSK_HOME/.config/openbox"
|
||||||
|
|
||||||
|
cat > "$KIOSK_HOME/.config/openbox/rc.xml" << 'EOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<openbox_config xmlns="http://openbox.org/3.4/rc">
|
||||||
|
<startup/>
|
||||||
|
</openbox_config>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$KIOSK_HOME/.config/openbox/autostart" << 'EOF'
|
||||||
|
(while true; do
|
||||||
|
python3 ~/kiosk-browser/browser.py
|
||||||
|
sleep 2
|
||||||
|
done) &
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── kiosk-restart helper ───────────────────────────────────────────
|
||||||
|
cat > /usr/local/bin/kiosk-restart << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
pkill -f "remote-debugging-port=9222" || true
|
||||||
|
pkill -f "python3.*browser" || true
|
||||||
|
sleep 1
|
||||||
|
echo "Kiosk restarted — new code is live"
|
||||||
|
EOF
|
||||||
|
chmod +x /usr/local/bin/kiosk-restart
|
||||||
|
|
||||||
|
# ── X11 hardening ──────────────────────────────────────────────────
|
||||||
|
echo "==> Hardening X11..."
|
||||||
|
mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
cat > /etc/X11/xorg.conf.d/50-kiosk.conf << 'EOF'
|
||||||
|
Section "ServerFlags"
|
||||||
|
Option "DontVTSwitch" "true"
|
||||||
|
Option "DontZap" "true"
|
||||||
|
Option "BlankTime" "0"
|
||||||
|
Option "StandbyTime" "0"
|
||||||
|
EndSection
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── SSH: allow root login for admin ───────────────────────────────
|
||||||
|
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# ── Permissions ────────────────────────────────────────────────────
|
||||||
|
echo "==> Setting permissions..."
|
||||||
|
chown -R "$KIOSK_USER:$KIOSK_USER" \
|
||||||
|
"$KIOSK_HOME/.config" \
|
||||||
|
"$REPO_DIR" \
|
||||||
|
"$HOME_DIR"
|
||||||
|
|
||||||
|
# ── Enable LightDM, remove first-boot setup ───────────────────────
|
||||||
|
systemctl enable lightdm
|
||||||
|
systemctl set-default graphical.target
|
||||||
|
# Remove auto-login and setup script so root doesn't auto-login after kiosk is running
|
||||||
|
rm -f /etc/systemd/system/getty@tty1.service.d/autologin.conf
|
||||||
|
rm -f /usr/local/bin/kiosk-setup
|
||||||
|
# Legacy cleanup (no-op if not present)
|
||||||
|
systemctl disable kiosk-firstboot 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/kiosk-firstboot.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================"
|
||||||
|
echo " Setup complete!"
|
||||||
|
echo " Kiosk will start after reboot as: $KIOSK_NAME ($KIOSK_USER)"
|
||||||
|
echo "============================================"
|
||||||
|
echo
|
||||||
|
sync
|
||||||
|
reboot
|
||||||
+6
-1
@@ -6,7 +6,7 @@ set -e
|
|||||||
echo "=== Installing packages ==="
|
echo "=== Installing packages ==="
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \
|
apt-get install -y python3-pyqt5 unclutter xorg openbox lightdm \
|
||||||
fonts-noto-color-emoji python3-pip
|
fonts-noto-color-emoji python3-pip xinput xdotool
|
||||||
|
|
||||||
# Install websocket-client
|
# Install websocket-client
|
||||||
pip3 install websocket-client --break-system-packages
|
pip3 install websocket-client --break-system-packages
|
||||||
@@ -38,6 +38,11 @@ mkdir -p /etc/X11/xorg.conf.d
|
|||||||
cp "$SCRIPT_DIR/system-config/xorg-50-kiosk-hardening.conf" \
|
cp "$SCRIPT_DIR/system-config/xorg-50-kiosk-hardening.conf" \
|
||||||
/etc/X11/xorg.conf.d/50-kiosk-hardening.conf
|
/etc/X11/xorg.conf.d/50-kiosk-hardening.conf
|
||||||
|
|
||||||
|
# Home screen (source in repo → served outside repo at file:///home/jgitta/kiosk-home/)
|
||||||
|
mkdir -p /home/jgitta/kiosk-home
|
||||||
|
cp "$SCRIPT_DIR/home-screen-index.html" /home/jgitta/kiosk-home/index.html
|
||||||
|
chown jgitta:jgitta /home/jgitta/kiosk-home/index.html
|
||||||
|
|
||||||
# Disable unused virtual consoles
|
# Disable unused virtual consoles
|
||||||
for i in 2 3 4 5 6; do
|
for i in 2 3 4 5 6; do
|
||||||
systemctl disable getty@tty$i 2>/dev/null || true
|
systemctl disable getty@tty$i 2>/dev/null || true
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>News</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra-wide custom high-contrast scrollbar for senior usability */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass container */
|
||||||
|
.container {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 36px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ffd700;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-box {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26px;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin: 40px 0 20px 0;
|
||||||
|
border-bottom: 3px solid rgba(56, 189, 248, 0.3);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion stories list */
|
||||||
|
.stories-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
scroll-margin-top: 20px; /* Keep beautiful breathing room at the top of viewport when focused */
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large tap target for header */
|
||||||
|
.story-header {
|
||||||
|
padding: 24px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-headline {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-chevron {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card.open .story-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth accordion drawer */
|
||||||
|
.story-body {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.35s ease-in-out;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card.open .story-body {
|
||||||
|
/* Transition is controlled dynamically via JS max-height for perfect animations */
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content {
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-summary {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-meta {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Giant Home Button */
|
||||||
|
.footer-nav {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 22px 55px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📰 Daily Newspaper</h1>
|
||||||
|
<p id="newspaper-date">Today's Edition</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="loading-box">🗞️ Delivery in progress... Please wait.</div>
|
||||||
|
|
||||||
|
<div id="news-content" style="display: none;">
|
||||||
|
<!-- World News Section -->
|
||||||
|
<div class="category-title">🌐 World News</div>
|
||||||
|
<div class="stories-list" id="world-news-list"></div>
|
||||||
|
|
||||||
|
<!-- National News Section -->
|
||||||
|
<div class="category-title">🇺🇸 National News</div>
|
||||||
|
<div class="stories-list" id="national-news-list"></div>
|
||||||
|
|
||||||
|
<!-- High-viewport spacer to guarantee that even the last stories in the list can scroll perfectly to the top headline -->
|
||||||
|
<div style="height: 75vh;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="index.html" class="home-btn">⌂ Go Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Format publication dates cleanly
|
||||||
|
function formatToday() {
|
||||||
|
const now = new Date();
|
||||||
|
return now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNews() {
|
||||||
|
document.getElementById('newspaper-date').textContent = formatToday();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("news-data.json");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderCategory("World News", data["World News"], "world-news-list");
|
||||||
|
renderCategory("National News", data["National News"], "national-news-list");
|
||||||
|
|
||||||
|
document.getElementById('loading-box').style.display = 'none';
|
||||||
|
document.getElementById('news-content').style.display = 'block';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loading-box').textContent = "⚠️ Preparing newspaper... Please wait a moment.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategory(catName, stories, containerId) {
|
||||||
|
const box = document.getElementById(containerId);
|
||||||
|
box.innerHTML = ""; // Clear loader
|
||||||
|
|
||||||
|
if (!stories || stories.length === 0) {
|
||||||
|
box.innerHTML = `<div style="padding: 20px; font-size: 20px; color: #94a3b8; text-align: center;">No stories available.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stories.forEach((story, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'story-card';
|
||||||
|
|
||||||
|
// Wrap each scraped paragraph in a comfortable <p> block for readability
|
||||||
|
const formattedSummary = story.summary
|
||||||
|
.split('\n\n')
|
||||||
|
.map(para => `<p style="margin-bottom:18px;">${para}</p>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="story-header" onclick="toggleStory(this)">
|
||||||
|
<div class="story-headline">${story.title}</div>
|
||||||
|
<div class="story-chevron">▸</div>
|
||||||
|
</div>
|
||||||
|
<div class="story-body">
|
||||||
|
<div class="story-content">
|
||||||
|
${story.image ? `<img src="${story.image}" style="width:100%; max-height:450px; object-fit:cover; border-radius:16px; margin-bottom:24px; box-shadow: 0 4px 15px rgba(0,0,0,0.35);" onerror="this.style.display='none'">` : ''}
|
||||||
|
<div class="story-summary">${formattedSummary}</div>
|
||||||
|
<div class="story-meta">📰 Source: ${story.source || 'Daily Digest'} | ⌚ Published: ${story.date || 'Recent'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
box.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive in-place accordion expansion with scroll-to-focus
|
||||||
|
function toggleStory(headerElement) {
|
||||||
|
const card = headerElement.parentElement;
|
||||||
|
const body = card.querySelector('.story-body');
|
||||||
|
const isOpen = card.classList.contains('open');
|
||||||
|
|
||||||
|
// Close ALL other story cards globally across all categories
|
||||||
|
const allCards = document.querySelectorAll('.story-card');
|
||||||
|
allCards.forEach(c => {
|
||||||
|
if (c !== card && c.classList.contains('open')) {
|
||||||
|
c.classList.remove('open');
|
||||||
|
const b = c.querySelector('.story-body');
|
||||||
|
if (b) {
|
||||||
|
b.style.transition = 'none';
|
||||||
|
b.style.maxHeight = '0px';
|
||||||
|
b.offsetHeight; // Force reflow
|
||||||
|
b.style.transition = ''; // Restore transition for next open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
card.classList.add('open');
|
||||||
|
if (body) {
|
||||||
|
body.style.maxHeight = body.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
// Smoothly scroll the newly opened story card to the top of the viewport
|
||||||
|
setTimeout(() => {
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 50); // Small delay to allow reflow, works perfectly and instantly
|
||||||
|
} else {
|
||||||
|
card.classList.remove('open');
|
||||||
|
if (body) {
|
||||||
|
body.style.maxHeight = '0px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = loadNews;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Downloaded and run by the preseed late_command after Debian base install.
|
||||||
|
# Fetches install-kiosk.sh, configures getty@tty1 to auto-login as root,
|
||||||
|
# and runs the setup wizard automatically on first reboot.
|
||||||
|
|
||||||
|
REPO_BASE="https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master"
|
||||||
|
|
||||||
|
echo "==> SeniorNet: downloading setup script..."
|
||||||
|
curl -fsSL "$REPO_BASE/install-kiosk.sh" -o /usr/local/bin/kiosk-setup
|
||||||
|
chmod +x /usr/local/bin/kiosk-setup
|
||||||
|
|
||||||
|
echo "==> SeniorNet: configuring auto-login on TTY1..."
|
||||||
|
mkdir -p /etc/systemd/system/getty@tty1.service.d
|
||||||
|
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << 'EOF'
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin root --noclear %I $TERM
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> SeniorNet: configuring setup to run on first login..."
|
||||||
|
cat >> /root/.bash_profile << 'EOF'
|
||||||
|
|
||||||
|
# SeniorNet first-boot setup — always pulls latest from Gitea, runs once
|
||||||
|
if [ -f /usr/local/bin/kiosk-setup ]; then
|
||||||
|
curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master/install-kiosk.sh \
|
||||||
|
-o /usr/local/bin/kiosk-setup 2>/dev/null || true
|
||||||
|
bash /usr/local/bin/kiosk-setup
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Allow root SSH login with password for debugging during setup
|
||||||
|
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
|
||||||
|
echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Boot to text mode for the setup wizard
|
||||||
|
systemctl set-default multi-user.target
|
||||||
|
|
||||||
|
echo "==> SeniorNet: ready. Setup wizard will run automatically on next reboot."
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# Debian Preseed — SeniorNet Kiosk
|
||||||
|
# Silent unattended Debian base install.
|
||||||
|
# After install, downloads preseed-bootstrap.sh which installs a first-boot
|
||||||
|
# setup wizard. On first reboot the wizard prompts for username/name,
|
||||||
|
# installs the kiosk, then reboots into kiosk mode.
|
||||||
|
|
||||||
|
# ── Locale / keyboard ──────────────────────────────────────────────
|
||||||
|
d-i debian-installer/locale string en_US.UTF-8
|
||||||
|
d-i keyboard-configuration/xkb-keymap select us
|
||||||
|
|
||||||
|
# ── Network ────────────────────────────────────────────────────────
|
||||||
|
# Auto, but early_command will pin to the first wired (eth/en) interface
|
||||||
|
# if one exists — prevents installer from defaulting to WiFi and asking
|
||||||
|
# for a WPA passphrase. Ethernet must be plugged in during install.
|
||||||
|
d-i netcfg/choose_interface select auto
|
||||||
|
d-i netcfg/get_hostname string kiosk
|
||||||
|
d-i netcfg/get_domain string local
|
||||||
|
|
||||||
|
# ── Mirror ─────────────────────────────────────────────────────────
|
||||||
|
d-i mirror/country string manual
|
||||||
|
d-i mirror/http/hostname string deb.debian.org
|
||||||
|
d-i mirror/http/directory string /debian
|
||||||
|
d-i mirror/http/proxy string
|
||||||
|
|
||||||
|
# ── Clock / timezone ───────────────────────────────────────────────
|
||||||
|
d-i time/zone string UTC
|
||||||
|
d-i clock-setup/utc boolean true
|
||||||
|
d-i clock-setup/ntp boolean true
|
||||||
|
|
||||||
|
# ── Accounts ───────────────────────────────────────────────────────
|
||||||
|
# Root only — install-kiosk.sh creates the kiosk user interactively.
|
||||||
|
d-i passwd/root-login boolean true
|
||||||
|
d-i passwd/make-user boolean false
|
||||||
|
d-i passwd/root-password password kiosk
|
||||||
|
d-i passwd/root-password-again password kiosk
|
||||||
|
|
||||||
|
# ── Disk — auto-detect first real disk ─────────────────────────────
|
||||||
|
# Handles /dev/sda, /dev/nvme0n1, /dev/vda, etc.
|
||||||
|
d-i preseed/early_command string \
|
||||||
|
DISK=$(ls /sys/block/ | grep -Ev '^(loop|sr|fd|ram)' | head -1); \
|
||||||
|
debconf-set partman-auto/disk "/dev/$DISK" || true; \
|
||||||
|
debconf-set grub-installer/bootdev "/dev/$DISK" || true; \
|
||||||
|
ETH=$(ls /sys/class/net/ 2>/dev/null | grep -E '^(eth|en)' | head -1 || true); \
|
||||||
|
if [ -n "$ETH" ]; then debconf-set netcfg/choose_interface "$ETH" || true; fi; \
|
||||||
|
true
|
||||||
|
|
||||||
|
d-i partman-auto/method string regular
|
||||||
|
d-i partman-auto/choose_recipe select atomic
|
||||||
|
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||||
|
d-i partman/choose_partition select finish
|
||||||
|
d-i partman/confirm_write_new_label boolean true
|
||||||
|
d-i partman/confirm boolean true
|
||||||
|
d-i partman/confirm_nooverwrite boolean true
|
||||||
|
|
||||||
|
# ── APT ────────────────────────────────────────────────────────────
|
||||||
|
d-i apt-setup/services-select multiselect security, updates
|
||||||
|
d-i apt-setup/security_host string security.debian.org
|
||||||
|
|
||||||
|
# ── Package selection ──────────────────────────────────────────────
|
||||||
|
tasksel tasksel/first multiselect standard, ssh-server
|
||||||
|
d-i pkgsel/include string curl git
|
||||||
|
|
||||||
|
# ── Bootloader ─────────────────────────────────────────────────────
|
||||||
|
d-i grub-installer/only boolean true
|
||||||
|
d-i grub-installer/bootdev string default
|
||||||
|
|
||||||
|
# ── Post-install bootstrap ─────────────────────────────────────────
|
||||||
|
# Downloads preseed-bootstrap.sh which installs the first-boot setup service.
|
||||||
|
d-i preseed/late_command string \
|
||||||
|
in-target bash -c 'curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master/preseed-bootstrap.sh | bash' ; \
|
||||||
|
(sleep 3 && reboot) &
|
||||||
|
|
||||||
|
# ── Finish ─────────────────────────────────────────────────────────
|
||||||
|
# reboot is triggered by late_command background job, bypassing the final dialog
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Weather</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1e3c72, #2a5298);
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass container card */
|
||||||
|
.container {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-name {
|
||||||
|
font-size: 38px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-text {
|
||||||
|
font-size: 26px;
|
||||||
|
margin: 40px 0;
|
||||||
|
color: #aac8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin: 30px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-temp {
|
||||||
|
font-size: 88px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon {
|
||||||
|
font-size: 96px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-desc {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feels-like {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info pills */
|
||||||
|
.details-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forecast layout */
|
||||||
|
.forecast-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-day {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-temp {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Go Home Navigation button */
|
||||||
|
.home-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 20px 50px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container" id="weather-container">
|
||||||
|
<div id="loading-box">
|
||||||
|
<div id="loading-text">🔍 Determining your location...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="weather-content" style="display: none;">
|
||||||
|
<div id="location-name">Detecting Location...</div>
|
||||||
|
|
||||||
|
<div class="weather-main">
|
||||||
|
<div class="weather-icon" id="main-icon">🌤️</div>
|
||||||
|
<div class="main-temp" id="temp-val">--°F</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-desc" id="condition-txt">Partly Cloudy</div>
|
||||||
|
<div class="feels-like" id="feels-like-txt">Feels like --°F</div>
|
||||||
|
|
||||||
|
<div class="details-row">
|
||||||
|
<div class="pill">💧 Humidity: <span id="humidity-val">--%</span></div>
|
||||||
|
<div class="pill">💨 Wind: <span id="wind-val">-- mph</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forecast-title">3-Day Forecast</div>
|
||||||
|
<div class="forecast-grid" id="forecast-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="index.html" class="home-btn">⌂ Go Home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// WMO Weather interpretation codes mapping
|
||||||
|
const WEATHER_CODES = {
|
||||||
|
0: { txt: "Sunny", emoji: "☀️" },
|
||||||
|
1: { txt: "Mainly Clear", emoji: "🌤️" },
|
||||||
|
2: { txt: "Partly Cloudy", emoji: "⛅" },
|
||||||
|
3: { txt: "Overcast", emoji: "☁️" },
|
||||||
|
45: { txt: "Foggy", emoji: "🌫️" },
|
||||||
|
48: { txt: "Depositing Rime Fog", emoji: "🌫️" },
|
||||||
|
51: { txt: "Light Drizzle", emoji: "🌦️" },
|
||||||
|
53: { txt: "Moderate Drizzle", emoji: "🌦️" },
|
||||||
|
55: { txt: "Dense Drizzle", emoji: "🌦️" },
|
||||||
|
61: { txt: "Slight Rain", emoji: "🌧️" },
|
||||||
|
63: { txt: "Moderate Rain", emoji: "🌧️" },
|
||||||
|
65: { txt: "Heavy Rain", emoji: "🌧️" },
|
||||||
|
71: { txt: "Light Snowfall", emoji: "🌨️" },
|
||||||
|
73: { txt: "Moderate Snowfall", emoji: "🌨️" },
|
||||||
|
75: { txt: "Heavy Snowfall", emoji: "🌨️" },
|
||||||
|
77: { txt: "Snow Grains", emoji: "🌨️" },
|
||||||
|
80: { txt: "Slight Showers", emoji: "🌧️" },
|
||||||
|
81: { txt: "Moderate Showers", emoji: "🌧️" },
|
||||||
|
82: { txt: "Violent Showers", emoji: "⛈️" },
|
||||||
|
85: { txt: "Slight Snow Showers", emoji: "🌨️" },
|
||||||
|
86: { txt: "Heavy Snow Showers", emoji: "🌨️" },
|
||||||
|
95: { txt: "Thunderstorm", emoji: "⛈️" },
|
||||||
|
96: { txt: "Thunderstorm with Hail", emoji: "⛈️" },
|
||||||
|
99: { txt: "Thunderstorm with Heavy Hail", emoji: "⛈️" }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWeatherInfo(code) {
|
||||||
|
return WEATHER_CODES[code] || { txt: "Weather Details", emoji: "🌤️" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWeather() {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading-text').textContent = "📊 Loading local weather forecasts...";
|
||||||
|
const resp = await fetch("weather-data.json");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const locName = data.location || "Your Area";
|
||||||
|
const fc = data.forecast;
|
||||||
|
|
||||||
|
// Render current stats
|
||||||
|
const current = fc.current;
|
||||||
|
const wInfo = getWeatherInfo(current.weather_code);
|
||||||
|
|
||||||
|
document.getElementById('location-name').textContent = locName;
|
||||||
|
document.getElementById('temp-val').textContent = `${Math.round(current.temperature_2m)}°F`;
|
||||||
|
document.getElementById('main-icon').textContent = wInfo.emoji;
|
||||||
|
document.getElementById('condition-txt').textContent = wInfo.txt;
|
||||||
|
document.getElementById('feels-like-txt').textContent = `Feels like ${Math.round(current.apparent_temperature)}°F`;
|
||||||
|
document.getElementById('humidity-val').textContent = `${current.relative_humidity_2m}%`;
|
||||||
|
document.getElementById('wind-val').textContent = `${Math.round(current.wind_speed_10m)} mph`;
|
||||||
|
|
||||||
|
// Render 3-Day Forecast
|
||||||
|
const daily = fc.daily;
|
||||||
|
const forecastBox = document.getElementById('forecast-box');
|
||||||
|
forecastBox.innerHTML = ""; // Clear loader
|
||||||
|
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const dateStr = daily.time[i];
|
||||||
|
const dateObj = new Date(dateStr + "T00:00:00");
|
||||||
|
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const code = daily.weather_code[i];
|
||||||
|
const fcInfo = getWeatherInfo(code);
|
||||||
|
const maxT = Math.round(daily.temperature_2m_max[i]);
|
||||||
|
const minT = Math.round(daily.temperature_2m_min[i]);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'forecast-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="fc-day">${dayName}</div>
|
||||||
|
<div class="fc-icon">${fcInfo.emoji}</div>
|
||||||
|
<div class="fc-temp">🔆 ${maxT}° / 🌙 ${minT}°F</div>
|
||||||
|
`;
|
||||||
|
forecastBox.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loader and show content
|
||||||
|
document.getElementById('loading-box').style.display = 'none';
|
||||||
|
document.getElementById('weather-content').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loading-text').textContent = "⚠️ Preparing weather data... Please wait a moment.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = initWeather;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user