Compare commits

..

43 Commits

Author SHA1 Message Date
jgitta 2969648aa1 deploy: fix scroll jump when closing previous story 2026-05-27 14:31:24 +00:00
jgitta e727ed35a9 deploy: adjust news story scrolling to headline top 2026-05-27 14:29:31 +00:00
jgitta 999dda22df deploy: smooth scroll newspaper focus 2026-05-27 14:27:15 +00:00
jgitta 4c474c0916 deploy: secure 100% paywall-free direct-link news 2026-05-27 14:24:49 +00:00
jgitta 585b7f2639 deploy: replace paywalled NYT with AP and CNBC 2026-05-27 14:24:16 +00:00
jgitta f02ac40b91 deploy: randomize and shuffle top RSS feeds 2026-05-27 14:21:30 +00:00
jgitta abe61d1156 deploy: cache and render source name for news items 2026-05-27 14:19:12 +00:00
jgitta 79b6d4857d deploy: increase accordion height and add premium scrollbar 2026-05-27 14:14:09 +00:00
jgitta 598bd83887 deploy: filter global navs from RSS news cache 2026-05-27 14:12:36 +00:00
jgitta 6a4eee9a15 feat: upgrade local newspaper to full-text and images 2026-05-27 14:12:08 +00:00
jgitta 4b1ea2da11 feat: add local ad-free newspaper 2026-05-27 14:09:15 +00:00
jgitta 0d31af526c deploy: integrate emoji font to first boot installer 2026-05-27 14:00:21 +00:00
jgitta 3d8aef5f22 deploy: accurate waterloo ia geolocation 2026-05-27 13:51:41 +00:00
jgitta ac6ca6d1b3 deploy: secure weather caching 2026-05-27 13:44:22 +00:00
jgitta 46cd4f77c9 deploy: weather patch 2026-05-27 13:40:56 +00:00
jgitta 1b06a29e53 feat: add local ad-free weather page 2026-05-27 13:33:44 +00:00
jgitta fd519a64b0 fix: don't pre-create Chromium profile dir in browser.py 2026-05-26 01:10:21 +00:00
jgitta a2c14a4a39 fix: don't pre-create Chromium profile dir — causes profile error on first launch 2026-05-26 01:06:01 +00:00
jgitta ab423ef1a7 fix: sync before reboot to ensure repo survives first-boot restart 2026-05-26 01:03:30 +00:00
jgitta e9ee8caccf fix: shallow clone (--depth=1) to skip 3.5GB of ISO history blobs 2026-05-26 01:00:00 +00:00
jgitta 68dccf958e fix: remove clear command, not needed for silent install 2026-05-26 00:58:07 +00:00
jgitta 0dcfe060e5 fix: robust SSH auth + always pull latest kiosk-setup from Gitea on boot
Replace sed-based PermitRootLogin fix with explicit appends to guarantee
root SSH with password works for debugging. .bash_profile now pulls the
latest install-kiosk.sh from Gitea before running so fixes take effect
without rebuilding the ISO or recreating the VM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:50:00 +00:00
jgitta 86f6334737 fix: replace xprop with x11-utils, use apt websocket-client package
xprop is not a Debian package name — it's in x11-utils. Also replace
pip3 install with python3-websocket from apt (same library, no pip needed).
This fixes the apt failure that prevented lightdm from being installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:46:31 +00:00
jgitta 2431c6b6ae fix: enable root SSH login in preseed-bootstrap for debugging
Allows SSH as root during first-boot setup phase so we can run
commands without relying on the Proxmox console for paste.
install-kiosk.sh already disables this after kiosk setup completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:36:02 +00:00
jgitta 5d3bcb679d fix: remove CDROM apt source before package install
Debian netinstall adds cdrom: to sources.list. With the ISO still
attached in the VM, apt-get update fails on it. Strip it before
running apt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:24:54 +00:00
jgitta 23779690d6 fix: bypass final installer dialog with background reboot in late_command
d-i finish-install/reboot_in_background doesn't suppress the 'Installation
complete, click Continue' dialog in Trixie. Instead, fork a background
reboot from late_command that fires 3s after setup completes, killing the
dialog before it can block. Also removes the redundant reboot_in_background
setting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:15:35 +00:00
jgitta 96838f9f67 fix: fully silent first-boot — no prompts, auto-reboot on completion
Removes all interactive prompts from install-kiosk.sh. Username fixed
to 'kiosk', display name fixed to 'Kiosk'. Reboots automatically after
setup without waiting for user input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:49:03 +00:00
jgitta 05f20355c8 fix: use generic 'kiosk' username, prompt only for senior's name
Removes the username prompt — all installs use 'kiosk' as the system
user. Only the senior's display name is asked during first-boot setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:48:10 +00:00
jgitta 165e77b9b8 fix: use getty auto-login instead of systemd TTY service for first-boot setup
Replaces kiosk-firstboot.service (which got SIGHUP'd by getty) with a
simpler approach: configure getty@tty1 to auto-login root, run kiosk-setup
from .bash_profile. Cleaner TTY ownership — no service/getty conflicts.
install-kiosk.sh now removes the auto-login config and kiosk-setup script
on completion so root doesn't auto-login after the kiosk is running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:36:42 +00:00
jgitta d3b05f981f fix: stop getty@tty1 before first-boot setup service to prevent SIGHUP kill
Conflicts=getty@tty1 ensures systemd releases TTY1 before kiosk-setup
runs interactively. Also add partman/choose_partition select finish to
preseed so disk write confirmation is fully automated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:33:20 +00:00
jgitta b73b8fab65 fix: prefer wired interface in preseed to avoid WiFi passphrase prompt 2026-05-24 11:36:09 +00:00
jgitta 19b02f91e9 fix: use Debian 13.5.0 stable ISO to fix kernel module mismatch; gitignore ISOs 2026-05-24 03:21:37 +00:00
jgitta dcdbedc26e feat: auto-increment version on deploy, show version in toolbar 2026-05-24 03:14:39 +00:00
jgitta 508328b282 fix: add isohybrid --uefi post-process to make ISO USB-bootable 2026-05-24 02:25:50 +00:00
jgitta b3bc19aa44 fix: push via internal Gitea URL to bypass Caddy 502 on git-receive-pack 2026-05-23 15:05:08 +00:00
jgitta fa329c7e3c fix: correct xorriso boot params for Trixie netinstall ISO structure 2026-05-23 14:35:24 +00:00
jgitta cb5bf7ff94 feat: generic user, dynamic paths, simplified ISO build pipeline 2026-05-23 14:26:25 +00:00
jgitta 312db58775 refactor: remove dead code, fix home screen config bug 2026-05-23 13:57:35 +00:00
jgitta e9207b4315 fix: configure deploy.sh to push with Gitea token authentication 2026-05-23 13:04:25 +00:00
jgitta b7866000b4 feat: add custom Debian ISO build for automated silent installation 2026-05-23 12:37:31 +00:00
jgitta 38f7ae467a refactor: use config.json with MVC architecture 2026-05-23 02:52:34 +00:00
jgitta c8534c967c Document Gitea layout from homelab-configs (Caddy, port 2222 SSH).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:51:54 +00:00
jgitta 8553705e47 Fix bookmarks menu dismiss, home Email URL, and deploy hygiene.
Close the hamburger panel on outside click/focus via X11 (xinput/xdotool)
without a fullscreen overlay; point home Email to Roundcube; sync
home-screen-index.html in deploy/install; remove credentials.md from git
and document private LAN-only Gitea usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:39:07 +00:00
20 changed files with 1742 additions and 394 deletions
-113
View File
@@ -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
View File
@@ -2,3 +2,5 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.env .env
credentials.md
iso-output/*.iso
+3
View File
@@ -0,0 +1,3 @@
{
"claudeCodeChat.permissions.yoloMode": true
}
+22 -5
View File
@@ -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
+99
View File
@@ -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)
+1 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
0.2.5
+451 -218
View File
@@ -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",
"red": "#c62828",
"red_hover": "#d32f2f",
"amber": "#7a5a10",
"amber_hover": "#9a7a20",
"purple": "#5a5a8a",
"purple_hover": "#7a7aaa",
"bg_dark": "#1e2a3a",
"bg_input": "#2a3f55",
"border": "#3a6da4",
"text_secondary": "#aac8e8",
},
"font_size": 18
}
MENU_STYLE = """ STYLES = {
QMenu { "button": """
background: #1e3a5c; color: white; QPushButton {{
border: 2px solid #3a6da4; border-radius: 8px; padding: 4px; background: {color}; color: white; border: none;
} border-radius: 10px; padding: 6px 14px;
QMenu::item { padding: 14px 36px 14px 16px; border-radius: 6px; font-size: 17px; } }}
QMenu::item:selected { background: #3a6da4; color: white; } QPushButton:hover {{ background: {hover}; }}
QMenu::separator { height: 1px; background: #3a6da4; margin: 4px 8px; } 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)
@@ -451,7 +536,7 @@ class BookmarkPanel(QWidget):
outer.setSpacing(0) outer.setSpacing(0)
outer.addWidget(scroll) outer.addWidget(scroll)
# Size: cap height at 80 % of screen below toolbar # Size: cap height at 80% of screen below toolbar
scroll_content.adjustSize() scroll_content.adjustSize()
ph = scroll_content.sizeHint().height() + 16 ph = scroll_content.sizeHint().height() + 16
pw = max(scroll_content.sizeHint().width() + 16, 340) pw = max(scroll_content.sizeHint().width() + 16, 340)
@@ -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}&current=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("&nbsp;", " ").replace("&amp;", "&").replace("&quot;", '"').replace("&apos;", "'").replace("&#39;", "'").replace("&ndash;", "-").replace("&mdash;", "-")
# 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
View File
@@ -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
View File
@@ -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"
}
}
-11
View File
@@ -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`
+10
View File
@@ -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.
+74 -3
View File
@@ -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..."
/usr/local/bin/kiosk-restart if [ -f "/usr/local/bin/kiosk-restart" ]; then
/usr/local/bin/kiosk-restart
else
ssh kiosk-dev "/usr/local/bin/kiosk-restart"
fi
echo "==> Trying production kiosk..." 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
View File
@@ -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>
+145
View File
@@ -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
View File
@@ -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
+336
View File
@@ -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>
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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>