Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef968fd10 | ||
|
|
9fb512cdab | ||
|
|
f0a9728016 | ||
|
|
6fdc5f4dd1 | ||
|
|
0af833a39d | ||
|
|
442b45c2e0 | ||
|
|
6f877d1107 | ||
|
|
3d02d28a8c | ||
|
|
7f230c8e73 | ||
|
|
b784d98c9d | ||
|
|
d01919e2ac | ||
|
|
10e991e158 | ||
|
|
770a6a1dfb | ||
|
|
522a33b127 | ||
|
|
5fc7450af7 | ||
|
|
023a64b62c | ||
|
|
49c35f765b | ||
|
|
76866e8bbc | ||
|
|
aa7950aa9e | ||
|
|
e08133dfc0 | ||
|
|
24c95c91f2 | ||
|
|
2ef8f68b9b | ||
|
|
f4da2608cd | ||
|
|
f0a0c4ad40 | ||
|
|
b4c844b124 | ||
|
|
a15478b382 | ||
|
|
7244cfd918 | ||
|
|
5b79608695 | ||
|
|
e828805b9c | ||
|
|
7cb4a661f9 | ||
|
|
eb7bc14a9c | ||
|
|
ead4500406 | ||
|
|
4085866134 | ||
|
|
763162fc57 | ||
|
|
d30f7c7260 | ||
|
|
d673d04259 | ||
|
|
6cc5296cd6 | ||
|
|
5a4e8a8734 | ||
|
|
3e665688c5 | ||
|
|
7f8947ade2 | ||
|
|
d0d39e48bb | ||
|
|
9221fc85aa | ||
|
|
b88d648e14 | ||
|
|
46903b93a4 | ||
|
|
c196a5f7e7 | ||
|
|
f91dbe4d5f | ||
|
|
5e7ffb2eb4 | ||
|
|
903a1e45e4 | ||
|
|
1987b0ee71 | ||
|
|
ff962a41c4 | ||
|
|
d6769d5207 | ||
|
|
f215c64085 | ||
|
|
36f09b2253 | ||
|
|
600f5174d8 | ||
|
|
d4f9233655 | ||
|
|
a66b1e6079 | ||
|
|
5cd2117a94 | ||
|
|
f7901a3684 | ||
|
|
b0c0008d44 | ||
|
|
50cf0bef1d | ||
|
|
1ba9e0a8aa | ||
|
|
c15ff2f098 | ||
|
|
b479b68219 | ||
|
|
b660ba89db | ||
|
|
fcbb890dbf | ||
|
|
fb4ee6fa62 | ||
|
|
4b85ae59fc | ||
|
|
c54ae352a1 | ||
|
|
ab76325c9b | ||
|
|
d4b75ca1b9 | ||
|
|
5792e201b6 | ||
|
|
0b5d958fcc | ||
|
|
96adf6a5e2 | ||
|
|
a170dc6228 | ||
|
|
125622cd74 | ||
|
|
7f171cf52b | ||
|
|
0dfc2dd3ef | ||
|
|
aee6b779eb | ||
|
|
08b33ef823 | ||
|
|
d6d6177dd9 | ||
|
|
ff67e422a4 | ||
|
|
e607e8cea0 | ||
|
|
b6ed4aad9c | ||
|
|
1fad3393ac | ||
|
|
d0b76dc6cf | ||
|
|
d411b05cae | ||
|
|
037a62e008 | ||
|
|
1c9447bc5d | ||
|
|
5d56c667da | ||
|
|
b0b558f015 | ||
|
|
a3313aa9b8 | ||
|
|
3eadcdb584 |
@@ -0,0 +1,133 @@
|
||||
# Builds the all-in-one Ubuntu Server 26.04 + kiosk install ISO
|
||||
# (iso/build-iso.sh) on demand: Actions tab -> "Build kiosk ISO" ->
|
||||
# Run workflow. The ISO (~2.8 GB) is attached to the run as a downloadable
|
||||
# artifact for a few days - see the run's Summary page.
|
||||
#
|
||||
# Pre-setting the admin account (needed for "fully-automatic") uses the
|
||||
# repository secret KIOSK_ADMIN_PASSWORD (Settings -> Secrets and
|
||||
# variables -> Actions) - never a workflow input, since inputs are shown
|
||||
# in the run log. Only a SHA-512 hash of it goes on the ISO, but anyone
|
||||
# who downloads the ISO can read that hash, so treat the artifact as
|
||||
# private and use a password you'll change after install.
|
||||
name: Build kiosk ISO
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: >-
|
||||
interactive: installer asks network/disk/user, kiosk install.sh
|
||||
runs with prompts on first boot. unattended-kiosk: same installer,
|
||||
kiosk installs with defaults and reboots into the kiosk.
|
||||
fully-automatic: no questions at all - WIPES THE LARGEST DISK
|
||||
(needs username + KIOSK_ADMIN_PASSWORD secret).
|
||||
type: choice
|
||||
default: interactive
|
||||
options:
|
||||
- interactive
|
||||
- unattended-kiosk
|
||||
- fully-automatic
|
||||
username:
|
||||
description: Admin username to pre-set (blank = asked during install). Needs the KIOSK_ADMIN_PASSWORD secret. Not "kiosk"/"admin".
|
||||
type: string
|
||||
default: ""
|
||||
hostname:
|
||||
description: Hostname (only used when username is set)
|
||||
type: string
|
||||
default: kiosk
|
||||
ssh_public_key:
|
||||
description: Optional SSH public key for the admin user (ssh-ed25519 AAAA...)
|
||||
type: string
|
||||
default: ""
|
||||
offline:
|
||||
description: >-
|
||||
Bundle everything install.sh downloads (~520 MB: all packages incl.
|
||||
the CUPS, LMS/Squeezelite, Intercom, VNC, WireGuard and hotspot
|
||||
addons, Node.js, Electron) so the kiosk and those addons install
|
||||
with no internet.
|
||||
type: boolean
|
||||
default: true
|
||||
retention_days:
|
||||
description: Days to keep the ISO artifact
|
||||
type: number
|
||||
default: 3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y -q xorriso apt-utils ubuntu-keyring
|
||||
|
||||
- name: Build ISO
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
OFFLINE: ${{ inputs.offline }}
|
||||
ADMIN_USERNAME: ${{ inputs.username }}
|
||||
ADMIN_HOSTNAME: ${{ inputs.hostname }}
|
||||
SSH_PUBLIC_KEY: ${{ inputs.ssh_public_key }}
|
||||
KIOSK_ADMIN_PASSWORD: ${{ secrets.KIOSK_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/iso"
|
||||
args=(--output "$RUNNER_TEMP/iso/ubuntu-kiosk-amd64.iso")
|
||||
case "$MODE" in
|
||||
unattended-kiosk) args+=(--unattended) ;;
|
||||
fully-automatic) args+=(--fully-automatic) ;;
|
||||
esac
|
||||
if [[ -n "$ADMIN_USERNAME" ]]; then
|
||||
if [[ -z "$KIOSK_ADMIN_PASSWORD" ]]; then
|
||||
echo "::error::username given but the KIOSK_ADMIN_PASSWORD repository secret isn't set"
|
||||
exit 1
|
||||
fi
|
||||
args+=(--username "$ADMIN_USERNAME" --hostname "$ADMIN_HOSTNAME")
|
||||
fi
|
||||
if [[ -n "$SSH_PUBLIC_KEY" ]]; then
|
||||
printf '%s\n' "$SSH_PUBLIC_KEY" > "$RUNNER_TEMP/ssh_key.pub"
|
||||
args+=(--ssh-key "$RUNNER_TEMP/ssh_key.pub")
|
||||
fi
|
||||
[[ "$OFFLINE" == "true" ]] && args+=(--offline)
|
||||
iso/build-iso.sh "${args[@]}"
|
||||
# Name the file after the Ubuntu point release the script picked.
|
||||
src="$(find iso/build/cache -name "*.iso" -print -quit)"
|
||||
kind="kiosk"; [[ "$OFFLINE" == "true" ]] && kind="kiosk-offline"
|
||||
name="$(basename "$src" .iso | sed "s/-live-server/-$kind/")-${MODE}-${GITHUB_SHA::7}.iso"
|
||||
mv "$RUNNER_TEMP/iso/ubuntu-kiosk-amd64.iso" "$RUNNER_TEMP/iso/$name"
|
||||
# Free the ~3 GB source ISO before the upload step needs scratch space.
|
||||
rm -rf iso/build/cache iso/build/offline-cache
|
||||
(cd "$RUNNER_TEMP/iso" && sha256sum "$name" > "$name.sha256")
|
||||
echo "ISO_NAME=$name" >> "$GITHUB_ENV"
|
||||
{
|
||||
echo "### $name"
|
||||
echo
|
||||
echo "- Mode: \`$MODE\`"
|
||||
echo "- Offline bundle: $([[ "$OFFLINE" == "true" ]] && echo "yes - installs with no internet" || echo "no - kiosk needs internet on first boot")"
|
||||
echo "- Admin user: ${ADMIN_USERNAME:-chosen during install}"
|
||||
echo "- Built from: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA::7}\`"
|
||||
echo "- SHA-256: \`$(cut -d' ' -f1 "$RUNNER_TEMP/iso/$name.sha256")\`"
|
||||
echo
|
||||
echo "Download it from **Artifacts** below (GitHub zips it; unzip, then write the .iso to a USB stick)."
|
||||
if [[ "$MODE" == "fully-automatic" ]]; then
|
||||
echo
|
||||
echo "> **Warning:** this ISO wipes the target machine's largest disk without asking."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload ISO
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.ISO_NAME }}
|
||||
path: |
|
||||
${{ runner.temp }}/iso/${{ env.ISO_NAME }}
|
||||
${{ runner.temp }}/iso/${{ env.ISO_NAME }}.sha256
|
||||
# An ISO is already compressed - zipping it again only costs time.
|
||||
compression-level: 0
|
||||
retention-days: ${{ inputs.retention_days }}
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
webui/test/fixtures/.fake-state/
|
||||
iso/build/
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ubuntu Based Kiosk
|
||||
|
||||
**Current Version:** 2.9.0 (check script header for latest version)
|
||||
**Current Version:** 2.17.0 (check script header for latest version)
|
||||
**Built with Claude Sonnet 4.6 AI assistance**
|
||||
**License:** GPL v3 - Keep derivatives open source
|
||||
**Repository:** https://github.com/outis1one/ubuntu-based-kiosk/
|
||||
@@ -54,13 +54,137 @@ chmod +x ubuntu-based-kiosk.sh && ./ubuntu-based-kiosk.sh
|
||||
|
||||
The installer will guide you through configuration during setup.
|
||||
|
||||
> The modular `./install.sh` (see "Modular Management" below) can also
|
||||
> provision a kiosk from scratch now, and has its own Upgrade, as an
|
||||
> alternative to the single-file installer above. `ubuntu-based-kiosk.sh`
|
||||
> remains the more battle-tested path.
|
||||
|
||||
---
|
||||
|
||||
## All-in-One Install ISO (Ubuntu Server 26.04 + Kiosk)
|
||||
|
||||
Instead of installing Ubuntu Server and then running the script, build a
|
||||
single ISO that does both: it installs Ubuntu Server 26.04, and on the first
|
||||
boot of the installed system it runs `./install.sh` automatically on the
|
||||
console.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/outis1one/ubuntu-based-kiosk.git
|
||||
cd ubuntu-based-kiosk
|
||||
iso/build-iso.sh # downloads + verifies the latest 26.04.x server ISO
|
||||
# -> iso/build/ubuntu-26.04.x-kiosk-amd64.iso
|
||||
sudo dd if=iso/build/ubuntu-26.04.*-kiosk-amd64.iso of=/dev/sdX bs=4M status=progress oflag=sync
|
||||
```
|
||||
|
||||
The script checks for its build tools (`xorriso`, `curl`, `git`, `openssl`)
|
||||
and offers to install any that are missing with `sudo apt install`. Pass
|
||||
`--install-deps` to install them without asking.
|
||||
|
||||
By default the installer only asks for **network** (WiFi works here),
|
||||
**disk**, and your **admin username/password** — language, keyboard and the
|
||||
SSH server are answered for you. On first boot, `install.sh` runs
|
||||
interactively on the screen, exactly like a manual install. The repo ends up
|
||||
at `~/ubuntu-based-kiosk` for the admin user, so `./install.sh` and
|
||||
Advanced → Upgrade work afterwards as usual.
|
||||
|
||||
Useful options (`iso/build-iso.sh --help` for all):
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8090`) or `./install.sh`. |
|
||||
| `--username NAME [--password PASS]` | Pre-set the admin account (password is prompted for if omitted; only a hash goes on the ISO). Not `kiosk`. |
|
||||
| `--hostname`, `--ssh-key FILE`, `--locale`, `--keyboard` | Pre-set those. |
|
||||
| `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
|
||||
| `--no-confirm` | Skip the "Continue with autoinstall?" prompt at boot. |
|
||||
| `--fully-automatic` | All of the above (needs `--username`): boot it and walk away. **Wipes a disk with no questions — label the USB stick.** |
|
||||
| `--offline` | Bundle everything the kiosk install downloads, so it works with **no internet** (see below). |
|
||||
| `--iso PATH` | Use an ISO you already have instead of downloading. |
|
||||
|
||||
### Offline installs (`--offline`)
|
||||
|
||||
```bash
|
||||
iso/build-iso.sh --offline # also offers to install apt-utils, python3, gnupg, ubuntu-keyring
|
||||
# -> iso/build/ubuntu-26.04.x-kiosk-offline-amd64.iso (~3.2 GB)
|
||||
```
|
||||
|
||||
This adds a ~520 MB bundle to the ISO (~3.3 GB total):
|
||||
- Every Ubuntu package the kiosk install needs, plus the addons that make
|
||||
sense on a network without internet: **CUPS printing** (all printer
|
||||
drivers), **LMS (Lyrion) music server** and **Squeezelite** player,
|
||||
**Asterisk Intercom**, **VNC**, **WireGuard** and the **Emergency
|
||||
Hotspot** - with their dependencies, as a local package repository.
|
||||
- Node.js 22 (NodeSource).
|
||||
- Prebuilt Electron and Web UI modules.
|
||||
|
||||
On first boot it still installs online when there's internet. With none,
|
||||
it waits 90 seconds and then installs entirely from the bundle. The bundle
|
||||
stays on the kiosk at `/opt/kiosk-offline`, so those addons install from
|
||||
the normal menus (`./install.sh` or the Web UI) with no internet too: when
|
||||
an install can't reach the Ubuntu archive, it uses the bundle by itself and
|
||||
then switches apt back to normal.
|
||||
|
||||
Notes:
|
||||
- Build it on an **amd64** Ubuntu/Debian machine, or let the GitHub
|
||||
workflow do it (its `offline` box is ticked by default). Rebuilds are
|
||||
quick because downloads are cached in `iso/build/offline-cache`.
|
||||
- Bundled versions are whatever was current when the ISO was built.
|
||||
Once the kiosk is online, normal updates and Advanced → Upgrade bring it
|
||||
current.
|
||||
- Tailscale and Netbird aren't bundled: they install from their vendors'
|
||||
scripts and only work with their vendors' servers on the internet.
|
||||
- On a network with no internet, set **Core Settings → Time Server (NTP)**
|
||||
to a time server on your network (most routers can serve time), or set
|
||||
the clock by hand there - otherwise the clock slowly drifts, and lockout
|
||||
times, quiet hours and power schedules drift with it.
|
||||
- `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it.
|
||||
|
||||
**Booting it from Easy2Boot (or another multiboot USB)?** Some of their boot
|
||||
modes need the ISO stored in one unbroken piece on the drive, and a 3.5 GB
|
||||
file copied onto a well-used drive usually isn't. The symptom is a brief
|
||||
black screen and straight back to the menu. From Linux, with the drive
|
||||
plugged in:
|
||||
|
||||
```bash
|
||||
sudo iso/e2b-contig.sh --check /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
|
||||
sudo iso/e2b-contig.sh /media/$USER/E2B/_ISO/LINUX/ubuntu-26.04.1-kiosk-offline-amd64.iso
|
||||
```
|
||||
|
||||
It rewrites just that file in one piece using ordinary file operations
|
||||
(never the filesystem's own structures), and keeps the original if anything
|
||||
doesn't verify. It temporarily fills the drive's free space to do it, so
|
||||
it takes a few minutes. `--move-out` lets it park the original on this
|
||||
computer when there isn't room for a second copy. NTFS, FAT32 and exFAT;
|
||||
on NTFS mounted through `ntfs-3g` it can usually read the layout, and if
|
||||
not it tells you to remount with the kernel's `ntfs3` driver. Also check
|
||||
the copy isn't simply damaged: `sha256sum` it on the build machine and on
|
||||
the drive.
|
||||
|
||||
**No Linux machine handy?** Build it on GitHub instead: **Actions → Build
|
||||
kiosk ISO → Run workflow**, pick a mode, and download the ISO from the run's
|
||||
**Artifacts** once it finishes (about 10 minutes; kept for 3 days by default).
|
||||
To pre-set the admin account (required for `fully-automatic`), first add a
|
||||
repository secret named `KIOSK_ADMIN_PASSWORD` (Settings → Secrets and
|
||||
variables → Actions). Passwords are never typed into the workflow form,
|
||||
because its inputs show up in the run log.
|
||||
|
||||
Notes:
|
||||
- The ISO carries a copy of this checkout's current commit (uncommitted
|
||||
changes are not included).
|
||||
- Without `--offline`, the kiosk machine needs internet on first boot
|
||||
(packages, Node.js, Electron). If there's none, the console waits for it
|
||||
and offers a shell to fix networking (`/etc/netplan/*.yaml`, then
|
||||
`sudo netplan apply`).
|
||||
- A failed or interrupted first-boot install runs again on the next boot, or
|
||||
now with `sudo systemctl start kiosk-firstboot`. Its log is
|
||||
`/var/lib/kiosk-firstboot/install.log`.
|
||||
|
||||
---
|
||||
|
||||
## Offline / Air-Gapped Download
|
||||
|
||||
If the kiosk machine can't reach GitHub directly (no browser, restrictive proxy, or you just prefer to grab the script on another computer and carry it over via USB), download it ahead of time instead of using the `curl`/`wget` one-liner above.
|
||||
|
||||
> **Note:** This only avoids needing internet access *to fetch the script*. The installer itself still requires the kiosk machine to have internet access while it runs — it uses `apt` to install packages, pulls Node.js from NodeSource, and runs `npm install` to fetch Electron (~120MB). There is currently no fully air-gapped/offline package bundle.
|
||||
> **Note:** This only avoids needing internet access *to fetch the script*. The installer itself still requires the kiosk machine to have internet access while it runs — it uses `apt` to install packages, pulls Node.js from NodeSource, and runs `npm install` to fetch Electron (~120MB). For a truly offline install, use the [All-in-One Install ISO](#all-in-one-install-iso-ubuntu-server-2604--kiosk) built with `--offline` instead.
|
||||
|
||||
**On a machine with internet access:**
|
||||
|
||||
@@ -96,6 +220,28 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
||||
- **Home URL** - Auto-return after inactivity on manual or hidden sites
|
||||
- **Pause functionality** - Temporarily pause rotation (configurable per-site)
|
||||
- **Navigation menu** - Quick access to all sites via key icon (top-left hot corner)
|
||||
- **Local files** - Enter an absolute path (e.g. `/home/kiosk/docs/menu.pdf`)
|
||||
instead of a web address and it's shown from the kiosk's own disk:
|
||||
HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file
|
||||
must be readable by the `kiosk` user.
|
||||
- **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and
|
||||
its images (jpg, png, gif, webp, bmp, svg, avif), Word documents
|
||||
(`.docx`) and PDFs play full-screen in file-name order with a crossfade -
|
||||
images 10 seconds each, documents and PDFs 20. A one-page document or
|
||||
PDF is shown whole; a longer one fills the width and scrolls slowly top
|
||||
to bottom during its time (PDFs: first 30 pages). Files added, removed or changed are picked up within a minute, no
|
||||
restart needed. Optional `slideshow.json` in the folder:
|
||||
`{"interval": 10, "docInterval": 20, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
|
||||
(`docInterval`: seconds per document/PDF; `fit`: `contain` shows the whole
|
||||
image, `cover` fills the screen and crops; `recursive` includes
|
||||
subfolders).
|
||||
- **Word documents** - A `.docx` path (e.g. `/home/kiosk/docs/menu.docx`)
|
||||
is rendered as pages (text, tables, images, lists), scaled to the screen
|
||||
width, via the bundled [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||
library - no LibreOffice needed, works offline. Replacing the file on
|
||||
disk updates the kiosk within a minute. Old `.doc` and LibreOffice
|
||||
`.odt` aren't supported; save those as `.docx` or PDF. Layout is close
|
||||
to Word's but not pixel-exact - for exact layout, export to PDF.
|
||||
|
||||
### Touch Controls
|
||||
- **2-finger horizontal swipe** - Switch between sites
|
||||
@@ -116,7 +262,10 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
||||
- **Session lockout** after configured inactivity
|
||||
- **Scheduled lockout** at specific time daily
|
||||
- **Display wake lockout** - Require password after display schedule
|
||||
- **Boot password** option - Require password on system startup
|
||||
- **Boot password** option - Require password on system startup, with the unlock code or **its own separate code**
|
||||
- **Password or PIN** - each code (unlock, boot) can be a password or a **4-8 digit PIN** entered on an on-screen number pad, so it works on a touch-only kiosk
|
||||
- **The kiosk's own codes** - set in Core Settings → Password Protection & Lockout or the Web UI; not the admin (sudo) password, which never needs to be shared
|
||||
- **Wrong-code slowdown** - after 5 wrong codes in a row the lock screen refuses input for 30 seconds, doubling up to 5 minutes, so a PIN can't be guessed through
|
||||
- **Full screen blocking** during lockout (no content visible)
|
||||
|
||||
### Navigation Security
|
||||
@@ -251,12 +400,20 @@ Both can be used at the same time — they serve different purposes:
|
||||
---
|
||||
|
||||
### Communication
|
||||
- **Easy Asterisk Intercom** - Voice communication and intercom system
|
||||
- Downloads latest version from Easy Asterisk repository
|
||||
- Automatic update detection and installation
|
||||
- Configuration preservation during updates
|
||||
- Full Asterisk PBX integration
|
||||
- SIP/PJSIP support for IP phones and softphones
|
||||
- **Asterisk Intercom** (`./install.sh` → Addons) - connects this kiosk
|
||||
as a Baresip SIP extension to an Asterisk server you already have
|
||||
running elsewhere; does not install or manage Asterisk itself
|
||||
- Manual or auto-answer (intercom) mode
|
||||
- Optional TLS/SRTP transport
|
||||
- Uninstall support (with or without removing saved credentials)
|
||||
- **Legacy Easy Asterisk Intercom** (`./ubuntu-based-kiosk.sh` → Addons,
|
||||
not yet retired) - the original three-option version: Client Only
|
||||
(same Baresip client as above), Server Only, or Full, where Server/
|
||||
Full download and run a third-party installer from a separate
|
||||
"Easy Asterisk" repository to stand up a whole Asterisk PBX on this
|
||||
device. That repository has since gone through a major rework
|
||||
upstream, so the modular `./install.sh` version above only carries
|
||||
the client/endpoint piece forward - see "Modular Management" below.
|
||||
|
||||
### Audio
|
||||
- **Lyrion Music Server (LMS)** - Formerly Logitech Media Server
|
||||
@@ -270,11 +427,36 @@ Both can be used at the same time — they serve different purposes:
|
||||
- **IPP Everywhere support**
|
||||
- **PDF printing** via cups-pdf
|
||||
|
||||
### Library Catalog (Koha)
|
||||
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
|
||||
- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
|
||||
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
|
||||
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
|
||||
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
|
||||
- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything
|
||||
- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests
|
||||
|
||||
### Remote Access
|
||||
- **VNC** - x11vnc for remote desktop
|
||||
- **WireGuard VPN** - Config paste support
|
||||
- **Tailscale VPN** - Auth key support
|
||||
- **Netbird VPN** - Setup key support
|
||||
- Headless join, no browser login (get a setup key from the Netbird dashboard):
|
||||
```bash
|
||||
sudo netbird up --setup-key YOUR_SETUP_KEY --allow-server-ssh
|
||||
```
|
||||
`--allow-server-ssh` enables Netbird's built-in SSH server (peers also need an SSH policy in the dashboard).
|
||||
|
||||
#### SSH Host aliases
|
||||
|
||||
`~/.ssh/config` lets you `ssh <alias>` instead of typing `ssh user@1.2.3.4` every time — especially handy once machines are reachable over a VPN/Netbird overlay network where the IP is easy to forget:
|
||||
|
||||
```
|
||||
Host myserver
|
||||
HostName 100.x.x.x
|
||||
User someuser
|
||||
Port 22
|
||||
```
|
||||
|
||||
### Advanced
|
||||
- **Emergency WiFi Hotspot** - Auto-starts if no internet after boot (configurable during install)
|
||||
@@ -287,7 +469,7 @@ Both can be used at the same time — they serve different purposes:
|
||||
## What This Script Installs
|
||||
|
||||
### Core Components
|
||||
- **Electron** v42.x (Chromium-based app framework)
|
||||
- **Electron** v44.x (Chromium-based app framework)
|
||||
- **Node.js** v20.x with npm
|
||||
- **Openbox** - Lightweight window manager
|
||||
- **LightDM** - Display manager with autologin
|
||||
@@ -338,6 +520,24 @@ Both can be used at the same time — they serve different purposes:
|
||||
- **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks
|
||||
- **Watchdog support** - Auto-revert bad WiFi configs
|
||||
- **Emergency hotspot** - Fallback if no internet
|
||||
- **WiFi from the kiosk screen** - press **Ctrl+Alt+Super+W** (Super = the Windows key) to join a WiFi network without the admin console, guarded by the kiosk's PIN/password - see below
|
||||
|
||||
### WiFi from the kiosk screen (consoles turned off)
|
||||
|
||||
With virtual consoles switched off, there's no way to reach the admin menu at the kiosk itself. To join a WiFi network anyway, plug in a keyboard and press **Ctrl+Alt+Super+W** (Super = the Windows key). A WiFi screen opens:
|
||||
|
||||
1. Enter the kiosk's code:
|
||||
- **Password protection on** (Core Settings → Password Protection & Lockout): the unlock **PIN**, or the unlock **password** if it's a password. Same slowdown after wrong tries as the lock screen.
|
||||
- **Password protection off:** the hidden-sites PIN (the F10 one - default `1234`).
|
||||
- Neither (protection off and the hidden-sites PIN turned off): no code is asked.
|
||||
2. Pick a network from the scan (strongest first, 🔒 = needs a password), or type its name.
|
||||
3. Type the password (empty for an open network) and press **Connect**.
|
||||
|
||||
It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kiosk-wifi.yaml`), so the connection persists across reboots. If the kiosk gets no address within 30 seconds (usually a wrong password) the previous WiFi setting is put back. Esc or Close shuts the screen; it also closes itself after 5 minutes untouched. After 5 wrong codes in a row, entry pauses (30s, doubling up to 5 min).
|
||||
|
||||
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
|
||||
|
||||
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
|
||||
|
||||
---
|
||||
|
||||
@@ -594,51 +794,55 @@ smb://WORKGROUP/COMPUTER/PrinterName
|
||||
# 4. Restart Kiosk Display
|
||||
```
|
||||
|
||||
### Installing Easy Asterisk Intercom
|
||||
### Installing Asterisk Intercom
|
||||
|
||||
The Easy Asterisk Intercom addon provides voice communication capabilities to your kiosk system.
|
||||
The Asterisk Intercom addon connects this kiosk as a SIP extension to an
|
||||
Asterisk server you already have running elsewhere (your own PBX, a
|
||||
Docker container, another box on the network - anywhere). It installs
|
||||
and configures Baresip as that extension; it does not install or manage
|
||||
Asterisk itself.
|
||||
|
||||
**Access the addon menu:**
|
||||
```bash
|
||||
./ubuntu-based-kiosk.sh
|
||||
# Select: 2) Addons
|
||||
# Then: 4) Easy Asterisk Intercom
|
||||
git clone https://github.com/outis1one/ubuntu-based-kiosk/
|
||||
cd ubuntu-based-kiosk
|
||||
./install.sh
|
||||
# Select: 2) Addons → Asterisk Intercom (SIP Extension)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Automatic installation** - Downloads and installs the latest version from the Easy Asterisk repository
|
||||
- **Update detection** - Checks for newer versions and prompts to update
|
||||
- **Safe re-runs** - Can be run multiple times without breaking existing configurations
|
||||
- **Config preservation** - Automatically backs up and restores configurations during updates
|
||||
- **Full Asterisk PBX** - Complete telephony features including SIP, extensions, voicemail
|
||||
**What you'll be asked for** (must match what's already configured on
|
||||
the Asterisk server): server IP/hostname, SIP port (default 5060, or
|
||||
5061 if you enable TLS), extension number, SIP password, and whether to
|
||||
auto-answer incoming calls (intercom mode) or ring for manual answer.
|
||||
|
||||
**Installation behavior:**
|
||||
- **First install:** Downloads latest version from https://github.com/outis1one/easy-asterisk
|
||||
- **Already installed (latest):** Prompts to re-run installation (preserves configs)
|
||||
- **Update available:** Prompts to update and shows version difference
|
||||
- **All scenarios:** Configuration files in `/etc/asterisk/` and installation settings are preserved
|
||||
|
||||
**Managing Easy Asterisk:**
|
||||
**Managing the client:**
|
||||
```bash
|
||||
# Check installation status
|
||||
systemctl status asterisk
|
||||
# Check status (as the kiosk user)
|
||||
sudo -u kiosk systemctl --user status baresip
|
||||
|
||||
# View Asterisk console
|
||||
asterisk -rvvv
|
||||
# Restart
|
||||
sudo -u kiosk systemctl --user restart baresip
|
||||
|
||||
# Restart Asterisk
|
||||
systemctl restart asterisk
|
||||
# View logs
|
||||
sudo -u kiosk journalctl --user -u baresip -f
|
||||
|
||||
# Configure intercom (rerun installation to update)
|
||||
./ubuntu-based-kiosk.sh
|
||||
# Select: 2) Addons → 4) Easy Asterisk Intercom
|
||||
# Reconfigure or uninstall
|
||||
./install.sh
|
||||
# Select: 2) Addons → Asterisk Intercom (SIP Extension)
|
||||
```
|
||||
|
||||
**Installation location:**
|
||||
- Installation files: `/opt/easy-asterisk/`
|
||||
- Configuration: `/etc/asterisk/`
|
||||
- Version tracking: `/opt/easy-asterisk/.version`
|
||||
- Config backups: `/opt/easy-asterisk/config_backup/`
|
||||
- Baresip config: `~kiosk/.baresip/` (`accounts`, `config`)
|
||||
- systemd user unit: `~kiosk/.config/systemd/user/baresip.service`
|
||||
|
||||
**Not covered here:** standing up the Asterisk PBX server itself. The
|
||||
legacy `ubuntu-based-kiosk.sh` still offers a Server/Full option that
|
||||
downloads and runs a third-party installer from a separate "Easy
|
||||
Asterisk" repository - that repository has since gone through a major
|
||||
rework upstream, so it isn't carried forward into this addon. If you
|
||||
need a PBX, set one up separately (that same legacy option, a
|
||||
FreePBX/Issabel image, a Dockerized Asterisk, etc.) and point this
|
||||
addon at it as a plain SIP extension.
|
||||
|
||||
### Updating Electron
|
||||
|
||||
@@ -729,11 +933,14 @@ The PIN file controls access to hidden sites (duration = -1):
|
||||
- **Default:** `1234`
|
||||
- **Configure via:** Main Menu → Core Settings → Sites → Configure Hidden Sites PIN
|
||||
- **Disable PIN:** Set content to `NOPIN` to allow any entry
|
||||
- **Custom PIN:** 4-8 digits
|
||||
- **Custom PIN:** 4-8 digits, independent of the lock screen's password/PIN (same or different, as you like)
|
||||
- **Stored hashed:** the file holds `sha256:<hash>`, never the digits. A file with plain digits (set by hand, or from before this) still works and is converted to the hashed form when the kiosk app starts.
|
||||
- **Wrong-PIN slowdown:** after 5 wrong PINs in a row, PIN entry is refused for 30 seconds, doubling up to 5 minutes. The hidden-sites PIN box shares this count with the WiFi screen when that uses this PIN.
|
||||
- Also guards the WiFi screen (Ctrl+Alt+Super+W) when password protection is off - otherwise that uses the unlock PIN/password.
|
||||
|
||||
```bash
|
||||
# Set custom PIN
|
||||
echo "5678" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
|
||||
# Set custom PIN (the menu does this for you)
|
||||
echo "sha256:$(echo -n 5678 | sha256sum | cut -d' ' -f1)" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
|
||||
|
||||
# Disable PIN protection
|
||||
echo "NOPIN" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
|
||||
@@ -876,7 +1083,7 @@ When "Are you still here?" prompt appears (on manual or hidden sites):
|
||||
**During Lockout:**
|
||||
- Full black screen (no content visible)
|
||||
- All browser views detached for security
|
||||
- Password prompt displayed
|
||||
- Password field, or number pad for a PIN (the boot screen uses the boot code if one is set)
|
||||
- Limited power menu (no Reload option to prevent bypass)
|
||||
- Rotation and timers paused
|
||||
|
||||
@@ -974,6 +1181,12 @@ Automatically creates a WiFi access point when internet connectivity is lost.
|
||||
- Optional configuration with custom SSID and password
|
||||
- Can be deferred and configured later
|
||||
|
||||
**What counts as "cut off"** (chosen when you set it up; `./install.sh`
|
||||
version): by default, *the local network itself* is unreachable - neither
|
||||
the router nor the internet answers - so a kiosk on a network without
|
||||
internet doesn't start the hotspot on every boot. "No internet" (the old
|
||||
check) and "a specific address doesn't answer" are options too.
|
||||
|
||||
**How It Works:**
|
||||
1. Service monitors internet connectivity every 30 seconds
|
||||
2. When internet is lost, automatically:
|
||||
@@ -1138,7 +1351,13 @@ This project bundles or installs several open-source components under their resp
|
||||
- **Repository:** https://github.com/netbirdio/netbird
|
||||
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
|
||||
|
||||
### Koha (Library Catalog add-on)
|
||||
- **License:** GPL-3.0-or-later
|
||||
- **Source:** https://koha-community.org
|
||||
- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine
|
||||
|
||||
### MariaDB and Docker (Library Catalog add-on)
|
||||
- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com)
|
||||
|
||||
---
|
||||
|
||||
@@ -1221,25 +1440,99 @@ terminal menu and the web UI, so they can't drift apart).
|
||||
start script and systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR`
|
||||
like every other addon; LMS's own apt repo/GPG key/ufw rules stay at
|
||||
their real fixed system paths, same as CUPS.
|
||||
- `menus/addon_asterisk_intercom.sh` — **Asterisk Intercom** (Addons):
|
||||
installs Baresip and registers this kiosk as a SIP extension against
|
||||
an Asterisk server you already have running elsewhere. Redesigned
|
||||
during migration, not a straight port — see "Recent Updates (v2.10.0)"
|
||||
below for why the legacy Server/Full PBX-install options didn't come
|
||||
along.
|
||||
- `menus/advanced_electron.sh` — **Electron Maintenance** (Advanced):
|
||||
manual update (with backup + rollback) and "fix blank screen" binary
|
||||
repair, combined into one submenu since both share the same
|
||||
binary-verification logic.
|
||||
- `menus/advanced_factory_reset.sh` — **Factory Reset** (Advanced):
|
||||
wipes `config.json` back to defaults; addons are untouched.
|
||||
- `menus/advanced_virtual_consoles.sh` — **Virtual Consoles** (Advanced):
|
||||
toggles Ctrl+Alt+F1-F8 terminal login access.
|
||||
- `menus/advanced_emergency_hotspot.sh` — **Emergency Hotspot**
|
||||
(Advanced): auto-starts a WiFi hotspot if no internet is detected 60
|
||||
seconds after boot. Its own runtime script/systemd unit go through
|
||||
`$BIN_DIR`/`$SYSTEMD_DIR` like every other addon.
|
||||
- `menus/complete_uninstall.sh` — **Complete Uninstall** (Core
|
||||
Settings): the last of the "destructive trio." Composed from every
|
||||
addon's own `*_do_uninstall` helper instead of re-implementing
|
||||
removal a second time — see "Recent Updates (v2.12.0)" below.
|
||||
- `menus/clone_settings.sh` — **Clone Settings** (Advanced): export/apply
|
||||
the portable parts of `config.json` across several kiosks that should
|
||||
share the same settings. New, not a legacy port — deliberately never
|
||||
copies machine-bound credentials (Authelia, WireGuard, Asterisk
|
||||
Intercom); see "Recent Updates (v2.13.0)" below.
|
||||
- `lib/electron.sh` — `electron_install_binary()`: verify/download the
|
||||
Electron binary and fix `chrome-sandbox` permissions. Shared between
|
||||
fresh provisioning and `menus/advanced_electron.sh`'s "Fix blank
|
||||
screen" action — the same repair sequence applies whether the binary
|
||||
never downloaded during the initial `npm install` or went missing
|
||||
later.
|
||||
- `lib/provision.sh` — first-time provisioning: packages, kiosk user,
|
||||
Node.js/Electron, LightDM+Openbox autologin, audio/video/HDMI/
|
||||
power-button hardware setup, firewall, then hands off to
|
||||
`core_settings_menu` and other already-migrated Advanced actions for
|
||||
initial configuration, rather than reimplementing that logic a third
|
||||
time. See "Recent Updates (v2.14.0)" below.
|
||||
- `kiosk-app/` — the Electron app source (`main.js`, `preload.js`, the
|
||||
dialog HTML files, `package.json`, `start.sh`), copied to the kiosk
|
||||
directory during provisioning and re-copied during Upgrade.
|
||||
- `provision/files/` — every other system template file provisioning
|
||||
installs (X11 configs, udev rules, systemd units, the power-button
|
||||
and HDMI-mirroring scripts, polkit rules), laid out mirroring their
|
||||
real destination path, e.g. `provision/files/etc/X11/xorg.conf.d/
|
||||
foo.conf` installs to `/etc/X11/xorg.conf.d/foo.conf`.
|
||||
- `menus/advanced_upgrade.sh` — **Upgrade** (Advanced): `git pull` (only
|
||||
as a clean fast-forward) plus re-running the same
|
||||
packages/kiosk-app/display/firewall/power-management provisioning
|
||||
steps, so any code or hardware-config change picked up by the pull
|
||||
actually takes effect. Also offers an on-demand Electron version
|
||||
check. See "Recent Updates (v2.15.0)" below.
|
||||
- `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons, installed by
|
||||
default during provisioning): a small Node/Express app running as a
|
||||
systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving
|
||||
a browser-based editor for Sites & Page Timing, Display & Interaction,
|
||||
and Password Protection & Lockout, plus install/reconfigure for CUPS,
|
||||
LMS/Squeezelite, and Asterisk Intercom, and Update — each reached
|
||||
through a narrow, allow-listed root helper
|
||||
(`webui_write_helper_script`) rather than any ambient privilege on
|
||||
the service itself. No login of its own, put it behind your own
|
||||
reverse proxy with Authelia forward-auth if it needs to be reachable
|
||||
beyond a trusted LAN. See "Recent Updates (v2.17.0)" below.
|
||||
- `install.sh` — entry point for the modular tool, now grouped **Core
|
||||
Settings / Addons / Advanced** like the legacy menu. Run it against an
|
||||
*already-installed* kiosk:
|
||||
Settings / Addons / Advanced** like the legacy menu. On a machine
|
||||
with no kiosk installed yet, it provisions one first (see
|
||||
`lib/provision.sh` above); on an already-installed kiosk, it goes
|
||||
straight to the same menus:
|
||||
```bash
|
||||
git clone https://github.com/outis1one/ubuntu-based-kiosk/
|
||||
cd ubuntu-based-kiosk
|
||||
./install.sh
|
||||
```
|
||||
|
||||
**Honest status:** this does not yet replace first-time installation, or
|
||||
most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000
|
||||
lines and still contains its own unremoved, unmodified copies of every
|
||||
menu above (plus Upgrade, Reinstall, Uninstall, 1 more Addon — Easy
|
||||
Asterisk Intercom — and the other 8 Advanced items — none of that has
|
||||
moved yet). Both copies coexist deliberately: the old ones stay until
|
||||
enough of Core Settings/Addons/Advanced is migrated to retire them in
|
||||
one pass, rather than leaving the legacy menu half-wired. Migration
|
||||
continues one `menus/*.sh` file at a time; first-time installation
|
||||
itself is the last and largest piece to move, if it moves at all.
|
||||
**Honest status:** first-time installation and Upgrade are now covered —
|
||||
`install.sh` provisions a kiosk from a bare Ubuntu Server box, not just
|
||||
an already-installed one, and can pull/apply its own updates — but
|
||||
`ubuntu-based-kiosk.sh` is still ~12,000 lines and still contains its
|
||||
own unremoved, unmodified copies of every menu above, including the
|
||||
legacy three-option (Client/Server/Full) Easy Asterisk Intercom — the
|
||||
modular version only replaces the Client option, by design. Full
|
||||
Reinstall is deliberately not being carried forward — it never worked
|
||||
reliably in the legacy script, and the modular tool already covers the
|
||||
same outcome more reliably as two already-tested pieces run back to
|
||||
back: Complete Uninstall (Core Settings), then `./install.sh` again to
|
||||
provision fresh. The legacy Export/Import Settings is also staying
|
||||
as-is; Clone Settings is a new, narrower feature alongside it, not a
|
||||
replacement for it — see "Recent Updates (v2.13.0)" below for why
|
||||
they're not the same thing.
|
||||
Both copies coexist deliberately: the old ones stay until enough of
|
||||
Core Settings/Addons/Advanced is migrated to retire them in one pass,
|
||||
rather than leaving the legacy menu half-wired.
|
||||
|
||||
**Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts
|
||||
— had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`)
|
||||
@@ -1266,9 +1559,80 @@ full migration pass.
|
||||
|
||||
## Project Status & Future Plans
|
||||
|
||||
**Current Version:** 2.9.0
|
||||
**Current Version:** 2.18.0
|
||||
|
||||
**Recent Updates (v2.9.0):**
|
||||
**Recent Updates (v2.18.0):**
|
||||
- **All-in-one install ISO** (`iso/build-iso.sh`, or Actions → Build kiosk ISO): Ubuntu Server 26.04 plus the kiosk in one USB stick — the Ubuntu install, then `install.sh` automatically on first boot. Interactive, unattended, or fully automatic. See "All-in-One Install ISO" above.
|
||||
- **Offline installs** (`--offline`): the ISO carries every package (including CUPS printing and all printer drivers), Node.js 22, and prebuilt Electron/Web UI modules, so a kiosk installs with no internet. With internet it still installs current versions online. Addons → CUPS Printing (menu or Web UI) installs from the bundle by itself when offline.
|
||||
- **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI.
|
||||
- **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up.
|
||||
- **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing.
|
||||
- **Hidden-sites PIN hashed, with the same slowdown:** the F10 PIN is now stored as a SHA-256 hash (existing PIN files convert automatically) and repeated wrong PINs pause entry (30s, doubling to 5 min), shared with the WiFi screen. The PIN pad no longer shows "Default PIN: 1234" on screen.
|
||||
- **Lock screen PINs and a separate boot code:** the unlock code and the boot code can each be a password or a 4-8 digit PIN (on-screen number pad, works on touch screens), and the boot lock can have its own code. Repeated wrong codes now pause the lock screen (30s, doubling to 5 min). Set in Core Settings → Password Protection & Lockout or the Web UI's Lockout page. Existing settings keep working unchanged (password, same code at boot).
|
||||
- **New: WiFi from the kiosk screen** - press Ctrl+Alt+Super+W to scan for and join a WiFi network right on the kiosk, guarded by the unlock PIN/password (or the hidden-sites PIN when password protection is off), for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen".
|
||||
- **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot.
|
||||
- **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet.
|
||||
- **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure.
|
||||
- **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet.
|
||||
- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes. Slideshow folders can mix `.docx` documents and PDFs (rendered with pdf.js) in with images.
|
||||
- **Electron upgrade:** v42.x → v44.x (42 leaves Electron's support window when 45 ships). Existing kiosks move to it via Advanced → Upgrade.
|
||||
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
||||
|
||||
**Previous (v2.17.0):**
|
||||
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
||||
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
|
||||
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
|
||||
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
|
||||
- **Full visual redesign:** a sidebar shell (Sites/Display/Lockout/Addons/Update) replacing the single scrolling page of three cards, both light and dark themes via `prefers-color-scheme`, no external font/CDN dependency.
|
||||
- A real bug was found and fixed by actually driving the redesigned UI in a headless browser, not just by reading the code: refreshing an addon's pill/button after a successful install used to rebuild the whole card, which raced (and usually lost to) the success status/log that same job had just written a moment earlier. Fixed to update the pill/buttons in place, leaving the completed job's log exactly as the user left it.
|
||||
- Uninstall-via-web is deliberately still not offered, for any addon — flagged as needing its own double-confirmation design, not bundled into this pass. WiFi, Timezone, Power/Display/Quiet Hours, Diagnostics, Remote Access, Authelia, Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings, and the fleet/multi-kiosk dashboard all remain out of scope for the web UI too — each a named, sequenced follow-up, not an oversight.
|
||||
|
||||
**Previous (v2.16.0):**
|
||||
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
||||
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
||||
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
|
||||
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
|
||||
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
|
||||
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
|
||||
|
||||
**Previous (v2.15.0):**
|
||||
- **New: Upgrade** (Advanced → Upgrade) — not a port of the legacy Upgrade, which re-extracted `main.js`/`preload.js`/etc from its own heredocs on every run. `kiosk-app/` and `provision/files/` are real files in this git checkout now, so the modular Upgrade is `git pull` (after confirming a clean working tree, and only as a fast-forward — never an automatic merge) followed by re-running the same packages/kiosk-app/display/firewall/power-management steps `lib/provision.sh` already has for a fresh install, reused rather than reimplemented. Skips the interactive first-run settings wizard and the "reboot now" prompt.
|
||||
- Also offers an on-demand Electron version check regardless of whether there was code to pull (Electron isn't versioned by this repo) — reuses the existing, already-tested `action_update_electron` as-is.
|
||||
- Requires a real git checkout (not the no-git ZIP download option) and a clean working tree; a diverged local history fails the pull cleanly with a clear message rather than attempting an automatic merge.
|
||||
- **Full Reinstall dropped, not carried forward.** It never worked reliably in the legacy script, and the modular tool already covers the same outcome more reliably as two already-tested pieces run back to back: Complete Uninstall (Core Settings), then `./install.sh` again to provision fresh — no need for a dedicated combined action.
|
||||
|
||||
**Previous (v2.14.0):**
|
||||
- **`./install.sh` now provisions a kiosk from scratch, not just manages an existing one.** Until now it only worked against an already-installed kiosk — `ubuntu-based-kiosk.sh` was still the only path from a bare Ubuntu Server box to a running one. On a machine with no kiosk-app directory yet, it now installs packages, creates the kiosk user, installs Node.js/Electron, sets up LightDM+Openbox autologin, audio/video/HDMI/power-button hardware handling, and the firewall, then hands off to the same Core Settings menus for initial configuration — matching the legacy script's own install-then-configure flow, on the modular codebase.
|
||||
- **New: `lib/provision.sh`**, the provisioning steps — built almost entirely by calling menus already migrated below (`core_settings_menu`, emergency hotspot, virtual consoles) instead of reimplementing that configuration logic a third time. Reuse cut it down to roughly 300 lines against the legacy script's ~4,000-line `first_time_install()`.
|
||||
- **New: `lib/electron.sh`** — `electron_install_binary()`, extracted out of `menus/advanced_electron.sh` so fresh provisioning and the existing "Fix blank screen" action share one implementation instead of two copies of the same repair sequence.
|
||||
- **New: `kiosk-app/`** (the Electron app source — `main.js`, `preload.js`, the dialog HTML files, `package.json`, `start.sh`) and **`provision/files/`** (every other system template file — X11 configs, udev rules, systemd units, the power-button and HDMI-mirroring scripts, polkit rules), extracted byte-for-byte out of `ubuntu-based-kiosk.sh`'s heredocs into real files, laid out mirroring their real destination paths.
|
||||
- **Bug found and fixed while writing this:** a bash `set -e` gotcha where testing a multi-statement function as an if-condition (`if ! some_func; then`) silently exempts everything inside that function from `set -e` for the duration of the call — found via direct testing, then swept for elsewhere in the codebase and also fixed in `menus/advanced_electron.sh`'s pre-existing "Fix blank screen" action, which had the same shape.
|
||||
- **Known, deliberate limitation carried over unchanged:** a few of the extracted system scripts (`start.sh`, `kiosk-hotplug.sh`, the power-button handler) hardcode the username `kiosk` rather than substituting `$KIOSK_USER`, exactly as the legacy script's quoted heredocs always did. Only matters if `$KIOSK_USER` is overridden from its default, which in practice is rare.
|
||||
- Upgrade and Full Reinstall are still not ported — both are coupled to `ubuntu-based-kiosk.sh`'s own heredoc self-extraction, a different mechanism than the new provisioning (which copies real files, not heredocs). `ubuntu-based-kiosk.sh` remains the way to upgrade/reinstall an existing install for now.
|
||||
|
||||
**Previous (v2.13.0):**
|
||||
- **New: Clone Settings** (Advanced → Clone Settings) — not a port of the legacy Export/Import Settings, a narrower MVP for the "set up one kiosk, then stamp out a dozen more like it" use case. Exports the portable parts of `config.json` (sites, display/touch/navigation, lockout, password protection) to a JSON file; applies that file to any other already-installed kiosk.
|
||||
- **Deliberately does not copy machine-bound credentials**, because copying them would be actively wrong: Authelia's encrypted password is keyed off `/etc/machine-id` and decrypts to garbage on another machine; a WireGuard private key is a device identity, and reusing one across machines is a peer conflict, not a saving; most Asterisk PBXes reject two simultaneous registrations to the same extension. Applying a profile prints these as an explicit "needs a human" checklist instead of silently skipping or cloning them.
|
||||
- Records which addons were present at export time and reports which are/aren't present on the target — doesn't install anything itself. Non-interactive addon installation (so applying a profile needs zero prompts — scriptable over SSH to a whole fleet) is a deliberate follow-up, not bundled into this MVP.
|
||||
|
||||
**Previous (v2.12.0):**
|
||||
- **Complete Uninstall migrated** — the last of the "destructive trio." Rather than re-implementing every addon's teardown a second time (the legacy shape — CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite removal all inlined again, independently of each addon's own uninstall action), `menus/complete_uninstall.sh` composes the `*_do_uninstall` helpers each addon already has. Every addon menu with an uninstall action was split into a confirm-and-call wrapper (unchanged from the user's perspective) plus a silent removal helper that both the wrapper and Complete Uninstall call — no duplicated logic anywhere, and if an addon's removal logic changes later, Complete Uninstall picks it up automatically.
|
||||
- **Important bug found and fixed while composing these:** several `*_do_uninstall` helpers (CUPS's `apt autoremove`/`apt clean`, VNC/WireGuard/Tailscale/Netbird's `apt remove`) had a bare, unguarded `apt` call. Previously this only risked aborting that one menu action if the package was already gone. Composed together as sequential calls inside Complete Uninstall, the same failure would have silently truncated the *entire* uninstall partway through — e.g. the kiosk user might never get removed because an already-uninstalled VPN client's `apt remove` failed first. Guarded all of them with `|| true`.
|
||||
- Non-addon teardown (kiosk user/files, Node.js, LightDM/Openbox, remaining systemd units/scripts, polkit rules, re-enabling virtual consoles, final package cleanup) stays inline in `menus/complete_uninstall.sh`, since no single addon owns those paths — same as the legacy script.
|
||||
- Upgrade and Full Reinstall remain in `ubuntu-based-kiosk.sh` only — both are coupled to its own heredoc self-extraction of main.js/preload.js/etc, which has no modular equivalent yet.
|
||||
|
||||
**Previous (v2.11.0):**
|
||||
- **4 more Advanced items migrated**, alongside Diagnostics: **Electron Maintenance** (`menus/advanced_electron.sh` — the legacy "Manual Electron Update" and "Fix Blank Screen" combined into one submenu, since both maintain the same installation and share the binary-repair logic), **Factory Reset** (`menus/advanced_factory_reset.sh` — wipes `config.json` only, addons untouched), **Virtual Consoles** (`menus/advanced_virtual_consoles.sh` — toggles Ctrl+Alt+F1-F8 terminal login), and **Emergency Hotspot** (`menus/advanced_emergency_hotspot.sh` — auto-starts a WiFi hotspot if no internet is detected 60 seconds after boot; its own runtime script and systemd unit now go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon's own files).
|
||||
- That's 8 of the legacy Advanced menu's 12 entries now covered. Not migrated this round: Export/Import Settings (pending a decision on whether to rebuild it around actual paths instead of a hardcoded per-addon step list, or whether the future web UI replaces the need for it) and Fix Squeezelite Audio (small enough that it may fold into the LMS addon instead of staying standalone — not decided yet).
|
||||
- Complete Uninstall (the last of the "destructive trio") is next, composed from each addon's own uninstall action plus core teardown rather than rewriting removal logic a second time. Upgrade and Full Reinstall stay in the legacy script for now — both are coupled to its own heredoc self-extraction of main.js/preload.js/etc, which has no modular equivalent yet.
|
||||
|
||||
**Previous (v2.10.0):**
|
||||
- **Asterisk Intercom migrated, and redesigned in the process.** The legacy addon offered Client Only (Baresip SIP client), Server Only, and Full (server + client) — the latter two downloaded and ran a third-party installer from a separate "Easy Asterisk" repository to stand up a whole Asterisk PBX. That repository has since gone through a major rework upstream, so the PBX-install path is dropped entirely rather than carrying a dependency on code that's moved on without it. The migrated addon (`menus/addon_asterisk_intercom.sh`) now does only the client/endpoint piece: install Baresip and register this kiosk as one SIP extension against an Asterisk server you already have running elsewhere. It never installs or manages Asterisk itself. The legacy script's own three-option version is untouched, same as every other migrated menu.
|
||||
- Dropped the dependency on the (now-reworked) Easy Asterisk repo's GitHub API for version tracking — reads the real installed `baresip` package version via `dpkg` instead.
|
||||
- **New capability:** an uninstall option for the Baresip client — the legacy addon never had one.
|
||||
- **Bug fix:** an unguarded `ver=$(baresip_installed_version)` assignment would have crashed the whole session the first time status was checked before Baresip was installed (`dpkg-query` legitimately fails when the package isn't there). Guarded with `|| true` before it shipped.
|
||||
|
||||
**Previous (v2.9.0):**
|
||||
- **LMS Server / Squeezelite Player migrated** — install/reconfigure/uninstall for both, in `./install.sh`. Squeezelite's own start script and systemd unit now go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon instead of hardcoded `/usr/local/bin`/`/etc/systemd/system`; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same approach as CUPS.
|
||||
- **Bug fix:** the legacy `install_lms()` enabled/started the detected service via `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` — piped through `tee`, the statement's exit status reflected `tee` (always 0), not `systemctl enable`, so a real enable/start failure was silently swallowed instead of falling through to a warning. Now uses the shared `enable_and_start_units()` helper.
|
||||
- **Bug fix (shared, backported to the legacy script too):** `is_service_enabled()`'s pre-check never matched a bare service name against `list-unit-files`' `"$service.service"` lines, so it always reported "not enabled" regardless of the real state. Dropped the dead pre-check — see "Modular Management" below.
|
||||
|
||||
+118
-30
@@ -1,29 +1,61 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# install.sh - Modular management entry point for Ubuntu Based Kiosk.
|
||||
# install.sh - Ubuntu Based Kiosk: install and manage, one entry point.
|
||||
#
|
||||
# This is NOT yet the full system installer - that is still the big
|
||||
# single-file script (ubuntu-based-kiosk.sh) documented in Readme.md, and
|
||||
# first-time provisioning of a new kiosk still goes through it. That file
|
||||
# still also contains its own (unmigrated, unmodified) copies of every
|
||||
# menu below - both copies coexist deliberately until enough of Core
|
||||
# Settings/Addons/Advanced has moved over to retire the old ones in one
|
||||
# pass. This entry point is the modular replacement, one menus/*.sh file
|
||||
# at a time, so a change to (say) the Sites menu can't accidentally break
|
||||
# WiFi setup or the uninstaller three thousand lines away.
|
||||
# On a bare Ubuntu Server box with no kiosk installed, this provisions
|
||||
# one (lib/provision.sh) - packages, kiosk user, LightDM/Openbox, the
|
||||
# Electron app, audio/video/power hardware setup - then hands off to the
|
||||
# same Core Settings/Addons/Advanced menus below for initial
|
||||
# configuration. On a machine that already has a kiosk, it skips
|
||||
# straight to those menus. Same entry point either way.
|
||||
#
|
||||
# ubuntu-based-kiosk.sh, the original single-file installer, still
|
||||
# exists and still works, but is no longer the only way to provision a
|
||||
# new kiosk. Upgrade (Advanced -> Upgrade) is now here too, but not a
|
||||
# port of the legacy version - that one re-extracted heredocs on every
|
||||
# run; kiosk-app/ and provision/files/ are real files in this git
|
||||
# checkout, so the modular Upgrade is `git pull` + re-running the same
|
||||
# provisioning steps, reused rather than reimplemented (see
|
||||
# menus/advanced_upgrade.sh). Full Reinstall is deliberately not
|
||||
# carried forward - it never worked reliably in the legacy script, and
|
||||
# the same outcome is already available here, more reliably, as two
|
||||
# already-tested pieces run back to back: Complete Uninstall (Core
|
||||
# Settings), then ./install.sh again to provision fresh.
|
||||
#
|
||||
# Migrated so far, grouped the same way the legacy menu groups them:
|
||||
# Core Settings: Sites & Page Timing, Display & Interaction, Timezone,
|
||||
# Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||
# Power/Display/Quiet Hours.
|
||||
# Time Server (NTP - menus/time_sync.sh, a LAN time server or manual
|
||||
# clock for networks without internet), Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||
# Power/Display/Quiet Hours, Complete Uninstall
|
||||
# (menus/complete_uninstall.sh - composed from every addon's own
|
||||
# uninstall helper rather than re-implementing removal a second time).
|
||||
# Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login
|
||||
# (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/
|
||||
# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server /
|
||||
# Squeezelite Player (menus/addon_lms_squeezelite.sh).
|
||||
# Squeezelite Player (menus/addon_lms_squeezelite.sh), Asterisk
|
||||
# Intercom - SIP extension client (menus/addon_asterisk_intercom.sh),
|
||||
# Web UI (menus/addon_webui.sh - browser-based editor for Sites/
|
||||
# Display/Lockout, the webui/ Node app installed as a systemd
|
||||
# service; no login of its own, put it behind your own reverse proxy
|
||||
# with Authelia forward-auth if it needs to be reachable beyond a
|
||||
# trusted LAN - see the file header), Library Catalog (menus/
|
||||
# addon_library.sh - a self-hosted Koha library catalogue in Docker with
|
||||
# Z39.50 lookup, label printing and a scan-to-add page, shown on the
|
||||
# kiosk as a public search terminal; installs Docker on demand via
|
||||
# lib/docker.sh).
|
||||
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
||||
# audio/network).
|
||||
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
||||
# manual update, fix blank screen), Factory Reset
|
||||
# (menus/advanced_factory_reset.sh), Virtual Consoles
|
||||
# (menus/advanced_virtual_consoles.sh), Emergency Hotspot
|
||||
# (menus/advanced_emergency_hotspot.sh), Clone Settings
|
||||
# (menus/clone_settings.sh - export/apply portable settings across
|
||||
# several kiosks; deliberately excludes machine-bound credentials
|
||||
# like Authelia/WireGuard/Asterisk Intercom - see the file header),
|
||||
# Upgrade (menus/advanced_upgrade.sh - git pull + re-provision, plus
|
||||
# an on-demand Electron version check).
|
||||
#
|
||||
# Usage (once the kiosk has already been installed):
|
||||
# Usage (works whether or not a kiosk is already installed):
|
||||
# git clone <repo>
|
||||
# cd ubuntu-based-kiosk
|
||||
# ./install.sh
|
||||
@@ -37,12 +69,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/menu.sh"
|
||||
# shellcheck source=lib/config.sh
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
# shellcheck source=lib/electron.sh
|
||||
source "$SCRIPT_DIR/lib/electron.sh"
|
||||
# shellcheck source=lib/docker.sh
|
||||
source "$SCRIPT_DIR/lib/docker.sh"
|
||||
# shellcheck source=menus/sites.sh
|
||||
source "$SCRIPT_DIR/menus/sites.sh"
|
||||
# shellcheck source=menus/display.sh
|
||||
source "$SCRIPT_DIR/menus/display.sh"
|
||||
# shellcheck source=menus/timezone.sh
|
||||
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||
# shellcheck source=menus/time_sync.sh
|
||||
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||
# shellcheck source=menus/hidden_pin.sh
|
||||
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||
# shellcheck source=menus/lockout.sh
|
||||
@@ -61,6 +99,38 @@ source "$SCRIPT_DIR/menus/addon_authelia.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||
# shellcheck source=menus/addon_lms_squeezelite.sh
|
||||
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||
# shellcheck source=menus/addon_asterisk_intercom.sh
|
||||
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||
# shellcheck source=menus/addon_webui.sh
|
||||
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||
# shellcheck source=menus/addon_library.sh
|
||||
source "$SCRIPT_DIR/menus/addon_library.sh"
|
||||
# shellcheck source=menus/advanced_electron.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||
# shellcheck source=menus/advanced_upgrade.sh
|
||||
# Depends on action_update_electron above and the provision_* functions
|
||||
# sourced later (lib/provision.sh) - safe either way, bash resolves
|
||||
# function calls at run time, not source time.
|
||||
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||
# shellcheck source=menus/advanced_factory_reset.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||
# shellcheck source=menus/advanced_virtual_consoles.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||
# shellcheck source=menus/advanced_emergency_hotspot.sh
|
||||
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||
# shellcheck source=menus/complete_uninstall.sh
|
||||
# Sourced last among menus/*.sh: composes the *_do_uninstall/
|
||||
# *_do_remove_all/*_do_disable helpers defined in every file above it.
|
||||
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||
# shellcheck source=menus/clone_settings.sh
|
||||
# Also composes detection helpers (*_is_installed) from every addon above.
|
||||
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||
# shellcheck source=lib/provision.sh
|
||||
# Sourced last of all: calls into core_settings_menu and the Advanced
|
||||
# actions below during first-time setup, so everything they depend on
|
||||
# must already be defined by the time it actually runs (not merely
|
||||
# sourced - bash resolves function calls at run time either way).
|
||||
source "$SCRIPT_DIR/lib/provision.sh"
|
||||
|
||||
################################################################################
|
||||
# Preflight
|
||||
@@ -81,17 +151,6 @@ if ! command -v jq &>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! is_kiosk_installed; then
|
||||
echo
|
||||
log_error "No installed kiosk found at ${KIOSK_DIR}."
|
||||
echo
|
||||
echo "This tool manages an already-installed kiosk. To provision a new"
|
||||
echo "one for the first time, use the full installer instead - see"
|
||||
echo "Readme.md ('Quick Install') for the current download command."
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Top-level menu - grouped the same way the legacy menu groups them
|
||||
# (Core Settings / Addons / Advanced), so the structure stays familiar
|
||||
@@ -103,19 +162,23 @@ core_settings_menu_builder() {
|
||||
"Sites & Page Timing"
|
||||
"Display & Interaction"
|
||||
"Timezone"
|
||||
"Time Server (NTP)"
|
||||
"Hidden Site PIN"
|
||||
"Password Protection & Lockout"
|
||||
"WiFi"
|
||||
"Power/Display/Quiet Hours"
|
||||
"Complete Uninstall"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
sites_menu
|
||||
display_menu
|
||||
timezone_menu
|
||||
time_sync_menu
|
||||
hidden_pin_menu
|
||||
lockout_menu
|
||||
wifi_menu
|
||||
power_schedule_menu
|
||||
complete_uninstall_menu
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,8 +187,8 @@ core_settings_menu() {
|
||||
}
|
||||
|
||||
addons_menu_builder() {
|
||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player")
|
||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu)
|
||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)")
|
||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu)
|
||||
}
|
||||
|
||||
addons_menu() {
|
||||
@@ -133,8 +196,8 @@ addons_menu() {
|
||||
}
|
||||
|
||||
advanced_menu_builder() {
|
||||
MENU_LABELS=("Diagnostics")
|
||||
MENU_HANDLERS=(diagnostics_menu)
|
||||
MENU_LABELS=("Diagnostics" "Electron Maintenance" "Factory Reset" "Virtual Consoles" "Emergency Hotspot" "Clone Settings" "Upgrade")
|
||||
MENU_HANDLERS=(diagnostics_menu advanced_electron_menu advanced_factory_reset_menu advanced_virtual_consoles_menu advanced_emergency_hotspot_menu clone_settings_menu advanced_upgrade_menu)
|
||||
}
|
||||
|
||||
advanced_menu() {
|
||||
@@ -150,4 +213,29 @@ main_menu_status() {
|
||||
echo "Managing kiosk at: ${KIOSK_DIR}"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Provision if there's nothing here yet, otherwise go straight to management.
|
||||
################################################################################
|
||||
|
||||
if ! is_kiosk_installed; then
|
||||
echo
|
||||
echo "No installed kiosk found at ${KIOSK_DIR}."
|
||||
echo "This will provision a new one on this machine."
|
||||
echo
|
||||
# Bare call, not `if run_first_time_install; then ...`: this is a
|
||||
# large multi-step function, and testing it as an if-condition would
|
||||
# exempt every step inside it from set -e for the duration - see the
|
||||
# comment at its own call to provision_install_app for why that
|
||||
# matters. Called bare, a real failure anywhere inside it halts the
|
||||
# whole script immediately (set -e's normal behavior); reaching the
|
||||
# lines below is itself proof every step succeeded. A declined
|
||||
# install prints "Cancelled" from inside the function and returns
|
||||
# non-zero, which the same bare-statement rule turns into a normal
|
||||
# exit here - nothing further to print either way.
|
||||
run_first_time_install
|
||||
echo
|
||||
echo "Run ./install.sh again to manage this kiosk."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_menu "UBUNTU BASED KIOSK - MANAGEMENT" main_menu_builder main_menu_status "Exit"
|
||||
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# iso/build-iso.sh - Build an Ubuntu Server 26.04 install ISO with the
|
||||
# kiosk built in, so there's no separate "install Ubuntu, then clone and
|
||||
# run ./install.sh" step.
|
||||
#
|
||||
# What the ISO does:
|
||||
# 1. Boots the stock Ubuntu Server installer (Subiquity), driven by an
|
||||
# autoinstall.yaml at the root of the ISO. By default it still asks
|
||||
# for network, disk and your admin username/password - everything
|
||||
# else (language, keyboard, SSH server, snaps...) is answered for
|
||||
# you. Flags below make any of those automatic too.
|
||||
# 2. At the end of the install (autoinstall late-commands), copies this
|
||||
# repo to /opt/ubuntu-based-kiosk on the new system and enables a
|
||||
# one-shot first-boot service (firstboot/kiosk-firstboot.service).
|
||||
# 3. On the first boot of the installed system, that service takes over
|
||||
# tty1 and runs ./install.sh as your admin user from
|
||||
# ~/ubuntu-based-kiosk - interactively by default (same prompts as a
|
||||
# manual install), or with --unattended taking every default and
|
||||
# rebooting straight into the kiosk. Configure sites afterwards via
|
||||
# the Web UI (http://<ip>:8090) or ./install.sh.
|
||||
#
|
||||
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
|
||||
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
||||
# boot records (BIOS + UEFI) are replayed from the original as-is.
|
||||
#
|
||||
# Requirements (on the build machine, any recent Ubuntu/Debian): the
|
||||
# script checks for its build tools and offers to install whatever's
|
||||
# missing with apt (or pass --install-deps to just do it). By hand:
|
||||
# sudo apt install xorriso curl git openssl
|
||||
# plus, for --offline (amd64 build machine only):
|
||||
# sudo apt install apt-utils python3 gnupg ubuntu-keyring
|
||||
#
|
||||
# Usage:
|
||||
# iso/build-iso.sh [options]
|
||||
#
|
||||
# --iso PATH Use this Ubuntu Server ISO instead of downloading.
|
||||
# --install-deps Install missing build tools with apt without asking.
|
||||
# --release VER Ubuntu release to download (default: 26.04 - picks
|
||||
# the newest 26.04.x live-server-amd64 point release).
|
||||
# --output PATH Output ISO (default: iso/build/ubuntu-<ver>-kiosk-amd64.iso)
|
||||
#
|
||||
# Installed system:
|
||||
# --username NAME Admin username (the account you SSH in with and run
|
||||
# ./install.sh as - must not be "kiosk"). Giving this
|
||||
# makes the installer's identity screen automatic.
|
||||
# --password PASS Admin password (prompted for if --username is given
|
||||
# without it, or read from $KIOSK_ADMIN_PASSWORD -
|
||||
# better than this flag on shared machines/CI, where
|
||||
# command lines are visible). Stored on the ISO only
|
||||
# as a SHA-512 crypt hash.
|
||||
# --hostname NAME Hostname (default: kiosk).
|
||||
# --realname NAME Full name for the admin account (default: Kiosk Admin).
|
||||
# --ssh-key FILE Add this public key file to the admin user's
|
||||
# authorized_keys.
|
||||
# --locale LOCALE Default: en_US.UTF-8
|
||||
# --keyboard LAYOUT Default: us
|
||||
# --auto-storage Don't ask about disks: wipe the largest disk and use
|
||||
# all of it (LVM). DESTROYS DATA on that disk.
|
||||
# --auto-network Don't show the network screen (DHCP on wired
|
||||
# interfaces). Leave this off if you need WiFi.
|
||||
# --no-confirm Don't ask "Continue with autoinstall?" at boot.
|
||||
# --fully-automatic Shorthand for --auto-storage --auto-network
|
||||
# --no-confirm --unattended; needs --username.
|
||||
# Booting this ISO will wipe a disk with NO questions.
|
||||
#
|
||||
# Kiosk install on first boot:
|
||||
# --unattended Run install.sh with all defaults, no prompts
|
||||
# (skips the Core Settings menu), then reboot.
|
||||
# --repo-url URL git remote recorded in the copied repo, used by
|
||||
# Advanced -> Upgrade (default: this checkout's
|
||||
# origin, else the upstream GitHub repo).
|
||||
# --offline Also bundle everything install.sh downloads (~520 MB:
|
||||
# every apt package incl. the CUPS, LMS/Squeezelite,
|
||||
# Asterisk Intercom, VNC, WireGuard and hotspot addons,
|
||||
# Node.js, prebuilt Electron/Web UI node_modules), so
|
||||
# first boot can install the kiosk with NO internet.
|
||||
# With internet it still installs online as usual;
|
||||
# offline it waits 90s, then uses the bundle. The
|
||||
# bundle stays on the kiosk at /opt/kiosk-offline
|
||||
# - Addons -> CUPS Printing then installs from it too
|
||||
# whenever the kiosk is offline.
|
||||
#
|
||||
# The repo copied onto the ISO is a clone of this checkout's current HEAD
|
||||
# (committed changes only - uncommitted edits are NOT included).
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
DEFAULT_REPO_URL="https://github.com/outis1one/ubuntu-based-kiosk.git"
|
||||
|
||||
SRC_ISO=""
|
||||
RELEASE="26.04"
|
||||
OUTPUT=""
|
||||
ADMIN_USER=""
|
||||
ADMIN_PASS="${KIOSK_ADMIN_PASSWORD:-}"
|
||||
HOSTNAME_="kiosk"
|
||||
REALNAME="Kiosk Admin"
|
||||
SSH_KEY_FILE=""
|
||||
LOCALE="en_US.UTF-8"
|
||||
KEYBOARD="us"
|
||||
AUTO_STORAGE=0
|
||||
AUTO_NETWORK=0
|
||||
NO_CONFIRM=0
|
||||
UNATTENDED=0
|
||||
OFFLINE=0
|
||||
INSTALL_DEPS=0
|
||||
REPO_URL=""
|
||||
|
||||
# Usernames the Ubuntu installer rejects (system users/groups) - copied
|
||||
# from subiquity's reserved-usernames so a bad --username fails here
|
||||
# instead of at boot with "Username is reserved by the system".
|
||||
RESERVED_USERNAMES="
|
||||
root daemon bin sys sync games man lp mail news uucp proxy www-data
|
||||
backup list irc gnats nobody adm tty disk kmem dialout fax voice cdrom
|
||||
floppy tape sudo audio dip operator src shadow utmp video sasl plugdev
|
||||
staff users nogroup netplan ftn mysql tac-plus alias qmail qmaild
|
||||
qmails qmailr qmailq qmaill qmailp asterisk vpopmail vchkpw slurm
|
||||
hacluster haclient grsec-tpe grsec-sock-all grsec-sock-clt
|
||||
grsec-sock-srv grsec-proc ceph opensrf libvirt-qemu admin Debian-exim
|
||||
bind crontab cupsys dcc dhcp dictd dnsmasq dovecot fetchmail firebird
|
||||
ftp fuse gdm haldaemon hplilp identd input jwhois klog kvm lpadmin maas
|
||||
messagebus mythtv netdev powerdev radvd render saned sbuild scanner sgx
|
||||
slocate ssh sshd ssl-cert sslwrap statd syslog telnetd tftpd
|
||||
"
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
info() { echo "==> $*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iso) SRC_ISO="$2"; shift 2 ;;
|
||||
--release) RELEASE="$2"; shift 2 ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--username) ADMIN_USER="$2"; shift 2 ;;
|
||||
--password) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--hostname) HOSTNAME_="$2"; shift 2 ;;
|
||||
--realname) REALNAME="$2"; shift 2 ;;
|
||||
--ssh-key) SSH_KEY_FILE="$2"; shift 2 ;;
|
||||
--locale) LOCALE="$2"; shift 2 ;;
|
||||
--keyboard) KEYBOARD="$2"; shift 2 ;;
|
||||
--auto-storage) AUTO_STORAGE=1; shift ;;
|
||||
--auto-network) AUTO_NETWORK=1; shift ;;
|
||||
--no-confirm) NO_CONFIRM=1; shift ;;
|
||||
--unattended) UNATTENDED=1; shift ;;
|
||||
--fully-automatic) AUTO_STORAGE=1; AUTO_NETWORK=1; NO_CONFIRM=1; UNATTENDED=1; shift ;;
|
||||
--repo-url) REPO_URL="$2"; shift 2 ;;
|
||||
--offline) OFFLINE=1; shift ;;
|
||||
--install-deps) INSTALL_DEPS=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
################################################################################
|
||||
# Validate
|
||||
################################################################################
|
||||
|
||||
# Build tools: command -> the apt package that provides it.
|
||||
declare -A DEP_PKG=([xorriso]=xorriso [curl]=curl [git]=git [openssl]=openssl [sha256sum]=coreutils)
|
||||
if (( OFFLINE )); then
|
||||
DEP_PKG+=([apt-ftparchive]=apt-utils [python3]=python3 [gpg]=gnupg [dpkg-deb]=dpkg)
|
||||
fi
|
||||
missing_pkgs=()
|
||||
for cmd in "${!DEP_PKG[@]}"; do
|
||||
command -v "$cmd" &>/dev/null || missing_pkgs+=("${DEP_PKG[$cmd]}")
|
||||
done
|
||||
if (( OFFLINE )) && [[ ! -r /usr/share/keyrings/ubuntu-archive-keyring.gpg ]]; then
|
||||
missing_pkgs+=(ubuntu-keyring)
|
||||
fi
|
||||
if (( ${#missing_pkgs[@]} )); then
|
||||
mapfile -t missing_pkgs < <(printf '%s\n' "${missing_pkgs[@]}" | sort -u)
|
||||
echo "Missing build tools: ${missing_pkgs[*]}"
|
||||
command -v apt-get &>/dev/null \
|
||||
|| die "install them with your package manager and re-run (this script expects Ubuntu/Debian)"
|
||||
if (( ! INSTALL_DEPS )); then
|
||||
[[ -t 0 ]] || die "install them first: sudo apt install ${missing_pkgs[*]} (or re-run with --install-deps)"
|
||||
read -r -p "Install them now with 'sudo apt install'? [Y/n] " reply
|
||||
[[ "${reply,,}" =~ ^(|y|yes)$ ]] || die "install them first: sudo apt install ${missing_pkgs[*]}"
|
||||
fi
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y "${missing_pkgs[@]}" || die "couldn't install: ${missing_pkgs[*]}"
|
||||
fi
|
||||
if (( OFFLINE )) && [[ "$(dpkg --print-architecture 2>/dev/null)" != "amd64" ]]; then
|
||||
die "--offline must be built on an amd64 (64-bit Intel/AMD) machine - it runs the bundled Node.js to build the kiosk's modules"
|
||||
fi
|
||||
|
||||
if [[ -n "$ADMIN_USER" ]]; then
|
||||
[[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid username: $ADMIN_USER"
|
||||
[[ "$ADMIN_USER" != "kiosk" && "$ADMIN_USER" != "root" ]] \
|
||||
|| die "username '$ADMIN_USER' is reserved (install.sh creates its own 'kiosk' user)"
|
||||
[[ " $(echo $RESERVED_USERNAMES) " != *" $ADMIN_USER "* ]] \
|
||||
|| die "username '$ADMIN_USER' is reserved by Ubuntu (a system user/group) - pick another"
|
||||
if [[ -z "$ADMIN_PASS" ]]; then
|
||||
[[ -t 0 ]] || die "--username needs a password: pass --password or set KIOSK_ADMIN_PASSWORD"
|
||||
read -r -s -p "Password for $ADMIN_USER: " ADMIN_PASS; echo
|
||||
read -r -s -p "Confirm password: " pass2; echo
|
||||
[[ "$ADMIN_PASS" == "$pass2" ]] || die "passwords don't match"
|
||||
[[ -n "$ADMIN_PASS" ]] || die "empty password"
|
||||
fi
|
||||
elif [[ -n "$ADMIN_PASS" ]]; then
|
||||
die "--password needs --username"
|
||||
fi
|
||||
|
||||
if (( AUTO_STORAGE && AUTO_NETWORK && NO_CONFIRM )) && [[ -z "$ADMIN_USER" ]]; then
|
||||
die "--fully-automatic (or all of --auto-storage --auto-network --no-confirm) needs --username"
|
||||
fi
|
||||
|
||||
[[ "$HOSTNAME_" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]] || die "invalid hostname: $HOSTNAME_"
|
||||
[[ -z "$SSH_KEY_FILE" || -r "$SSH_KEY_FILE" ]] || die "can't read --ssh-key file: $SSH_KEY_FILE"
|
||||
|
||||
if [[ -z "$REPO_URL" ]]; then
|
||||
REPO_URL="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
|
||||
# Local proxies/mirrors and ssh remotes aren't reachable from a kiosk.
|
||||
[[ "$REPO_URL" == https://github.com/* ]] || REPO_URL="$DEFAULT_REPO_URL"
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
WORK="$(mktemp -d "$BUILD_DIR/work.XXXXXX")"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
################################################################################
|
||||
# Source ISO - download and verify unless one was given
|
||||
################################################################################
|
||||
|
||||
if [[ -z "$SRC_ISO" ]]; then
|
||||
base="https://releases.ubuntu.com/$RELEASE"
|
||||
info "Looking up the latest Ubuntu $RELEASE live-server ISO..."
|
||||
sums="$(curl -fsSL "$base/SHA256SUMS")" || die "couldn't fetch $base/SHA256SUMS"
|
||||
iso_name="$(grep -oE "ubuntu-${RELEASE//./\\.}(\.[0-9]+)?-live-server-amd64\.iso" <<<"$sums" | sort -V | tail -1)"
|
||||
[[ -n "$iso_name" ]] || die "no live-server-amd64 ISO listed in $base/SHA256SUMS"
|
||||
expected="$(grep -F "*$iso_name" <<<"$sums" | awk '{print $1}')"
|
||||
|
||||
mkdir -p "$BUILD_DIR/cache"
|
||||
SRC_ISO="$BUILD_DIR/cache/$iso_name"
|
||||
if [[ -f "$SRC_ISO" ]] && echo "$expected $SRC_ISO" | sha256sum -c --status; then
|
||||
info "Using cached $iso_name"
|
||||
else
|
||||
info "Downloading $iso_name (~3 GB)..."
|
||||
curl -fL --retry 5 -C - -o "$SRC_ISO" "$base/$iso_name" \
|
||||
|| curl -fL --retry 5 -o "$SRC_ISO" "$base/$iso_name"
|
||||
info "Verifying checksum..."
|
||||
echo "$expected $SRC_ISO" | sha256sum -c --status \
|
||||
|| { rm -f "$SRC_ISO"; die "checksum mismatch for $iso_name - deleted, re-run to retry"; }
|
||||
fi
|
||||
fi
|
||||
[[ -f "$SRC_ISO" ]] || die "ISO not found: $SRC_ISO"
|
||||
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
src_base="$(basename "$SRC_ISO" .iso)"
|
||||
kind="kiosk"; (( OFFLINE )) && kind="kiosk-offline"
|
||||
OUTPUT="$BUILD_DIR/${src_base/-live-server/-$kind}.iso"
|
||||
[[ "$OUTPUT" != "$BUILD_DIR/$src_base.iso" ]] || OUTPUT="$BUILD_DIR/${src_base}-$kind.iso"
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Stage the repo + first-boot files that go on the ISO under /kiosk
|
||||
################################################################################
|
||||
|
||||
# Branch the copied repo is on - what Advanced -> Upgrade pulls. CI
|
||||
# checkouts (actions/checkout) are a detached HEAD, so fall back to the
|
||||
# branch CI says it built, then main.
|
||||
BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
[[ "$BRANCH" != "HEAD" ]] || BRANCH="${GITHUB_REF_NAME:-main}"
|
||||
COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
|
||||
info "Staging repo ($BRANCH @ ${COMMIT:0:7})..."
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
|
||||
echo " WARNING: uncommitted changes in $REPO_ROOT are NOT included - commit them first if you need them."
|
||||
fi
|
||||
mkdir -p "$WORK/kiosk/firstboot"
|
||||
# init + fetch by commit rather than `git clone --branch`, which can't
|
||||
# clone a detached HEAD.
|
||||
stage_repo="$WORK/kiosk/ubuntu-based-kiosk"
|
||||
git init --quiet "$stage_repo"
|
||||
git -C "$stage_repo" fetch --quiet --depth 1 "file://$REPO_ROOT" "$COMMIT"
|
||||
git -C "$stage_repo" checkout --quiet -B "$BRANCH" FETCH_HEAD
|
||||
git -C "$stage_repo" remote add origin "$REPO_URL"
|
||||
git -C "$stage_repo" config "branch.$BRANCH.remote" origin
|
||||
git -C "$stage_repo" config "branch.$BRANCH.merge" "refs/heads/$BRANCH"
|
||||
|
||||
install -m 755 "$SCRIPT_DIR/firstboot/kiosk-firstboot" "$WORK/kiosk/firstboot/"
|
||||
install -m 644 "$SCRIPT_DIR/firstboot/kiosk-firstboot.service" "$WORK/kiosk/firstboot/"
|
||||
install -m 755 "$SCRIPT_DIR/firstboot/kiosk-offline-apt" "$WORK/kiosk/firstboot/"
|
||||
cat > "$WORK/kiosk/firstboot/kiosk-firstboot.conf" <<EOF
|
||||
# Read by /usr/local/sbin/kiosk-firstboot. Written by iso/build-iso.sh.
|
||||
# 1 = take every install.sh default and reboot into the kiosk; 0 = interactive.
|
||||
KIOSK_UNATTENDED=$UNATTENDED
|
||||
EOF
|
||||
|
||||
if (( OFFLINE )); then
|
||||
info "Building offline bundle (packages, Node.js, Electron)..."
|
||||
"$SCRIPT_DIR/offline/build-bundle.sh" "$WORK/kiosk/offline" "$SRC_ISO" "$stage_repo" "$BUILD_DIR/offline-cache"
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# autoinstall.yaml
|
||||
################################################################################
|
||||
|
||||
# Single-quoted YAML scalar.
|
||||
yq() { printf "'%s'" "${1//\'/\'\'}"; }
|
||||
|
||||
interactive=()
|
||||
(( AUTO_NETWORK )) || interactive+=(network)
|
||||
(( AUTO_STORAGE )) || interactive+=(storage)
|
||||
[[ -n "$ADMIN_USER" ]] || interactive+=(identity)
|
||||
|
||||
{
|
||||
echo "#cloud-config"
|
||||
echo "# Generated by iso/build-iso.sh - see that file for what this does."
|
||||
echo "autoinstall:"
|
||||
echo " version: 1"
|
||||
if (( ${#interactive[@]} )); then
|
||||
echo " interactive-sections:"
|
||||
for s in "${interactive[@]}"; do echo " - $s"; done
|
||||
fi
|
||||
echo " locale: $(yq "$LOCALE")"
|
||||
echo " keyboard:"
|
||||
echo " layout: $(yq "$KEYBOARD")"
|
||||
# Pre-selected answer for the storage screen when it's interactive;
|
||||
# the whole answer when --auto-storage.
|
||||
echo " storage:"
|
||||
echo " layout:"
|
||||
echo " name: lvm"
|
||||
echo " sizing-policy: all"
|
||||
if [[ -n "$ADMIN_USER" ]]; then
|
||||
echo " identity:"
|
||||
echo " hostname: $(yq "$HOSTNAME_")"
|
||||
echo " realname: $(yq "$REALNAME")"
|
||||
echo " username: $(yq "$ADMIN_USER")"
|
||||
echo " password: $(yq "$(openssl passwd -6 -stdin <<<"$ADMIN_PASS")")"
|
||||
fi
|
||||
echo " ssh:"
|
||||
echo " install-server: true"
|
||||
echo " allow-pw: true"
|
||||
if [[ -n "$SSH_KEY_FILE" ]]; then
|
||||
echo " authorized-keys:"
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
echo " - $(yq "$key")"
|
||||
done < "$SSH_KEY_FILE"
|
||||
fi
|
||||
cat <<'EOF'
|
||||
late-commands:
|
||||
- mkdir -p /target/opt /target/usr/local/sbin /target/var/lib/kiosk-firstboot
|
||||
- cp -a /cdrom/kiosk/ubuntu-based-kiosk /target/opt/ubuntu-based-kiosk
|
||||
- chmod -R u+w /target/opt/ubuntu-based-kiosk
|
||||
- install -m 755 /cdrom/kiosk/firstboot/kiosk-firstboot /target/usr/local/sbin/kiosk-firstboot
|
||||
- install -m 644 /cdrom/kiosk/firstboot/kiosk-firstboot.service /target/etc/systemd/system/kiosk-firstboot.service
|
||||
- install -m 644 /cdrom/kiosk/firstboot/kiosk-firstboot.conf /target/etc/kiosk-firstboot.conf
|
||||
- install -m 755 /cdrom/kiosk/firstboot/kiosk-offline-apt /target/usr/local/sbin/kiosk-offline-apt
|
||||
- touch /target/var/lib/kiosk-firstboot/pending
|
||||
- curtin in-target --target=/target -- systemctl enable kiosk-firstboot.service
|
||||
EOF
|
||||
if (( OFFLINE )); then
|
||||
echo " - cp -a /cdrom/kiosk/offline /target/opt/kiosk-offline"
|
||||
echo " - chmod -R u+w,a+rX /target/opt/kiosk-offline"
|
||||
fi
|
||||
} > "$WORK/autoinstall.yaml"
|
||||
|
||||
################################################################################
|
||||
# grub.cfg - same file serves BIOS and UEFI boot on the server ISO
|
||||
################################################################################
|
||||
|
||||
xorriso -osirrox on -indev "$SRC_ISO" -extract /boot/grub/grub.cfg "$WORK/grub.cfg" 2>/dev/null \
|
||||
|| die "couldn't read /boot/grub/grub.cfg from $SRC_ISO - is it an Ubuntu Server ISO?"
|
||||
chmod u+w "$WORK/grub.cfg"
|
||||
grep -q '/casper/vmlinuz' "$WORK/grub.cfg" || die "unexpected grub.cfg layout in $SRC_ISO"
|
||||
|
||||
sed -i 's/Try or Install Ubuntu Server/Install Ubuntu Based Kiosk/' "$WORK/grub.cfg"
|
||||
if (( NO_CONFIRM )); then
|
||||
sed -i -E '/linux[[:space:]]+\/casper\/vmlinuz/ s/[[:space:]]+---/ autoinstall ---/' "$WORK/grub.cfg"
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Write the new ISO
|
||||
################################################################################
|
||||
|
||||
info "Writing $OUTPUT..."
|
||||
rm -f "$OUTPUT"
|
||||
xorriso -indev "$SRC_ISO" -outdev "$OUTPUT" \
|
||||
-map "$WORK/autoinstall.yaml" /autoinstall.yaml \
|
||||
-map "$WORK/kiosk" /kiosk \
|
||||
-map "$WORK/grub.cfg" /boot/grub/grub.cfg \
|
||||
-boot_image any replay \
|
||||
-padding included \
|
||||
> "$WORK/xorriso.log" 2>&1 \
|
||||
|| { cat "$WORK/xorriso.log" >&2; rm -f "$OUTPUT"; die "xorriso failed"; }
|
||||
|
||||
echo
|
||||
info "Done: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
|
||||
echo
|
||||
echo "Installer asks for : ${interactive[*]:-nothing}$( (( NO_CONFIRM )) || echo " (plus a 'Continue with autoinstall?' confirmation)")"
|
||||
echo "Admin user : ${ADMIN_USER:-chosen during install}"
|
||||
echo "First boot : $( (( UNATTENDED )) && echo "unattended install.sh, then reboots into the kiosk" || echo "interactive install.sh on the console")"
|
||||
echo "Offline bundle : $( (( OFFLINE )) && echo "yes - installs with no internet (used after 90s offline)" || echo "no - first boot needs internet (add --offline to bundle everything)")"
|
||||
echo
|
||||
echo "Write it to a USB stick (replace /dev/sdX - this erases it):"
|
||||
echo " sudo dd if='$OUTPUT' of=/dev/sdX bs=4M status=progress oflag=sync"
|
||||
if (( AUTO_STORAGE && NO_CONFIRM )); then
|
||||
echo
|
||||
echo "WARNING: this ISO wipes the target's largest disk without asking. Label the stick."
|
||||
fi
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# iso/e2b-contig.sh - Make a file on a USB drive contiguous, from Linux.
|
||||
#
|
||||
# Easy2Boot (and other multiboot loaders) can only boot some ISO files when
|
||||
# they sit on the drive in one unbroken piece. A big ISO copied onto a
|
||||
# well-used drive usually lands in several pieces, and Linux has no
|
||||
# defragmenter for NTFS/FAT32/exFAT. This rewrites the one file so it is
|
||||
# contiguous, using only ordinary file operations - it never writes
|
||||
# filesystem structures directly, so the worst case is "still not
|
||||
# contiguous", not a damaged drive.
|
||||
#
|
||||
# How: a plain copy just fills the drive's scattered free gaps again. So
|
||||
# 1. fill the free space with temporary filler files (plugging every gap),
|
||||
# 2. find the longest physically unbroken run among them and free exactly
|
||||
# that run,
|
||||
# 3. write the copy - the only free space left is that run,
|
||||
# 4. delete the fillers, verify the copy's SHA-256, swap it in.
|
||||
# This writes about as much data as the drive has free (progress shown) -
|
||||
# minutes on USB 3, longer on USB 2 or a nearly empty big drive.
|
||||
#
|
||||
# If there's no unbroken run as big as the file while the original is still
|
||||
# on the drive, --move-out first parks the original on this computer's disk
|
||||
# (checksummed), deletes it from the drive to free its pieces, and retries.
|
||||
#
|
||||
# Usage:
|
||||
# sudo iso/e2b-contig.sh [--check] [--move-out] [--temp-dir DIR] [--yes] FILE...
|
||||
#
|
||||
# --check Only report whether each file is contiguous.
|
||||
# --move-out Allowed to park the original on this computer if needed
|
||||
# (default temp dir: /var/tmp).
|
||||
# --temp-dir D Where to park it.
|
||||
# --yes Don't ask for confirmation.
|
||||
#
|
||||
# Works with NTFS (ntfs-3g or the ntfs3 kernel driver), FAT32 and exFAT -
|
||||
# anything filefrag can map. Run as root (filefrag needs it on most of
|
||||
# these filesystems).
|
||||
################################################################################
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CHECK_ONLY=0
|
||||
MOVE_OUT=0
|
||||
ASSUME_YES=0
|
||||
TEMP_DIR=/var/tmp
|
||||
# Filler size. Small enough that fillers fit inside the drive's free gaps
|
||||
# (a filler spanning two gaps can't be part of an unbroken run); kept in
|
||||
# subfolders of 1000 because FAT32 caps the number of files per folder.
|
||||
CHUNK_MB=4
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
info() { echo "==> $*"; }
|
||||
|
||||
FILES=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) CHECK_ONLY=1; shift ;;
|
||||
--move-out) MOVE_OUT=1; shift ;;
|
||||
--temp-dir) TEMP_DIR="$2"; shift 2 ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) die "unknown option: $1" ;;
|
||||
*) FILES+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
(( ${#FILES[@]} )) || die "no file given - usage: sudo $0 [--check] FILE..."
|
||||
[[ $EUID -eq 0 ]] || die "run with sudo (filefrag needs root on NTFS/FAT/exFAT)"
|
||||
for cmd in filefrag findmnt blkid sha256sum dd awk df stat; do
|
||||
command -v "$cmd" &>/dev/null || die "'$cmd' not found (packages: e2fsprogs, util-linux, coreutils)"
|
||||
done
|
||||
|
||||
# extents FILE -> "physical_start physical_end" per extent (filesystem
|
||||
# blocks), in file order. Empty output = couldn't map (unsupported).
|
||||
extents() {
|
||||
filefrag -e "$1" 2>/dev/null | awk '
|
||||
/^ *[0-9]+:/ {
|
||||
gsub(/\.\./, " "); gsub(/:/, " ")
|
||||
# fields: idx logical_start logical_end physical_start physical_end length ...
|
||||
print $4, $5
|
||||
}'
|
||||
}
|
||||
|
||||
# Block size filefrag reports in.
|
||||
fs_block_size() {
|
||||
filefrag -e "$1" 2>/dev/null | sed -n 's/.*(\([0-9]*\) blocks of \([0-9]*\) bytes).*/\2/p' | head -1
|
||||
}
|
||||
|
||||
# pieces FILE -> number of physically separate pieces (adjacent extents
|
||||
# count as one; filefrag splits long runs into several extents).
|
||||
pieces() {
|
||||
extents "$1" | awk 'NR==1 {n=1; prev=$2; next} { if ($1 != prev + 1) n++; prev=$2 } END { print n+0 }'
|
||||
}
|
||||
|
||||
confirm() {
|
||||
(( ASSUME_YES )) && return 0
|
||||
local reply
|
||||
read -r -p "$1 [y/N] " reply
|
||||
[[ "${reply,,}" == y || "${reply,,}" == yes ]]
|
||||
}
|
||||
|
||||
FILL_DIR=""
|
||||
TMP_COPY=""
|
||||
cleanup() {
|
||||
[[ -n "$FILL_DIR" && -d "$FILL_DIR" ]] && rm -rf -- "$FILL_DIR"
|
||||
[[ -n "$TMP_COPY" && -f "$TMP_COPY" ]] && rm -f -- "$TMP_COPY"
|
||||
sync
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'echo; echo "Interrupted - removing temporary files..."; exit 130' INT TERM
|
||||
|
||||
# fill_and_carve MOUNT NEED_BYTES -> 0 if it left exactly one free run of at
|
||||
# least NEED_BYTES (fillers stay in $FILL_DIR until cleanup), 1 if no such run.
|
||||
fill_and_carve() {
|
||||
local mnt="$1" need="$2"
|
||||
FILL_DIR="$mnt/.e2b-contig-fill.$$"
|
||||
mkdir -p "$FILL_DIR" || return 1
|
||||
local free_mb
|
||||
free_mb=$(( $(df -B1M --output=avail "$mnt" | tail -1) ))
|
||||
info "Filling $free_mb MB of free space with temporary files to plug the gaps..."
|
||||
local n=0 written=0 fname
|
||||
while :; do
|
||||
n=$((n + 1))
|
||||
fname="$FILL_DIR/d$(printf %04d $((n / 1000)))/f$(printf %07d $n)"
|
||||
mkdir -p "${fname%/*}" 2>/dev/null || break
|
||||
# dd stops (keeping what it wrote) when the drive is full.
|
||||
dd if=/dev/zero of="$fname" bs=1M count="$CHUNK_MB" status=none 2>/dev/null
|
||||
local rc=$?
|
||||
local sz
|
||||
sz=$(stat -c %s "$fname" 2>/dev/null || echo 0)
|
||||
written=$((written + sz))
|
||||
(( n % 25 == 0 )) && printf '\r %d / %d MB' "$((written / 1048576))" "$free_mb"
|
||||
(( rc != 0 || sz < CHUNK_MB * 1048576 )) && break
|
||||
done
|
||||
printf '\r %d / %d MB\n' "$((written / 1048576))" "$free_mb"
|
||||
sync
|
||||
|
||||
# Single-piece fillers sorted by physical position; the longest chain
|
||||
# where each starts right after the previous ends is the best run.
|
||||
local bs
|
||||
bs=$(fs_block_size "$FILL_DIR/d0000/f0000001")
|
||||
[[ -n "$bs" ]] || { echo " Couldn't map the filler files (filefrag unsupported here)."; return 1; }
|
||||
# The map lives on this computer, not on the (now full) drive.
|
||||
local f list
|
||||
list=$(mktemp) || return 1
|
||||
info "Mapping where the filler files landed..."
|
||||
for f in "$FILL_DIR"/d*/f*; do
|
||||
[[ -s "$f" ]] || continue
|
||||
# One filefrag call per filler (there can be thousands): keep it only
|
||||
# if it's a single piece, as "start end path".
|
||||
extents "$f" | awk -v f="$f" '
|
||||
NR == 1 { s = $1; e = $2; n = 1; next }
|
||||
{ if ($1 == e + 1) e = $2; else n++ }
|
||||
END { if (NR > 0 && n == 1) print s, e, f }' >> "$list"
|
||||
done
|
||||
local best
|
||||
best=$(sort -n "$list" | awk -v bs="$bs" '
|
||||
{ if (NR == 1 || $1 != prev_end + 1) { start_line = NR; run_bytes = 0; files = "" }
|
||||
run_bytes += ($2 - $1 + 1) * bs; files = files " " $3; prev_end = $2
|
||||
if (run_bytes > best_bytes) { best_bytes = run_bytes; best_files = files } }
|
||||
END { print best_bytes + 0, best_files }')
|
||||
rm -f -- "$list"
|
||||
local best_bytes=${best%% *}
|
||||
info "Longest unbroken free run found: $((best_bytes / 1048576)) MB (need $((need / 1048576)) MB)"
|
||||
if (( best_bytes < need )); then
|
||||
return 1
|
||||
fi
|
||||
# Free just enough of that run (from its start) for the file - keeping the
|
||||
# rest plugged so the copy can't wander elsewhere.
|
||||
local freed=0 g
|
||||
for g in ${best#* }; do
|
||||
(( freed >= need )) && break
|
||||
freed=$((freed + $(stat -c %s "$g")))
|
||||
rm -f -- "$g"
|
||||
done
|
||||
sync
|
||||
return 0
|
||||
}
|
||||
|
||||
# restore_parked PARKED FILE SUM - copy a parked original back onto the
|
||||
# drive (not contiguous - just safe) and only delete the parked copy once
|
||||
# the restored one verifies.
|
||||
restore_parked() {
|
||||
local parked="$1" file="$2" sum="$3"
|
||||
info "Putting the original back..."
|
||||
if cp -- "$parked" "$file" && [[ "$(sha256sum "$file" | cut -d' ' -f1)" == "$sum" ]]; then
|
||||
rm -f -- "$parked"
|
||||
echo " Original restored (unchanged, still in pieces)."
|
||||
else
|
||||
echo " !! Couldn't verify the restored file - the original is kept safe at $parked"
|
||||
fi
|
||||
}
|
||||
|
||||
# make_contiguous FILE
|
||||
make_contiguous() {
|
||||
local file="$1"
|
||||
[[ -f "$file" ]] || { echo "$file: not a file"; return 1; }
|
||||
file=$(realpath "$file")
|
||||
local mnt fstype dev
|
||||
read -r dev fstype mnt < <(findmnt -n -o SOURCE,FSTYPE,TARGET --target "$file")
|
||||
local size
|
||||
size=$(stat -c %s "$file")
|
||||
local p
|
||||
p=$(pieces "$file")
|
||||
echo
|
||||
echo "$file"
|
||||
echo " drive: $dev ($fstype) mounted at $mnt, size $((size / 1048576)) MB"
|
||||
if [[ -z "$(extents "$file")" ]]; then
|
||||
echo " Can't read this file's layout through this driver ($fstype)."
|
||||
# FUSE drivers (fusefat, exfat-fuse) can't map files; the kernel
|
||||
# drivers can - and ntfs-3g usually can, but ntfs3 always can.
|
||||
local real kdrv=""
|
||||
real=$(blkid -o value -s TYPE "$dev" 2>/dev/null)
|
||||
case "$real" in
|
||||
ntfs) kdrv=ntfs3 ;;
|
||||
exfat) kdrv=exfat ;;
|
||||
vfat|msdos) kdrv=vfat ;;
|
||||
esac
|
||||
if [[ -n "$kdrv" ]]; then
|
||||
echo " Remount it with the kernel's $kdrv driver and retry:"
|
||||
echo " sudo umount $dev && sudo mount -t $kdrv $dev $mnt"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
if [[ "$p" == 1 ]]; then
|
||||
echo " Contiguous (1 piece) - nothing to do."
|
||||
return 0
|
||||
fi
|
||||
echo " NOT contiguous: $p pieces."
|
||||
(( CHECK_ONLY )) && return 2
|
||||
|
||||
confirm " Rewrite it in one piece? (temporarily fills the drive's free space)" || { echo " Skipped."; return 2; }
|
||||
|
||||
info "Checksumming the original..."
|
||||
local sum
|
||||
sum=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
|
||||
local source="$file" parked=""
|
||||
if ! fill_and_carve "$mnt" "$size"; then
|
||||
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||
if (( ! MOVE_OUT )); then
|
||||
echo " Not enough unbroken free space with the original still on the drive."
|
||||
echo " Re-run with --move-out to park the original on this computer meanwhile,"
|
||||
echo " or free up space on the drive (move a few other large files off)."
|
||||
return 1
|
||||
fi
|
||||
local avail
|
||||
avail=$(( $(df -B1 --output=avail "$TEMP_DIR" | tail -1) ))
|
||||
(( avail > size )) || { echo " Not enough room in $TEMP_DIR to park it ($((size / 1048576)) MB needed)."; return 1; }
|
||||
parked="$TEMP_DIR/$(basename "$file").e2b-contig-parked"
|
||||
info "Parking the original in $parked..."
|
||||
cp -- "$file" "$parked" && [[ "$(sha256sum "$parked" | cut -d' ' -f1)" == "$sum" ]] \
|
||||
|| { rm -f -- "$parked"; echo " Parking copy failed its checksum - nothing changed."; return 1; }
|
||||
rm -f -- "$file"; sync
|
||||
source="$parked"
|
||||
if ! fill_and_carve "$mnt" "$size"; then
|
||||
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||
echo " Still no unbroken run big enough."
|
||||
restore_parked "$parked" "$file" "$sum"
|
||||
echo " Free up space on the drive (it needs one unbroken $((size / 1048576)) MB area) and retry."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_COPY="$(dirname "$file")/.e2b-contig.$$.tmp"
|
||||
info "Writing the copy into the freed run..."
|
||||
if ! dd if="$source" of="$TMP_COPY" bs=4M conv=fsync status=progress; then
|
||||
echo " Copy failed."
|
||||
rm -f -- "$TMP_COPY"; TMP_COPY=""; rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
|
||||
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
|
||||
return 1
|
||||
fi
|
||||
rm -rf -- "$FILL_DIR"; FILL_DIR=""
|
||||
sync
|
||||
|
||||
local newp
|
||||
newp=$(pieces "$TMP_COPY")
|
||||
info "Verifying..."
|
||||
if [[ "$(sha256sum "$TMP_COPY" | cut -d' ' -f1)" != "$sum" ]]; then
|
||||
echo " !! The copy doesn't match the original's checksum - discarding it."
|
||||
rm -f -- "$TMP_COPY"; TMP_COPY=""; sync
|
||||
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$newp" != 1 ]]; then
|
||||
echo " The copy still came out in $newp pieces (was $p)."
|
||||
fi
|
||||
# Same directory, so a rename: the data stays where it was written.
|
||||
mv -f -- "$TMP_COPY" "$file" && TMP_COPY=""
|
||||
[[ -n "$parked" ]] && rm -f -- "$parked"
|
||||
sync
|
||||
if [[ "$(pieces "$file")" == 1 ]]; then
|
||||
echo " ✓ Contiguous now (1 piece), checksum verified."
|
||||
return 0
|
||||
fi
|
||||
echo " Checksum verified, but still $(pieces "$file") pieces - see the notes above."
|
||||
return 1
|
||||
}
|
||||
|
||||
rc=0
|
||||
for f in "${FILES[@]}"; do
|
||||
make_contiguous "$f" || rc=1
|
||||
done
|
||||
exit "$rc"
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# kiosk-firstboot - runs ubuntu-based-kiosk's ./install.sh on the first
|
||||
# boot of a system installed from the custom ISO (iso/build-iso.sh).
|
||||
#
|
||||
# install.sh refuses to run as root and expects a regular sudo user, so
|
||||
# this runs it as the admin account the Ubuntu installer created (the
|
||||
# first regular user, uid 1000), from a copy of the repo in that user's
|
||||
# home - the same layout as a manual `git clone && ./install.sh`, so
|
||||
# re-running ./install.sh later and Advanced -> Upgrade both work as
|
||||
# usual afterwards.
|
||||
#
|
||||
# For the duration of the install only, that user gets passwordless sudo
|
||||
# (nobody has typed a password yet, and in unattended mode nobody will);
|
||||
# the drop-in is removed on every exit path, including the reboot at the
|
||||
# end of install.sh (see ExecStopPost in kiosk-firstboot.service).
|
||||
#
|
||||
# Settings come from /etc/kiosk-firstboot.conf, written at ISO build time:
|
||||
# KIOSK_UNATTENDED=1 take every prompt's default, skip the Core
|
||||
# Settings menu, reboot into the kiosk when done.
|
||||
# KIOSK_UNATTENDED=0 (default) normal interactive install.sh on tty1.
|
||||
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
|
||||
# for internet before installing from the bundled
|
||||
# packages instead (default 90).
|
||||
# KIOSK_STALL_TIMEOUT=N on an --offline ISO, unattended: if the install
|
||||
# prints nothing for N seconds (a stalled download on
|
||||
# a flaky link), stop it and finish from the bundled
|
||||
# packages instead (default 600).
|
||||
################################################################################
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CONF=/etc/kiosk-firstboot.conf
|
||||
STATE_DIR=/var/lib/kiosk-firstboot
|
||||
PENDING="$STATE_DIR/pending"
|
||||
DONE_MARKER="$STATE_DIR/done"
|
||||
SUDOERS_DROPIN=/etc/sudoers.d/90-kiosk-firstboot
|
||||
APT_DROPIN=/etc/apt/apt.conf.d/90-kiosk-firstboot
|
||||
REPO_SRC=/opt/ubuntu-based-kiosk
|
||||
LOG="$STATE_DIR/install.log"
|
||||
OFFLINE_BUNDLE=/opt/kiosk-offline
|
||||
OFFLINE_APT=/usr/local/sbin/kiosk-offline-apt
|
||||
OFFLINE_FLAG="$STATE_DIR/offline-mode"
|
||||
|
||||
KIOSK_UNATTENDED=0
|
||||
KIOSK_OFFLINE_WAIT=90
|
||||
KIOSK_STALL_TIMEOUT=600
|
||||
# shellcheck source=/dev/null
|
||||
[[ -f "$CONF" ]] && source "$CONF"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$SUDOERS_DROPIN" "$APT_DROPIN"
|
||||
# Offline mode was only for this install - hand apt back to the real
|
||||
# archive so normal updates work once the kiosk is online.
|
||||
if [[ -f "$OFFLINE_FLAG" ]]; then
|
||||
"$OFFLINE_APT" off >/dev/null 2>&1 || true
|
||||
rm -f "$OFFLINE_FLAG"
|
||||
fi
|
||||
if [[ -f "$DONE_MARKER" ]]; then
|
||||
rm -f "$PENDING"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--cleanup" ]]; then
|
||||
cleanup
|
||||
exit 0
|
||||
fi
|
||||
|
||||
have_bundle() { [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; }
|
||||
|
||||
# Finish this install from the bundled packages instead of the internet
|
||||
# (cleanup() turns it back off). install.sh notices via lib/config.sh's
|
||||
# offline_mode_active.
|
||||
switch_to_offline() {
|
||||
touch "$OFFLINE_FLAG"
|
||||
if ! "$OFFLINE_APT" on; then
|
||||
echo "ERROR: couldn't switch apt to the offline bundle."
|
||||
rm -f "$OFFLINE_FLAG"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
|
||||
# this unit and normally starts by itself once we exit; this just covers
|
||||
# a getty that was stopped or never queued (e.g. started by hand later).
|
||||
release_tty() {
|
||||
systemctl --no-block start getty@tty1.service 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT TERM HUP
|
||||
|
||||
banner() {
|
||||
clear
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Ubuntu Based Kiosk - First Boot Setup"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
}
|
||||
|
||||
# Interactive-mode escape hatch when something needs a human: retry,
|
||||
# drop to a shell as the admin user to fix it, or give up until next boot.
|
||||
# Returns 0 to retry, 1 to give up.
|
||||
offer_retry() {
|
||||
local choice offline_opt=""
|
||||
have_bundle && [[ ! -f "$OFFLINE_FLAG" ]] && offline_opt=" [O] Install from the offline bundle on this disk"
|
||||
while true; do
|
||||
echo
|
||||
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt$offline_opt"
|
||||
read -r -p "Choice [R]: " choice
|
||||
case "${choice,,}" in
|
||||
""|r) return 0 ;;
|
||||
o) [[ -n "$offline_opt" ]] && switch_to_offline && return 0 ;;
|
||||
s) runuser -l "$ADMIN_USER" ;;
|
||||
q)
|
||||
echo "Setup will run again on next boot, or run it now with:"
|
||||
echo " sudo systemctl start kiosk-firstboot"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Returns 0 once online, 2 to install from the offline bundle instead
|
||||
# (only when there is one), 1 to give up.
|
||||
wait_for_internet() {
|
||||
local waited=0
|
||||
until curl -fsS --max-time 10 -o /dev/null https://deb.nodesource.com/ 2>/dev/null \
|
||||
|| curl -fsS --max-time 10 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null; do
|
||||
if (( waited == 0 )); then
|
||||
echo "Waiting for an internet connection (needed for packages,"
|
||||
echo "Node.js and Electron)..."
|
||||
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; then
|
||||
echo "This disk has the offline bundle: installing from it instead"
|
||||
echo "if there's still no internet after ${KIOSK_OFFLINE_WAIT}s."
|
||||
fi
|
||||
fi
|
||||
sleep 10
|
||||
waited=$((waited + 10))
|
||||
if [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]] && (( waited >= KIOSK_OFFLINE_WAIT )); then
|
||||
echo "No internet after ${waited}s - installing from the offline bundle on this disk."
|
||||
return 2
|
||||
fi
|
||||
if (( waited % 120 == 0 )); then
|
||||
echo "Still no internet after ${waited}s. Network status:"
|
||||
networkctl list --no-pager 2>/dev/null || ip -brief addr
|
||||
if [[ "$KIOSK_UNATTENDED" != "1" ]]; then
|
||||
echo "WiFi or static IP? Use the Shell option and edit /etc/netplan/*.yaml, then 'sudo netplan apply'."
|
||||
offer_retry || return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "Internet connection OK."
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
# Keep kernel/systemd chatter off the screen the installer is drawing on.
|
||||
dmesg -n 1 2>/dev/null || true
|
||||
chvt 1 2>/dev/null || true
|
||||
export TERM=linux
|
||||
|
||||
ADMIN_USER="$(getent passwd 1000 | cut -d: -f1)"
|
||||
banner
|
||||
if [[ -z "$ADMIN_USER" ]]; then
|
||||
echo "ERROR: no regular user (uid 1000) found - can't run install.sh."
|
||||
echo "Create one, then: sudo systemctl start kiosk-firstboot"
|
||||
release_tty
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ADMIN_USER" == "kiosk" ]]; then
|
||||
echo "ERROR: the admin account is named 'kiosk', which install.sh reserves"
|
||||
echo "for the kiosk's own restricted user. Reinstall with a different username."
|
||||
release_tty
|
||||
exit 1
|
||||
fi
|
||||
ADMIN_HOME="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
|
||||
REPO_DEST="$ADMIN_HOME/ubuntu-based-kiosk"
|
||||
|
||||
if [[ ! -d "$REPO_DEST" ]]; then
|
||||
cp -a "$REPO_SRC" "$REPO_DEST"
|
||||
chmod -R u+w "$REPO_DEST"
|
||||
chown -R "$ADMIN_USER:$ADMIN_USER" "$REPO_DEST"
|
||||
fi
|
||||
|
||||
echo "Admin user : $ADMIN_USER"
|
||||
echo "Installer : $REPO_DEST/install.sh"
|
||||
echo "Mode : $([[ "$KIOSK_UNATTENDED" == "1" ]] && echo "unattended (defaults, reboots when done)" || echo "interactive")"
|
||||
echo "Log : $LOG"
|
||||
echo
|
||||
|
||||
# Anything else on a fresh system that wants the dpkg lock (e.g. the
|
||||
# first unattended-upgrades run): wait for it rather than failing. Not
|
||||
# `cloud-init status --wait` - cloud-init's final stage runs after
|
||||
# multi-user.target, which waits for this service: a deadlock.
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_DROPIN"
|
||||
|
||||
wait_for_internet
|
||||
case $? in
|
||||
0) ;;
|
||||
2)
|
||||
# install.sh notices this by itself (lib/config.sh's
|
||||
# offline_mode_active) and unpacks prebuilt node_modules instead
|
||||
# of running npm; cleanup() switches it back off.
|
||||
touch "$OFFLINE_FLAG"
|
||||
if ! "$OFFLINE_APT" on; then
|
||||
echo "ERROR: couldn't switch apt to the offline bundle."
|
||||
release_tty
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*) release_tty; exit 1 ;;
|
||||
esac
|
||||
|
||||
# install.sh's own preflight needs jq; Advanced -> Upgrade needs git.
|
||||
echo "Installing installer prerequisites (jq, git)..."
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update -q >/dev/null
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -q jq git curl >/dev/null
|
||||
|
||||
cat > "$SUDOERS_DROPIN".tmp <<SUDOERS
|
||||
# Temporary - removed by /usr/local/sbin/kiosk-firstboot when it exits.
|
||||
$ADMIN_USER ALL=(ALL) NOPASSWD: ALL
|
||||
Defaults:$ADMIN_USER env_keep += "DEBIAN_FRONTEND"
|
||||
SUDOERS
|
||||
chmod 440 "$SUDOERS_DROPIN".tmp
|
||||
if ! visudo -cf "$SUDOERS_DROPIN".tmp >/dev/null; then
|
||||
rm -f "$SUDOERS_DROPIN".tmp
|
||||
echo "ERROR: generated sudoers drop-in failed validation."
|
||||
release_tty
|
||||
exit 1
|
||||
fi
|
||||
mv "$SUDOERS_DROPIN".tmp "$SUDOERS_DROPIN"
|
||||
|
||||
run_install() {
|
||||
local -a envs=(
|
||||
HOME="$ADMIN_HOME" USER="$ADMIN_USER" LOGNAME="$ADMIN_USER"
|
||||
SHELL=/bin/bash TERM=linux LANG="${LANG:-C.UTF-8}"
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
# config.sh would otherwise derive this from SUDO_USER/whoami.
|
||||
BUILD_USER="$ADMIN_USER"
|
||||
KIOSK_UNATTENDED="$KIOSK_UNATTENDED"
|
||||
KIOSK_PROVISION_DONE_MARKER="$DONE_MARKER"
|
||||
)
|
||||
# Even interactively: stdout goes through tee, which debconf dialogs
|
||||
# cannot draw on, and nothing install.sh installs needs a debconf answer.
|
||||
envs+=(DEBIAN_FRONTEND=noninteractive)
|
||||
|
||||
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
|
||||
# config.sh reads as "the admin running this tool".
|
||||
# A stalled download on a flaky connection would otherwise hang an
|
||||
# unattended install forever. With the offline bundle as a safety net,
|
||||
# watch the log: no new output for KIOSK_STALL_TIMEOUT seconds means
|
||||
# stuck, so the whole process group is stopped (the caller then
|
||||
# finishes from the bundle).
|
||||
if [[ "$KIOSK_UNATTENDED" == "1" ]] && have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||
touch "$LOG"
|
||||
setsid bash -c '
|
||||
runuser -u "$1" -- env -i "${@:4}" bash -c "cd \"\$1\" && exec ./install.sh" _ "$2" 2>&1 | tee -a "$3"
|
||||
exit "${PIPESTATUS[0]}"
|
||||
' _ "$ADMIN_USER" "$REPO_DEST" "$LOG" "${envs[@]}" &
|
||||
local pid=$!
|
||||
(
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 20
|
||||
age=$(( $(date +%s) - $(stat -c %Y "$LOG") ))
|
||||
if (( age > KIOSK_STALL_TIMEOUT )); then
|
||||
echo
|
||||
echo "No output for ${age}s - the online install looks stuck; stopping it."
|
||||
kill -TERM -- "-$pid" 2>/dev/null
|
||||
sleep 30
|
||||
kill -KILL -- "-$pid" 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
) &
|
||||
local watcher=$!
|
||||
wait "$pid"
|
||||
local rc=$?
|
||||
kill "$watcher" 2>/dev/null
|
||||
wait "$watcher" 2>/dev/null
|
||||
return "$rc"
|
||||
fi
|
||||
runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
|
||||
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
|
||||
2>&1 | tee -a "$LOG"
|
||||
return "${PIPESTATUS[0]}"
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while true; do
|
||||
run_install
|
||||
rc=$?
|
||||
if [[ -f "$DONE_MARKER" ]]; then
|
||||
# Reached only when the final "Reboot now?" was declined.
|
||||
echo
|
||||
echo "Kiosk install complete. Reboot to start the kiosk: sudo reboot"
|
||||
cleanup
|
||||
release_tty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
|
||||
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
|
||||
# Online attempt failed or stalled: the bundle on this disk is
|
||||
# always going to work, so use it rather than retry the same thing.
|
||||
if have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||
echo "Online install didn't finish - continuing from the offline bundle on this disk."
|
||||
if switch_to_offline; then
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if (( attempt >= 3 )); then
|
||||
echo "Giving up after $attempt attempts; will try again next boot."
|
||||
release_tty
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Retrying in 60 seconds (attempt $attempt of 3)..."
|
||||
sleep 60
|
||||
else
|
||||
offer_retry || { release_tty; exit 1; }
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,43 @@
|
||||
# Installed by the custom ISO (iso/build-iso.sh) and enabled from its
|
||||
# autoinstall late-commands. Runs install.sh once, on tty1, the first
|
||||
# time the freshly installed system boots - ordered before getty@tty1,
|
||||
# so the login prompt only appears on tty1 once this has finished and
|
||||
# an interactive install has the keyboard and screen to itself. /var/lib/kiosk-firstboot/pending is removed once the install
|
||||
# finishes, so this is a no-op on every boot after that; an interrupted
|
||||
# or failed install leaves it in place and simply runs again next boot.
|
||||
#
|
||||
# Ordering, learned the hard way on 26.04:
|
||||
# - NOT After=cloud-final.service: cloud-final is After=multi-user.target,
|
||||
# and multi-user.target (which wants this unit) is After= it - an
|
||||
# ordering cycle, which systemd "fixes" at boot by dropping a job: this
|
||||
# one, so the install silently never ran. (The script doesn't wait for
|
||||
# cloud-init either, for the same reason - it would deadlock.)
|
||||
# - NOT Conflicts=getty@tty1: getty.target pulls getty@tty1 into the same
|
||||
# boot transaction, and systemd resolves a conflict between two wanted
|
||||
# jobs by dropping one. Before= alone is enough - getty@tty1 waits for
|
||||
# this oneshot to finish.
|
||||
[Unit]
|
||||
Description=Ubuntu Based Kiosk first-boot install
|
||||
After=network-online.target systemd-user-sessions.service plymouth-quit-wait.service
|
||||
Wants=network-online.target
|
||||
Before=getty@tty1.service
|
||||
ConditionPathExists=/var/lib/kiosk-firstboot/pending
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/kiosk-firstboot
|
||||
# Belt and braces for the temporary sudoers/apt drop-ins when install.sh
|
||||
# reboots out from under the script (its own EXIT trap normally does this).
|
||||
ExecStopPost=/usr/local/sbin/kiosk-firstboot --cleanup
|
||||
StandardInput=tty-force
|
||||
StandardOutput=tty
|
||||
StandardError=tty
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TimeoutStartSec=infinity
|
||||
TimeoutStopSec=30
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# kiosk-offline-apt - point apt at the offline install bundle, or back.
|
||||
#
|
||||
# sudo kiosk-offline-apt on apt uses ONLY /opt/kiosk-offline/apt
|
||||
# sudo kiosk-offline-apt off back to the normal Ubuntu archive
|
||||
# kiosk-offline-apt status
|
||||
#
|
||||
# Installed by the custom ISO (iso/build-iso.sh); the bundle itself is
|
||||
# only there on ISOs built with --offline. kiosk-firstboot turns this on
|
||||
# by itself when first boot finds no internet, and back off afterwards;
|
||||
# so does the CUPS addon install (lib/config.sh's
|
||||
# run_with_offline_fallback). By hand it's for installing anything else
|
||||
# from the bundle with no internet. While it's on, install.sh also
|
||||
# unpacks the bundle's prebuilt node_modules instead of running npm
|
||||
# (lib/config.sh's offline_mode_active checks for the file this writes).
|
||||
#
|
||||
# "Only" matters: with the normal archive still listed, apt would pick
|
||||
# newer versions from it that it then can't download.
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE_DIR=/opt/kiosk-offline
|
||||
APT_CONF=/etc/apt/apt.conf.d/90-kiosk-offline
|
||||
|
||||
case "${1:-status}" in
|
||||
on)
|
||||
[[ $EUID -eq 0 ]] || { echo "Run with sudo" >&2; exit 1; }
|
||||
if [[ ! -f "$BUNDLE_DIR/apt/Packages" ]]; then
|
||||
echo "No offline bundle at $BUNDLE_DIR (ISO not built with --offline)" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$BUNDLE_DIR/sources.d"
|
||||
echo "deb [trusted=yes] file:$BUNDLE_DIR/apt ./" > "$BUNDLE_DIR/offline.list"
|
||||
cat > "$APT_CONF" <<EOF
|
||||
// Written by kiosk-offline-apt - remove with: sudo kiosk-offline-apt off
|
||||
Dir::Etc::SourceList "$BUNDLE_DIR/offline.list";
|
||||
Dir::Etc::SourceParts "$BUNDLE_DIR/sources.d";
|
||||
Acquire::Languages "none";
|
||||
EOF
|
||||
apt-get update -q >/dev/null
|
||||
echo "apt now installs from the offline bundle only ($BUNDLE_DIR)."
|
||||
;;
|
||||
off)
|
||||
[[ $EUID -eq 0 ]] || { echo "Run with sudo" >&2; exit 1; }
|
||||
rm -f "$APT_CONF"
|
||||
echo "apt is back on the normal Ubuntu sources."
|
||||
echo "Run 'sudo apt update' once there's internet."
|
||||
;;
|
||||
status)
|
||||
if [[ -f "$APT_CONF" ]]; then
|
||||
echo "on (apt uses $BUNDLE_DIR/apt only)"
|
||||
else
|
||||
echo "off"
|
||||
fi
|
||||
[[ -f "$BUNDLE_DIR/bundle-info.txt" ]] && sed -n '1,3p;5,6p' "$BUNDLE_DIR/bundle-info.txt"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: kiosk-offline-apt on|off|status" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# iso/offline/build-bundle.sh - Build the offline install bundle that
|
||||
# `iso/build-iso.sh --offline` puts on the ISO, so a kiosk can be installed
|
||||
# with no internet at all. Called by build-iso.sh; not normally run by hand.
|
||||
#
|
||||
# Usage: build-bundle.sh OUT_DIR SOURCE_ISO REPO_DIR CACHE_DIR
|
||||
#
|
||||
# OUT_DIR ends up as /opt/kiosk-offline on the installed system:
|
||||
# apt/ Local apt repository (flat, [trusted=yes]) with every
|
||||
# package install.sh's provisioning and the addons ask
|
||||
# for (CUPS, LMS + Squeezelite, Asterisk Intercom, VNC,
|
||||
# WireGuard, Emergency Hotspot - not Tailscale/Netbird,
|
||||
# which need their vendors' servers anyway), Lyrion's
|
||||
# own .deb, NodeSource's nodejs, and all their
|
||||
# dependencies that a fresh Ubuntu Server install from
|
||||
# SOURCE_ISO doesn't already have.
|
||||
# npm/kiosk-app-node_modules.tar.gz Prebuilt node_modules (Electron
|
||||
# npm/webui-node_modules.tar.gz included) in place of `npm install`.
|
||||
# bundle-info.txt What was bundled, from where, when.
|
||||
#
|
||||
# Package lists are read from REPO_DIR's own lib/provision.sh and menus/
|
||||
# (PROVISION_APT_PACKAGES, CUPS_APT_PACKAGES, SQUEEZELITE_APT_PACKAGES,
|
||||
# LMS_DEB_URL, ...), so the bundle always matches what install.sh asks for.
|
||||
#
|
||||
# Dependencies are resolved against SOURCE_ISO's own package manifest - a
|
||||
# fake dpkg status built from it (fake-dpkg-status.py) - so apt only
|
||||
# downloads what's actually missing and makes the same choices (virtual
|
||||
# packages, alternatives) it will make on the real target.
|
||||
#
|
||||
# CACHE_DIR keeps apt's downloaded .debs, npm's cache and Electron's
|
||||
# download between builds, so a rebuild only fetches what changed.
|
||||
#
|
||||
# Build host: Ubuntu or Debian, amd64 (Node.js from the bundled .deb is
|
||||
# run here to build node_modules). Needs: apt-get, apt-ftparchive
|
||||
# (apt-utils), dpkg-deb, python3, gpg, curl, xorriso, and
|
||||
# /usr/share/keyrings/ubuntu-archive-keyring.gpg (ubuntu-keyring).
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="$1"
|
||||
SRC_ISO="$2"
|
||||
REPO_DIR="$3"
|
||||
CACHE_DIR="$4"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
UBUNTU_MIRROR="${UBUNTU_MIRROR:-http://archive.ubuntu.com/ubuntu}"
|
||||
NODESOURCE_REPO="https://deb.nodesource.com/node_22.x"
|
||||
NODESOURCE_KEY="https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key"
|
||||
UBUNTU_KEYRING=/usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
|
||||
# Needed on the target beyond install.sh's own lists: kiosk-firstboot's
|
||||
# prerequisites, wget (lib/electron.sh's fallback), and openssh-server
|
||||
# (installed by the Ubuntu installer only when it had a network).
|
||||
EXTRA_APT_PACKAGES=(nodejs jq git curl wget unzip openssh-server)
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
info() { echo " $*"; }
|
||||
|
||||
for cmd in apt-get apt-ftparchive dpkg-deb dpkg python3 gpg curl xorriso tar gzip; do
|
||||
command -v "$cmd" &>/dev/null \
|
||||
|| die "--offline needs '$cmd' - on Ubuntu/Debian: sudo apt install apt-utils dpkg python3 gnupg curl xorriso"
|
||||
done
|
||||
[[ "$(dpkg --print-architecture)" == "amd64" ]] \
|
||||
|| die "--offline must be built on an amd64 machine (it runs the bundled Node.js to build node_modules)"
|
||||
[[ -r "$UBUNTU_KEYRING" ]] \
|
||||
|| die "missing $UBUNTU_KEYRING - install it: sudo apt install ubuntu-keyring"
|
||||
|
||||
rm -rf "$OUT_DIR/apt" "$OUT_DIR/npm"
|
||||
mkdir -p "$OUT_DIR/apt" "$OUT_DIR/npm" "$CACHE_DIR"
|
||||
# apt's Dir= and Signed-By need absolute paths.
|
||||
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||
CACHE_DIR="$(cd "$CACHE_DIR" && pwd)"
|
||||
REPO_DIR="$(cd "$REPO_DIR" && pwd)"
|
||||
SRC_ISO="$(cd "$(dirname "$SRC_ISO")" && pwd)/$(basename "$SRC_ISO")"
|
||||
|
||||
################################################################################
|
||||
# Which Ubuntu release, and what a default server install already has
|
||||
################################################################################
|
||||
|
||||
codename="$(xorriso -indev "$SRC_ISO" -ls /dists 2>/dev/null \
|
||||
| tr -d "'" | awk '{print $NF}' | grep -E '^[a-z]+$' | head -1)"
|
||||
[[ -n "$codename" ]] || die "couldn't tell the Ubuntu release of $SRC_ISO (no /dists on it)"
|
||||
|
||||
manifest="$CACHE_DIR/server.manifest"
|
||||
xorriso -osirrox on -indev "$SRC_ISO" \
|
||||
-extract /casper/ubuntu-server-minimal.ubuntu-server.manifest.full "$manifest" 2>/dev/null \
|
||||
|| die "no server package manifest on $SRC_ISO (casper/ubuntu-server-minimal.ubuntu-server.manifest.full)"
|
||||
chmod u+w "$manifest"
|
||||
|
||||
################################################################################
|
||||
# Standalone apt environment for $codename (never touches the host's apt)
|
||||
################################################################################
|
||||
|
||||
R="$CACHE_DIR/aptroot-$codename"
|
||||
mkdir -p "$R/etc/apt/apt.conf.d" "$R/etc/apt/preferences.d" "$R/etc/apt/sources.list.d" \
|
||||
"$R/var/lib/apt/lists/partial" "$R/var/cache/apt/archives/partial" "$R/var/lib/dpkg" "$R/keys"
|
||||
: > "$R/etc/apt/sources.list"
|
||||
cp "$UBUNTU_KEYRING" "$R/keys/ubuntu.gpg"
|
||||
curl -fsSL "$NODESOURCE_KEY" | gpg --dearmor --yes -o "$R/keys/nodesource.gpg"
|
||||
cat > "$R/etc/apt/sources.list.d/bundle.sources" <<EOF
|
||||
Types: deb
|
||||
URIs: $UBUNTU_MIRROR
|
||||
Suites: $codename $codename-updates $codename-security
|
||||
Components: main restricted universe multiverse
|
||||
Signed-By: $R/keys/ubuntu.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: $NODESOURCE_REPO
|
||||
Suites: nodistro
|
||||
Components: main
|
||||
Signed-By: $R/keys/nodesource.gpg
|
||||
EOF
|
||||
|
||||
APT=(apt-get
|
||||
-o "Dir=$R" -o "Dir::State::status=$R/var/lib/dpkg/status"
|
||||
-o APT::Architecture=amd64 -o APT::Architectures::=amd64
|
||||
-o Acquire::Languages=none -o Acquire::GzipIndexes=false
|
||||
-o Debug::NoLocking=1 -q)
|
||||
|
||||
info "Fetching $codename package indexes..."
|
||||
: > "$R/var/lib/dpkg/status"
|
||||
"${APT[@]}" update >/dev/null
|
||||
python3 "$HERE/fake-dpkg-status.py" "$manifest" "$R/var/lib/apt/lists" > "$R/var/lib/dpkg/status"
|
||||
"${APT[@]}" check >/dev/null || die "baseline from the ISO manifest is inconsistent"
|
||||
|
||||
################################################################################
|
||||
# Packages
|
||||
################################################################################
|
||||
|
||||
# shellcheck disable=SC1091,SC2034
|
||||
# Each list lives next to the code that installs it; lib/config.sh first
|
||||
# for the path defaults those files use at the top level.
|
||||
mapfile -t packages < <(
|
||||
SCRIPT_DIR="$REPO_DIR"
|
||||
source "$REPO_DIR/lib/config.sh"
|
||||
source "$REPO_DIR/lib/provision.sh"
|
||||
source "$REPO_DIR/menus/addon_cups.sh"
|
||||
source "$REPO_DIR/menus/addon_lms_squeezelite.sh"
|
||||
source "$REPO_DIR/menus/addon_asterisk_intercom.sh"
|
||||
source "$REPO_DIR/menus/addon_remote_access.sh"
|
||||
source "$REPO_DIR/menus/advanced_emergency_hotspot.sh"
|
||||
printf '%s\n' "${PROVISION_APT_PACKAGES[@]}" "${PROVISION_INTEL_APT_PACKAGES[@]}" \
|
||||
"${CUPS_APT_PACKAGES[@]}" "${SQUEEZELITE_APT_PACKAGES[@]}" \
|
||||
"${ASTERISK_INTERCOM_APT_PACKAGES[@]}" "${VNC_APT_PACKAGES[@]}" \
|
||||
"${WIREGUARD_APT_PACKAGES[@]}" "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}" \
|
||||
"${EXTRA_APT_PACKAGES[@]}"
|
||||
)
|
||||
(( ${#packages[@]} > 20 )) || die "couldn't read the package lists from $REPO_DIR"
|
||||
|
||||
# Lyrion Music Server isn't in Ubuntu's archive: bundle the exact .deb the
|
||||
# LMS addon downloads online, and let apt resolve its dependencies from it
|
||||
# (a local .deb path in the install list).
|
||||
# shellcheck disable=SC1091,SC2034
|
||||
lms_url="$(SCRIPT_DIR="$REPO_DIR"; source "$REPO_DIR/lib/config.sh"; source "$REPO_DIR/menus/addon_lms_squeezelite.sh"; echo "$LMS_DEB_URL")"
|
||||
lms_deb="$CACHE_DIR/$(basename "$lms_url")"
|
||||
if [[ ! -s "$lms_deb" ]]; then
|
||||
info "Downloading $(basename "$lms_url")..."
|
||||
curl -fL --retry 3 -o "$lms_deb.part" "$lms_url" || die "couldn't download $lms_url"
|
||||
mv "$lms_deb.part" "$lms_deb"
|
||||
fi
|
||||
dpkg-deb -I "$lms_deb" >/dev/null 2>&1 || { rm -f "$lms_deb"; die "$lms_deb isn't a valid .deb - deleted, re-run to retry"; }
|
||||
packages+=("$lms_deb")
|
||||
|
||||
info "Resolving ${#packages[@]} packages (with recommends, as install.sh installs them)..."
|
||||
# --print-uris against an empty archive dir: it leaves out anything already
|
||||
# in the cache, which on a rebuild would be everything.
|
||||
rm -rf "$R/empty-archives"; mkdir -p "$R/empty-archives/partial"
|
||||
mapfile -t debs < <("${APT[@]}" -qq -o "Dir::Cache::archives=$R/empty-archives" \
|
||||
install --print-uris -y "${packages[@]}" | awk '$1 !~ /^.file:/ {print $2}') # the local LMS .deb is copied below
|
||||
(( ${#debs[@]} )) || die "apt resolved nothing to download - check the output above"
|
||||
|
||||
info "Downloading ${#debs[@]} .debs (cached between builds)..."
|
||||
"${APT[@]}" install --download-only -y "${packages[@]}" >/dev/null
|
||||
|
||||
for f in "${debs[@]}"; do
|
||||
cp "$R/var/cache/apt/archives/$f" "$OUT_DIR/apt/"
|
||||
done
|
||||
cp "$lms_deb" "$OUT_DIR/apt/"
|
||||
(
|
||||
cd "$OUT_DIR/apt"
|
||||
apt-ftparchive packages . > Packages 2>/dev/null
|
||||
gzip -9 -k Packages
|
||||
apt-ftparchive release . > Release
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# node_modules, built with the exact Node.js being bundled
|
||||
################################################################################
|
||||
|
||||
node_deb="$(printf '%s\n' "${debs[@]}" | grep -E '^nodejs_' | head -1 || true)"
|
||||
[[ -n "$node_deb" ]] || node_deb="$(cd "$R/var/cache/apt/archives" && ls nodejs_*_amd64.deb 2>/dev/null | sort -V | tail -1)"
|
||||
[[ -n "$node_deb" ]] || die "no nodejs .deb downloaded"
|
||||
node_root="$CACHE_DIR/node-root"
|
||||
rm -rf "$node_root"
|
||||
dpkg-deb -x "$R/var/cache/apt/archives/$node_deb" "$node_root"
|
||||
export PATH="$node_root/usr/bin:$PATH"
|
||||
export npm_config_cache="$CACHE_DIR/npm-cache"
|
||||
export electron_config_cache="$CACHE_DIR/electron-cache"
|
||||
export npm_config_update_notifier=false npm_config_fund=false npm_config_audit=false
|
||||
info "Building node_modules with Node.js $(node -v)..."
|
||||
|
||||
# build_modules NAME SRC_DIR NPM_ARGS...
|
||||
build_modules() {
|
||||
local name="$1" src="$2"; shift 2
|
||||
local tmp="$CACHE_DIR/build-$name"
|
||||
rm -rf "$tmp"; mkdir -p "$tmp"
|
||||
cp "$src/package.json" "$tmp/"
|
||||
[[ -f "$src/package-lock.json" ]] && cp "$src/package-lock.json" "$tmp/"
|
||||
(cd "$tmp" && npm install --no-progress "$@" >/dev/null) || die "npm install failed for $name"
|
||||
# Electron's package no longer fetches its binary at install time (no
|
||||
# postinstall since ~v42) - lib/electron.sh runs this same install.js
|
||||
# on the target when online; offline it has to already be in dist/.
|
||||
if [[ -f "$tmp/node_modules/electron/install.js" ]]; then
|
||||
# Retried: a large download cut off mid-stream crashes it outright.
|
||||
local try
|
||||
for try in 1 2 3; do
|
||||
(cd "$tmp" && node node_modules/electron/install.js) && break
|
||||
(( try < 3 )) || die "Electron binary download failed (3 attempts)"
|
||||
info "Electron download failed - retrying ($((try + 1))/3)..."
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
tar -czf "$OUT_DIR/npm/${name}-node_modules.tar.gz" --owner=0 --group=0 -C "$tmp" node_modules
|
||||
info "$name: $(du -sh "$tmp/node_modules" | cut -f1) -> $(du -h "$OUT_DIR/npm/${name}-node_modules.tar.gz" | cut -f1) compressed"
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
build_modules kiosk-app "$REPO_DIR/kiosk-app"
|
||||
tar -tzf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/dist/electron >/dev/null 2>&1 \
|
||||
|| die "Electron binary missing from the built kiosk-app modules (download blocked?)"
|
||||
build_modules webui "$REPO_DIR/webui" --omit=dev
|
||||
|
||||
################################################################################
|
||||
|
||||
electron_ver="$(tar -xzOf "$OUT_DIR/npm/kiosk-app-node_modules.tar.gz" node_modules/electron/package.json \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
|
||||
{
|
||||
echo "Ubuntu Based Kiosk offline bundle"
|
||||
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Ubuntu: $codename (resolved against $(basename "$SRC_ISO"))"
|
||||
echo "Packages: ${#debs[@]} .debs for: ${packages[*]}"
|
||||
echo "Node.js: ${node_deb}"
|
||||
echo "Electron: $electron_ver"
|
||||
} > "$OUT_DIR/bundle-info.txt"
|
||||
|
||||
info "Bundle: $(du -sh "$OUT_DIR" | cut -f1) (${#debs[@]} packages, Node.js $(node -v), Electron $electron_ver)"
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write a dpkg status file claiming a set of packages is installed.
|
||||
|
||||
Usage: fake-dpkg-status.py MANIFEST LISTS_DIR > status
|
||||
|
||||
MANIFEST is an Ubuntu ISO's casper/*.manifest.full ("name[:arch]<TAB>version"
|
||||
per line) - the packages a default Ubuntu Server install ends up with.
|
||||
LISTS_DIR is an apt lists directory holding uncompressed *_Packages indexes.
|
||||
|
||||
iso/offline/build-bundle.sh resolves the kiosk's packages against this, so
|
||||
apt only downloads what a freshly installed server is actually missing
|
||||
(and makes the same alternative/virtual-package choices the real target
|
||||
will), instead of the whole dependency closure down to libc6.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
KEEP = ("Package", "Essential", "Priority", "Section", "Architecture",
|
||||
"Multi-Arch", "Source", "Version", "Replaces", "Provides", "Depends",
|
||||
"Pre-Depends", "Recommends", "Suggests", "Breaks", "Conflicts")
|
||||
|
||||
|
||||
def stanzas(path):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
cur = []
|
||||
for line in f:
|
||||
if line.strip() == "":
|
||||
if cur:
|
||||
yield cur
|
||||
cur = []
|
||||
else:
|
||||
cur.append(line.rstrip("\n"))
|
||||
if cur:
|
||||
yield cur
|
||||
|
||||
|
||||
def parse(lines):
|
||||
fields, order, key = {}, [], None
|
||||
for line in lines:
|
||||
if line[:1] in (" ", "\t") and key:
|
||||
fields[key] += "\n" + line
|
||||
else:
|
||||
key, _, val = line.partition(":")
|
||||
fields[key] = val.strip()
|
||||
order.append(key)
|
||||
return fields
|
||||
|
||||
|
||||
def main():
|
||||
manifest, lists_dir = sys.argv[1], sys.argv[2]
|
||||
wanted = {}
|
||||
with open(manifest) as f:
|
||||
for line in f:
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) != 2 or parts[0].startswith("snap:"):
|
||||
continue
|
||||
wanted[parts[0].split(":")[0]] = parts[1]
|
||||
|
||||
exact, any_version = {}, {}
|
||||
for path in glob.glob(os.path.join(lists_dir, "*_Packages")):
|
||||
for st in stanzas(path):
|
||||
p = parse(st)
|
||||
name = p.get("Package")
|
||||
if name not in wanted:
|
||||
continue
|
||||
any_version.setdefault(name, p)
|
||||
if p.get("Version") == wanted[name]:
|
||||
exact[name] = p
|
||||
|
||||
missing = 0
|
||||
for name, version in sorted(wanted.items()):
|
||||
p = exact.get(name) or any_version.get(name)
|
||||
if p is None:
|
||||
missing += 1
|
||||
continue
|
||||
out = [f"Package: {name}", "Status: install ok installed"]
|
||||
for k in KEEP[1:]:
|
||||
if k == "Version":
|
||||
out.append(f"Version: {version}")
|
||||
elif k in p:
|
||||
val = p[k]
|
||||
# The archive no longer carries this exact version (an
|
||||
# update superseded it), so these fields came from a
|
||||
# different one and its dependencies (version pins on
|
||||
# siblings, a newer kernel ABI...) don't describe what's
|
||||
# installed. Already-installed packages' own dependencies
|
||||
# are assumed satisfied anyway, so just leave them out.
|
||||
if name not in exact and k in ("Depends", "Pre-Depends", "Recommends"):
|
||||
continue
|
||||
# Same for versioned Breaks/Conflicts - drop them outright
|
||||
# (unversioned ones still hold for any version).
|
||||
if name not in exact and k in ("Breaks", "Conflicts"):
|
||||
val = ", ".join(c.strip() for c in val.split(",") if "(" not in c)
|
||||
if not val:
|
||||
continue
|
||||
out.append(f"{k}: {val}")
|
||||
print("\n".join(out) + "\n")
|
||||
print(f"fake-dpkg-status: {len(wanted) - missing} packages marked installed,"
|
||||
f" {missing} not in the archive indexes (skipped)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Document</title>
|
||||
<!-- Shown for a site that's a local .docx file - see docview.js. The
|
||||
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #666; }
|
||||
#msg {
|
||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
<script src="docx-render.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="msg">Loading document…</div>
|
||||
<div id="doc"></div>
|
||||
<script>
|
||||
(() => {
|
||||
const docEl = document.getElementById('doc');
|
||||
const msg = document.getElementById('msg');
|
||||
let cls = null;
|
||||
|
||||
function showMessage(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
docEl.innerHTML = '';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth));
|
||||
|
||||
window.kioskDocView = {
|
||||
async show(d) {
|
||||
if (d.error) { showMessage(d.error); return; }
|
||||
if (!window.KioskDocx || !KioskDocx.available()) {
|
||||
showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl);
|
||||
msg.style.display = 'none';
|
||||
KioskDocx.fit(docEl, cls, window.innerWidth);
|
||||
// Only called on first load or when the file changed on disk -
|
||||
// start a changed document from the top.
|
||||
window.scrollTo(0, 0);
|
||||
} catch (e) {
|
||||
showMessage(`Couldn't show ${d.file}: ${e.message || e}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Word document (.docx) support for main.js.
|
||||
//
|
||||
// A site whose URL is a local .docx file (file:///home/kiosk/docs/menu.docx,
|
||||
// entered in the Sites menu or Web UI as /home/kiosk/docs/menu.docx) is
|
||||
// rendered with docx-preview (an npm dependency of this app - pure
|
||||
// JavaScript, works offline) instead of Chromium offering to download it.
|
||||
// main.js swaps the file URL for docview.html via resolveSiteUrl(), then
|
||||
// attach() hands the page the file's bytes - the page itself has no
|
||||
// filesystem access (contextIsolation, no Node). The file is re-checked
|
||||
// every minute and re-rendered if it changed, so replacing the document
|
||||
// on disk updates the kiosk without a restart.
|
||||
//
|
||||
// Only .docx (Word 2007+). Old binary .doc and LibreOffice .odt aren't
|
||||
// supported by docx-preview - save those as .docx or PDF.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { fileURLToPath, pathToFileURL } = require('url');
|
||||
|
||||
const VIEWER_PAGE = path.join(__dirname, 'docview.html');
|
||||
const REFRESH_MS = 60000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
// The .docx path a site URL points at, or null.
|
||||
function docxPath(url) {
|
||||
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||
let p;
|
||||
try {
|
||||
p = fileURLToPath(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return path.extname(p).toLowerCase() === '.docx' ? p : null;
|
||||
}
|
||||
|
||||
// What a view should actually load for a configured site URL.
|
||||
function resolveSiteUrl(url) {
|
||||
const file = docxPath(url);
|
||||
if (!file) return url;
|
||||
const u = pathToFileURL(VIEWER_PAGE);
|
||||
u.searchParams.set('file', file);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function readDoc(file) {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (st.size > MAX_BYTES) return { file, error: `Document is too large to show (${Math.round(st.size / 1048576)} MB).` };
|
||||
return { file, mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||
} catch (e) {
|
||||
return { file, error: e.code === 'ENOENT' ? `File not found: ${file}` : `Can't read ${file}: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Feed a view's viewer page the document now and again whenever the file
|
||||
// changes. Call once per view; safe on views that never show a document.
|
||||
function attach(webContents) {
|
||||
let lastKey = '';
|
||||
const send = (force) => {
|
||||
if (webContents.isDestroyed()) return;
|
||||
const current = webContents.getURL();
|
||||
if (!current.startsWith(pathToFileURL(VIEWER_PAGE).href)) return;
|
||||
const file = new URL(current).searchParams.get('file');
|
||||
if (!file) return;
|
||||
let key;
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
key = `${st.mtimeMs}:${st.size}`;
|
||||
} catch (e) {
|
||||
key = 'missing';
|
||||
}
|
||||
if (!force && key === lastKey) return;
|
||||
lastKey = key;
|
||||
webContents.executeJavaScript(`window.kioskDocView&&window.kioskDocView.show(${JSON.stringify(readDoc(file))})`)
|
||||
.catch(() => {});
|
||||
};
|
||||
webContents.on('did-finish-load', () => send(true));
|
||||
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||
webContents.once('destroyed', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
module.exports = { resolveSiteUrl, docxPath, attach };
|
||||
@@ -0,0 +1,110 @@
|
||||
// Browser-side .docx rendering shared by docview.html (one document as a
|
||||
// site) and slideshow.html (documents mixed into a folder slideshow).
|
||||
// Needs node_modules/jszip and node_modules/docx-preview loaded first.
|
||||
//
|
||||
// Each render gets its own class prefix and keeps its generated styles
|
||||
// inside its own container, so two documents on screen at once (a
|
||||
// slideshow crossfade) can't restyle each other, and removing the
|
||||
// container removes its styles.
|
||||
(() => {
|
||||
let counter = 0;
|
||||
|
||||
// Word draws most bullets with private-use characters from the Symbol
|
||||
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
|
||||
// render as blanks. docx-preview passes them through into its generated
|
||||
// list CSS; swap the common ones for real Unicode equivalents.
|
||||
const SYMBOL_BULLETS = {
|
||||
'': '•', // Symbol bullet -> •
|
||||
'': '▪', // Wingdings square -> ▪
|
||||
'': '➢', // Wingdings arrowhead -> ➢
|
||||
'': '❖', // Wingdings diamond -> ❖
|
||||
'': '✓', // Wingdings check -> ✓
|
||||
'': '■', // Wingdings black square -> ■
|
||||
'': '❏', // Wingdings box -> ❏
|
||||
};
|
||||
|
||||
function fixSymbolBullets(container) {
|
||||
container.querySelectorAll('style').forEach((st) => {
|
||||
let css = st.textContent;
|
||||
const before = css;
|
||||
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
|
||||
css = css.split(from).join(to);
|
||||
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 ").
|
||||
const hex = from.charCodeAt(0).toString(16);
|
||||
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
|
||||
}
|
||||
if (css !== before) st.textContent = css;
|
||||
});
|
||||
}
|
||||
|
||||
window.KioskDocx = {
|
||||
available() {
|
||||
return !!(window.docx && window.JSZip);
|
||||
},
|
||||
|
||||
base64ToBytes(b64) {
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
},
|
||||
|
||||
// Render into `container` (emptied first). Returns the class prefix
|
||||
// used, for fit().
|
||||
async render(bytes, container) {
|
||||
const cls = `kdocx${++counter}`;
|
||||
container.innerHTML = '';
|
||||
await window.docx.renderAsync(bytes, container, container, {
|
||||
className: cls,
|
||||
inWrapper: true,
|
||||
ignoreLastRenderedPageBreak: true,
|
||||
experimental: true,
|
||||
useBase64URL: true,
|
||||
});
|
||||
const wrapper = container.querySelector(`.${cls}-wrapper`);
|
||||
if (wrapper) {
|
||||
wrapper.style.background = 'transparent';
|
||||
wrapper.style.padding = '16px 0';
|
||||
}
|
||||
container.querySelectorAll(`section.${cls}`).forEach((s) => {
|
||||
s.style.marginBottom = '16px';
|
||||
s.style.boxShadow = '0 2px 8px rgba(0,0,0,.4)';
|
||||
});
|
||||
fixSymbolBullets(container);
|
||||
return cls;
|
||||
},
|
||||
|
||||
// Scale `zoomEl` so the first page fills `width` (capped at maxZoom),
|
||||
// so a portrait page is readable on a landscape screen without
|
||||
// sideways scrolling. CSS zoom keeps scrolling and touch coordinates
|
||||
// consistent.
|
||||
fit(zoomEl, cls, width, maxZoom = 1.6) {
|
||||
const page = zoomEl.querySelector(`section.${cls}`);
|
||||
if (!page) return;
|
||||
zoomEl.style.zoom = 1;
|
||||
const pageWidth = page.getBoundingClientRect().width + 32;
|
||||
zoomEl.style.zoom = Math.min(width / pageWidth, maxZoom);
|
||||
},
|
||||
|
||||
// Scale `zoomEl` so the whole first page fits in width x height
|
||||
// (letterboxed, like an image) - for one-page documents in a slideshow,
|
||||
// where scrolling through the page's empty bottom would be pointless.
|
||||
fitPage(zoomEl, cls, width, height) {
|
||||
const page = zoomEl.querySelector(`section.${cls}`);
|
||||
if (!page) return;
|
||||
zoomEl.style.zoom = 1;
|
||||
const r = page.getBoundingClientRect();
|
||||
zoomEl.style.zoom = Math.min(width / (r.width + 32), height / (r.height + 32));
|
||||
},
|
||||
|
||||
// True when the whole document fits on one physical page. Not just
|
||||
// "one <section>": docx-preview doesn't paginate by itself - it only
|
||||
// starts a new section at an explicit page break - so a long document
|
||||
// without breaks is one very tall section. Each section's min-height
|
||||
// is the paper height, so content that fits leaves it at that height.
|
||||
isSinglePage(zoomEl, cls) {
|
||||
const pages = zoomEl.querySelectorAll(`section.${cls}`);
|
||||
if (pages.length !== 1) return false;
|
||||
const paper = parseFloat(getComputedStyle(pages[0]).minHeight);
|
||||
if (!paper) return false;
|
||||
return pages[0].getBoundingClientRect().height <= paper * 1.02;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
.container { text-align: center; max-width: 600px; }
|
||||
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||
.message { font-size: 20px; margin-bottom: 20px; line-height: 1.5; }
|
||||
.countdown { font-size: 72px; font-weight: bold; color: #e74c3c; margin: 20px 0; }
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 20px 40px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #27ae60;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.btn-primary:hover { background: #229954; }
|
||||
.btn-primary:active { background: #1e8449; }
|
||||
|
||||
.btn-extend { background: #3498db; }
|
||||
.btn-extend:hover { background: #2980b9; }
|
||||
.btn-extend:active { background: #21618c; }
|
||||
|
||||
.btn-home {
|
||||
background: #95a5a6;
|
||||
grid-column: 1 / -1;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.btn-home:hover { background: #7f8c8d; }
|
||||
|
||||
.info {
|
||||
font-size: 14px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>👋 Are you still here?</h2>
|
||||
<div class="message">
|
||||
No activity detected. Choose an option:
|
||||
</div>
|
||||
<div class="countdown" id="countdown">15</div>
|
||||
|
||||
<div class="options">
|
||||
<button class="btn btn-primary" onclick="imHere(0)">
|
||||
✓ Yes, I'm here!
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(15)">
|
||||
🍿 15 more minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(30)">
|
||||
⏱️ 30 more minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(60)">
|
||||
🎬 1 hour
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="imHere(120)">
|
||||
📺 2 hours
|
||||
</button>
|
||||
|
||||
<button class="btn btn-home" onclick="goHome()">
|
||||
🏠 Return to home now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
ℹ️ Extensions pause the inactivity timer<br>
|
||||
Media playback (video/audio) automatically pauses the timer<br>
|
||||
Maximum extension: 4 hours (safety timeout)
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
let count = 15;
|
||||
const interval = setInterval(() => {
|
||||
count--;
|
||||
document.getElementById('countdown').textContent = count;
|
||||
if (count <= 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
function imHere(minutes) {
|
||||
clearInterval(interval);
|
||||
console.log('[PROMPT] User selected: '+(minutes===0?'Continue':minutes+' minutes'));
|
||||
ipcRenderer.send('user-still-here', minutes);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
clearInterval(interval);
|
||||
console.log('[PROMPT] User requested immediate home return');
|
||||
ipcRenderer.send('user-still-here', -1);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
imHere(0);
|
||||
} else if (e.key === 'Escape') {
|
||||
goHome();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.keyboard { width: 100%; max-width: 1200px; margin: 0 auto; }
|
||||
.row { display: flex; justify-content: center; margin-bottom: 8px; gap: 6px; }
|
||||
.key {
|
||||
min-width: 60px; height: 60px;
|
||||
background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%);
|
||||
border: 2px solid #4a5f7f; border-radius: 8px; color: white;
|
||||
font-size: 24px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.1s; user-select: none; box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.key:active {
|
||||
transform: scale(0.95);
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
border-color: #5dade2;
|
||||
}
|
||||
.key.space { flex: 3; }
|
||||
.key.wide { min-width: 90px; }
|
||||
.key.extra-wide { min-width: 120px; }
|
||||
.key.special {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
|
||||
font-size: 16px;
|
||||
}
|
||||
.key.enter {
|
||||
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||
border-color: #52be80;
|
||||
}
|
||||
.key.backspace {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
border-color: #ec7063;
|
||||
}
|
||||
.key.shift, .key.caps {
|
||||
background: linear-gradient(135deg, #f39c12 0%, #d68910 100%);
|
||||
border-color: #f8c471;
|
||||
}
|
||||
.key.shift.active, .key.caps.active {
|
||||
background: linear-gradient(135deg, #16a085 0%, #138d75 100%);
|
||||
border-color: #48c9b0;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #bdc3c7;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(231, 76, 60, 0.9);
|
||||
border: 2px solid #e74c3c;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 9999;
|
||||
}
|
||||
.close-btn:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(192, 57, 43, 0.9);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="close-btn" onclick="closeKeyboard()">×</div>
|
||||
|
||||
<div class="keyboard">
|
||||
<div class="header">⌨️ Keyboard - Click icon or swipe to reopen</div>
|
||||
|
||||
<!-- Number Row -->
|
||||
<div class="row">
|
||||
<div class="key" data-key="1" data-shift="!" onclick="typeKey(this)">1</div>
|
||||
<div class="key" data-key="2" data-shift="@" onclick="typeKey(this)">2</div>
|
||||
<div class="key" data-key="3" data-shift="#" onclick="typeKey(this)">3</div>
|
||||
<div class="key" data-key="4" data-shift="$" onclick="typeKey(this)">4</div>
|
||||
<div class="key" data-key="5" data-shift="%" onclick="typeKey(this)">5</div>
|
||||
<div class="key" data-key="6" data-shift="^" onclick="typeKey(this)">6</div>
|
||||
<div class="key" data-key="7" data-shift="&" onclick="typeKey(this)">7</div>
|
||||
<div class="key" data-key="8" data-shift="*" onclick="typeKey(this)">8</div>
|
||||
<div class="key" data-key="9" data-shift="(" onclick="typeKey(this)">9</div>
|
||||
<div class="key" data-key="0" data-shift=")" onclick="typeKey(this)">0</div>
|
||||
<div class="key" data-key="-" data-shift="_" onclick="typeKey(this)">-</div>
|
||||
<div class="key" data-key="=" data-shift="+" onclick="typeKey(this)">=</div>
|
||||
<div class="key backspace wide" onclick="typeKey(this)" data-special="Backspace">⌫</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Row -->
|
||||
<div class="row">
|
||||
<div class="key special wide" onclick="typeKey(this)" data-special="Tab">Tab</div>
|
||||
<div class="key" data-key="q" onclick="typeKey(this)">q</div>
|
||||
<div class="key" data-key="w" onclick="typeKey(this)">w</div>
|
||||
<div class="key" data-key="e" onclick="typeKey(this)">e</div>
|
||||
<div class="key" data-key="r" onclick="typeKey(this)">r</div>
|
||||
<div class="key" data-key="t" onclick="typeKey(this)">t</div>
|
||||
<div class="key" data-key="y" onclick="typeKey(this)">y</div>
|
||||
<div class="key" data-key="u" onclick="typeKey(this)">u</div>
|
||||
<div class="key" data-key="i" onclick="typeKey(this)">i</div>
|
||||
<div class="key" data-key="o" onclick="typeKey(this)">o</div>
|
||||
<div class="key" data-key="p" onclick="typeKey(this)">p</div>
|
||||
<div class="key" data-key="[" data-shift="{" onclick="typeKey(this)">[</div>
|
||||
<div class="key" data-key="]" data-shift="}" onclick="typeKey(this)">]</div>
|
||||
<div class="key" data-key="\\" data-shift="|" onclick="typeKey(this)">\</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Row -->
|
||||
<div class="row">
|
||||
<div class="key caps special extra-wide" onclick="toggleCaps()" id="caps-key">Caps</div>
|
||||
<div class="key" data-key="a" onclick="typeKey(this)">a</div>
|
||||
<div class="key" data-key="s" onclick="typeKey(this)">s</div>
|
||||
<div class="key" data-key="d" onclick="typeKey(this)">d</div>
|
||||
<div class="key" data-key="f" onclick="typeKey(this)">f</div>
|
||||
<div class="key" data-key="g" onclick="typeKey(this)">g</div>
|
||||
<div class="key" data-key="h" onclick="typeKey(this)">h</div>
|
||||
<div class="key" data-key="j" onclick="typeKey(this)">j</div>
|
||||
<div class="key" data-key="k" onclick="typeKey(this)">k</div>
|
||||
<div class="key" data-key="l" onclick="typeKey(this)">l</div>
|
||||
<div class="key" data-key=";" data-shift=":" onclick="typeKey(this)">;</div>
|
||||
<div class="key" data-key="'" data-shift='"' onclick="typeKey(this)">'</div>
|
||||
<div class="key enter extra-wide" onclick="typeKey(this)" data-special="Enter">↵</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row -->
|
||||
<div class="row">
|
||||
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-left">⇧</div>
|
||||
<div class="key" data-key="z" onclick="typeKey(this)">z</div>
|
||||
<div class="key" data-key="x" onclick="typeKey(this)">x</div>
|
||||
<div class="key" data-key="c" onclick="typeKey(this)">c</div>
|
||||
<div class="key" data-key="v" onclick="typeKey(this)">v</div>
|
||||
<div class="key" data-key="b" onclick="typeKey(this)">b</div>
|
||||
<div class="key" data-key="n" onclick="typeKey(this)">n</div>
|
||||
<div class="key" data-key="m" onclick="typeKey(this)">m</div>
|
||||
<div class="key" data-key="," data-shift="<" onclick="typeKey(this)">,</div>
|
||||
<div class="key" data-key="." data-shift=">" onclick="typeKey(this)">.</div>
|
||||
<div class="key" data-key="/" data-shift="?" onclick="typeKey(this)">/</div>
|
||||
<div class="key shift extra-wide" onclick="toggleShift()" id="shift-right">⇧</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Row -->
|
||||
<div class="row">
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||
<div class="key space" onclick="typeKey(this)" data-special=" ">Space</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Alt">Alt</div>
|
||||
<div class="key special" onclick="typeKey(this)" data-special="Control">Ctrl</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { ipcRenderer } = require('electron');
|
||||
let shiftPressed = false;
|
||||
let capsLock = false;
|
||||
|
||||
// CRITICAL: Do NOT preventDefault on keyboard events
|
||||
// This allows physical keyboard to work alongside OSK
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Don't interfere with physical keyboard
|
||||
// Just update our visual state if needed
|
||||
if (e.key === 'CapsLock') {
|
||||
capsLock = !capsLock;
|
||||
updateCapsDisplay();
|
||||
}
|
||||
}, {passive: true});
|
||||
|
||||
function updateKeyDisplay() {
|
||||
const keys = document.querySelectorAll('.key[data-key]');
|
||||
keys.forEach(key => {
|
||||
const baseKey = key.getAttribute('data-key');
|
||||
const shiftKey = key.getAttribute('data-shift');
|
||||
if (/^[a-z]$/.test(baseKey)) {
|
||||
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||
key.textContent = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||
} else if (shiftKey) {
|
||||
key.textContent = shiftPressed ? shiftKey : baseKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function typeKey(element) {
|
||||
const special = element.getAttribute('data-special');
|
||||
if (special) {
|
||||
ipcRenderer.send('keyboard-type', special);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = element.getAttribute('data-key');
|
||||
const shiftKey = element.getAttribute('data-shift');
|
||||
let finalKey = baseKey;
|
||||
|
||||
if (/^[a-z]$/.test(baseKey)) {
|
||||
const shouldBeUpper = (shiftPressed && !capsLock) || (!shiftPressed && capsLock);
|
||||
finalKey = shouldBeUpper ? baseKey.toUpperCase() : baseKey.toLowerCase();
|
||||
} else if (shiftPressed && shiftKey) {
|
||||
finalKey = shiftKey;
|
||||
}
|
||||
|
||||
ipcRenderer.send('keyboard-type', finalKey);
|
||||
|
||||
if (shiftPressed) {
|
||||
shiftPressed = false;
|
||||
updateShiftDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShift() {
|
||||
shiftPressed = !shiftPressed;
|
||||
updateShiftDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
|
||||
function toggleCaps() {
|
||||
capsLock = !capsLock;
|
||||
updateCapsDisplay();
|
||||
updateKeyDisplay();
|
||||
}
|
||||
|
||||
function updateShiftDisplay() {
|
||||
document.querySelectorAll('.shift').forEach(key => {
|
||||
if (shiftPressed) key.classList.add('active');
|
||||
else key.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function updateCapsDisplay() {
|
||||
const capsKey = document.getElementById('caps-key');
|
||||
if (capsLock) capsKey.classList.add('active');
|
||||
else capsKey.classList.remove('active');
|
||||
}
|
||||
|
||||
function closeKeyboard() {
|
||||
ipcRenderer.send('close-keyboard');
|
||||
}
|
||||
|
||||
updateKeyDisplay();
|
||||
|
||||
// Tell main process we're ready
|
||||
ipcRenderer.send('keyboard-ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Locked</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.box { text-align: center; width: 100%; max-width: 400px; padding: 20px; }
|
||||
h1 { font-size: 32px; margin-bottom: 30px; }
|
||||
.hidden { display: none !important; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
font-size: 18px;
|
||||
border: 2px solid #fff;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#pin-display {
|
||||
font-size: 44px;
|
||||
letter-spacing: 16px;
|
||||
min-height: 64px;
|
||||
line-height: 64px;
|
||||
border-bottom: 2px solid #555;
|
||||
margin-bottom: 24px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.pad button {
|
||||
padding: 22px 0;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
border: 1px solid #444;
|
||||
border-radius: 50px;
|
||||
}
|
||||
.pad button:active { background: #555; }
|
||||
.pad .ok { background: #fff; color: #000; font-size: 22px; }
|
||||
.pad .back { font-size: 24px; }
|
||||
button.unlock {
|
||||
padding: 15px 30px;
|
||||
font-size: 18px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.4; }
|
||||
.error { color: #f44; margin-top: 18px; min-height: 22px; font-size: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1 id="title">Session Locked</h1>
|
||||
|
||||
<div id="password-entry" class="hidden">
|
||||
<input type="password" id="password" autocomplete="off">
|
||||
<button class="unlock" id="unlock">Unlock</button>
|
||||
</div>
|
||||
|
||||
<div id="pin-entry" class="hidden">
|
||||
<div id="pin-display"></div>
|
||||
<div class="pad" id="pad"></div>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
const params = new URLSearchParams(location.search);
|
||||
const isPin = params.get('type') === 'pin';
|
||||
const isBoot = params.get('mode') === 'boot';
|
||||
const $ = id => document.getElementById(id);
|
||||
let pin = '';
|
||||
let blockedUntil = 0;
|
||||
let blockTimer = null;
|
||||
|
||||
$('title').textContent = isBoot ? 'Kiosk Locked' : 'Session Locked';
|
||||
|
||||
function submit(code) {
|
||||
if (Date.now() < blockedUntil || !code) return;
|
||||
ipcRenderer.send('check-lockout-password', code);
|
||||
}
|
||||
|
||||
function setDisabled(on) {
|
||||
for (const b of document.querySelectorAll('button')) b.disabled = on;
|
||||
$('password').disabled = on;
|
||||
}
|
||||
|
||||
if (isPin) {
|
||||
$('pin-entry').classList.remove('hidden');
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', 'OK'];
|
||||
for (const k of keys) {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = k;
|
||||
if (k === 'OK') b.className = 'ok';
|
||||
if (k === '⌫') b.className = 'back';
|
||||
b.addEventListener('click', () => press(k));
|
||||
$('pad').appendChild(b);
|
||||
}
|
||||
const show = () => { $('pin-display').textContent = '•'.repeat(pin.length); };
|
||||
window.press = k => {
|
||||
if (Date.now() < blockedUntil) return;
|
||||
if (k === 'OK') { submit(pin); return; }
|
||||
if (k === '⌫') pin = pin.slice(0, -1);
|
||||
else if (pin.length < 8) pin += k;
|
||||
$('error').textContent = '';
|
||||
show();
|
||||
};
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key >= '0' && e.key <= '9') press(e.key);
|
||||
else if (e.key === 'Backspace') press('⌫');
|
||||
else if (e.key === 'Enter') press('OK');
|
||||
});
|
||||
window.clearEntry = () => { pin = ''; show(); };
|
||||
show();
|
||||
} else {
|
||||
$('password-entry').classList.remove('hidden');
|
||||
$('password').placeholder = 'Enter password to unlock';
|
||||
$('password').focus();
|
||||
$('unlock').addEventListener('click', () => submit($('password').value));
|
||||
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') submit($('password').value); });
|
||||
window.clearEntry = () => { $('password').value = ''; $('password').focus(); };
|
||||
}
|
||||
|
||||
ipcRenderer.on('password-incorrect', (event, info) => {
|
||||
clearEntry();
|
||||
const wait = (info && info.waitSeconds) || 0;
|
||||
const what = isPin ? 'PIN' : 'password';
|
||||
if (!wait) { $('error').textContent = 'Incorrect ' + what; return; }
|
||||
blockedUntil = Date.now() + wait * 1000;
|
||||
setDisabled(true);
|
||||
clearInterval(blockTimer);
|
||||
const tick = () => {
|
||||
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||
if (left <= 0) {
|
||||
clearInterval(blockTimer);
|
||||
setDisabled(false);
|
||||
$('error').textContent = '';
|
||||
if (!isPin) $('password').focus();
|
||||
return;
|
||||
}
|
||||
$('error').textContent = 'Too many wrong tries - wait ' + left + 's';
|
||||
};
|
||||
tick();
|
||||
blockTimer = setInterval(tick, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "kiosk-app",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
"electron": "^44.0.0",
|
||||
"docx-preview": "^0.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^6.3.289"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
.container { text-align: center; max-width: 600px; }
|
||||
h2 { font-size: 32px; margin-bottom: 20px; }
|
||||
.message { font-size: 20px; margin-bottom: 30px; line-height: 1.5; }
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 20px 40px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
}
|
||||
.btn-extend { background: #e67e22; }
|
||||
.btn-extend:hover { background: #d35400; }
|
||||
.btn-extend:active { background: #ba4a00; }
|
||||
|
||||
.btn-cancel {
|
||||
background: #95a5a6;
|
||||
grid-column: 1 / -1;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
}
|
||||
.btn-cancel:hover { background: #7f8c8d; }
|
||||
|
||||
.info {
|
||||
font-size: 14px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.countdown {
|
||||
font-size: 16px;
|
||||
color: #e74c3c;
|
||||
margin-top: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>⏸️ Pause Timers</h2>
|
||||
<div class="message">
|
||||
Select how long to pause rotation and inactivity timers:
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<button class="btn btn-extend" onclick="selectTime(15)">
|
||||
🍿 15 minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(30)">
|
||||
⏱️ 30 minutes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(60)">
|
||||
🎬 1 hour
|
||||
</button>
|
||||
|
||||
<button class="btn btn-extend" onclick="selectTime(120)">
|
||||
📺 2 hours
|
||||
</button>
|
||||
|
||||
<button class="btn btn-cancel" onclick="selectTime(0)">
|
||||
✗ Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
After the time expires, normal rotation and return-to-home logic will resume.
|
||||
</div>
|
||||
<div class="countdown" id="countdown">Auto-closing in 30 seconds...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
let timeLeft = 30;
|
||||
let countdownInterval;
|
||||
|
||||
function selectTime(minutes) {
|
||||
clearInterval(countdownInterval);
|
||||
ipcRenderer.send('pause-time-selected', minutes);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
timeLeft--;
|
||||
document.getElementById('countdown').textContent = 'Auto-closing in ' + timeLeft + ' seconds...';
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
selectTime(0);
|
||||
}
|
||||
}
|
||||
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
// Browser-side PDF rendering for slideshow.html, via pdf.js (pdfjs-dist,
|
||||
// an npm dependency of this app). A module because pdf.js only ships as
|
||||
// one; it sets window.KioskPdf for slideshow.html's classic script.
|
||||
//
|
||||
// Pages are drawn to canvases (no viewer toolbar, and the slideshow can
|
||||
// scroll them like a Word document). pdf.js >= 4.2.67 is required - older
|
||||
// versions could run JavaScript embedded in a crafted PDF (CVE-2024-4367);
|
||||
// isEvalSupported:false is set as well, belt and braces.
|
||||
import * as pdfjs from './node_modules/pdfjs-dist/build/pdf.min.mjs';
|
||||
|
||||
const base = new URL('./node_modules/pdfjs-dist/', import.meta.url).href;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = base + 'build/pdf.worker.min.mjs';
|
||||
|
||||
// Rendering every page of a huge PDF would take a long time and a lot of
|
||||
// memory for something shown for seconds - stop after this many.
|
||||
const MAX_PAGES = 30;
|
||||
|
||||
window.KioskPdf = {
|
||||
// Render `bytes` into `container` (emptied first), sized for a screen of
|
||||
// width x height: a one-page PDF fits entirely (like an image); a longer
|
||||
// one fits the width, pages stacked, for the slideshow to scroll.
|
||||
async render(bytes, container, width, height) {
|
||||
container.innerHTML = '';
|
||||
const task = pdfjs.getDocument({
|
||||
// A copy: pdf.js transfers (empties) the buffer it's given, and the
|
||||
// slideshow caches these bytes to show the PDF again next round.
|
||||
data: bytes.slice(),
|
||||
isEvalSupported: false,
|
||||
cMapUrl: base + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: base + 'standard_fonts/',
|
||||
});
|
||||
try {
|
||||
const doc = await task.promise;
|
||||
const count = Math.min(doc.numPages, MAX_PAGES);
|
||||
const single = doc.numPages === 1;
|
||||
const gap = 16;
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.gap = gap + 'px';
|
||||
container.style.padding = (single ? 0 : gap) + 'px 0';
|
||||
container.style.minHeight = single ? '100%' : '';
|
||||
container.style.justifyContent = single ? 'center' : '';
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const natural = page.getViewport({ scale: 1 });
|
||||
const scale = single
|
||||
? Math.min(width / natural.width, height / natural.height)
|
||||
: Math.min((width - 2 * gap) / natural.width, 1.6 * (96 / 72));
|
||||
const vp = page.getViewport({ scale: scale * dpr });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||
canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||
canvas.style.background = '#fff';
|
||||
canvas.style.boxShadow = single ? '' : '0 2px 8px rgba(0,0,0,.4)';
|
||||
container.appendChild(canvas);
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||
page.cleanup();
|
||||
}
|
||||
return { pages: doc.numPages, rendered: count };
|
||||
} finally {
|
||||
// Frees the document and its worker-side data (pdf.js 6 has this on
|
||||
// the loading task, not the document).
|
||||
task.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { width: 100%; max-width: 400px; }
|
||||
h2 { text-align: center; margin-bottom: 30px; font-size: 24px; }
|
||||
#pin-display {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
border: 3px solid #34495e;
|
||||
border-radius: 12px;
|
||||
background: #34495e;
|
||||
color: #ecf0f1;
|
||||
letter-spacing: 20px;
|
||||
min-height: 90px;
|
||||
line-height: 50px;
|
||||
font-family: monospace;
|
||||
}
|
||||
#error { color: #e74c3c; text-align: center; display: none; margin: 10px 0; font-weight: bold; font-size: 18px; }
|
||||
.numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; }
|
||||
.numpad button {
|
||||
padding: 30px;
|
||||
font-size: 32px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #34495e;
|
||||
color: #ecf0f1;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.numpad button:active { background: #3498db; }
|
||||
.numpad button:hover { background: #475d6d; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.btn { padding: 20px; font-size: 20px; cursor: pointer; border: none; border-radius: 12px; font-weight: bold; }
|
||||
#clear { background: #f39c12; color: white; }
|
||||
#backspace { background: #e67e22; color: white; }
|
||||
#submit { background: #27ae60; color: white; }
|
||||
#cancel { background: #e74c3c; color: white; }
|
||||
.info { text-align: center; font-size: 14px; color: #95a5a6; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>🔒 Enter PIN</h2>
|
||||
<div id="pin-display">••••</div>
|
||||
<div id="error">❌ Incorrect PIN</div>
|
||||
<div class="numpad">
|
||||
<button onclick="addDigit('1')">1</button>
|
||||
<button onclick="addDigit('2')">2</button>
|
||||
<button onclick="addDigit('3')">3</button>
|
||||
<button onclick="addDigit('4')">4</button>
|
||||
<button onclick="addDigit('5')">5</button>
|
||||
<button onclick="addDigit('6')">6</button>
|
||||
<button onclick="addDigit('7')">7</button>
|
||||
<button onclick="addDigit('8')">8</button>
|
||||
<button onclick="addDigit('9')">9</button>
|
||||
<button id="clear" onclick="clearPin()">Clear</button>
|
||||
<button onclick="addDigit('0')">0</button>
|
||||
<button id="backspace" onclick="backspace()">⌫</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="submit" onclick="submitPin()">✓ Submit</button>
|
||||
<button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button>
|
||||
</div>
|
||||
<div class="info">4-8 digits</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
let enteredPin = '';
|
||||
let blockedUntil = 0;
|
||||
let blockTimer = null;
|
||||
function updateDisplay() {
|
||||
const display = document.getElementById('pin-display');
|
||||
if (enteredPin.length === 0) {
|
||||
display.textContent = '••••';
|
||||
display.style.color = '#7f8c8d';
|
||||
} else {
|
||||
display.textContent = '•'.repeat(enteredPin.length);
|
||||
display.style.color = '#ecf0f1';
|
||||
}
|
||||
}
|
||||
function addDigit(digit) {
|
||||
if (Date.now() < blockedUntil) return;
|
||||
if (enteredPin.length < 8) {
|
||||
enteredPin += digit;
|
||||
updateDisplay();
|
||||
document.getElementById('error').style.display = 'none';
|
||||
}
|
||||
}
|
||||
function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); }
|
||||
function clearPin() { enteredPin = ''; updateDisplay(); document.getElementById('error').style.display = 'none'; }
|
||||
function showError(text) {
|
||||
document.getElementById('error').textContent = '❌ ' + text;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
}
|
||||
// Checked in the main process (the stored PIN is hashed), which also
|
||||
// refuses input for a while after repeated wrong PINs.
|
||||
async function submitPin() {
|
||||
if (Date.now() < blockedUntil) return;
|
||||
if (enteredPin.length < 4) {
|
||||
showError('PIN must be 4-8 digits');
|
||||
return;
|
||||
}
|
||||
const r = await ipcRenderer.invoke('check-hidden-pin', enteredPin)
|
||||
.catch(() => ({ok: false, error: 'PIN check failed'}));
|
||||
enteredPin = '';
|
||||
updateDisplay();
|
||||
if (r.ok) {
|
||||
ipcRenderer.send('pin-correct');
|
||||
return;
|
||||
}
|
||||
if (!r.waitSeconds) { showError(r.error || 'Incorrect PIN'); return; }
|
||||
blockedUntil = Date.now() + r.waitSeconds * 1000;
|
||||
clearInterval(blockTimer);
|
||||
const tick = () => {
|
||||
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||
if (left <= 0) {
|
||||
clearInterval(blockTimer);
|
||||
document.getElementById('error').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
showError('Too many wrong tries - wait ' + left + 's');
|
||||
};
|
||||
tick();
|
||||
blockTimer = setInterval(tick, 500);
|
||||
}
|
||||
function cancel() { ipcRenderer.send('pin-cancelled'); }
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key >= '0' && e.key <= '9') addDigit(e.key);
|
||||
else if (e.key === 'Backspace') backspace();
|
||||
else if (e.key === 'Enter') submitPin();
|
||||
else if (e.key === 'Escape') cancel();
|
||||
});
|
||||
updateDisplay();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,931 @@
|
||||
const {contextBridge,ipcRenderer}=require('electron');
|
||||
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
console.log(' Gestures:');
|
||||
console.log(' 3-finger DOWN: Toggle hidden tabs (PIN required)');
|
||||
console.log(' 2-finger HORIZONTAL: Switch between sites');
|
||||
console.log(' 1-finger HORIZONTAL: Navigate within page');
|
||||
console.log(' Navigation: Top-left key icon for site menu');
|
||||
console.log('════════════════════════════════════════════════════════════');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
notifyActivity: () => ipcRenderer.send('user-activity'),
|
||||
showKeyboard: () => ipcRenderer.send('show-keyboard'),
|
||||
closeKeyboard: () => ipcRenderer.send('close-keyboard'),
|
||||
keyboardActivity: () => ipcRenderer.send('keyboard-activity'),
|
||||
showPauseDialog: () => ipcRenderer.send('show-pause-dialog')
|
||||
});
|
||||
|
||||
// Pause button state (MUST be outside DOMContentLoaded to persist across page loads)
|
||||
let pauseButton=null;
|
||||
let pauseButtonShouldShow=false;
|
||||
let pauseButtonShown=false;
|
||||
let pauseButtonHideTimer=null;
|
||||
const PAUSE_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Pause button functions (must be outside DOMContentLoaded for IPC listener)
|
||||
function createPauseButton(){
|
||||
if(pauseButton)return;
|
||||
|
||||
pauseButton=document.createElement('div');
|
||||
pauseButton.id='electron-pause-button';
|
||||
pauseButton.innerHTML='<div style="display:flex;gap:4px;"><div style="width:6px;height:24px;background:white;border-radius:2px;"></div><div style="width:6px;height:24px;background:white;border-radius:2px;"></div></div>';
|
||||
pauseButton.title='Pause rotation';
|
||||
pauseButton.style.cssText=`
|
||||
position:fixed;bottom:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(230,126,34,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
pauseButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ipcRenderer.send('show-pause-dialog');
|
||||
});
|
||||
|
||||
document.body.appendChild(pauseButton);
|
||||
}
|
||||
|
||||
function showPauseButton(){
|
||||
if(!pauseButton)createPauseButton();
|
||||
pauseButton.style.display='flex';
|
||||
pauseButtonShown=true;
|
||||
|
||||
// Clear existing hide timer
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
pauseButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[PAUSE-BTN] Auto-hiding after '+PAUSE_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePauseButton();
|
||||
},PAUSE_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePauseButton(){
|
||||
if(pauseButtonHideTimer){
|
||||
clearTimeout(pauseButtonHideTimer);
|
||||
pauseButtonHideTimer=null;
|
||||
}
|
||||
if(pauseButton){
|
||||
pauseButton.style.display='none';
|
||||
pauseButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
// Declare variables at top level so IPC handlers and DOMContentLoaded can share them
|
||||
let keyboardButtonEnabled=true;
|
||||
let keyboardVisible=false;
|
||||
let keyboardIcon=null;
|
||||
let navButtonEnabled=true;
|
||||
let navButton=null;
|
||||
let navButtonShown=false;
|
||||
let navButtonHideTimer=null;
|
||||
let navMenu=null;
|
||||
let navMenuVisible=false;
|
||||
let navMenuTimer=null;
|
||||
const NAV_MENU_TIMEOUT=30000; // 30 seconds
|
||||
const NAV_BUTTON_HIDE_DELAY=5000; // Hide after 5 seconds of inactivity
|
||||
|
||||
// Listen for pause button visibility control from main process
|
||||
// CRITICAL: This must be outside DOMContentLoaded so it doesn't reset on page load
|
||||
ipcRenderer.on('pause-button-visibility',(event,shouldShow)=>{
|
||||
console.log('[PAUSE-BTN] Visibility update: shouldShow='+shouldShow);
|
||||
pauseButtonShouldShow=shouldShow;
|
||||
if(!shouldShow){
|
||||
// If button should not show on this site, hide it immediately
|
||||
console.log('[PAUSE-BTN] Hiding button (manual site)');
|
||||
hidePauseButton();
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Button enabled - will show on user interaction');
|
||||
}
|
||||
// If shouldShow is true, button will appear on user interaction
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
|
||||
keyboardButtonEnabled=enabled;
|
||||
console.log('[KEYBOARD-BTN] Keyboard button enabled: '+enabled);
|
||||
// Note: keyboardIcon may not exist yet if page hasn't loaded
|
||||
if(keyboardIcon&&!enabled){
|
||||
keyboardIcon.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
|
||||
navButtonEnabled=enabled;
|
||||
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
|
||||
if(navButton&&!enabled){
|
||||
navButton.style.display='none';
|
||||
}
|
||||
if(navMenu&&!enabled){
|
||||
navMenu.style.display='none';
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
document.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
const SWIPE_THRESHOLD=120;
|
||||
const SWIPE_MAX_TIME=500;
|
||||
const SWIPE_TOLERANCE=50;
|
||||
|
||||
let touchStartX=0;
|
||||
let touchStartY=0;
|
||||
let touchStartTime=0;
|
||||
let fingerCount=0;
|
||||
let lastKeyboardRequest=0;
|
||||
let keyboardAutoClosedThisSession=false;
|
||||
const KEYBOARD_REQUEST_THROTTLE=1000;
|
||||
|
||||
const activityEvents=[
|
||||
'mousedown','mouseup','mousemove','click','dblclick',
|
||||
'wheel','scroll',
|
||||
'keydown','keyup','keypress',
|
||||
'touchstart','touchmove','touchend',
|
||||
'pointerdown','pointerup','pointermove',
|
||||
'input','change'
|
||||
];
|
||||
|
||||
let lastActivityNotification=0;
|
||||
const ACTIVITY_THROTTLE=1000;
|
||||
|
||||
function notifyActivity(){
|
||||
const now=Date.now();
|
||||
if(now-lastActivityNotification>ACTIVITY_THROTTLE){
|
||||
if(window.electronAPI?.notifyActivity){
|
||||
window.electronAPI.notifyActivity();
|
||||
lastActivityNotification=now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityEvents.forEach(eventType=>{
|
||||
document.addEventListener(eventType,notifyActivity,{
|
||||
passive:true,
|
||||
capture:true
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-state-changed',(event,visible)=>{
|
||||
keyboardVisible=visible;
|
||||
if(visible){
|
||||
showKeyboardIcon();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
}else{
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('keyboard-auto-closed',()=>{
|
||||
keyboardAutoClosedThisSession=true;
|
||||
});
|
||||
|
||||
function createKeyboardIcon(){
|
||||
if(keyboardIcon||!keyboardButtonEnabled)return;
|
||||
|
||||
|
||||
keyboardIcon=document.createElement('div');
|
||||
keyboardIcon.id='electron-keyboard-icon';
|
||||
keyboardIcon.innerHTML='⌨️';
|
||||
keyboardIcon.style.cssText=`
|
||||
position:fixed;bottom:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(52,152,219,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
font-size:32px;cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
keyboardIcon.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
keyboardAutoClosedThisSession=false;
|
||||
if(keyboardVisible){
|
||||
ipcRenderer.send('close-keyboard');
|
||||
}else{
|
||||
ipcRenderer.send('show-keyboard');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(keyboardIcon);
|
||||
}
|
||||
|
||||
function showKeyboardIcon(){
|
||||
if(!keyboardButtonEnabled)return;
|
||||
if(!keyboardIcon)createKeyboardIcon();
|
||||
if(keyboardIcon)keyboardIcon.style.display='flex';
|
||||
}
|
||||
|
||||
function hideKeyboardIcon(){
|
||||
if(keyboardIcon)keyboardIcon.style.display='none';
|
||||
}
|
||||
|
||||
function createNavButton(){
|
||||
if(navButton||!navButtonEnabled)return;
|
||||
|
||||
navButton=document.createElement('div');
|
||||
navButton.id='electron-nav-button';
|
||||
// Use SVG key icon instead of emoji for better compatibility
|
||||
navButton.innerHTML='<svg width="32" height="32" viewBox="0 0 24 24" fill="white"><path d="M12.65 10C11.7 7.31 8.9 5.5 5.77 6.12c-2.29.46-4.15 2.29-4.63 4.58C.32 14.57 3.26 18 7 18c2.61 0 4.83-1.67 5.65-4H17v2c0 1.1.9 2 2 2s2-.9 2-2v-2c1.1 0 2-.9 2-2s-.9-2-2-2h-8.35zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>';
|
||||
navButton.title='Navigation Menu';
|
||||
navButton.style.cssText=`
|
||||
position:fixed;top:20px;left:20px;width:60px;height:60px;
|
||||
background:rgba(155,89,182,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
|
||||
navButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Button clicked');
|
||||
try{
|
||||
toggleNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error toggling menu:',err);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(navButton);
|
||||
}
|
||||
|
||||
// Power button in top-right corner (follows same show/hide logic as nav button)
|
||||
let powerButton=null;
|
||||
let powerButtonHideTimer=null;
|
||||
const POWER_BUTTON_HIDE_DELAY=5000; // Same as nav button
|
||||
function createPowerButton(){
|
||||
if(powerButton)return;
|
||||
powerButton=document.createElement('div');
|
||||
powerButton.id='electron-power-button';
|
||||
// Power icon SVG
|
||||
powerButton.innerHTML='<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42C17.99 7.86 19 9.81 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.19 1.01-4.14 2.58-5.42L6.17 5.17C4.23 6.82 3 9.26 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9c0-2.74-1.23-5.18-3.17-6.83z"/></svg>';
|
||||
powerButton.title='Power Menu';
|
||||
powerButton.style.cssText=`
|
||||
position:fixed;top:20px;right:20px;width:60px;height:60px;
|
||||
background:rgba(231,76,60,0.95);border:3px solid rgba(255,255,255,0.9);
|
||||
border-radius:50%;display:none;align-items:center;justify-content:center;
|
||||
cursor:pointer;z-index:999999;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.4);user-select:none;
|
||||
`;
|
||||
powerButton.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[POWER] Button clicked');
|
||||
ipcRenderer.send('show-power-menu');
|
||||
});
|
||||
document.body.appendChild(powerButton);
|
||||
}
|
||||
|
||||
function showPowerButton(){
|
||||
if(!powerButton)createPowerButton();
|
||||
if(powerButton){
|
||||
powerButton.style.display='flex';
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
powerButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[POWER-BTN] Auto-hiding after '+POWER_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hidePowerButton();
|
||||
},POWER_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hidePowerButton(){
|
||||
if(powerButtonHideTimer){
|
||||
clearTimeout(powerButtonHideTimer);
|
||||
powerButtonHideTimer=null;
|
||||
}
|
||||
if(powerButton){
|
||||
powerButton.style.display='none';
|
||||
}
|
||||
}
|
||||
|
||||
function showNavButton(){
|
||||
if(!navButtonEnabled)return;
|
||||
if(!navButton)createNavButton();
|
||||
if(navButton){
|
||||
navButton.style.display='flex';
|
||||
navButtonShown=true;
|
||||
}
|
||||
|
||||
// Clear existing hide timer
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
|
||||
// Set new hide timer - button will auto-hide after inactivity
|
||||
navButtonHideTimer=setTimeout(()=>{
|
||||
console.log('[NAV-BTN] Auto-hiding after '+NAV_BUTTON_HIDE_DELAY+'ms inactivity');
|
||||
hideNavButton();
|
||||
},NAV_BUTTON_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function hideNavButton(){
|
||||
if(navButtonHideTimer){
|
||||
clearTimeout(navButtonHideTimer);
|
||||
navButtonHideTimer=null;
|
||||
}
|
||||
if(navButton){
|
||||
navButton.style.display='none';
|
||||
navButtonShown=false;
|
||||
}
|
||||
}
|
||||
|
||||
function createNavMenu(){
|
||||
if(navMenu)return;
|
||||
console.log('[NAV] Creating navigation menu');
|
||||
|
||||
navMenu=document.createElement('div');
|
||||
navMenu.id='electron-nav-menu';
|
||||
navMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
max-width:90%;max-height:90%;overflow:hidden;
|
||||
box-shadow:0 10px 40px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Close button clicked');
|
||||
hideNavMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const columns=document.createElement('div');
|
||||
columns.style.cssText='display:flex;gap:40px;margin-top:20px;max-height:70vh;';
|
||||
|
||||
// Column 1: Sites (scrollable)
|
||||
const sitesCol=document.createElement('div');
|
||||
sitesCol.style.cssText='flex:1;min-width:300px;display:flex;flex-direction:column;';
|
||||
sitesCol.innerHTML='<h2 style="color:white;margin-bottom:20px;">Sites</h2>';
|
||||
const sitesList=document.createElement('div');
|
||||
sitesList.id='nav-sites-list';
|
||||
sitesList.style.cssText='display:flex;flex-direction:column;gap:10px;overflow-y:auto;padding-right:10px;';
|
||||
sitesCol.appendChild(sitesList);
|
||||
|
||||
// Column 2: Gesture Cheat Sheet (fixed, no scroll)
|
||||
const cheatCol=document.createElement('div');
|
||||
cheatCol.style.cssText='flex:1;min-width:300px;overflow-y:hidden;';
|
||||
cheatCol.innerHTML=`
|
||||
<h2 style="color:white;margin-bottom:20px;">Touch Gestures</h2>
|
||||
<div style="color:#ecf0f1;line-height:1.8;font-size:16px;">
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">2-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Switch between sites</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#3498db;">1-Finger Horizontal Swipe</div>
|
||||
<div style="padding-left:15px;">Navigate within page (arrow keys)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:15px;">
|
||||
<div style="font-weight:bold;color:#9b59b6;">3-Finger Down Swipe</div>
|
||||
<div style="padding-left:15px;">Toggle hidden tabs (PIN required)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:25px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2);">
|
||||
<div style="font-weight:bold;color:#e74c3c;">Keyboard Shortcuts</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+]</kbd> Next tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Shift+Tab</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+[</kbd> Previous tab</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">F10</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+H</kbd> Toggle hidden tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Escape</kbd> Return to normal tabs</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+Delete</kbd> or <kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+Alt+P</kbd> Power menu</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="padding-left:15px;"><kbd style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:3px;">Ctrl+K</kbd> Toggle keyboard</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
columns.appendChild(sitesCol);
|
||||
columns.appendChild(cheatCol);
|
||||
content.appendChild(columns);
|
||||
navMenu.appendChild(content);
|
||||
|
||||
navMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===navMenu){
|
||||
console.log('[NAV] Background clicked, closing menu');
|
||||
hideNavMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent clicks inside content from closing menu
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(navMenu);
|
||||
console.log('[NAV] Navigation menu created and appended to body');
|
||||
}
|
||||
|
||||
function toggleNavMenu(){
|
||||
console.log('[NAV] Toggle menu, current state:',navMenuVisible);
|
||||
if(navMenuVisible){
|
||||
hideNavMenu();
|
||||
}else{
|
||||
showNavMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function showNavMenu(){
|
||||
console.log('[NAV] Showing navigation menu');
|
||||
try{
|
||||
if(!navMenu){
|
||||
createNavMenu();
|
||||
}
|
||||
|
||||
// Request sites data
|
||||
loadSitesIntoNav();
|
||||
|
||||
navMenu.style.display='flex';
|
||||
navMenuVisible=true;
|
||||
|
||||
// Force reflow and repaint to ensure proper rendering
|
||||
navMenu.offsetHeight;
|
||||
navMenu.style.opacity='0';
|
||||
setTimeout(()=>{
|
||||
navMenu.style.transition='opacity 0.15s ease-in';
|
||||
navMenu.style.opacity='1';
|
||||
},10);
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
}
|
||||
navMenuTimer=setTimeout(()=>{
|
||||
console.log('[NAV] Auto-dismissing menu after 30 seconds');
|
||||
hideNavMenu();
|
||||
},NAV_MENU_TIMEOUT);
|
||||
|
||||
console.log('[NAV] Menu displayed, 30-second timer started');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hideNavMenu(){
|
||||
console.log('[NAV] Hiding navigation menu');
|
||||
try{
|
||||
if(navMenuTimer){
|
||||
clearTimeout(navMenuTimer);
|
||||
navMenuTimer=null;
|
||||
}
|
||||
if(navMenu){
|
||||
navMenu.style.display='none';
|
||||
navMenu.style.opacity='1';
|
||||
navMenu.style.transition='';
|
||||
}
|
||||
navMenuVisible=false;
|
||||
console.log('[NAV] Menu hidden');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Power menu overlay (with 30-second auto-dismiss)
|
||||
let powerMenu=null;
|
||||
let powerMenuVisible=false;
|
||||
let powerMenuTimer=null;
|
||||
const POWER_MENU_TIMEOUT=30000;
|
||||
let powerMenuInfo={version:'',localIP:'',vpnIP:''};
|
||||
|
||||
function createPowerMenu(){
|
||||
if(powerMenu)return;
|
||||
console.log('[POWER-MENU] Creating power menu');
|
||||
|
||||
powerMenu=document.createElement('div');
|
||||
powerMenu.id='electron-power-menu';
|
||||
powerMenu.style.cssText=`
|
||||
position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;
|
||||
z-index:999998;pointer-events:auto;
|
||||
`;
|
||||
|
||||
const content=document.createElement('div');
|
||||
content.style.cssText=`
|
||||
position:relative;background:rgba(44,62,80,0.98);border-radius:20px;padding:40px;
|
||||
min-width:400px;max-width:90%;box-shadow:0 10px 40px rgba(0,0,0,0.5);text-align:center;
|
||||
`;
|
||||
|
||||
const closeBtn=document.createElement('div');
|
||||
closeBtn.innerHTML='✕';
|
||||
closeBtn.style.cssText=`
|
||||
position:absolute;top:10px;right:10px;font-size:32px;color:white;
|
||||
cursor:pointer;width:40px;height:40px;display:flex;align-items:center;
|
||||
justify-content:center;border-radius:50%;background:rgba(231,76,60,0.8);
|
||||
user-select:none;
|
||||
`;
|
||||
closeBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
hidePowerMenu();
|
||||
});
|
||||
content.appendChild(closeBtn);
|
||||
|
||||
const title=document.createElement('h2');
|
||||
title.textContent='Power Options';
|
||||
title.style.cssText='color:white;margin-bottom:20px;font-size:28px;';
|
||||
content.appendChild(title);
|
||||
|
||||
const infoDiv=document.createElement('div');
|
||||
infoDiv.id='power-menu-info';
|
||||
infoDiv.style.cssText='color:#bdc3c7;margin-bottom:30px;font-size:14px;line-height:1.6;';
|
||||
content.appendChild(infoDiv);
|
||||
|
||||
const buttonsDiv=document.createElement('div');
|
||||
buttonsDiv.style.cssText='display:flex;flex-direction:column;gap:15px;';
|
||||
|
||||
const btnStyle=`
|
||||
padding:20px 40px;font-size:20px;border:none;border-radius:10px;
|
||||
cursor:pointer;font-weight:bold;transition:transform 0.2s,opacity 0.2s;
|
||||
`;
|
||||
|
||||
const shutdownBtn=document.createElement('button');
|
||||
shutdownBtn.textContent='⏻ Shutdown';
|
||||
shutdownBtn.style.cssText=btnStyle+'background:#e74c3c;color:white;';
|
||||
shutdownBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','shutdown');
|
||||
});
|
||||
|
||||
const restartBtn=document.createElement('button');
|
||||
restartBtn.textContent='↻ Restart';
|
||||
restartBtn.style.cssText=btnStyle+'background:#f39c12;color:white;';
|
||||
restartBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','restart');
|
||||
});
|
||||
|
||||
const reloadBtn=document.createElement('button');
|
||||
reloadBtn.textContent='⟳ Reload App';
|
||||
reloadBtn.style.cssText=btnStyle+'background:#3498db;color:white;';
|
||||
reloadBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
ipcRenderer.send('power-action','reload');
|
||||
});
|
||||
|
||||
const cancelBtn=document.createElement('button');
|
||||
cancelBtn.textContent='Cancel';
|
||||
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
|
||||
cancelBtn.addEventListener('click',()=>{
|
||||
hidePowerMenu();
|
||||
});
|
||||
|
||||
buttonsDiv.appendChild(shutdownBtn);
|
||||
buttonsDiv.appendChild(restartBtn);
|
||||
buttonsDiv.appendChild(reloadBtn);
|
||||
buttonsDiv.appendChild(cancelBtn);
|
||||
content.appendChild(buttonsDiv);
|
||||
|
||||
powerMenu.appendChild(content);
|
||||
|
||||
powerMenu.addEventListener('click',(e)=>{
|
||||
if(e.target===powerMenu){
|
||||
hidePowerMenu();
|
||||
}
|
||||
});
|
||||
|
||||
content.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.appendChild(powerMenu);
|
||||
}
|
||||
|
||||
function showPowerMenu(info){
|
||||
console.log('[POWER-MENU] Showing power menu');
|
||||
try{
|
||||
if(!powerMenu)createPowerMenu();
|
||||
|
||||
// Update info display
|
||||
const infoDiv=document.getElementById('power-menu-info');
|
||||
if(infoDiv&&info){
|
||||
let infoText='Version: '+info.version+'<br>Local: '+info.localIP;
|
||||
if(info.vpnIP){
|
||||
infoText+='<br>VPN: '+info.vpnIP;
|
||||
}
|
||||
infoDiv.innerHTML=infoText;
|
||||
}
|
||||
|
||||
powerMenu.style.display='flex';
|
||||
powerMenuVisible=true;
|
||||
|
||||
// Set 30-second auto-dismiss timer
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
}
|
||||
powerMenuTimer=setTimeout(()=>{
|
||||
console.log('[POWER-MENU] Auto-dismissing after 30 seconds');
|
||||
hidePowerMenu();
|
||||
},POWER_MENU_TIMEOUT);
|
||||
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error showing menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
function hidePowerMenu(){
|
||||
console.log('[POWER-MENU] Hiding power menu');
|
||||
try{
|
||||
if(powerMenuTimer){
|
||||
clearTimeout(powerMenuTimer);
|
||||
powerMenuTimer=null;
|
||||
}
|
||||
if(powerMenu){
|
||||
powerMenu.style.display='none';
|
||||
}
|
||||
powerMenuVisible=false;
|
||||
}catch(err){
|
||||
console.error('[POWER-MENU] Error hiding menu:',err);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for power menu display request from main process
|
||||
ipcRenderer.on('display-power-menu',(event,info)=>{
|
||||
showPowerMenu(info);
|
||||
});
|
||||
|
||||
function loadSitesIntoNav(){
|
||||
console.log('[NAV] Requesting config from main process');
|
||||
try{
|
||||
ipcRenderer.send('get-config');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error requesting config:',err);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on('config-data',(event,config)=>{
|
||||
console.log('[NAV] Received config data:',config);
|
||||
try{
|
||||
const sitesList=document.getElementById('nav-sites-list');
|
||||
if(!sitesList){
|
||||
console.error('[NAV] Sites list element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!config||!config.tabs){
|
||||
console.error('[NAV] Invalid config data');
|
||||
sitesList.innerHTML='<div style="color:white;padding:10px;">No sites configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sitesList.innerHTML='';
|
||||
let siteCount=0;
|
||||
|
||||
config.tabs.forEach((tab,index)=>{
|
||||
// Skip hidden tabs (duration === -1)
|
||||
if(tab.duration===-1){
|
||||
console.log('[NAV] Skipping hidden tab at index',index);
|
||||
return;
|
||||
}
|
||||
|
||||
const siteBtn=document.createElement('div');
|
||||
const displayName=tab.name||tab.url;
|
||||
siteBtn.textContent=displayName;
|
||||
siteBtn.style.cssText=`
|
||||
padding:15px 20px;background:rgba(52,152,219,0.7);color:white;
|
||||
border-radius:10px;cursor:pointer;font-size:18px;
|
||||
transition:all 0.3s;border:3px solid rgba(52,152,219,0.9);
|
||||
user-select:none;font-weight:normal;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.2);
|
||||
`;
|
||||
siteBtn.addEventListener('mouseenter',()=>{
|
||||
siteBtn.style.background='rgba(41,128,185,1)';
|
||||
siteBtn.style.borderColor='rgba(255,255,255,0.9)';
|
||||
siteBtn.style.fontWeight='bold';
|
||||
siteBtn.style.transform='translateY(-2px)';
|
||||
siteBtn.style.boxShadow='0 4px 12px rgba(0,0,0,0.4)';
|
||||
});
|
||||
siteBtn.addEventListener('mouseleave',()=>{
|
||||
siteBtn.style.background='rgba(52,152,219,0.7)';
|
||||
siteBtn.style.borderColor='rgba(52,152,219,0.9)';
|
||||
siteBtn.style.fontWeight='normal';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 2px 8px rgba(0,0,0,0.2)';
|
||||
});
|
||||
siteBtn.addEventListener('mousedown',()=>{
|
||||
siteBtn.style.background='rgba(31,97,141,1)';
|
||||
siteBtn.style.transform='translateY(0)';
|
||||
siteBtn.style.boxShadow='0 1px 4px rgba(0,0,0,0.3)';
|
||||
});
|
||||
siteBtn.addEventListener('click',(e)=>{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('[NAV] Navigating to tab',index);
|
||||
try{
|
||||
ipcRenderer.send('navigate-to-tab',index);
|
||||
hideNavMenu();
|
||||
}catch(err){
|
||||
console.error('[NAV] Error navigating:',err);
|
||||
}
|
||||
});
|
||||
|
||||
sitesList.appendChild(siteBtn);
|
||||
siteCount++;
|
||||
});
|
||||
|
||||
console.log('[NAV] Loaded',siteCount,'sites into menu');
|
||||
}catch(err){
|
||||
console.error('[NAV] Error processing config data:',err);
|
||||
}
|
||||
});
|
||||
|
||||
function isTextInput(el){
|
||||
if(!el)return false;
|
||||
const tag=(el.tagName||'').toLowerCase();
|
||||
const type=(el.type||'').toLowerCase();
|
||||
const editable=el.isContentEditable||el.contentEditable==='true';
|
||||
return(tag==='input'&&['text','email','password','search','tel','url','number'].includes(type))||tag==='textarea'||editable;
|
||||
}
|
||||
|
||||
document.addEventListener('focusin',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
showKeyboardIcon();
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('focusout',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
setTimeout(()=>{
|
||||
if(!isTextInput(document.activeElement)){
|
||||
hideKeyboardIcon();
|
||||
}
|
||||
},100);
|
||||
}
|
||||
},true);
|
||||
|
||||
document.addEventListener('mousedown',(e)=>{
|
||||
if(keyboardButtonEnabled&&isTextInput(e.target)){
|
||||
if(keyboardVisible){
|
||||
if(window.electronAPI?.keyboardActivity){
|
||||
window.electronAPI.keyboardActivity();
|
||||
}
|
||||
}else{
|
||||
keyboardAutoClosedThisSession=false;
|
||||
const now=Date.now();
|
||||
if(now-lastKeyboardRequest>KEYBOARD_REQUEST_THROTTLE){
|
||||
lastKeyboardRequest=now;
|
||||
setTimeout(()=>ipcRenderer.send('show-keyboard'),50);
|
||||
}
|
||||
}
|
||||
}
|
||||
},true);
|
||||
|
||||
// Shared debounce prevents double-firing when both touch and pointer events fire
|
||||
let lastSwipeSent=0;
|
||||
function sendSwipeIPC(direction){
|
||||
const now=Date.now();
|
||||
if(now-lastSwipeSent<500)return;
|
||||
lastSwipeSent=now;
|
||||
ipcRenderer.send(direction);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart',e=>{
|
||||
if(e.touches.length>=1){
|
||||
touchStartX=e.touches[0].clientX;
|
||||
touchStartY=e.touches[0].clientY;
|
||||
touchStartTime=Date.now();
|
||||
fingerCount=e.touches.length;
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('touchend',e=>{
|
||||
if(e.changedTouches.length>=1){
|
||||
const touchEndX=e.changedTouches[0].clientX;
|
||||
const touchEndY=e.changedTouches[0].clientY;
|
||||
const deltaX=touchEndX-touchStartX;
|
||||
const deltaY=touchEndY-touchStartY;
|
||||
const deltaTime=Date.now()-touchStartTime;
|
||||
|
||||
if(deltaTime>SWIPE_MAX_TIME)return;
|
||||
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
|
||||
if(fingerCount===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(fingerCount===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(fingerCount===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
// Pointer event fallback — handles devices/drivers where touchstart/touchend don't fire
|
||||
// (e.g. Electron 42 on some Linux touchscreen drivers that only generate PointerEvents)
|
||||
let ptrIds=new Set();
|
||||
let ptrPeak=0;
|
||||
let ptrStartX=0,ptrStartY=0,ptrStartTime=0;
|
||||
|
||||
document.addEventListener('pointerdown',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.add(e.pointerId);
|
||||
if(ptrIds.size===1){ptrStartX=e.clientX;ptrStartY=e.clientY;ptrStartTime=Date.now();ptrPeak=1;}
|
||||
else{ptrPeak=Math.max(ptrPeak,ptrIds.size);}
|
||||
},{passive:true});
|
||||
|
||||
document.addEventListener('pointerup',e=>{
|
||||
if(e.pointerType!=='touch')return;
|
||||
ptrIds.delete(e.pointerId);
|
||||
if(ptrIds.size!==0)return;
|
||||
const deltaTime=Date.now()-ptrStartTime;
|
||||
if(deltaTime>SWIPE_MAX_TIME){ptrPeak=0;return;}
|
||||
const deltaX=e.clientX-ptrStartX;
|
||||
const deltaY=e.clientY-ptrStartY;
|
||||
const absX=Math.abs(deltaX);
|
||||
const absY=Math.abs(deltaY);
|
||||
if(ptrPeak===3&&absY>SWIPE_THRESHOLD&&absX<SWIPE_TOLERANCE&&deltaY>0){
|
||||
console.log('[TOUCH] 3-finger DOWN (ptr) - toggle hidden tabs');
|
||||
ipcRenderer.send('toggle-hidden');
|
||||
}else if(ptrPeak===2&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
console.log('[TOUCH] 2-finger HORIZONTAL (ptr) - change tab');
|
||||
sendSwipeIPC(deltaX>0?'swipe-right':'swipe-left');
|
||||
}else if(ptrPeak===1&&absX>SWIPE_THRESHOLD&&absY<SWIPE_TOLERANCE){
|
||||
const key=deltaX>0?'ArrowRight':'ArrowLeft';
|
||||
const keyCode=deltaX>0?39:37;
|
||||
['keydown','keyup'].forEach(eventType=>{
|
||||
document.dispatchEvent(new KeyboardEvent(eventType,{
|
||||
key:key,code:key,keyCode:keyCode,which:keyCode,bubbles:true,cancelable:true
|
||||
}));
|
||||
});
|
||||
}
|
||||
ptrPeak=0;
|
||||
},{passive:true});
|
||||
|
||||
// Show pause button on user interaction (for rotation sites only)
|
||||
let lastUserInteraction=0;
|
||||
const USER_INTERACTION_THROTTLE=500;
|
||||
|
||||
function handleUserInteraction(eventType){
|
||||
const now=Date.now();
|
||||
if(now-lastUserInteraction<USER_INTERACTION_THROTTLE)return;
|
||||
lastUserInteraction=now;
|
||||
|
||||
console.log('[PAUSE-BTN] User interaction ('+eventType+') - shouldShow='+pauseButtonShouldShow+', shown='+pauseButtonShown);
|
||||
// Show/refresh pause button if allowed on this site
|
||||
if(pauseButtonShouldShow){
|
||||
if(!pauseButtonShown){
|
||||
console.log('[PAUSE-BTN] Showing pause button now');
|
||||
}else{
|
||||
console.log('[PAUSE-BTN] Resetting auto-hide timer');
|
||||
}
|
||||
showPauseButton(); // This will reset the hide timer
|
||||
}
|
||||
|
||||
// Always show navigation button on user interaction (if enabled)
|
||||
if(navButtonEnabled){
|
||||
showNavButton();
|
||||
}
|
||||
|
||||
// Always show power button on user interaction
|
||||
showPowerButton();
|
||||
}
|
||||
|
||||
// Show pause button on any user interaction
|
||||
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
|
||||
pauseButtonTriggers.forEach(eventType=>{
|
||||
document.addEventListener(eventType,()=>handleUserInteraction(eventType),{passive:true,capture:true});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Slideshow</title>
|
||||
<!-- Shown for a site that's a local folder - see slideshow.js. The item
|
||||
list arrives from main.js via window.kioskSlideshow.update(); a Word
|
||||
document's bytes are requested by setting the page title (see
|
||||
TITLE_REQUEST in slideshow.js) and arrive via kioskSlideshow.docData(). -->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
||||
.slide {
|
||||
position: absolute; inset: 0; overflow: hidden;
|
||||
opacity: 0; transition-property: opacity;
|
||||
}
|
||||
.slide.show { opacity: 1; }
|
||||
.slide img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.slide.doc { background: #666; }
|
||||
#msg {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #888; font: 24px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
<script src="docx-render.js"></script>
|
||||
<script type="module" src="pdf-render.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="slide" id="a"></div>
|
||||
<div class="slide" id="b"></div>
|
||||
<div id="msg">Loading slideshow…</div>
|
||||
<script>
|
||||
(() => {
|
||||
const TITLE = 'Slideshow';
|
||||
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||
const layers = [document.getElementById('a'), document.getElementById('b')];
|
||||
const msg = document.getElementById('msg');
|
||||
let front = 0;
|
||||
let order = [];
|
||||
let pos = -1;
|
||||
let timer = null;
|
||||
let scrollAnim = null;
|
||||
let state = null;
|
||||
let generation = 0; // bumps on every (re)start, cancels stale work
|
||||
const docCache = new Map(); // url -> { mtime, bytes }
|
||||
const pendingDocs = new Map(); // url -> { resolve, reject, timeout }
|
||||
|
||||
const keyOf = (it) => it && `${it.type}:${it.url}:${it.mtime || ''}`;
|
||||
|
||||
function shuffled(list) {
|
||||
const a = list.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
clearTimeout(timer);
|
||||
cancelAnimationFrame(scrollAnim);
|
||||
scrollAnim = null;
|
||||
}
|
||||
|
||||
function showMessage(text) {
|
||||
stopTimers();
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
layers.forEach((l) => { l.classList.remove('show'); l.innerHTML = ''; });
|
||||
}
|
||||
|
||||
// Ask main.js for a document's bytes (see slideshow.js); cached by mtime
|
||||
// so a document is only re-read when it changed on disk.
|
||||
function getDocBytes(item) {
|
||||
const hit = docCache.get(item.url);
|
||||
if (hit && hit.mtime === item.mtime) return Promise.resolve(hit.bytes);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingDocs.delete(item.url);
|
||||
document.title = TITLE;
|
||||
reject(new Error('timed out reading the file'));
|
||||
}, 20000);
|
||||
pendingDocs.set(item.url, { resolve, reject, timeout, mtime: item.mtime });
|
||||
// main.js notices title *changes*; every answer (or timeout) puts it
|
||||
// back to TITLE, so each request is a change.
|
||||
document.title = TITLE_REQUEST + item.url;
|
||||
});
|
||||
}
|
||||
|
||||
// Fill `layer` with `item`, resolving once it's ready to fade in.
|
||||
async function prepare(layer, item) {
|
||||
layer.innerHTML = '';
|
||||
layer.scrollTop = 0;
|
||||
layer.classList.toggle('doc', item.type !== 'image');
|
||||
if (item.type === 'image') {
|
||||
const img = new Image();
|
||||
img.style.objectFit = state.options.fit;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error('unreadable image'));
|
||||
img.src = item.url;
|
||||
});
|
||||
layer.appendChild(img);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'pdf') {
|
||||
if (!window.KioskPdf) throw new Error('PDF renderer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const box = document.createElement('div');
|
||||
layer.appendChild(box);
|
||||
await KioskPdf.render(bytes, box, window.innerWidth, window.innerHeight);
|
||||
return;
|
||||
}
|
||||
if (!KioskDocx.available()) throw new Error('Word document viewer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const zoomEl = document.createElement('div');
|
||||
layer.appendChild(zoomEl);
|
||||
const cls = await KioskDocx.render(bytes, zoomEl);
|
||||
// One page: show all of it at once. Longer: fit the width and let
|
||||
// autoScroll() move through it.
|
||||
if (KioskDocx.isSinglePage(zoomEl, cls)) {
|
||||
KioskDocx.fitPage(zoomEl, cls, window.innerWidth, window.innerHeight);
|
||||
zoomEl.style.display = 'flex';
|
||||
zoomEl.style.justifyContent = 'center';
|
||||
} else {
|
||||
KioskDocx.fit(zoomEl, cls, window.innerWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// A document taller than the screen scrolls smoothly top to bottom
|
||||
// during its time on screen: holds at the top for the first 15%,
|
||||
// reaches the bottom at 85%, then holds.
|
||||
function autoScroll(layer, seconds) {
|
||||
const distance = layer.scrollHeight - layer.clientHeight;
|
||||
if (distance <= 4) return;
|
||||
const start = performance.now();
|
||||
const total = seconds * 1000;
|
||||
const step = (now) => {
|
||||
const t = Math.min(Math.max(((now - start) / total - 0.15) / 0.7, 0), 1);
|
||||
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
layer.scrollTop = distance * eased;
|
||||
if (t < 1) scrollAnim = requestAnimationFrame(step);
|
||||
};
|
||||
scrollAnim = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
async function next(attempts = 0) {
|
||||
stopTimers();
|
||||
if (!order.length) return;
|
||||
if (attempts >= order.length) { showMessage('None of the files in this folder could be shown.'); return; }
|
||||
const gen = generation;
|
||||
pos = (pos + 1) % order.length;
|
||||
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(order);
|
||||
const item = order[pos];
|
||||
const back = layers[1 - front];
|
||||
try {
|
||||
await prepare(back, item);
|
||||
} catch (e) {
|
||||
console.warn('[SLIDESHOW] Skipping', item.url, '-', e.message || e);
|
||||
if (gen === generation) next(attempts + 1);
|
||||
return;
|
||||
}
|
||||
if (gen !== generation) return; // folder changed while loading
|
||||
msg.style.display = 'none';
|
||||
const old = layers[front];
|
||||
back.classList.add('show');
|
||||
old.classList.remove('show');
|
||||
front = 1 - front;
|
||||
// Free the old slide (a rendered document can be large) once faded out.
|
||||
setTimeout(() => { if (!old.classList.contains('show')) old.innerHTML = ''; },
|
||||
state.options.transition * 1000 + 100);
|
||||
const isDoc = item.type !== 'image';
|
||||
const seconds = isDoc ? state.options.docInterval : state.options.interval;
|
||||
if (isDoc) autoScroll(back, seconds);
|
||||
// A lone document still cycles, so its scroll starts over from the top.
|
||||
if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000);
|
||||
}
|
||||
|
||||
window.kioskSlideshow = {
|
||||
update(s) {
|
||||
const first = !state;
|
||||
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
|
||||
const currentKey = keyOf(order[pos]);
|
||||
state = s;
|
||||
layers.forEach((l) => { l.style.transitionDuration = s.options.transition + 's'; });
|
||||
layers.forEach((l) => l.querySelectorAll('img').forEach((img) => { img.style.objectFit = s.options.fit; }));
|
||||
// Drop cached documents that left the folder.
|
||||
for (const url of docCache.keys()) {
|
||||
if (!s.items.some((it) => it.url === url)) docCache.delete(url);
|
||||
}
|
||||
if (!s.items.length) {
|
||||
order = [];
|
||||
generation++;
|
||||
showMessage(s.exists ? `No images, documents or PDFs in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||
return;
|
||||
}
|
||||
if (sameItems && !first) return;
|
||||
// Folder changed: keep showing the current item if it's still there
|
||||
// unchanged and carry on from it, otherwise restart from the top.
|
||||
generation++;
|
||||
order = s.options.shuffle ? shuffled(s.items) : s.items.slice();
|
||||
const keep = currentKey ? order.findIndex((it) => keyOf(it) === currentKey) : -1;
|
||||
if (keep >= 0 && !first) {
|
||||
pos = keep;
|
||||
clearTimeout(timer);
|
||||
const it = order[pos];
|
||||
const seconds = it.type !== 'image' ? s.options.docInterval : s.options.interval;
|
||||
if (order.length > 1 || it.type !== 'image') timer = setTimeout(next, seconds * 1000);
|
||||
} else {
|
||||
pos = -1;
|
||||
next();
|
||||
}
|
||||
},
|
||||
|
||||
docData(url, d) {
|
||||
const p = pendingDocs.get(url);
|
||||
if (!p) return;
|
||||
pendingDocs.delete(url);
|
||||
clearTimeout(p.timeout);
|
||||
document.title = TITLE;
|
||||
if (d.error) { p.reject(new Error(d.error)); return; }
|
||||
const bytes = KioskDocx.base64ToBytes(d.data);
|
||||
docCache.set(url, { mtime: p.mtime, bytes });
|
||||
// Keep at most 10 documents' bytes around.
|
||||
while (docCache.size > 10) docCache.delete(docCache.keys().next().value);
|
||||
p.resolve(bytes);
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
// Folder slideshow support for main.js.
|
||||
//
|
||||
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
|
||||
// in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a
|
||||
// full-screen slideshow of the images, Word documents (.docx) and PDFs in it,
|
||||
// instead of Chromium's directory listing. main.js swaps the folder URL
|
||||
// for slideshow.html via resolveSiteUrl(), then attach() hands the page
|
||||
// its item list - the page itself has no filesystem access
|
||||
// (contextIsolation, no Node). The folder is re-read every minute, so
|
||||
// files added, removed or changed show up without restarting the kiosk.
|
||||
//
|
||||
// Images load directly by file:// URL. A document's bytes are fetched on
|
||||
// demand: the page sets its title to TITLE_REQUEST + the document's URL,
|
||||
// and attach() answers via kioskSlideshow.docData() - but only for a
|
||||
// document (.docx/.pdf) in that slideshow's own current item list, so a page can't use
|
||||
// this to read anything else on disk.
|
||||
//
|
||||
// Optional slideshow.json in the folder (all keys optional):
|
||||
// { "interval": 10, seconds per image (default 10, minimum 2)
|
||||
// "docInterval": 20, seconds per document - .docx or PDF (default 2x interval,
|
||||
// minimum 5); a document longer than the
|
||||
// screen scrolls slowly top to bottom in it
|
||||
// "shuffle": false, random order instead of by file name
|
||||
// "fit": "contain", images: "contain" (whole image, letterboxed)
|
||||
// or "cover" (fill the screen, cropped)
|
||||
// "transition": 1, crossfade seconds (0 = instant cut)
|
||||
// "recursive": false } include files in subfolders
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { fileURLToPath, pathToFileURL } = require('url');
|
||||
|
||||
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
||||
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
||||
const DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' };
|
||||
const REFRESH_MS = 60000;
|
||||
const MAX_DOC_BYTES = 50 * 1024 * 1024;
|
||||
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||
const DEFAULTS = { interval: 10, docInterval: null, shuffle: false, fit: 'contain', transition: 1, recursive: false };
|
||||
|
||||
// The folder path a site URL points at, or null if it isn't a slideshow.
|
||||
// A file:// URL counts when it's an existing directory, or ends in "/"
|
||||
// (so a folder that isn't there yet - e.g. a USB stick not plugged in -
|
||||
// still shows the slideshow's "nothing to show" message, not an error).
|
||||
function slideshowDir(url) {
|
||||
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||
let p;
|
||||
try {
|
||||
p = fileURLToPath(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (url.endsWith('/')) return p;
|
||||
try {
|
||||
return fs.statSync(p).isDirectory() ? p : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// What a view should actually load for a configured site URL.
|
||||
function resolveSiteUrl(url) {
|
||||
const dir = slideshowDir(url);
|
||||
if (!dir) return url;
|
||||
const u = pathToFileURL(SLIDESHOW_PAGE);
|
||||
u.searchParams.set('dir', dir);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function readOptions(dir) {
|
||||
const opts = { ...DEFAULTS };
|
||||
let user = {};
|
||||
try {
|
||||
user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
|
||||
} catch (e) {
|
||||
// No (or unreadable) slideshow.json - defaults.
|
||||
}
|
||||
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
|
||||
opts.docInterval = Number(user.docInterval) > 0 ? Math.max(5, Number(user.docInterval)) : opts.interval * 2;
|
||||
if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle;
|
||||
if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit;
|
||||
if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2);
|
||||
if (typeof user.recursive === 'boolean') opts.recursive = user.recursive;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function listItems(dir, recursive, depth = 0) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
// Skip hidden files and Word's "~$name.docx" lock files.
|
||||
if (e.name.startsWith('.') || e.name.startsWith('~$')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (e.isDirectory()) {
|
||||
if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1));
|
||||
} else if (IMAGE_EXT.has(ext)) {
|
||||
out.push({ path: full, type: 'image' });
|
||||
} else if (DOC_TYPES[ext]) {
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ }
|
||||
out.push({ path: full, type: DOC_TYPES[ext], mtime });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshot(dir) {
|
||||
const options = readOptions(dir);
|
||||
const items = listItems(dir, options.recursive)
|
||||
.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) }));
|
||||
return { dir, exists: fs.existsSync(dir), options, items };
|
||||
}
|
||||
|
||||
function readDoc(fileUrl) {
|
||||
try {
|
||||
const file = fileURLToPath(fileUrl);
|
||||
const st = fs.statSync(file);
|
||||
if (st.size > MAX_DOC_BYTES) return { error: `too large (${Math.round(st.size / 1048576)} MB)` };
|
||||
return { mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||
} catch (e) {
|
||||
return { error: e.code === 'ENOENT' ? 'file not found' : e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Feed a view's slideshow page its item list now and every REFRESH_MS
|
||||
// while it stays on a slideshow, and answer its document requests. Call
|
||||
// once per view (main.js does, right after creating it); safe on views
|
||||
// that never show a slideshow.
|
||||
function attach(webContents) {
|
||||
const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href;
|
||||
let lastSent = '';
|
||||
let current = null; // latest snapshot sent to the page
|
||||
|
||||
const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix);
|
||||
|
||||
const send = (force) => {
|
||||
if (!onSlideshow()) return;
|
||||
const dir = new URL(webContents.getURL()).searchParams.get('dir');
|
||||
if (!dir) return;
|
||||
const snap = snapshot(dir);
|
||||
const payload = JSON.stringify(snap);
|
||||
if (!force && payload === lastSent) return;
|
||||
lastSent = payload;
|
||||
current = snap;
|
||||
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
webContents.on('did-finish-load', () => send(true));
|
||||
webContents.on('page-title-updated', (event, title) => {
|
||||
if (!title.startsWith(TITLE_REQUEST) || !onSlideshow() || !current) return;
|
||||
const url = title.slice(TITLE_REQUEST.length);
|
||||
if (!current.items.some((it) => it.type !== 'image' && it.url === url)) return;
|
||||
const doc = readDoc(url);
|
||||
webContents.executeJavaScript(
|
||||
`window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`,
|
||||
).catch(() => {});
|
||||
});
|
||||
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||
webContents.once('destroyed', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
cd /home/kiosk/kiosk-app
|
||||
|
||||
# Wait for network
|
||||
for i in {1..30}; do
|
||||
ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
export DISPLAY=:0
|
||||
export XAUTHORITY=/home/kiosk/.Xauthority
|
||||
export ELECTRON_ENABLE_LOGGING=1
|
||||
|
||||
# Ensure PipeWire is running
|
||||
systemctl --user is-active --quiet pipewire || systemctl --user start pipewire
|
||||
systemctl --user is-active --quiet pipewire-pulse || systemctl --user start pipewire-pulse
|
||||
systemctl --user is-active --quiet wireplumber || systemctl --user start wireplumber
|
||||
|
||||
# Wait for PipeWire
|
||||
for i in {1..10}; do
|
||||
pactl info >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
exec node_modules/electron/dist/electron . \
|
||||
--no-sandbox --disable-gpu-sandbox --disable-dev-shm-usage \
|
||||
--enable-features=UseOzonePlatform --ozone-platform=x11 \
|
||||
--enable-audio-service-sandbox=false --autoplay-policy=no-user-gesture-required \
|
||||
--password-store=basic \
|
||||
2>&1 | tee -a /home/kiosk/electron.log
|
||||
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WiFi</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h2 { text-align: center; margin-bottom: 16px; font-size: 26px; }
|
||||
.hidden { display: none !important; }
|
||||
label { display: block; margin: 14px 0 6px; font-size: 16px; color: #bdc3c7; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 20px;
|
||||
border: 2px solid #34495e;
|
||||
border-radius: 10px;
|
||||
background: #34495e;
|
||||
color: #ecf0f1;
|
||||
outline: none;
|
||||
}
|
||||
select {
|
||||
width: 100%; padding: 14px; font-size: 18px; border: 2px solid #34495e;
|
||||
border-radius: 10px; background: #34495e; color: #ecf0f1;
|
||||
}
|
||||
input:focus { border-color: #3498db; }
|
||||
.row { display: flex; gap: 10px; align-items: center; }
|
||||
.row input { flex: 1; }
|
||||
button {
|
||||
padding: 14px 20px;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: #34495e;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.primary { background: #27ae60; }
|
||||
.scan { background: #2980b9; }
|
||||
.close { background: #e74c3c; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 20px; }
|
||||
#status {
|
||||
background: #34495e;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#message { margin-top: 16px; font-size: 17px; font-weight: bold; text-align: center; min-height: 24px; }
|
||||
#message.error { color: #e74c3c; }
|
||||
#message.ok { color: #2ecc71; }
|
||||
#message.busy { color: #f1c40f; }
|
||||
#networks {
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border-radius: 10px;
|
||||
background: #34495e;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#networks li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #2c3e50;
|
||||
cursor: pointer;
|
||||
}
|
||||
#networks li:last-child { border-bottom: none; }
|
||||
#networks li.selected { background: #2980b9; }
|
||||
#networks li .meta { color: #bdc3c7; font-size: 15px; white-space: nowrap; margin-left: 12px; }
|
||||
#networks li.empty { color: #95a5a6; cursor: default; }
|
||||
.hint { text-align: center; font-size: 13px; color: #95a5a6; margin-top: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📶 WiFi</h2>
|
||||
|
||||
<div id="pin-step" class="hidden">
|
||||
<label for="pin" id="pin-label">Enter the PIN</label>
|
||||
<input id="pin" type="password" inputmode="numeric" autocomplete="off">
|
||||
<div class="actions">
|
||||
<button class="primary" id="pin-ok">✓ OK</button>
|
||||
<button class="close" data-close>✗ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wifi-step" class="hidden">
|
||||
<div id="status">Checking…</div>
|
||||
|
||||
<div class="row" style="margin-top: 14px">
|
||||
<label style="margin: 0; flex: 1">Networks nearby</label>
|
||||
<button class="scan" id="scan">⟳ Scan</button>
|
||||
</div>
|
||||
<ul id="networks"><li class="empty">Scanning…</li></ul>
|
||||
|
||||
<label for="ssid">Network name</label>
|
||||
<input id="ssid" type="text" autocomplete="off" spellcheck="false">
|
||||
|
||||
<label for="password">Password (empty for an open network)</label>
|
||||
<div class="row">
|
||||
<input id="password" type="password" autocomplete="off" spellcheck="false">
|
||||
<button id="show">Show</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="connect">Connect</button>
|
||||
<button class="close" data-close>✗ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="region-panel" class="hidden" style="margin-top: 18px">
|
||||
<summary style="font-size: 17px; cursor: pointer; color: #bdc3c7">🌍 Region (country, locale, keyboard)</summary>
|
||||
<label for="country">WiFi country (2 letters, e.g. US) - needed to join many 5 GHz networks</label>
|
||||
<input id="country" type="text" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
|
||||
<label for="locale">Language / locale</label>
|
||||
<select id="locale"></select>
|
||||
<label for="keyboard">Keyboard layout</label>
|
||||
<select id="keyboard"></select>
|
||||
<div class="actions">
|
||||
<button class="primary" id="region-save">Save region</button>
|
||||
<button id="region-from-locale">Country from locale</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="message"></div>
|
||||
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
||||
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
const $ = id => document.getElementById(id);
|
||||
let busy = false;
|
||||
|
||||
function request(action, extra) {
|
||||
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
|
||||
.catch(e => ({ok: false, error: String(e && e.message || e)}));
|
||||
}
|
||||
|
||||
function message(text, kind) {
|
||||
const m = $('message');
|
||||
m.textContent = text || '';
|
||||
m.className = kind || '';
|
||||
}
|
||||
|
||||
function setBusy(on) {
|
||||
busy = on;
|
||||
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
|
||||
}
|
||||
|
||||
function signalBars(dbm) {
|
||||
if (dbm >= -55) return '▂▄▆█';
|
||||
if (dbm >= -67) return '▂▄▆';
|
||||
if (dbm >= -78) return '▂▄';
|
||||
return '▂';
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const s = await request('status');
|
||||
const box = $('status');
|
||||
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
|
||||
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
|
||||
box.textContent = '';
|
||||
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
|
||||
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
|
||||
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
|
||||
line(`Adapter: ${s.iface}`);
|
||||
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
message('Scanning…', 'busy');
|
||||
const list = $('networks');
|
||||
const r = await request('scan');
|
||||
list.textContent = '';
|
||||
if (!r.ok) {
|
||||
message(r.error, 'error');
|
||||
list.innerHTML = '<li class="empty">No scan results</li>';
|
||||
} else {
|
||||
message('');
|
||||
if (!r.networks.length) list.innerHTML = '<li class="empty">No networks found - type the name below</li>';
|
||||
for (const n of r.networks) {
|
||||
const li = document.createElement('li');
|
||||
const name = document.createElement('span');
|
||||
name.textContent = n.ssid;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = (n.secure ? '🔒 ' : '') + signalBars(n.signal);
|
||||
li.append(name, meta);
|
||||
li.addEventListener('click', () => {
|
||||
for (const other of list.children) other.classList.remove('selected');
|
||||
li.classList.add('selected');
|
||||
$('ssid').value = n.ssid;
|
||||
$('password').value = '';
|
||||
$('password').focus();
|
||||
});
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (busy) return;
|
||||
const ssid = $('ssid').value;
|
||||
const password = $('password').value;
|
||||
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
|
||||
setBusy(true);
|
||||
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
|
||||
const r = await request('connect', {ssid, password});
|
||||
if (r.ok) {
|
||||
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
|
||||
$('password').value = '';
|
||||
} else {
|
||||
message(r.error || 'Connection failed', 'error');
|
||||
}
|
||||
setBusy(false);
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
function fillSelect(sel, items, current) {
|
||||
sel.textContent = '';
|
||||
const list = items.slice();
|
||||
if (current && !list.includes(current)) list.unshift(current);
|
||||
for (const v of list) {
|
||||
const o = document.createElement('option');
|
||||
o.value = v; o.textContent = v;
|
||||
if (v === current) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegion() {
|
||||
const r = await request('region-status');
|
||||
if (!r.ok) return;
|
||||
$('country').value = r.country || '';
|
||||
fillSelect($('locale'), r.locales || [], r.locale);
|
||||
fillSelect($('keyboard'), r.keyboards || [], r.keyboard);
|
||||
$('region-panel').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function saveRegion() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
message('Saving region…', 'busy');
|
||||
const r = await request('region-set', {
|
||||
country: $('country').value.trim(),
|
||||
locale: $('locale').value,
|
||||
keyboard: $('keyboard').value,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
message(r.restart ? '✓ Saved. Locale and keyboard apply after a restart; the country applies now.' : '✓ Saved', 'ok');
|
||||
loadRegion();
|
||||
} else {
|
||||
message(r.error || 'Could not save', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// en_US.UTF-8 -> US
|
||||
function countryFromLocale() {
|
||||
const m = /_([A-Z]{2})/.exec($('locale').value);
|
||||
if (m) $('country').value = m[1];
|
||||
else message("That locale doesn't name a country", 'error');
|
||||
}
|
||||
|
||||
async function unlock() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const r = await request('unlock', {pin: $('pin').value});
|
||||
setBusy(false);
|
||||
$('pin').value = '';
|
||||
if (r.ok) { showWifi(); } else { message(r.error, 'error'); $('pin').focus(); }
|
||||
}
|
||||
|
||||
function showWifi() {
|
||||
message('');
|
||||
$('pin-step').classList.add('hidden');
|
||||
$('wifi-step').classList.remove('hidden');
|
||||
refreshStatus();
|
||||
loadRegion();
|
||||
scan();
|
||||
}
|
||||
|
||||
$('pin-ok').addEventListener('click', unlock);
|
||||
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
||||
$('scan').addEventListener('click', scan);
|
||||
$('region-save').addEventListener('click', saveRegion);
|
||||
$('region-from-locale').addEventListener('click', countryFromLocale);
|
||||
$('connect').addEventListener('click', connect);
|
||||
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
||||
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
||||
$('show').addEventListener('click', () => {
|
||||
const p = $('password');
|
||||
p.type = p.type === 'password' ? 'text' : 'password';
|
||||
$('show').textContent = p.type === 'password' ? 'Show' : 'Hide';
|
||||
});
|
||||
for (const b of document.querySelectorAll('[data-close]')) b.addEventListener('click', () => request('close'));
|
||||
|
||||
request('needs-pin').then(r => {
|
||||
if (r.ok && r.needsPin) {
|
||||
if (r.codeType === 'password') {
|
||||
$('pin-label').textContent = 'Enter the kiosk password';
|
||||
$('pin').removeAttribute('inputmode');
|
||||
}
|
||||
$('pin-step').classList.remove('hidden');
|
||||
$('pin').focus();
|
||||
} else {
|
||||
showWifi();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+92
-1
@@ -29,8 +29,33 @@
|
||||
: "${CRON_D_DIR:=/etc/cron.d}"
|
||||
: "${BIN_DIR:=/usr/local/bin}"
|
||||
: "${NETPLAN_DIR:=/etc/netplan}"
|
||||
: "${TIMESYNCD_CONF_DIR:=/etc/systemd/timesyncd.conf.d}"
|
||||
: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}"
|
||||
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
||||
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
||||
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
|
||||
: "${SUDOERS_D_DIR:=/etc/sudoers.d}"
|
||||
# The one root-owned script the Web UI's addon-install/Update actions are
|
||||
# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it
|
||||
# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's
|
||||
# header). Under $BIN_DIR since that's already the convention for every
|
||||
# other addon's own scripts.
|
||||
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
||||
|
||||
# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker
|
||||
# stack is deployed here from this checkout's library/ directory. Root-owned
|
||||
# on purpose - it holds the generated database/admin passwords in .env.
|
||||
: "${LIBRARY_DIR:=/opt/kiosk-library}"
|
||||
|
||||
# Offline install bundle - only present on systems installed from an ISO
|
||||
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
||||
# package provisioning and the CUPS addon install) plus prebuilt
|
||||
# node_modules for the kiosk app and Web UI. Offline mode is switched on
|
||||
# by /usr/local/sbin/kiosk-offline-apt (which writes $KIOSK_OFFLINE_APT_CONF
|
||||
# pointing apt at that repository only) - the first-boot service does that
|
||||
# automatically when there's no internet; by hand: `sudo kiosk-offline-apt on`.
|
||||
: "${KIOSK_OFFLINE_DIR:=/opt/kiosk-offline}"
|
||||
: "${KIOSK_OFFLINE_APT_CONF:=/etc/apt/apt.conf.d/90-kiosk-offline}"
|
||||
|
||||
# The admin account actually running this tool (as opposed to $KIOSK_USER,
|
||||
# the kiosk's own restricted account) - used where an addon needs to grant
|
||||
@@ -61,10 +86,66 @@ LOCKOUT_AT_TIME=""
|
||||
LOCKOUT_ACTIVE_START=""
|
||||
LOCKOUT_ACTIVE_END=""
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
# "password" or "pin" (4-8 digits, unlocked with an on-screen keypad).
|
||||
LOCKOUT_CODE_TYPE="password"
|
||||
# A separate code for the boot lock screen - empty means the unlock code
|
||||
# above is used at boot too. Hashed like LOCKOUT_PASSWORD.
|
||||
BOOT_PASSWORD=""
|
||||
BOOT_CODE_TYPE="password"
|
||||
AUTHELIA_URL=""
|
||||
AUTHELIA_USERNAME=""
|
||||
AUTHELIA_ENCRYPTED_PASSWORD=""
|
||||
|
||||
offline_mode_active() {
|
||||
[[ -f "$KIOSK_OFFLINE_APT_CONF" && -d "$KIOSK_OFFLINE_DIR/apt" ]]
|
||||
}
|
||||
|
||||
# offline_install_node_modules NAME DEST_DIR
|
||||
# Unpacks the bundle's prebuilt node_modules for NAME (kiosk-app, webui)
|
||||
# into DEST_DIR as $KIOSK_USER, in place of `npm install` - which can't
|
||||
# reach the npm registry (or Electron's GitHub download) offline.
|
||||
offline_install_node_modules() {
|
||||
local name="$1" dest="$2"
|
||||
local tarball="$KIOSK_OFFLINE_DIR/npm/${name}-node_modules.tar.gz"
|
||||
if [[ ! -f "$tarball" ]]; then
|
||||
log_error "Offline bundle has no prebuilt modules for $name ($tarball)"
|
||||
return 1
|
||||
fi
|
||||
echo "Offline mode: unpacking prebuilt $name modules from $KIOSK_OFFLINE_DIR..."
|
||||
sudo -u "$KIOSK_USER" rm -rf "$dest/node_modules"
|
||||
sudo -u "$KIOSK_USER" tar -xzf "$tarball" -C "$dest"
|
||||
}
|
||||
|
||||
# Quick reachability check for the package mirrors install.sh needs.
|
||||
have_internet() {
|
||||
curl -fsS --max-time 8 -o /dev/null http://archive.ubuntu.com/ubuntu/ 2>/dev/null \
|
||||
|| curl -fsS --max-time 8 -o /dev/null https://deb.nodesource.com/ 2>/dev/null
|
||||
}
|
||||
|
||||
# run_with_offline_fallback CMD [ARGS...]
|
||||
# Runs CMD normally - but on a kiosk installed from an --offline ISO with
|
||||
# no internet right now, first points apt at the bundle on this disk
|
||||
# (kiosk-offline-apt on) and switches it back afterwards, whatever CMD
|
||||
# returns. Lets apt-based installs (e.g. the CUPS addon) work from the
|
||||
# normal menus and the Web UI with no extra steps. Everywhere else
|
||||
# (manual installs, or online) it just runs CMD.
|
||||
run_with_offline_fallback() {
|
||||
local restore=0 rc=0
|
||||
if ! offline_mode_active && [[ -f "$KIOSK_OFFLINE_DIR/apt/Packages" ]] && ! have_internet; then
|
||||
log_info "No internet - installing from the offline bundle on this disk ($(sed -n 's/^Built: *//p' "$KIOSK_OFFLINE_DIR/bundle-info.txt" 2>/dev/null))"
|
||||
if sudo kiosk-offline-apt on >/dev/null; then
|
||||
restore=1
|
||||
else
|
||||
log_warning "Couldn't switch apt to the offline bundle - trying normally"
|
||||
fi
|
||||
fi
|
||||
"$@" || rc=$?
|
||||
if (( restore )); then
|
||||
sudo kiosk-offline-apt off >/dev/null || true
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
kiosk_user_exists() {
|
||||
id "$KIOSK_USER" &>/dev/null
|
||||
}
|
||||
@@ -144,6 +225,13 @@ load_existing_config() {
|
||||
boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null)
|
||||
[[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
|
||||
local code_type
|
||||
code_type=$(sudo -u "$KIOSK_USER" jq -r '.lockoutCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
|
||||
[[ "$code_type" == "pin" ]] && LOCKOUT_CODE_TYPE="pin" || LOCKOUT_CODE_TYPE="password"
|
||||
BOOT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.bootPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||
code_type=$(sudo -u "$KIOSK_USER" jq -r '.bootCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
|
||||
[[ "$code_type" == "pin" ]] && BOOT_CODE_TYPE="pin" || BOOT_CODE_TYPE="password"
|
||||
|
||||
AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||
AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||
AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
|
||||
@@ -220,10 +308,13 @@ save_config() {
|
||||
--arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \
|
||||
--arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \
|
||||
--argjson requirePasswordOnBoot "$boot_password_json" \
|
||||
--arg lockoutCodeType "${LOCKOUT_CODE_TYPE:-password}" \
|
||||
--arg bootPassword "${BOOT_PASSWORD:-}" \
|
||||
--arg bootCodeType "${BOOT_CODE_TYPE:-password}" \
|
||||
--arg autheliaURL "${AUTHELIA_URL:-}" \
|
||||
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
|
||||
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
|
||||
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
|
||||
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
|
||||
|
||||
if [[ ${#URLS[@]} -gt 0 ]]; then
|
||||
for idx in "${!URLS[@]}"; do
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a
|
||||
# containerised service (the Library Catalog addon is the first).
|
||||
#
|
||||
# Everything here goes through `sudo docker ...`: the kiosk admin runs
|
||||
# install.sh as a regular user and is deliberately NOT put in the `docker`
|
||||
# group (docker-group membership is root-equivalent).
|
||||
#
|
||||
# Depends on: lib/menu.sh (log_* helpers) being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Overridable so tests can substitute stubs for the real binaries.
|
||||
: "${DOCKER_BIN:=docker}"
|
||||
|
||||
docker_is_installed() {
|
||||
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
|
||||
&& sudo "$DOCKER_BIN" compose version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
docker_daemon_running() {
|
||||
sudo "$DOCKER_BIN" info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Install Docker Engine + the Compose plugin if they are missing.
|
||||
#
|
||||
# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2):
|
||||
# preferred because it also works through the offline apt bundle's mirror and
|
||||
# gets security updates with everything else. On releases without
|
||||
# docker-compose-v2 (22.04), fall back to Docker's official install script.
|
||||
docker_ensure_installed() {
|
||||
if docker_is_installed; then
|
||||
docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Installing Docker..."
|
||||
if ! sudo apt-get update -qq; then
|
||||
log_error "apt update failed - check the network/package sources and try again"
|
||||
return 1
|
||||
fi
|
||||
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \
|
||||
|| { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; }
|
||||
else
|
||||
log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script"
|
||||
have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; }
|
||||
local script
|
||||
script=$(mktemp)
|
||||
if ! curl -fsSL https://get.docker.com -o "$script"; then
|
||||
rm -f "$script"
|
||||
log_error "Could not download Docker's install script"
|
||||
return 1
|
||||
fi
|
||||
sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; }
|
||||
rm -f "$script"
|
||||
fi
|
||||
|
||||
sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
if ! docker_is_installed || ! docker_daemon_running; then
|
||||
log_error "Docker was installed but is not working - check: sudo systemctl status docker"
|
||||
return 1
|
||||
fi
|
||||
log_success "Docker installed"
|
||||
}
|
||||
|
||||
# docker_compose DIR args... -> sudo docker compose --project-directory DIR args...
|
||||
docker_compose() {
|
||||
local dir="$1"; shift
|
||||
sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@"
|
||||
}
|
||||
|
||||
# docker_container_state NAME -> running | exited | restarting | ... | missing
|
||||
docker_container_state() {
|
||||
sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing"
|
||||
}
|
||||
|
||||
# docker_container_health NAME -> healthy | unhealthy | starting | none | missing
|
||||
docker_container_health() {
|
||||
sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \
|
||||
|| echo "missing"
|
||||
}
|
||||
|
||||
# docker_wait_http URL [TIMEOUT_SECONDS]
|
||||
# Polls until URL answers 2xx/3xx; returns 1 on timeout.
|
||||
docker_wait_http() {
|
||||
local url="$1" timeout="${2:-300}" waited=0
|
||||
while (( waited < timeout )); do
|
||||
if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# docker_free_port START -> prints the first TCP port >= START nothing listens on.
|
||||
docker_free_port() {
|
||||
local port="$1"
|
||||
while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do
|
||||
port=$((port + 1))
|
||||
done
|
||||
echo "$port"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# lib/electron.sh - Electron binary install/repair, shared between fresh
|
||||
# provisioning (lib/provision.sh) and ongoing maintenance
|
||||
# (menus/advanced_electron.sh's "Fix blank screen" action) - the exact
|
||||
# same repair sequence applies whether the binary never downloaded during
|
||||
# `npm install` or went missing later.
|
||||
#
|
||||
# Depends on: lib/config.sh being sourced first (for $KIOSK_DIR/$KIOSK_USER).
|
||||
################################################################################
|
||||
|
||||
# Re-verify/download the Electron binary and fix chrome-sandbox
|
||||
# permissions, without touching package.json or reinstalling anything else.
|
||||
electron_install_binary() {
|
||||
local electron_bin="$KIOSK_DIR/node_modules/electron/dist/electron"
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Electron binary missing - retrying via install.js..."
|
||||
sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && ELECTRON_FORCE_DOWNLOAD=true node node_modules/electron/install.js" || true
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_warning "Attempting direct download of Electron binary (~120MB)..."
|
||||
local electron_ver
|
||||
electron_ver=$(sudo -u "$KIOSK_USER" node -e \
|
||||
"try{console.log(require('$KIOSK_DIR/node_modules/electron/package.json').version)}catch(e){}" 2>/dev/null || true)
|
||||
if [[ -n "$electron_ver" ]]; then
|
||||
local electron_url="https://github.com/electron/electron/releases/download/v${electron_ver}/electron-v${electron_ver}-linux-x64.zip"
|
||||
log_info "Downloading Electron v${electron_ver} directly..."
|
||||
local tmp_zip
|
||||
tmp_zip=$(mktemp --suffix=.zip)
|
||||
if wget --timeout=300 --tries=3 -O "$tmp_zip" "$electron_url"; then
|
||||
command -v unzip &>/dev/null || sudo apt install -y unzip
|
||||
chmod 644 "$tmp_zip"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR/node_modules/electron/" 2>/dev/null || true
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_DIR/node_modules/electron/dist"
|
||||
sudo -u "$KIOSK_USER" unzip -o "$tmp_zip" -d "$KIOSK_DIR/node_modules/electron/dist/" || true
|
||||
sudo -u "$KIOSK_USER" chmod +x "$electron_bin" || true
|
||||
fi
|
||||
rm -f "$tmp_zip"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$electron_bin"; then
|
||||
log_error "Electron binary download failed after all attempts."
|
||||
log_error "Check your internet connection and try again."
|
||||
return 1
|
||||
fi
|
||||
log_success "Electron binary verified"
|
||||
|
||||
# chrome-sandbox MUST be owned by root and setuid, or Electron shows a blank screen.
|
||||
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||
if sudo -u "$KIOSK_USER" test -f "$sandbox"; then
|
||||
sudo chown root:root "$sandbox"
|
||||
sudo chmod 4755 "$sandbox"
|
||||
log_success "Chrome sandbox permissions set (required for display)"
|
||||
fi
|
||||
}
|
||||
+10
@@ -118,6 +118,16 @@ ask_yes_no() {
|
||||
local default="${2:-n}"
|
||||
local answer
|
||||
|
||||
# Unattended first-boot install (iso/ - KIOSK_UNATTENDED=1 in
|
||||
# /etc/kiosk-firstboot.conf): nobody is at the keyboard, so take the
|
||||
# default every prompt already offers and echo it so the log still
|
||||
# shows what was decided.
|
||||
if [[ "${KIOSK_UNATTENDED:-0}" == "1" ]]; then
|
||||
echo "$prompt (y/n) [$default]: $default (unattended)"
|
||||
validate_yes_no "$default"
|
||||
return
|
||||
fi
|
||||
|
||||
while true; do
|
||||
read -r -p "$prompt (y/n) [$default]: " answer
|
||||
answer="${answer:-$default}"
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# lib/provision.sh - First-time kiosk provisioning: turns a bare Ubuntu
|
||||
# Server box into a working kiosk. This is the piece the modular tool
|
||||
# never had - everything else in menus/*.sh only manages a kiosk that
|
||||
# already exists.
|
||||
#
|
||||
# The legacy ubuntu-based-kiosk.sh did this in one ~4,000-line function
|
||||
# (first_time_install) that mixed three different things together:
|
||||
# 1. ~2,900 lines of embedded app source (main.js, preload.js, 4 HTML
|
||||
# dialogs, package.json, start.sh), written via `sudo tee ... <<'EOF'`.
|
||||
# 2. A few hundred more lines of embedded system scripts/units/configs
|
||||
# (HDMI mirroring, audio routing, hotplug udev rules, power button
|
||||
# handling, etc), written the same way.
|
||||
# 3. The actual provisioning logic - roughly 1,000 lines once (1) and
|
||||
# (2) are out of the way.
|
||||
#
|
||||
# Every one of those embedded files used a quoted heredoc delimiter
|
||||
# (<<'EOF', not <<EOF) - meaning none of them did variable substitution
|
||||
# at write time - so they've been extracted byte-for-byte into real
|
||||
# files: the app source under kiosk-app/, everything else under
|
||||
# provision/files/ (mirroring its real destination path, e.g.
|
||||
# provision/files/etc/X11/xorg.conf.d/foo.conf -> /etc/X11/xorg.conf.d/foo.conf).
|
||||
# This function copies them into place instead of re-embedding them, and
|
||||
# calls straight into the Core Settings / Addons / Advanced menus this
|
||||
# tool already has for configuration - sites, timezone, touch/nav
|
||||
# settings, password protection, WiFi, schedules, emergency hotspot, and
|
||||
# virtual consoles are NOT reimplemented a third time here.
|
||||
#
|
||||
# One known, deliberate limitation carried over unchanged: several of
|
||||
# the extracted system scripts (start.sh, kiosk-hotplug.sh, the power
|
||||
# button handler) hardcode the username "kiosk" rather than using
|
||||
# $KIOSK_USER, exactly as the legacy heredocs did (quoted heredocs can't
|
||||
# substitute at write time either way). Fine for the common case since
|
||||
# $KIOSK_USER is virtually never overridden outside this project's own
|
||||
# tests, but a real gap if someone ever does. Not fixed here - fixing it
|
||||
# means moving those scripts off static templates onto generated ones,
|
||||
# which is more risk than this pass should take on.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced
|
||||
# first, and every menus/*.sh this calls into for configuration
|
||||
# (including menus/addon_webui.sh, for provision_configure_webui below).
|
||||
################################################################################
|
||||
|
||||
PROVISION_FILES="$SCRIPT_DIR/provision/files"
|
||||
KIOSK_APP_SRC="$SCRIPT_DIR/kiosk-app"
|
||||
|
||||
# provision_install_file SRC_REL DEST [MODE]
|
||||
# Copies a template from provision/files/SRC_REL to the real system path
|
||||
# DEST (creating parent directories as needed) as root, mode 644 unless
|
||||
# overridden - pass 755 for scripts, 750 for the openbox autostart.
|
||||
provision_install_file() {
|
||||
local src_rel="$1" dest="$2" mode="${3:-644}"
|
||||
sudo install -D -m "$mode" "$PROVISION_FILES/$src_rel" "$dest"
|
||||
}
|
||||
|
||||
# Package lists live in variables, not inline, so iso/offline/build-bundle.sh
|
||||
# (the offline install ISO) can read the exact same lists to bundle - add a
|
||||
# package here and the offline bundle picks it up on its next build.
|
||||
PROVISION_APT_PACKAGES=(
|
||||
xorg openbox lightdm unclutter screen curl git build-essential
|
||||
ca-certificates gnupg lsb-release jq ufw x11-xserver-utils xinput
|
||||
vainfo mesa-utils libgl1-mesa-dri libglx-mesa0 mesa-vulkan-drivers
|
||||
libva2 libva-drm2 libva-x11-2 mesa-va-drivers
|
||||
libegl-mesa0 libegl1-mesa-dev libgles2-mesa-dev
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber pipewire-audio-client-libraries alsa-utils libnotify-bin
|
||||
gstreamer1.0-pipewire libspa-0.2-bluetooth
|
||||
systemd-timesyncd acpid xbindkeys xdotool python3-evdev unzip
|
||||
net-tools ncdu evtest
|
||||
# WiFi: a default Ubuntu Server install only has these if WiFi was set
|
||||
# up in the installer - without them netplan can't join a WiFi network
|
||||
# and Core Settings -> WiFi has nothing to scan with. In the base list
|
||||
# so an offline install gets them (from the bundle) before that menu.
|
||||
wpasupplicant iw
|
||||
)
|
||||
PROVISION_INTEL_APT_PACKAGES=(
|
||||
intel-gpu-tools xserver-xorg-video-intel i965-va-driver intel-media-va-driver
|
||||
)
|
||||
|
||||
provision_install_packages() {
|
||||
echo "[1/10] Installing packages..."
|
||||
sudo apt update
|
||||
sudo apt install -y "${PROVISION_APT_PACKAGES[@]}"
|
||||
|
||||
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
||||
sudo apt install -y "${PROVISION_INTEL_APT_PACKAGES[@]}"
|
||||
provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf
|
||||
fi
|
||||
|
||||
sudo systemctl enable systemd-timesyncd
|
||||
sudo systemctl start systemd-timesyncd
|
||||
log_success "Packages installed, NTP time sync enabled"
|
||||
}
|
||||
|
||||
provision_create_kiosk_user() {
|
||||
echo "[2/10] Creating kiosk user..."
|
||||
local kiosk_groups=(audio video input plugdev netdev)
|
||||
# Not every Ubuntu release ships all of these - 26.04 Server has no
|
||||
# netdev group - and useradd refuses to create the user at all if any
|
||||
# -G group is missing. Create missing ones as plain system groups
|
||||
# (no-op when they exist).
|
||||
local g
|
||||
for g in "${kiosk_groups[@]}"; do
|
||||
sudo groupadd -f -r "$g"
|
||||
done
|
||||
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||
sudo useradd -m -s /bin/bash -G "$(IFS=,; echo "${kiosk_groups[*]}")" "$KIOSK_USER"
|
||||
echo "$KIOSK_USER:kiosk" | sudo chpasswd
|
||||
log_success "Kiosk user created (default password: kiosk - change it)"
|
||||
else
|
||||
log_success "Kiosk user already exists"
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME"
|
||||
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/pipewire/pipewire.conf.d"
|
||||
provision_install_file "pipewire/99-noise-cancellation.conf" \
|
||||
"$KIOSK_HOME/.config/pipewire/pipewire.conf.d/99-noise-cancellation.conf"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config"
|
||||
}
|
||||
|
||||
provision_install_nodejs() {
|
||||
echo "[3/10] Installing Node.js..."
|
||||
if ! command -v node &>/dev/null; then
|
||||
if offline_mode_active; then
|
||||
# The bundle carries NodeSource's own nodejs package.
|
||||
sudo apt install -y nodejs
|
||||
else
|
||||
curl -fsSL --connect-timeout 20 --max-time 300 https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
fi
|
||||
fi
|
||||
echo "Node.js: $(node -v)"
|
||||
}
|
||||
|
||||
provision_install_app() {
|
||||
echo "[4/10] Installing kiosk app..."
|
||||
# *.mjs: pdf-render.mjs (pdf.js only ships as a JavaScript module).
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.mjs "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
# Not `chown "$KIOSK_DIR"/*.js ...`: that glob is expanded by *this*
|
||||
# (admin) shell before sudo runs, and the kiosk user's home is private
|
||||
# (mode 750 on 26.04), so it can't be listed - the pattern stays literal
|
||||
# and chown fails. find runs as root. Top level only: node_modules is
|
||||
# already the kiosk user's, and chrome-sandbox inside it must stay root's.
|
||||
sudo find "$KIOSK_DIR" -maxdepth 1 -type f -exec chown "$KIOSK_USER:$KIOSK_USER" {} +
|
||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||
|
||||
if offline_mode_active; then
|
||||
offline_install_node_modules kiosk-app "$KIOSK_DIR"
|
||||
else
|
||||
echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..."
|
||||
sudo -u "$KIOSK_USER" bash -lc "
|
||||
npm config set fetch-timeout 600000
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 30000
|
||||
npm config set fetch-retry-maxtimeout 300000
|
||||
"
|
||||
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then
|
||||
log_error "npm install failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
electron_install_binary
|
||||
}
|
||||
|
||||
# Not moved to lib/electron.sh or anywhere else - this is the one piece
|
||||
# of first-time setup with no modular equivalent to call into, and
|
||||
# nothing else needs it.
|
||||
provision_configure_lightdm_autologin() {
|
||||
sudo mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
# nopasswdlogin is checked by PAM on Ubuntu 24.04; autologin group for older versions
|
||||
sudo groupadd -f nopasswdlogin
|
||||
sudo groupadd -f autologin
|
||||
sudo usermod -aG nopasswdlogin,autologin "$KIOSK_USER"
|
||||
# [Seat:*] works on all LightDM versions; [SeatDefaults] is ignored on newer Ubuntu
|
||||
sudo tee /etc/lightdm/lightdm.conf.d/10-kiosk.conf > /dev/null <<EOF
|
||||
[Seat:*]
|
||||
autologin-user=$KIOSK_USER
|
||||
autologin-user-timeout=0
|
||||
user-session=openbox
|
||||
autologin-session=openbox
|
||||
greeter-hide-users=true
|
||||
greeter-show-manual-login=false
|
||||
allow-guest=false
|
||||
EOF
|
||||
}
|
||||
|
||||
provision_configure_display() {
|
||||
echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..."
|
||||
|
||||
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse"
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
|
||||
# Touch screens always use libinput (proper multitouch for Chromium
|
||||
# gestures) and virtual consoles start enabled - matches the choice
|
||||
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
|
||||
# flip this later, and does below if declined at the end of setup.
|
||||
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
|
||||
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
|
||||
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
|
||||
|
||||
provision_install_file "openbox/autostart" "$KIOSK_HOME/.config/openbox/autostart" 750
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.config/openbox/autostart"
|
||||
sudo -u "$KIOSK_USER" touch "$KIOSK_HOME/.xbindkeysrc"
|
||||
|
||||
# HDMI hotplug: re-mirror any newly connected external display without
|
||||
# waiting for the next login, triggered by udev on DRM "change" events.
|
||||
provision_install_file "usr/local/bin/kiosk-hotplug.sh" /usr/local/bin/kiosk-hotplug.sh 755
|
||||
provision_install_file "etc/systemd/system/kiosk-hotplug.service" /etc/systemd/system/kiosk-hotplug.service
|
||||
provision_install_file "etc/udev/rules.d/99-kiosk-hotplug.rules" /etc/udev/rules.d/99-kiosk-hotplug.rules
|
||||
sudo systemctl daemon-reload
|
||||
sudo udevadm control --reload-rules
|
||||
|
||||
provision_configure_lightdm_autologin
|
||||
provision_install_wifi_helper
|
||||
|
||||
log_success "Display configured"
|
||||
}
|
||||
|
||||
# The kiosk app's WiFi screen (Ctrl+Alt+Super+W) - joining WiFi from
|
||||
# the kiosk itself when the console is switched off. The app runs as the
|
||||
# kiosk user; the netplan write is done by this one root helper, which
|
||||
# the kiosk user may run through sudo and nothing else (it only does
|
||||
# status/scan/connect - see its header). Here rather than its own
|
||||
# provisioning step so Advanced -> Upgrade, which re-runs this function,
|
||||
# installs it on an existing kiosk too. The sudoers rule is validated
|
||||
# before it goes in place, like the Web UI's: a broken sudoers.d file
|
||||
# breaks sudo for everyone.
|
||||
provision_install_wifi_helper() {
|
||||
provision_install_file "usr/local/bin/kiosk-wifi-helper" "$BIN_DIR/kiosk-wifi-helper" 755
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${BIN_DIR}/kiosk-wifi-helper" > "$tmp"
|
||||
if sudo visudo -c -f "$tmp" &>/dev/null; then
|
||||
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi"
|
||||
else
|
||||
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
|
||||
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
|
||||
sudo udevadm control --reload-rules 2>/dev/null || true
|
||||
}
|
||||
|
||||
provision_configure_firewall() {
|
||||
echo "[6/10] Configuring firewall..."
|
||||
sudo ufw --force enable
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow ssh
|
||||
log_success "Firewall configured (SSH allowed, incoming denied by default)"
|
||||
}
|
||||
|
||||
provision_configure_power_management() {
|
||||
echo "[7/10] Configuring power management..."
|
||||
sudo mkdir -p /etc/polkit-1/localauthority/50-local.d
|
||||
provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-volume-up" /usr/local/bin/kiosk-volume-up 755
|
||||
provision_install_file "usr/local/bin/kiosk-volume-down" /usr/local/bin/kiosk-volume-down 755
|
||||
|
||||
provision_install_file "usr/local/bin/kiosk-power-button.sh" /usr/local/bin/kiosk-power-button.sh 755
|
||||
# Backwards-compatible second location some older docs/scripts reference.
|
||||
sudo cp /usr/local/bin/kiosk-power-button.sh "$KIOSK_HOME/trigger-power-menu.sh"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/trigger-power-menu.sh"
|
||||
provision_install_file "usr/local/bin/test-power-button" /usr/local/bin/test-power-button 755
|
||||
|
||||
sudo rm -f /etc/acpi/events/powerbtn* /etc/acpi/events/power* 2>/dev/null || true
|
||||
provision_install_file "etc/acpi/events/kiosk-power-button" /etc/acpi/events/kiosk-power-button
|
||||
provision_install_file "etc/acpi/events/kiosk-power-pbtn" /etc/acpi/events/kiosk-power-pbtn
|
||||
provision_install_file "etc/acpi/events/kiosk-power-pwr" /etc/acpi/events/kiosk-power-pwr
|
||||
|
||||
sudo mkdir -p /etc/systemd/logind.conf.d
|
||||
provision_install_file "etc/systemd/logind.conf.d/power-button.conf" /etc/systemd/logind.conf.d/power-button.conf
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart systemd-logind
|
||||
sudo systemctl enable acpid
|
||||
sudo systemctl restart acpid
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet acpid; then
|
||||
log_success "Power button configured"
|
||||
else
|
||||
log_warning "acpid may not be running properly - check: sudo systemctl status acpid"
|
||||
fi
|
||||
}
|
||||
|
||||
# Installed by default now, not opt-in - reuses menus/addon_webui.sh's
|
||||
# own install helpers directly rather than duplicating them, with a
|
||||
# fixed default port and no prompt (first-time install already has
|
||||
# plenty). See that file's header for the privilege model (a narrow
|
||||
# allow-listed root helper, not the service itself running as root) and
|
||||
# why this exists at all: browser-based config editing, and - via that
|
||||
# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk
|
||||
# Intercom and Update, none of which are reimplemented here.
|
||||
provision_configure_webui() {
|
||||
echo "[8/10] Installing Web UI..."
|
||||
if ! webui_install_app_files; then
|
||||
log_warning "Web UI install failed - configure later: Addons -> Web UI"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local port="8090"
|
||||
webui_write_env_file "$port"
|
||||
webui_write_unit_file
|
||||
webui_write_helper_script
|
||||
if ! webui_write_sudoers_file; then
|
||||
log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if enable_and_start_units kiosk-webui; then
|
||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||
else
|
||||
log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
# Configuration from here on is NOT reimplemented - it's the exact same
|
||||
# Core Settings / Advanced menus this tool already uses to manage a
|
||||
# kiosk after install, called directly instead of duplicated.
|
||||
provision_configure_kiosk_settings() {
|
||||
echo "[9/10] Configuring kiosk settings..."
|
||||
echo "Core Settings is next - sites, timezone, touch/navigation,"
|
||||
echo "password protection, WiFi, and schedules. Skip and configure"
|
||||
echo "later via ./install.sh if you'd rather do this after reboot."
|
||||
echo
|
||||
if [[ "${KIOSK_UNATTENDED:-0}" == "1" ]]; then
|
||||
# The menu itself is interactive - no default to take. Sites etc
|
||||
# are set afterwards via the Web UI or ./install.sh instead.
|
||||
log_info "Unattended install - skipping Core Settings (configure later via the Web UI or ./install.sh)"
|
||||
elif ask_yes_no "Configure Core Settings now?" "y"; then
|
||||
core_settings_menu
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Emergency Hotspot auto-starts a WiFi hotspot if no internet is"
|
||||
echo "detected after boot, so you can connect and reconfigure remotely."
|
||||
if ask_yes_no "Configure emergency hotspot now?" "n"; then
|
||||
action_configure_emergency_hotspot
|
||||
else
|
||||
log_info "Configure later: Advanced -> Emergency Hotspot"
|
||||
fi
|
||||
|
||||
if [[ "${KIOSK_UNATTENDED:-0}" != "1" ]]; then
|
||||
ssh_first_time_setup
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
|
||||
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
|
||||
action_disable_virtual_consoles
|
||||
fi
|
||||
}
|
||||
|
||||
provision_finish() {
|
||||
echo "[10/10] Done."
|
||||
echo
|
||||
log_success "Core installation complete!"
|
||||
echo
|
||||
echo "Run ./install.sh again anytime to configure Core Settings, Addons"
|
||||
echo "(CUPS, LMS/Squeezelite, Remote Access, Authelia, Asterisk Intercom),"
|
||||
echo "or Advanced options."
|
||||
echo
|
||||
# Lets a caller (iso/firstboot/kiosk-firstboot) tell a finished
|
||||
# install apart from an interrupted one - is_kiosk_installed is
|
||||
# already true from step 4 on, and the reboot below can kill the
|
||||
# caller before it sees this function return.
|
||||
if [[ -n "${KIOSK_PROVISION_DONE_MARKER:-}" ]]; then
|
||||
sudo install -D -m 644 /dev/null "$KIOSK_PROVISION_DONE_MARKER"
|
||||
fi
|
||||
if ask_yes_no "Reboot now to start the kiosk?" "y"; then
|
||||
echo "Rebooting in 3 seconds..."
|
||||
sleep 3
|
||||
sudo reboot
|
||||
else
|
||||
log_warning "Remember to reboot before the kiosk will start: sudo reboot"
|
||||
fi
|
||||
}
|
||||
|
||||
run_first_time_install() {
|
||||
clear
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Ubuntu Based Kiosk - First-Time Installation"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
echo "This will install a HEADLESS KIOSK (no desktop environment):"
|
||||
echo
|
||||
echo "CORE:"
|
||||
echo " - Kiosk user with auto-login"
|
||||
echo " - LightDM + Openbox (minimal window manager)"
|
||||
echo " - Electron browser"
|
||||
echo " - Multi-site rotation with touch controls"
|
||||
echo " - Hardware video acceleration"
|
||||
echo " - Audio support (PipeWire)"
|
||||
echo " - Time synchronization (NTP)"
|
||||
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
||||
echo " addon install/reconfigure and Update - no login of its own,"
|
||||
echo " see Addons -> Web UI)"
|
||||
echo
|
||||
echo "OPTIONAL (configure after install, via Addons):"
|
||||
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
||||
echo " - CUPS printing"
|
||||
echo " - Remote desktop (VNC), VPN (WireGuard/Tailscale/Netbird)"
|
||||
echo " - Authelia auto-login, Asterisk Intercom"
|
||||
echo
|
||||
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; return 1; }
|
||||
|
||||
# Cache sudo credentials upfront so they don't expire mid-install.
|
||||
sudo -v
|
||||
|
||||
provision_install_packages
|
||||
provision_create_kiosk_user
|
||||
provision_install_nodejs
|
||||
# Bare call, not `if ! provision_install_app; then ...`: testing a
|
||||
# multi-statement function's result as an if-condition exempts
|
||||
# everything *inside* that function from set -e for the duration -
|
||||
# an early step failing (e.g. the `cp` before npm install even
|
||||
# runs) would silently not stop the later steps. provision_install_app
|
||||
# already reports its own npm-install failure via a guarded
|
||||
# single-command `if`, which doesn't have this problem; letting its
|
||||
# overall exit status propagate here as a bare statement preserves
|
||||
# real fail-fast for every step in between.
|
||||
provision_install_app
|
||||
provision_configure_display
|
||||
provision_configure_firewall
|
||||
provision_configure_power_management
|
||||
provision_configure_webui
|
||||
provision_configure_kiosk_settings
|
||||
provision_finish
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Generated by install.sh - contains passwords
|
||||
.env
|
||||
# Extra CA certificates dropped in for building behind a proxy
|
||||
koha/certs/*.crt
|
||||
quickadd/certs/*.crt
|
||||
# Generated by install.sh --domain
|
||||
Caddyfile
|
||||
caddy-root.crt
|
||||
@@ -0,0 +1,316 @@
|
||||
# Room for Books: a home library (Koha + the Room for Books site, in Docker)
|
||||
|
||||
A self-hosted library catalogue you can set up in one command:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
It installs Docker if it is missing, generates the passwords, builds the images,
|
||||
starts everything and prints the addresses and your login. Running it again is safe;
|
||||
it never overwrites your `.env` (passwords) or your data.
|
||||
|
||||
| What | Address | Login |
|
||||
|---|---|---|
|
||||
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password |
|
||||
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) |
|
||||
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none |
|
||||
|
||||
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
|
||||
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
|
||||
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
|
||||
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
|
||||
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
|
||||
|
||||
Ports move up to the next free number if one is taken; the final addresses are printed
|
||||
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
|
||||
(`8099`), which is handy behind a proxy but not needed otherwise.
|
||||
|
||||
## What it is made of
|
||||
|
||||
* **Koha**: the open-source library system, built from the Koha project's own Debian
|
||||
packages (no third-party Koha images). Installed and configured automatically; there is
|
||||
no web installer to click through.
|
||||
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
|
||||
* **MariaDB**: the database.
|
||||
|
||||
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
|
||||
|
||||
## Adding books
|
||||
|
||||
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
|
||||
scanner works as a keyboard, so just scan.
|
||||
|
||||
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
|
||||
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
|
||||
2. If you already have the title, you are asked whether to add **another copy**.
|
||||
3. Otherwise the record is added with a barcode (see **Barcodes** below), the Dewey (or LC) call
|
||||
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
|
||||
you picked.
|
||||
4. Nothing found? A short form lets you type the title and it is added anyway.
|
||||
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
|
||||
are never reused, because a label may already be printed.
|
||||
|
||||
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
|
||||
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
|
||||
for many of them.
|
||||
|
||||
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
|
||||
stack of books without looking at the screen.
|
||||
|
||||
### Scanning with a phone camera
|
||||
|
||||
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
|
||||
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
|
||||
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
|
||||
|
||||
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
|
||||
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
|
||||
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
|
||||
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
|
||||
can't read the photo you are told, and nothing is added.
|
||||
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
|
||||
Bluetooth scanner paired to the phone also works, since it types into the page.
|
||||
|
||||
## Barcodes: your choice
|
||||
|
||||
Barcodes are optional. Open **Barcodes and settings** at the bottom of the Add books page:
|
||||
|
||||
| Choice | What happens |
|
||||
|---|---|
|
||||
| **Number them automatically** (default) | Each new item gets the next number, zero-padded to 7 digits (`0000042`). |
|
||||
| **I stick a pre-printed barcode on each one** | After the ISBN is recognised you scan the sticker on the book. A sticker that is already in use is refused. |
|
||||
| **No barcodes for now** | Items are added without one; the catalogue still shows them. |
|
||||
|
||||
Later, in the same panel:
|
||||
|
||||
* **Give them barcodes** numbers every item that has none, in one go, and shows the range to print.
|
||||
* **Print a sheet of barcodes ahead of time** reserves a block (say 80 numbers), and the link opens
|
||||
Koha's label printer with that range filled in. Print the sheet, and stick the labels on whenever you like.
|
||||
Reserved numbers are never handed out again.
|
||||
|
||||
Good to know:
|
||||
|
||||
* Koha's range printer needs both ends to be the same length, so barcodes are fixed-width digits. They are
|
||||
**at least 7 digits** on purpose: 6-digit all-number Code 39 barcodes can be misread as the pharmacy
|
||||
"Code 32" format by some scanners (checked with a decoder: 7 digits read back perfectly, 6 did not).
|
||||
* Prefixes (such as `HOME-0042`) are not offered: Koha's range printer cannot print them.
|
||||
* Barcode types Koha can print: Code 39 (default, read by every scanner), Code 39 with a Modulo-43 or
|
||||
Modulo-10 check digit (needs a scanner set up for check digits), EAN-13 (exactly 12-13 digits),
|
||||
COOP 2 of 5 and Industrial 2 of 5. Change it under *Cataloging → Label creator → Label layouts*.
|
||||
* Koha's own automatic barcode is switched off so there is one numbering scheme. Items added in the staff
|
||||
interface have no barcode until you press **Give them barcodes**.
|
||||
|
||||
## Printing labels
|
||||
|
||||
From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
|
||||
just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
|
||||
and print at 100% scale (not "fit to page") so the labels line up.
|
||||
|
||||
| Template | Sheet |
|
||||
|---|---|
|
||||
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
|
||||
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
|
||||
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
|
||||
|
||||
| Layout | Prints |
|
||||
|---|---|
|
||||
| Spine + barcode | call number (split over lines) with the barcode below |
|
||||
| Spine - call number | call number only |
|
||||
| Barcode only | barcode |
|
||||
|
||||
## People and lending
|
||||
|
||||
Lending is set up for a home library: 3-week loans, up to 25 items, 3 renewals, **no fines**.
|
||||
Change it under *Administration → Circulation and fines rules*.
|
||||
|
||||
1. **Add a person**: staff interface → *Patrons → New patron → Friend / borrower*. Only the name is
|
||||
required; the **library card number is assigned automatically** (`1000001`, `1000002`, ...). Numbers
|
||||
start well away from item barcodes so a scan can never mean both.
|
||||
2. **Print the card**: *Tools → Patron card creator → New batch*, add the patron's number, *Export card
|
||||
batch*, template **Avery 5371**, layout **Library card**. The card carries the name and a scannable barcode.
|
||||
3. **Check out**: *Circulation → Check out*. Scan the person's card, then scan each book. The due date appears
|
||||
on screen.
|
||||
4. **Check in**: *Circulation → Check in*, scan the book.
|
||||
|
||||
A USB or Bluetooth scanner types the number and presses Enter, so it works in all of these boxes.
|
||||
|
||||
## Finding the books
|
||||
|
||||
The public catalogue searches title, author, subject and ISBN, and shows each copy's
|
||||
availability, call number and **shelving location**.
|
||||
|
||||
On the Add books page, pick the **Shelf / room** before you scan; every book you add
|
||||
gets it. The choice is saved on the server, so every device shares it. If all your books are in one
|
||||
place, leave it on *(not set / all in one place)*; nothing is shown and nothing needs changing. Change it
|
||||
whenever you move to another shelf.
|
||||
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
|
||||
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
|
||||
for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
|
||||
|
||||
## Common tasks
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --status # addresses, login, container health
|
||||
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
|
||||
sudo ./install.sh --stop # stop everything
|
||||
sudo ./install.sh --start # start again
|
||||
sudo docker logs -f library-koha
|
||||
```
|
||||
|
||||
Settings live in `.env` (created on first run, mode 600). Useful ones:
|
||||
|
||||
| Setting | Meaning |
|
||||
|---|---|
|
||||
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
|
||||
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
|
||||
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
|
||||
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
|
||||
|
||||
After editing `.env` run `sudo docker compose up -d`.
|
||||
|
||||
## Behind a proxy or on a locked-down network
|
||||
|
||||
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
|
||||
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
|
||||
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
|
||||
|
||||
## Backups
|
||||
|
||||
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
|
||||
`library_koha-data` (search index, uploads). Back up the database with:
|
||||
|
||||
```bash
|
||||
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
|
||||
```
|
||||
|
||||
The search index is rebuilt automatically if it is lost.
|
||||
|
||||
## Security notes
|
||||
|
||||
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
|
||||
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
|
||||
HTTPS in front.
|
||||
* Quick Add uses the staff `admin` login to talk to Koha's API.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
install.sh one-command setup and --status/--update/--stop/--start
|
||||
docker-compose.yml db + koha + quickadd
|
||||
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
|
||||
bootstrap.pl (headless install), seed/ (item types, Z39.50
|
||||
servers, label templates/layouts)
|
||||
quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||
```
|
||||
|
||||
## Signing in once
|
||||
|
||||
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||
|
||||
## The home page (the Room for Books site at `/add/`)
|
||||
|
||||
**One address.** `http://<ip>:<port>/` (and later `https://your-domain/` behind Caddy) opens this page; there is no separate
|
||||
port to remember. Koha's own staff screens live on the same address at `/cgi-bin/koha/mainpage.pl`, reached from the menu
|
||||
(*Koha (full staff screens)*). Everyone signs in at the same prompt: staff with the admin login, borrowers with their card number
|
||||
and password, the guest with the guest login.
|
||||
|
||||
One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
|
||||
(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
|
||||
(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
|
||||
and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books.
|
||||
|
||||
**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list
|
||||
of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have
|
||||
are marked, with an *Add another copy* button.
|
||||
|
||||
## Everyday use (details)
|
||||
|
||||
One page for the day-to-day, so you rarely need Koha's own screens:
|
||||
|
||||
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
|
||||
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
|
||||
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
|
||||
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
|
||||
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
|
||||
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
|
||||
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
|
||||
- **People**: add someone with just a name (the card number is assigned automatically), search, and
|
||||
**Print card** for a library card with a scannable Code 39 barcode.
|
||||
- **Add books**: scan an ISBN (or use the phone camera), as before.
|
||||
|
||||
The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
|
||||
|
||||
### Collections, QR cards, more about a book
|
||||
|
||||
- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the
|
||||
Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed.
|
||||
- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text
|
||||
or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo.
|
||||
**Print everyone's cards** (People) lays out a sheet of cards.
|
||||
- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing,
|
||||
WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it.
|
||||
|
||||
### Reading levels (AR) and spreadsheets
|
||||
|
||||
AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it.
|
||||
Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the
|
||||
**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads
|
||||
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
|
||||
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
|
||||
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
|
||||
|
||||
### Live camera scanning (needs HTTPS)
|
||||
|
||||
Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
|
||||
certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
|
||||
phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
|
||||
browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
|
||||
|
||||
### Copying reading levels from AR BookFind
|
||||
|
||||
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
|
||||
points, quiz number and interest level fill in by themselves; check them and press Save.
|
||||
|
||||
### Tags, and borrowers signing in
|
||||
|
||||
- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
|
||||
book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
|
||||
the usual backups of that folder include.
|
||||
- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
|
||||
their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
|
||||
people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
|
||||
sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
|
||||
password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
|
||||
default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
|
||||
Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
|
||||
|
||||
### Reading levels: pop-up or in-page AR BookFind
|
||||
|
||||
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
|
||||
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
|
||||
it into AR's search; AR's own page cannot be pre-filled from here.
|
||||
|
||||
### Same ports on every install
|
||||
|
||||
New installs of this stack use the same three ports whether you install it from the kiosk add-on or from `ubuntu-post-install`:
|
||||
**8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the
|
||||
public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs
|
||||
keep the ports they already have.
|
||||
|
||||
## HTTPS with Caddy (and the live camera scanner)
|
||||
|
||||
Phones only allow a live camera on an HTTPS page. One command adds Caddy in front of the library:
|
||||
|
||||
```bash
|
||||
# a real domain that points at this machine (ports 80 and 443 reachable): the certificate is automatic
|
||||
sudo ./install.sh --domain library.example.com
|
||||
|
||||
# a name only your own network knows (library.home.lan): Caddy makes its own certificate authority
|
||||
sudo ./install.sh --domain library.home.lan --lan-tls # then install caddy-root.crt on your phones
|
||||
```
|
||||
|
||||
It writes `Caddyfile`, starts a `caddy` container (the compose `caddy` profile), sets `STAFF_URL=https://<name>` and closes the plain-HTTP
|
||||
ports to the network (`BIND_ADDR=127.0.0.1`; add `--keep-direct` to leave them open). Then use `https://<name>/`: everything works
|
||||
as before, and the **Live scan** buttons appear on Add books and Lend / return. Behind your own Caddy instead, point it at the library
|
||||
port: `library.example.com { reverse_proxy <this machine>:8098 }` and set `STAFF_URL=https://library.example.com` in `.env`.
|
||||
@@ -0,0 +1,102 @@
|
||||
name: library
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
container_name: library-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MARIADB_DATABASE: koha_library
|
||||
MARIADB_USER: koha_library
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"]
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
koha:
|
||||
build:
|
||||
context: ./koha
|
||||
args:
|
||||
KOHA_SUITE: ${KOHA_SUITE:-stable}
|
||||
image: library-koha:local
|
||||
container_name: library-koha
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
quickadd:
|
||||
condition: service_started
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||
KOHA_SSO_SECRET: ${SSO_SECRET:-}
|
||||
KOHA_DB_HOST: db
|
||||
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
|
||||
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
|
||||
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
|
||||
KOHA_SHELVES: ${SHELVES:-}
|
||||
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||
ports:
|
||||
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
|
||||
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80"
|
||||
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081"
|
||||
volumes:
|
||||
- koha-data:/var/lib/koha
|
||||
|
||||
# Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API).
|
||||
quickadd:
|
||||
build:
|
||||
context: ./quickadd
|
||||
image: library-quickadd:local
|
||||
container_name: library-quickadd
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_API: http://koha:8081/api/v1
|
||||
SSO_SECRET: ${SSO_SECRET:-}
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
|
||||
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||
OPAC_PORT: ${OPAC_PORT:-8097}
|
||||
STAFF_PORT: ${STAFF_PORT:-8098}
|
||||
OPAC_URL: ${OPAC_URL:-}
|
||||
STAFF_URL: ${STAFF_URL:-}
|
||||
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
|
||||
ports:
|
||||
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082"
|
||||
volumes:
|
||||
- quickadd-data:/data
|
||||
|
||||
# HTTPS with an automatic certificate (and so the live camera on phones): only started when
|
||||
# `./install.sh --domain <name>` has set COMPOSE_PROFILES=caddy in .env.
|
||||
caddy:
|
||||
profiles: ["caddy"]
|
||||
image: caddy:2
|
||||
container_name: library-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- koha
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
koha-data:
|
||||
quickadd-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian.
|
||||
#
|
||||
# sudo ./install.sh # install Docker if needed, build, start
|
||||
# sudo ./install.sh --status # show URLs and container health
|
||||
# sudo ./install.sh --update # rebuild with the latest Koha and restart
|
||||
# sudo ./install.sh --stop | --start
|
||||
# sudo ./install.sh --domain library.example.com # HTTPS via Caddy (certificate is automatic)
|
||||
# sudo ./install.sh --domain library.home.lan --lan-tls # HTTPS for a name only your network knows (own CA)
|
||||
# add --keep-direct to also keep the plain-HTTP ports open on the network
|
||||
#
|
||||
# Optional environment variables:
|
||||
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
|
||||
# LIBRARY_CODE=HOME short code for the library (letters/digits)
|
||||
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
|
||||
# ITEM_TYPES="CD=Compact disc" extra item types (first install only)
|
||||
# OPAC_PORT=8097 STAFF_PORT=8098 preferred host ports (next free is used)
|
||||
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
|
||||
c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; }
|
||||
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
|
||||
c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; }
|
||||
RUN_USER="${SUDO_USER:-root}"
|
||||
|
||||
# --- Docker -------------------------------------------------------------------
|
||||
ensure_docker() {
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed"
|
||||
else
|
||||
c_info "Installing Docker (official get.docker.com script)..."
|
||||
command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin
|
||||
c_ok "Docker installed"
|
||||
fi
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then
|
||||
usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)"
|
||||
fi
|
||||
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
|
||||
else # minimal systems without iproute2
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
|
||||
fi
|
||||
}
|
||||
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
|
||||
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
|
||||
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||
|
||||
dc() { docker compose --project-directory "$HERE" "$@"; }
|
||||
|
||||
# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared
|
||||
# IPs; retry with a growing pause instead of failing the whole install.
|
||||
with_retry() {
|
||||
local n=1 max=5 wait=15
|
||||
until "$@"; do
|
||||
[ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; }
|
||||
c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..."
|
||||
sleep "$wait"; n=$((n + 1)); wait=$((wait * 2))
|
||||
done
|
||||
}
|
||||
|
||||
show_status() {
|
||||
[ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; }
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
|
||||
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
|
||||
ip="localhost"
|
||||
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
|
||||
fi
|
||||
dc ps
|
||||
echo
|
||||
if [ -n "${DOMAIN:-}" ]; then
|
||||
echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it"
|
||||
echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)"
|
||||
fi
|
||||
echo " Your library: http://$ip:$STAFF_PORT"
|
||||
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
|
||||
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
|
||||
echo " It opens the Find a book page: search only, it cannot add or change anything."
|
||||
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
|
||||
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# --- HTTPS (Caddy) options --------------------------------------------------------
|
||||
DOMAIN_ARG="" LAN_TLS=0 KEEP_DIRECT=0 ACTION=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN_ARG="${2:-}"; [ -n "$DOMAIN_ARG" ] || { c_err "--domain needs a name"; exit 1; }; shift 2 ;;
|
||||
--lan-tls) LAN_TLS=1; shift ;;
|
||||
--keep-direct) KEEP_DIRECT=1; shift ;;
|
||||
*) ACTION="${ACTION:+$ACTION }$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
set -- $ACTION
|
||||
if [ -n "$DOMAIN_ARG" ] && ! printf '%s' "$DOMAIN_ARG" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'; then
|
||||
c_err "That does not look like a host name: $DOMAIN_ARG"; exit 1
|
||||
fi
|
||||
|
||||
# Write (or replace) KEY=VALUE in .env
|
||||
env_set() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; }
|
||||
|
||||
configure_caddy() {
|
||||
local name="$DOMAIN_ARG"
|
||||
# Caddy needs 80 and 443 (it is not moved to other ports: browsers expect these).
|
||||
if ! dc ps --status running --services 2>/dev/null | grep -qx caddy; then
|
||||
for p in 80 443; do
|
||||
port_in_use "$p" && { c_err "Port $p is already in use on this machine - Caddy needs it. Stop what is using it, or use your own reverse proxy (see README)."; exit 1; }
|
||||
done
|
||||
fi
|
||||
{
|
||||
echo "# Generated by install.sh --domain - Caddy fetches and renews the certificate by itself."
|
||||
echo "$name {"
|
||||
[ "$LAN_TLS" = 1 ] && echo " tls internal"
|
||||
echo " encode zstd gzip"
|
||||
echo " reverse_proxy koha:8081"
|
||||
echo "}"
|
||||
} > Caddyfile
|
||||
env_set DOMAIN "$name"
|
||||
env_set COMPOSE_PROFILES caddy
|
||||
env_set STAFF_URL "https://$name"
|
||||
[ "$KEEP_DIRECT" = 1 ] || env_set BIND_ADDR 127.0.0.1
|
||||
c_ok "Caddy will serve https://$name"
|
||||
if [ "$LAN_TLS" = 1 ]; then
|
||||
c_info "Own-CA certificate: phones must trust Caddy's root, which is copied to $HERE/caddy-root.crt after start."
|
||||
else
|
||||
c_info "The name must point at this machine (DNS) and ports 80/443 must reach it, or the certificate cannot be issued."
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--status) show_status; exit 0 ;;
|
||||
--stop) dc stop; exit 0 ;;
|
||||
--start) dc start; exit 0 ;;
|
||||
--update)
|
||||
ensure_docker
|
||||
with_retry dc build --pull --no-cache koha
|
||||
dc up -d
|
||||
show_status; exit 0 ;;
|
||||
"" ) ;;
|
||||
*) c_err "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
|
||||
ensure_docker
|
||||
|
||||
# --- Settings (.env is created once and never overwritten) ------------------------
|
||||
if [ ! -f .env ]; then
|
||||
c_info "Generating settings and passwords..."
|
||||
TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
|
||||
umask 077
|
||||
cat > .env << ENVEOF
|
||||
# Generated by install.sh - keep this file private.
|
||||
LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
|
||||
LIBRARY_CODE=${LIBRARY_CODE:-HOME}
|
||||
SHELVES="${SHELVES:-}"
|
||||
ITEM_TYPES="${ITEM_TYPES:-}"
|
||||
TZ=${TZ_NOW}
|
||||
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}")
|
||||
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}")
|
||||
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}")
|
||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
|
||||
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=$(rand 16)
|
||||
VIEWER_USER=guest
|
||||
VIEWER_PASSWORD=$(rand 14)
|
||||
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
|
||||
SSO_SECRET=$(rand 40)
|
||||
DB_ROOT_PASSWORD=$(rand 32)
|
||||
DB_PASSWORD=$(rand 32)
|
||||
KOHA_SUITE=stable
|
||||
ENVEOF
|
||||
umask 022
|
||||
[ "$RUN_USER" = root ] || chown "$RUN_USER" .env
|
||||
else
|
||||
c_info "Re-using existing .env"
|
||||
# Settings added in later versions: append them to an older .env.
|
||||
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||
echo "QUICKADD_PORT=$(free_port 8099)" >> .env
|
||||
fi
|
||||
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||
fi
|
||||
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
|
||||
# Existing installs keep their current public-catalogue behaviour.
|
||||
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
||||
if ! grep -q '^BIND_ADDR=' .env; then
|
||||
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -n "$DOMAIN_ARG" ] && configure_caddy
|
||||
|
||||
if [ -n "${EXTRA_CA:-}" ]; then
|
||||
cp "$EXTRA_CA" koha/certs/extra-ca.crt
|
||||
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
|
||||
c_info "Trusting extra CA during the build: $EXTRA_CA"
|
||||
fi
|
||||
|
||||
# --- Build and start ---------------------------------------------------------------
|
||||
c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..."
|
||||
with_retry dc build
|
||||
c_info "Starting..."
|
||||
with_retry dc up -d
|
||||
|
||||
# --- Wait until the site answers ---------------------------------------------------
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
c_info "First start installs the database - waiting until Koha reports healthy..."
|
||||
for i in $(seq 1 180); do
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)"
|
||||
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8099}/healthz" 2>/dev/null; then READY=1; break; fi
|
||||
# One restart is tolerated (Docker's restart policy); a crash loop is not.
|
||||
if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then
|
||||
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
|
||||
|
||||
c_ok "Your library is running."
|
||||
if [ -n "${DOMAIN:-}" ] && grep -q '^ tls internal' Caddyfile 2>/dev/null; then
|
||||
for i in $(seq 1 20); do
|
||||
docker cp library-caddy:/data/caddy/pki/authorities/local/root.crt "$HERE/caddy-root.crt" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
[ -f "$HERE/caddy-root.crt" ] && c_info "Install this certificate on phones/computers that should trust https://$DOMAIN: $HERE/caddy-root.crt"
|
||||
fi
|
||||
echo
|
||||
show_status
|
||||
@@ -0,0 +1,56 @@
|
||||
# Koha (open-source library system) built from the Koha project's own Debian
|
||||
# packages - no third-party Koha images involved.
|
||||
#
|
||||
# KOHA_SUITE stable (default) | oldstable | unstable
|
||||
# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files;
|
||||
# set "yes" if you want to install other languages later
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG KOHA_SUITE=stable
|
||||
ARG KEEP_TRANSLATIONS=no
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=C.UTF-8
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata
|
||||
|
||||
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||
RUN update-ca-certificates
|
||||
|
||||
# Each step stands alone and fails the build on error: a swallowed failure here once produced a
|
||||
# "successful" image with no Koha in it. memcached and cron are named explicitly rather than
|
||||
# trusted to arrive as dependencies of koha-common.
|
||||
RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \
|
||||
> /etc/apt/sources.list.d/koha.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends koha-common mariadb-client memcached cron
|
||||
|
||||
RUN a2enmod rewrite headers proxy_http cgi deflate >/dev/null \
|
||||
&& (a2dissite 000-default >/dev/null 2>&1 || true)
|
||||
|
||||
RUN if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \
|
||||
&& rm -rf /var/lib/apt/lists/* /usr/share/doc/*
|
||||
|
||||
# Refuse to produce an image that cannot run.
|
||||
RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-schema apache2ctl memcached cron mariadb zebrasrv; do \
|
||||
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
|
||||
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
|
||||
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
|
||||
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
|
||||
|
||||
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||
COPY seed/ /opt/koha-init/seed/
|
||||
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||
|
||||
EXPOSE 80 8081
|
||||
VOLUME ["/var/lib/koha"]
|
||||
HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \
|
||||
CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1
|
||||
ENTRYPOINT ["/opt/koha-init/entrypoint.sh"]
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/perl
|
||||
# Headless replacement for Koha's web installer + onboarding wizard.
|
||||
# Run as the instance user: koha-shell -c "perl bootstrap.pl" <instance>
|
||||
#
|
||||
# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a
|
||||
# future Koha upgrade keeps working), then seeds a home-library setup.
|
||||
use Modern::Perl;
|
||||
use C4::Context;
|
||||
use C4::Installer;
|
||||
use Koha;
|
||||
use Koha::Libraries;
|
||||
use Koha::Patrons;
|
||||
use Koha::Patron::Categories;
|
||||
use Koha::CirculationRules;
|
||||
use Koha::ItemTypes;
|
||||
use Koha::AuthorisedValues;
|
||||
|
||||
$| = 1;
|
||||
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
|
||||
my $admin_pass = $ENV{KOHA_ADMIN_PASS};
|
||||
die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass;
|
||||
my %env = (
|
||||
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
|
||||
admin_pass => $admin_pass,
|
||||
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library',
|
||||
code => uc( ( $ENV{KOHA_LIBRARY_CODE} || 'HOME' ) =~ s/[^A-Za-z0-9]//gr ) || 'HOME',
|
||||
);
|
||||
|
||||
my $installer = C4::Installer->new();
|
||||
my $dbh = C4::Context->dbh;
|
||||
my $intranet = C4::Context->config('intranetdir');
|
||||
my $data = "$intranet/installer/data/mysql";
|
||||
|
||||
# Already installed? (the entrypoint checks too; this is a safety net)
|
||||
my ($tables) = $dbh->selectrow_array(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()");
|
||||
if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; }
|
||||
|
||||
say "bootstrap: loading database schema...";
|
||||
if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; }
|
||||
|
||||
$installer->set_marcflavour_syspref('marc21');
|
||||
|
||||
# Same ordering rules the web installer applies (sysprefs first, userflags last).
|
||||
my @files = (
|
||||
sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ),
|
||||
sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ),
|
||||
);
|
||||
# Optional data worth having: matching rules, "Fast add" framework (a short
|
||||
# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values.
|
||||
for my $f (qw(
|
||||
marcflavour/marc21/optional/marc21_default_matching_rules.yml
|
||||
marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml
|
||||
optional/marc21_holdings_coded_values.yml
|
||||
optional/marc21_relatorterms.yml
|
||||
optional/auth_val.yml
|
||||
optional/csv_profiles.yml
|
||||
)) {
|
||||
push @files, "$data/en/$f" if -f "$data/en/$f";
|
||||
}
|
||||
|
||||
say "bootstrap: loading ", scalar(@files), " framework / data files...";
|
||||
my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files );
|
||||
for my $lvl (@$list) {
|
||||
for my $f ( @{ $lvl->{fwklist} } ) {
|
||||
say " ! $f->{fwkname}: $f->{error}" if $f->{error};
|
||||
}
|
||||
}
|
||||
|
||||
$installer->set_version_syspref();
|
||||
$installer->set_languages_syspref('en');
|
||||
|
||||
# ---- Home-library seed data (YAML in the same format Koha ships) ----------
|
||||
for my $y (sort glob("$seed_dir/*.yml")) {
|
||||
my $err = $installer->load_sql($y);
|
||||
say " seed $y: ", ( $err ? "ERROR $err" : 'ok' );
|
||||
}
|
||||
|
||||
# ---- Library, patron categories, owner account ----------------------------
|
||||
Koha::Library->new({
|
||||
branchcode => $env{code}, branchname => $env{library},
|
||||
})->store unless Koha::Libraries->find( $env{code} );
|
||||
|
||||
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
|
||||
Koha::Patron::Category->new({
|
||||
categorycode => $c->[0], description => $c->[1], category_type => $c->[2],
|
||||
enrolmentperiod => 999, overduenoticerequired => 0,
|
||||
})->store unless Koha::Patron::Categories->find( $c->[0] );
|
||||
}
|
||||
|
||||
my $admin = Koha::Patron->new({
|
||||
cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
|
||||
branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
|
||||
})->store;
|
||||
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
|
||||
|
||||
# ---- Make "add a book" need as few decisions as possible -------------------
|
||||
# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so
|
||||
# a Z39.50 import can be saved without touching that field.
|
||||
$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
|
||||
WHERE tagfield = '942' AND tagsubfield = 'c'});
|
||||
|
||||
# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework
|
||||
# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it
|
||||
# to the catalogue's `ean` column (used by the quick-add page's duplicate check).
|
||||
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
|
||||
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')});
|
||||
|
||||
# ---- Extra item types and shelves chosen at install time -------------------
|
||||
# KOHA_ITEM_TYPES / KOHA_SHELVES: "CODE=Description;CODE=Description". Koha has no
|
||||
# API for adding shelves later, so they are declared here (more can be added in
|
||||
# Administration -> Authorized values -> LOC).
|
||||
sub pairs {
|
||||
my ($raw) = @_;
|
||||
return map { my ( $c, $d ) = split /=/, $_, 2; $c =~ s/[^A-Za-z0-9_]//g; ( uc $c, ( $d // $c ) =~ s/^\s+|\s+$//gr ) }
|
||||
grep { /\S/ } split /;/, ( $raw // '' );
|
||||
}
|
||||
{
|
||||
my @t = pairs( $ENV{KOHA_ITEM_TYPES} );
|
||||
while ( my ( $c, $d ) = splice @t, 0, 2 ) {
|
||||
next unless length $c;
|
||||
Koha::ItemType->new( { itemtype => substr( $c, 0, 10 ), description => $d } )->store
|
||||
unless Koha::ItemTypes->find( substr( $c, 0, 10 ) );
|
||||
}
|
||||
my @s = pairs( $ENV{KOHA_SHELVES} );
|
||||
while ( my ( $c, $d ) = splice @s, 0, 2 ) {
|
||||
next unless length $c;
|
||||
Koha::AuthorisedValue->new( { category => 'LOC', authorised_value => substr( $c, 0, 80 ), lib => $d, lib_opac => $d } )->store
|
||||
unless Koha::AuthorisedValues->search( { category => 'LOC', authorised_value => substr( $c, 0, 80 ) } )->count;
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Lending rules --------------------------------------------------------
|
||||
# A fresh Koha refuses every checkout ("No circulation rule is defined for this
|
||||
# patron and itemtype combination") until a default rule exists. One catch-all
|
||||
# rule for a home library: 3 weeks, 25 items, 3 renewals, no fines.
|
||||
Koha::CirculationRules->set_rules({
|
||||
branchcode => undef, categorycode => undef, itemtype => undef,
|
||||
rules => {
|
||||
issuelength => 21, lengthunit => 'days', maxissueqty => 25, maxonsiteissueqty => '',
|
||||
renewalsallowed => 3, renewalperiod => 21, norenewalbefore => '', noautorenewalbefore => '',
|
||||
auto_renew => 0, no_auto_renewal_after => '', no_auto_renewal_after_hard_limit => '',
|
||||
unseen_renewals_allowed => '',
|
||||
fine => 0, finedays => 0, firstremind => 0, chargeperiod => 0, chargeperiod_charge_at => 0,
|
||||
overduefinescap => '', cap_fine_to_replacement_price => 0, rentaldiscount => 0,
|
||||
maxsuspensiondays => '', suspension_chargeperiod => 1,
|
||||
hardduedate => '', hardduedatecompare => -1,
|
||||
reservesallowed => 5, holds_per_record => 1, holds_per_day => '', onshelfholds => 1,
|
||||
opacitemholds => 'N', decreaseloanholds => '', article_requests => 'no',
|
||||
recalls_allowed => undef, recalls_per_record => undef, on_shelf_recalls => undef,
|
||||
recall_due_date_interval => undef, recall_overdue_fine => undef,
|
||||
recall_shelf_time => undef, holds_pickup_period => undef,
|
||||
},
|
||||
});
|
||||
|
||||
# ---- Preferences ----------------------------------------------------------
|
||||
my %prefs = (
|
||||
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
||||
autoMemberNum => 1, # new patrons get the next library-card number automatically
|
||||
finesMode => 'off', # a home library does not charge overdue fines
|
||||
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
||||
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
||||
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
|
||||
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||
OPACSearchForTitleIn => '',
|
||||
BiblioDefaultView => 'normal',
|
||||
opacuserlogin => 1,
|
||||
OpacMaintenance => 0,
|
||||
ExpireReservesMaxPickUpDelay => 0,
|
||||
AllowOfflineCirculation => 0,
|
||||
marcflavour => 'MARC21',
|
||||
LibraryName => $env{library},
|
||||
timezone => $ENV{TZ} || 'UTC',
|
||||
);
|
||||
C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs;
|
||||
|
||||
say "bootstrap: done. Staff login: $env{admin_user}";
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/perl
|
||||
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
|
||||
# the schema upgrade), so existing installs pick these up too:
|
||||
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
|
||||
# * the "Library tools" block on the staff home page
|
||||
use Modern::Perl;
|
||||
use C4::Context;
|
||||
use Koha::Patrons;
|
||||
use Koha::Patron::Categories;
|
||||
use Koha::Caches;
|
||||
|
||||
my $dbh = C4::Context->dbh;
|
||||
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
|
||||
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
|
||||
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
|
||||
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
|
||||
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
|
||||
$p->delete;
|
||||
}
|
||||
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
|
||||
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
|
||||
}
|
||||
|
||||
# Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
|
||||
# (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
|
||||
my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
|
||||
unless ( -e $flag ) {
|
||||
my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
|
||||
my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
|
||||
if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
|
||||
$dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
|
||||
$dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
|
||||
say "ensure: patron passwords may be short (4+ characters, no strength rule)";
|
||||
Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
|
||||
}
|
||||
if ( open my $fh, '>', $flag ) { close $fh }
|
||||
}
|
||||
|
||||
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
|
||||
my $html = '<div class="main-heading"><h3>Room for Books</h3></div><ul class="list-unstyled">'
|
||||
. '<li><a class="btn btn-primary" href="/add/">Open Room for Books</a> (library, lend / return, people, add books)</li>'
|
||||
. '<li><a href="/add/#find">Find a book</a></li></ul>';
|
||||
my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
|
||||
unless ($id) {
|
||||
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
|
||||
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
|
||||
$id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
|
||||
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
|
||||
VALUES (?, 'Room for Books', ?, 'default')}, undef, $id, $html );
|
||||
say "ensure: added the staff home 'Room for Books' block";
|
||||
}
|
||||
else {
|
||||
$dbh->do( q{UPDATE additional_contents_localizations SET title = 'Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
|
||||
undef, $html, $id );
|
||||
}
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
# Container entrypoint: (re)creates the Koha instance config from environment
|
||||
# variables on every start, installs the database on the first start, then runs
|
||||
# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha.
|
||||
set -euo pipefail
|
||||
|
||||
INST="${KOHA_INSTANCE:-library}"
|
||||
DB_HOST="${KOHA_DB_HOST:-db}"
|
||||
DB_NAME="${KOHA_DB_NAME:-koha_library}"
|
||||
DB_USER="${KOHA_DB_USER:-koha_library}"
|
||||
: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}"
|
||||
: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}"
|
||||
STAFF_PORT=8081
|
||||
export TZ="${TZ:-UTC}"
|
||||
|
||||
log() { echo "[koha-init] $*"; }
|
||||
|
||||
# Marker the container healthcheck waits for. Before the database exists Koha
|
||||
# serves its web-installer page with HTTP 200, so "port answers" is not "ready".
|
||||
rm -f /run/koha-ready
|
||||
|
||||
# --- 1. memcached (Koha caches sysprefs and sessions in it) -----------------
|
||||
memcached -u memcache -m 64 -d
|
||||
cron
|
||||
|
||||
# --- 2. wait for MariaDB ----------------------------------------------------
|
||||
log "waiting for database at $DB_HOST..."
|
||||
for i in $(seq 1 120); do
|
||||
mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break
|
||||
[ "$i" = 120 ] && { log "database never became ready"; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# --- 3. instance config (regenerated each time a container is created) ------
|
||||
if [ ! -d "/etc/koha/sites/$INST" ]; then
|
||||
log "creating Koha instance '$INST'..."
|
||||
sed -i \
|
||||
-e 's|^DOMAIN=.*|DOMAIN=""|' \
|
||||
-e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \
|
||||
-e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \
|
||||
/etc/koha/koha-sites.conf
|
||||
echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd
|
||||
koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \
|
||||
--memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true
|
||||
|
||||
# No RabbitMQ in this stack: workers fall back to polling the database.
|
||||
CONF="/etc/koha/sites/$INST/koha-conf.xml"
|
||||
perl -0pi -e 's|\s*<message_broker>.*?</message_broker>||s' "$CONF"
|
||||
|
||||
# Serve both interfaces through Plack (much faster than plain CGI).
|
||||
SITE="/etc/apache2/sites-available/$INST.conf"
|
||||
# The *-plack.conf files add the proxy rules on top of the plain ones (which
|
||||
# still provide static files and rewrites), so enable them in addition.
|
||||
sed -i \
|
||||
-e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \
|
||||
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||
|
||||
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
|
||||
# (The staff and public catalogue sites cannot share one address: Koha gives both the
|
||||
# same URL shapes. The staff site is the natural home for a home library.)
|
||||
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
||||
# Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
|
||||
# secret header nobody outside can forge: it is overwritten here on every request) that the
|
||||
# request came through the staff site, and Quick Add then asks Koha whether the browser's login
|
||||
# cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
|
||||
# about that fails, Quick Add just asks for its own login as before.
|
||||
SSO_BLOCK=""
|
||||
if [ -n "${KOHA_SSO_SECRET:-}" ]; then
|
||||
install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
|
||||
printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
|
||||
SSO_BLOCK=" <IfModule mod_headers.c>
|
||||
<Location /add/>
|
||||
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
|
||||
</Location>
|
||||
</IfModule>"
|
||||
fi
|
||||
cat > /etc/koha/apache-quickadd.conf << APACHE
|
||||
<IfModule mod_proxy_http.c>
|
||||
RedirectMatch ^/add\$ /add/
|
||||
# The bare address (http://ip:port/ or https://your-domain/) opens the Room for Books home page;
|
||||
# Koha's own staff screens stay at /cgi-bin/koha/mainpage.pl.
|
||||
RedirectMatch ^/\$ /add/
|
||||
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
${SSO_BLOCK}
|
||||
</IfModule>
|
||||
APACHE
|
||||
sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf"
|
||||
fi
|
||||
|
||||
# koha-create insists on mpm_itk being enabled, but inside a container it can
|
||||
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
||||
# page). Now that the instance exists, swap to plain prefork; Apache runs as the
|
||||
# instance user (Plack does the real work as that user already).
|
||||
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||
sed -i '/AssignUserID/d' "$SITE"
|
||||
sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \
|
||||
-e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars
|
||||
grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf
|
||||
grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf
|
||||
fi
|
||||
chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true
|
||||
|
||||
# --- 4. install or upgrade the database -------------------------------------
|
||||
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||
if [ "$TABLES" = "0" ]; then
|
||||
log "first start: installing Koha (about a minute)..."
|
||||
# (not koha-shell -c: it wraps the command in single quotes of its own)
|
||||
runuser -u "$INST-koha" -- env \
|
||||
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||
KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
|
||||
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" KOHA_LIBRARY_CODE="${KOHA_LIBRARY_CODE:-HOME}" \
|
||||
KOHA_SHELVES="${KOHA_SHELVES:-}" KOHA_ITEM_TYPES="${KOHA_ITEM_TYPES:-}" TZ="$TZ" \
|
||||
perl /opt/koha-init/bootstrap.pl
|
||||
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||
[ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; }
|
||||
FIRST_RUN=1
|
||||
else
|
||||
log "existing database found - applying any pending upgrades..."
|
||||
koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)"
|
||||
FIRST_RUN=0
|
||||
fi
|
||||
|
||||
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
|
||||
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||
perl /opt/koha-init/ensure.pl \
|
||||
|| log "WARNING: ensure.pl reported a problem (see above)"
|
||||
|
||||
# --- 5. background services (their `daemon` processes die with the container,
|
||||
# so they are started explicitly on every start) ------------------------
|
||||
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||
log "building search index..."
|
||||
koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true
|
||||
fi
|
||||
for svc in koha-zebra koha-indexer koha-worker koha-plack; do
|
||||
"$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \
|
||||
|| log "WARNING: could not start $svc"
|
||||
done
|
||||
|
||||
# koha-create / koha-plack may have started Apache already; run it in the foreground instead.
|
||||
apache2ctl -k stop >/dev/null 2>&1 || true
|
||||
for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done
|
||||
rm -f /var/run/apache2/apache2.pid
|
||||
|
||||
touch /run/koha-ready
|
||||
log "ready - library catalogue on :80, staff interface on :$STAFF_PORT"
|
||||
exec apache2ctl -D FOREGROUND
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
|
||||
#
|
||||
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
|
||||
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
|
||||
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
|
||||
# that know the secret get an answer.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use C4::Context;
|
||||
use C4::Circulation qw( AddReturn );
|
||||
use Koha::Items;
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
my $barcode = $q->param('barcode') // '';
|
||||
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
|
||||
|
||||
my $item = Koha::Items->find( { barcode => $barcode } );
|
||||
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
|
||||
|
||||
# AddReturn needs to know which branch it is happening at.
|
||||
my $branch = $item->homebranch;
|
||||
my ( $doreturn, $messages, $borrower, $was_out );
|
||||
eval {
|
||||
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
|
||||
$was_out = $item->checkout ? 1 : 0;
|
||||
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
|
||||
1;
|
||||
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
|
||||
reply(
|
||||
$q, '200 OK',
|
||||
{
|
||||
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
|
||||
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
|
||||
messages => [ sort keys %{ $messages || {} } ],
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session).
|
||||
#
|
||||
# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as
|
||||
# someone allowed to edit the catalogue?" so staff do not have to log in a second time.
|
||||
#
|
||||
# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that
|
||||
# are invoked by pages authenticated in the usual way) and only answers callers that present a
|
||||
# shared secret, so it cannot be used to probe sessions from outside. The browser's address is
|
||||
# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use C4::Auth qw( check_cookie_auth );
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named
|
||||
# sub would otherwise see a stale copy of a file-level variable.
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
# 1. The caller must know the shared secret (a file only the Koha user and root can read).
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
# 2. Plausible-looking inputs only.
|
||||
my $sid = $q->param('sid') // '';
|
||||
my $ip = $q->param('ip') // '';
|
||||
reply( $q, '200 OK', { status => 'failed' } )
|
||||
unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/;
|
||||
|
||||
# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in.
|
||||
my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } );
|
||||
reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session;
|
||||
reply( $q, '200 OK', { status => $status || 'failed' } );
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl).
|
||||
#
|
||||
# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE),
|
||||
# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret
|
||||
# guard as quickadd-session / quickadd-return; only the two categories below can be touched.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use Koha::AuthorisedValues;
|
||||
use Koha::AuthorisedValue;
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
my $cat = $q->param('category') // '';
|
||||
my $value = $q->param('value') // '';
|
||||
my $desc = $q->param('description') // '';
|
||||
utf8::decode($desc);
|
||||
reply( $q, '200 OK', { status => 'invalid' } )
|
||||
unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80;
|
||||
|
||||
my $out = eval {
|
||||
my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count;
|
||||
return { status => 'exists' } if $exists;
|
||||
Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store;
|
||||
{ status => 'added' };
|
||||
};
|
||||
reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } );
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description:
|
||||
- "Item types for a home library: books, movies, music, games"
|
||||
tables:
|
||||
- itemtypes:
|
||||
translatable: [ description ]
|
||||
multiline: []
|
||||
rows:
|
||||
- { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" }
|
||||
- { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" }
|
||||
- { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" }
|
||||
- { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" }
|
||||
- { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" }
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
- "Public Z39.50 servers used by Cataloging > Z39.50/SRU search"
|
||||
tables:
|
||||
- z3950servers:
|
||||
translatable: [ servername ]
|
||||
multiline: []
|
||||
rows:
|
||||
- { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
description:
|
||||
- "Label sheets and layouts for Tools > Labels (spine labels and barcodes)"
|
||||
tables:
|
||||
- creator_templates:
|
||||
translatable: [ template_desc ]
|
||||
multiline: [ ]
|
||||
rows:
|
||||
# Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label
|
||||
# plus barcode on one label.
|
||||
- template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)"
|
||||
template_desc: "3 columns, 10 rows. Spine label with barcode."
|
||||
page_width: 8.5
|
||||
page_height: 11
|
||||
label_width: 2.63
|
||||
label_height: 1
|
||||
top_text_margin: 0.139
|
||||
left_text_margin: 0.1
|
||||
top_margin: 0.35
|
||||
left_margin: 0.23
|
||||
cols: 3
|
||||
rows: 10
|
||||
col_gap: 0.13
|
||||
row_gap: 0
|
||||
units: "INCH"
|
||||
creator: "Labels"
|
||||
|
||||
# Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers.
|
||||
- template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)"
|
||||
template_desc: "4 columns, 20 rows. Barcode-only stickers."
|
||||
page_width: 8.5
|
||||
page_height: 11
|
||||
label_width: 1.75
|
||||
label_height: 0.5
|
||||
top_text_margin: 0.1
|
||||
left_text_margin: 0.05
|
||||
top_margin: 0.5
|
||||
left_margin: 0.3
|
||||
cols: 4
|
||||
rows: 20
|
||||
col_gap: 0.3
|
||||
row_gap: 0
|
||||
units: "INCH"
|
||||
creator: "Labels"
|
||||
|
||||
# Demco 1" x 1.5" spine labels (Koha's own sample measurements).
|
||||
- template_code: "Demco spine | 1 x 1.5 (50 per sheet)"
|
||||
template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines."
|
||||
page_width: 8.5
|
||||
page_height: 11
|
||||
label_width: 1.5
|
||||
label_height: 1
|
||||
top_text_margin: 0.236
|
||||
left_text_margin: 0
|
||||
top_margin: 0.5
|
||||
left_margin: 0.25
|
||||
cols: 5
|
||||
rows: 10
|
||||
col_gap: 0.0625
|
||||
row_gap: 0
|
||||
units: "INCH"
|
||||
creator: "Labels"
|
||||
|
||||
- creator_layouts:
|
||||
translatable: [ layout_name, layout_xml ]
|
||||
multiline: [ layout_xml ]
|
||||
rows:
|
||||
# callnum_split splits "613.2 SMI" onto separate lines the way spine
|
||||
# labels are normally read (Dewey/LC aware).
|
||||
- layout_name: "Spine - call number"
|
||||
barcode_type: CODE39
|
||||
start_label: 1
|
||||
printing_type: BIB
|
||||
guidebox: 0
|
||||
oblique_title: 0
|
||||
font: TB
|
||||
font_size: 11
|
||||
units: POINT
|
||||
callnum_split: 1
|
||||
text_justify: C
|
||||
format_string: "itemcallnumber"
|
||||
layout_xml:
|
||||
- "<opt></opt>"
|
||||
creator: "Labels"
|
||||
|
||||
- layout_name: "Spine + barcode"
|
||||
barcode_type: CODE39
|
||||
start_label: 1
|
||||
printing_type: BIBBAR
|
||||
guidebox: 0
|
||||
oblique_title: 0
|
||||
font: TB
|
||||
font_size: 10
|
||||
units: POINT
|
||||
callnum_split: 1
|
||||
text_justify: C
|
||||
format_string: "itemcallnumber"
|
||||
layout_xml:
|
||||
- "<opt></opt>"
|
||||
creator: "Labels"
|
||||
|
||||
- layout_name: "Barcode only"
|
||||
barcode_type: CODE39
|
||||
start_label: 1
|
||||
printing_type: BAR
|
||||
guidebox: 0
|
||||
oblique_title: 0
|
||||
font: TR
|
||||
font_size: 10
|
||||
units: POINT
|
||||
callnum_split: 0
|
||||
text_justify: L
|
||||
format_string: "barcode"
|
||||
layout_xml:
|
||||
- "<opt></opt>"
|
||||
creator: "Labels"
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description:
|
||||
- "Library card sheet and layout for Tools > Patron card creator"
|
||||
tables:
|
||||
- creator_templates:
|
||||
translatable: [ template_desc ]
|
||||
multiline: [ ]
|
||||
rows:
|
||||
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
|
||||
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
|
||||
template_desc: "Library cards: 2 columns, 5 rows."
|
||||
page_width: 8.5
|
||||
page_height: 11
|
||||
label_width: 3.5
|
||||
label_height: 2
|
||||
top_text_margin: 0
|
||||
left_text_margin: 0
|
||||
top_margin: 0.5
|
||||
left_margin: 0.75
|
||||
cols: 2
|
||||
rows: 5
|
||||
col_gap: 0
|
||||
row_gap: 0
|
||||
units: "INCH"
|
||||
creator: "Patroncards"
|
||||
|
||||
- creator_layouts:
|
||||
translatable: [ layout_name, layout_xml ]
|
||||
multiline: [ layout_xml ]
|
||||
rows:
|
||||
# Name and library at the top, card number as a scannable barcode.
|
||||
- layout_name: "Library card"
|
||||
barcode_type: CODE39
|
||||
start_label: 1
|
||||
printing_type: BAR
|
||||
guidebox: 0
|
||||
oblique_title: 0
|
||||
font: TR
|
||||
font_size: 10
|
||||
units: POINT
|
||||
callnum_split: 0
|
||||
text_justify: L
|
||||
format_string: "barcode"
|
||||
layout_xml:
|
||||
- "<opt page_side=\"F\" units=\"POINT\">"
|
||||
- " <text><firstname> <surname></text>"
|
||||
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
|
||||
- " <text>Library card</text>"
|
||||
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
|
||||
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
|
||||
- "</opt>"
|
||||
creator: "Patroncards"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
|
||||
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
|
||||
# one small layer instead of another base-image pull.
|
||||
FROM debian:bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||
RUN update-ca-certificates
|
||||
|
||||
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
|
||||
|
||||
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||
&& mkdir -p /app /data && chown quickadd /data
|
||||
COPY app.py index.html /app/
|
||||
USER quickadd
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8082
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
|
||||
CMD ["python3", "/app/app.py"]
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
|
||||
sure the running Quick Add reads them (at the size the page sends, 2400 px).
|
||||
|
||||
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
|
||||
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
|
||||
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
|
||||
"""
|
||||
import base64, io, json, os, sys, urllib.request
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
||||
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def scan(data, kind):
|
||||
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
|
||||
headers={"Content-Type": "image/jpeg", "Authorization": auth})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.load(r)["codes"]
|
||||
|
||||
|
||||
def shrunk(path, m=2400): # what the page does before uploading
|
||||
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
||||
k = min(1, m / max(im.size))
|
||||
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
|
||||
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
|
||||
|
||||
|
||||
bad = 0
|
||||
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
|
||||
base = n[:-4]
|
||||
exp_file = os.path.join(D, base + ".expect")
|
||||
exp = open(exp_file).read() if os.path.exists(exp_file) else None
|
||||
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
|
||||
ok = (codes[:1] == [exp]) if exp else (codes == [])
|
||||
bad += not ok
|
||||
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Generate phone-photo-like test images containing real barcodes."""
|
||||
import random, io, os
|
||||
import zxingcpp
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
|
||||
|
||||
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
random.seed(7)
|
||||
|
||||
|
||||
def barcode(fmt, text, scale=4, height=90):
|
||||
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
|
||||
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
|
||||
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
||||
|
||||
|
||||
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
|
||||
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
|
||||
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
|
||||
d = ImageDraw.Draw(bg)
|
||||
for _ in range(60): # clutter: stripes, blobs, text-ish bars
|
||||
x, y = random.randint(0, w), random.randint(0, h)
|
||||
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
|
||||
fill=tuple(random.randint(0, 255) for _ in range(3)))
|
||||
label = code_img.copy()
|
||||
k = (w * size_frac) / label.width
|
||||
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
|
||||
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
|
||||
pad.paste(label, (30, 30))
|
||||
if tilt:
|
||||
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
|
||||
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
|
||||
if blur:
|
||||
bg = bg.filter(ImageFilter.GaussianBlur(blur))
|
||||
bg = ImageEnhance.Contrast(bg).enhance(contrast)
|
||||
bg = ImageEnhance.Brightness(bg).enhance(bright)
|
||||
if noise:
|
||||
px = bg.load()
|
||||
for _ in range(noise):
|
||||
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
|
||||
v = random.randint(0, 255); px[x, y] = (v, v, v)
|
||||
buf = io.BytesIO()
|
||||
kw = {"quality": quality}
|
||||
if exif is not None:
|
||||
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
|
||||
bg.save(buf, "JPEG", **kw)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
E13 = zxingcpp.BarcodeFormat.EAN13
|
||||
UPCA = zxingcpp.BarcodeFormat.UPCA
|
||||
C39 = zxingcpp.BarcodeFormat.Code39
|
||||
|
||||
cases = {
|
||||
# name: (expected, bytes)
|
||||
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
|
||||
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
|
||||
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
|
||||
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
|
||||
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
|
||||
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
|
||||
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
|
||||
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
|
||||
}
|
||||
for name, (exp, data) in cases.items():
|
||||
open(f"{OUT}/{name}.jpg", "wb").write(data)
|
||||
open(f"{OUT}/{name}.expect", "w").write(exp)
|
||||
|
||||
# sticker (Code 39, 7 digits) and a photo with no barcode at all
|
||||
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
|
||||
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
|
||||
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
|
||||
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
|
||||
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
|
||||
for n in sorted(os.listdir(OUT)):
|
||||
if n.endswith(".jpg"):
|
||||
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_asterisk_intercom.sh - "Asterisk Intercom" addon: connect the
|
||||
# kiosk as a SIP extension to an *existing* Asterisk server.
|
||||
#
|
||||
# The legacy addon offered three options: Client Only (a Baresip SIP
|
||||
# client - what this file is), Server Only, and Full (server + client).
|
||||
# Server/Full downloaded and ran a third-party installer from a separate
|
||||
# "Easy Asterisk" repository to stand up a whole Asterisk PBX. That
|
||||
# repository has since gone through a major rework upstream, so wiring a
|
||||
# full PBX install through it here no longer makes sense to maintain -
|
||||
# and most kiosk deployments don't need this device to *be* the PBX
|
||||
# anyway. This addon now does only the client/endpoint piece: install
|
||||
# Baresip and register it as one extension against an Asterisk server
|
||||
# the user already has running somewhere else. It never installs or
|
||||
# manages Asterisk itself.
|
||||
#
|
||||
# Two other things fixed while narrowing the scope:
|
||||
# - The legacy client path tracked its own version by calling out to the
|
||||
# (now-reworked) Easy Asterisk repo's GitHub API and stamping a
|
||||
# "<repo-version>-client" string in a side file. That coupling is
|
||||
# exactly what's being dropped, so version tracking now just reads the
|
||||
# real installed `baresip` package version via dpkg - one less network
|
||||
# dependency and one less thing to keep in sync with an external repo.
|
||||
# - The legacy addon had no uninstall option for the client at all -
|
||||
# added below.
|
||||
#
|
||||
# Real system state: apt package, a per-user config directory under
|
||||
# $KIOSK_HOME, and a systemd --user unit for $KIOSK_USER (not a system
|
||||
# service - Baresip needs the desktop session's PulseAudio/PipeWire
|
||||
# socket). Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, all
|
||||
# stubbed at the command level in tests - there's no real D-Bus user
|
||||
# session to target in a test container regardless.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip"
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||
# pulseaudio-utils/pipewire-pulse: pactl, for audio routing.
|
||||
ASTERISK_INTERCOM_APT_PACKAGES=(baresip pulseaudio-utils pipewire-pulse)
|
||||
BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user"
|
||||
|
||||
# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus
|
||||
# address it needs to find that user's session. Always call from an
|
||||
# `if`/`&&`/`||` context - see run_menu's own comment on why a bare call
|
||||
# that can legitimately fail must never be a standalone statement.
|
||||
baresip_systemctl_user() {
|
||||
local kiosk_uid
|
||||
kiosk_uid=$(id -u "$KIOSK_USER" 2>/dev/null) || return 1
|
||||
sudo -u "$KIOSK_USER" \
|
||||
XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${kiosk_uid}/bus" \
|
||||
systemctl --user "$@"
|
||||
}
|
||||
|
||||
baresip_installed_version() {
|
||||
dpkg-query -W -f='${Version}' baresip 2>/dev/null || true
|
||||
}
|
||||
|
||||
# "Installed" means both the package and a written account - a bare
|
||||
# `apt install baresip` with no configured extension isn't something
|
||||
# this menu should call done.
|
||||
baresip_is_installed() {
|
||||
# sudo: it's under the kiosk user's home, which the admin running this
|
||||
# can't look inside (mode 750) - a plain [[ -f ]] is always false there.
|
||||
command -v baresip &>/dev/null && sudo test -f "$BARESIP_CONFIG_DIR/accounts"
|
||||
}
|
||||
|
||||
baresip_is_running() {
|
||||
baresip_systemctl_user is-active --quiet baresip.service 2>/dev/null
|
||||
}
|
||||
|
||||
addon_asterisk_intercom_status() {
|
||||
if baresip_is_installed; then
|
||||
local ver
|
||||
ver=$(baresip_installed_version)
|
||||
if baresip_is_running; then
|
||||
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Running"
|
||||
else
|
||||
echo "Asterisk Intercom: Installed (v${ver:-unknown}) - Not running"
|
||||
fi
|
||||
if sudo test -f "$BARESIP_CONFIG_DIR/accounts"; then
|
||||
local account
|
||||
account=$(sudo head -1 "$BARESIP_CONFIG_DIR/accounts" 2>/dev/null)
|
||||
local extension="${account#<sip:}"
|
||||
extension="${extension%%@*}"
|
||||
[[ -n "$extension" ]] && echo " Extension: $extension"
|
||||
fi
|
||||
else
|
||||
echo "Asterisk Intercom: Not installed"
|
||||
fi
|
||||
echo "ℹ Connects this kiosk as a SIP extension to an Asterisk server"
|
||||
echo " you already have running elsewhere - it does not install or"
|
||||
echo " manage Asterisk itself."
|
||||
}
|
||||
|
||||
addon_asterisk_intercom_menu_builder() {
|
||||
if baresip_is_installed; then
|
||||
MENU_LABELS=("Reconfigure (new server/extension)" "Uninstall")
|
||||
MENU_HANDLERS=(action_configure_asterisk_intercom action_uninstall_asterisk_intercom)
|
||||
else
|
||||
MENU_LABELS=("Connect to an Asterisk server")
|
||||
MENU_HANDLERS=(action_configure_asterisk_intercom)
|
||||
fi
|
||||
}
|
||||
|
||||
addon_asterisk_intercom_menu() {
|
||||
run_menu "ASTERISK INTERCOM (SIP EXTENSION)" addon_asterisk_intercom_menu_builder addon_asterisk_intercom_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_configure_asterisk_intercom() {
|
||||
echo
|
||||
if baresip_is_installed; then
|
||||
echo "Asterisk Intercom is already configured."
|
||||
ask_yes_no "Reconfigure with a different server/extension?" "n" || { pause; return; }
|
||||
fi
|
||||
|
||||
echo "Enter the details of the Asterisk server this kiosk should"
|
||||
echo "register to as an extension (these must match what's already"
|
||||
echo "configured on that server)."
|
||||
echo
|
||||
|
||||
local server_ip=""
|
||||
while [[ -z "$server_ip" ]]; do
|
||||
server_ip=$(ask_text "Server IP or hostname" "")
|
||||
[[ -z "$server_ip" ]] && log_error "Server address is required"
|
||||
done
|
||||
|
||||
local server_port
|
||||
server_port=$(ask_integer "Server port" 5060 1 65535)
|
||||
|
||||
local extension=""
|
||||
while [[ -z "$extension" ]]; do
|
||||
extension=$(ask_text "Extension number (e.g. 201)" "")
|
||||
[[ -z "$extension" ]] && log_error "Extension is required"
|
||||
done
|
||||
|
||||
local password=""
|
||||
while [[ -z "$password" ]]; do
|
||||
read -r -s -p "SIP password: " password
|
||||
echo
|
||||
[[ -z "$password" ]] && log_error "Password is required"
|
||||
done
|
||||
|
||||
local answermode="manual"
|
||||
if ask_yes_no "Auto-answer incoming calls (intercom mode)?" "n"; then
|
||||
answermode="auto"
|
||||
fi
|
||||
|
||||
local transport="udp"
|
||||
local media_enc=""
|
||||
if ask_yes_no "Use TLS encryption?" "n"; then
|
||||
transport="tls"
|
||||
media_enc=";mediaenc=srtp"
|
||||
if [[ "$server_port" == "5060" ]]; then
|
||||
server_port=5061
|
||||
echo "Note: port changed to 5061 for TLS"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Configuration summary:"
|
||||
echo " Server: ${server_ip}:${server_port}"
|
||||
echo " Extension: ${extension}"
|
||||
echo " Answer: $([[ "$answermode" == "auto" ]] && echo "Auto" || echo "Manual")"
|
||||
echo " TLS: $([[ "$transport" == "tls" ]] && echo "Yes" || echo "No")"
|
||||
echo
|
||||
ask_yes_no "Proceed with installation?" "y" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
echo
|
||||
echo "Installing Baresip..."
|
||||
if ! command -v baresip &>/dev/null || ! command -v pactl &>/dev/null; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${ASTERISK_INTERCOM_APT_PACKAGES[@]}"; then
|
||||
log_error "Failed to install baresip package"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$BARESIP_CONFIG_DIR"
|
||||
|
||||
sudo tee "$BARESIP_CONFIG_DIR/accounts" > /dev/null <<EOF
|
||||
<sip:${extension}@${server_ip}:${server_port};transport=${transport}>;auth_pass=${password};answermode=${answermode}${media_enc}
|
||||
EOF
|
||||
|
||||
if [[ ! -f "$BARESIP_CONFIG_DIR/config" ]]; then
|
||||
sudo tee "$BARESIP_CONFIG_DIR/config" > /dev/null <<'BARESIPCONFIG'
|
||||
# Baresip configuration for Asterisk Intercom
|
||||
|
||||
# Audio settings
|
||||
audio_player pulse,default
|
||||
audio_source pulse,default
|
||||
audio_alert pulse,default
|
||||
|
||||
# Call settings
|
||||
call_local_timeout 120
|
||||
call_max_calls 4
|
||||
|
||||
# Network settings
|
||||
net_interface
|
||||
|
||||
# SIP settings
|
||||
sip_trans_bsize 128
|
||||
sip_verify_server no
|
||||
|
||||
# Module loading
|
||||
module pulse.so
|
||||
module account.so
|
||||
module contact.so
|
||||
module menu.so
|
||||
module stdio.so
|
||||
module uuid.so
|
||||
module debug_cmd.so
|
||||
BARESIPCONFIG
|
||||
fi
|
||||
|
||||
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "$BARESIP_CONFIG_DIR"
|
||||
sudo chmod 600 "$BARESIP_CONFIG_DIR/accounts"
|
||||
|
||||
sudo mkdir -p "$BARESIP_USER_SERVICE_DIR"
|
||||
sudo tee "$BARESIP_USER_SERVICE_DIR/baresip.service" > /dev/null <<'BARESIPUNIT'
|
||||
[Unit]
|
||||
Description=Baresip SIP Client
|
||||
After=pipewire.service pipewire-pulse.service
|
||||
Wants=pipewire-pulse.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/baresip -f %h/.baresip
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PULSE_SERVER=unix:/run/user/%U/pulse/native
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
BARESIPUNIT
|
||||
sudo chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.config"
|
||||
|
||||
if baresip_systemctl_user daemon-reload 2>/dev/null && \
|
||||
baresip_systemctl_user enable baresip.service 2>/dev/null && \
|
||||
baresip_systemctl_user start baresip.service 2>/dev/null; then
|
||||
log_success "Baresip service enabled and started"
|
||||
else
|
||||
log_warning "Baresip files written, but enabling/starting the user service failed - it will start automatically on next login. Check: systemctl --user status baresip"
|
||||
fi
|
||||
|
||||
echo
|
||||
log_success "Asterisk Intercom configured"
|
||||
echo " Config dir: ${BARESIP_CONFIG_DIR}"
|
||||
echo " Server: ${server_ip}:${server_port}"
|
||||
echo " Extension: ${extension}"
|
||||
echo
|
||||
echo "Management commands (as $KIOSK_USER):"
|
||||
echo " Check status: systemctl --user status baresip"
|
||||
echo " Restart: systemctl --user restart baresip"
|
||||
echo " View logs: journalctl --user -u baresip -f"
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_uninstall_asterisk_intercom() {
|
||||
echo
|
||||
ask_yes_no "Remove Asterisk Intercom (Baresip)?" "n" || { echo "Cancelled"; pause; return; }
|
||||
asterisk_intercom_do_uninstall ask
|
||||
pause
|
||||
}
|
||||
|
||||
# The actual removal, no confirmation prompt - shared with Complete
|
||||
# Uninstall so that operation doesn't need to re-implement this teardown
|
||||
# a second time. $1: "ask" to prompt about config removal interactively
|
||||
# (the normal case), "purge" to remove config without asking (Complete
|
||||
# Uninstall).
|
||||
asterisk_intercom_do_uninstall() {
|
||||
local data_choice="${1:-ask}"
|
||||
|
||||
baresip_systemctl_user stop baresip.service 2>/dev/null || true
|
||||
baresip_systemctl_user disable baresip.service 2>/dev/null || true
|
||||
sudo rm -f "$BARESIP_USER_SERVICE_DIR/baresip.service"
|
||||
sudo apt remove -y baresip 2>/dev/null || true
|
||||
|
||||
local purge_config=false
|
||||
if [[ "$data_choice" == "purge" ]]; then
|
||||
purge_config=true
|
||||
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove saved SIP configuration too?" "n"; then
|
||||
purge_config=true
|
||||
fi
|
||||
|
||||
if $purge_config; then
|
||||
sudo rm -rf "$BARESIP_CONFIG_DIR"
|
||||
log_success "Asterisk Intercom removed (configuration deleted)"
|
||||
else
|
||||
log_success "Asterisk Intercom removed (configuration preserved)"
|
||||
fi
|
||||
}
|
||||
+31
-13
@@ -14,8 +14,19 @@
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Also read by iso/offline/build-bundle.sh to bundle these for offline installs.
|
||||
CUPS_APT_PACKAGES=(
|
||||
cups cups-client cups-filters printer-driver-all
|
||||
printer-driver-cups-pdf hplip printer-driver-gutenprint
|
||||
foomatic-db-compressed-ppds openprinting-ppds
|
||||
)
|
||||
|
||||
cups_is_installed() {
|
||||
dpkg -l 2>/dev/null | grep -q "^ii\s\+cups\s"
|
||||
# Not `dpkg -l | grep -q`: grep -q exits at the first match, dpkg
|
||||
# takes SIGPIPE writing the rest of its long list, and under
|
||||
# install.sh's `set -o pipefail` that made this report "not
|
||||
# installed" for an installed CUPS.
|
||||
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' cups 2>/dev/null)" == "ii "* ]]
|
||||
}
|
||||
|
||||
cups_is_active() {
|
||||
@@ -53,21 +64,23 @@ addon_cups_menu() {
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
cups_apt_install() {
|
||||
if ! sudo apt update; then
|
||||
log_error "apt update failed - check network/package sources and try again"
|
||||
return 1
|
||||
fi
|
||||
if ! sudo apt install -y "${CUPS_APT_PACKAGES[@]}"; then
|
||||
log_error "CUPS package installation failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_install_cups() {
|
||||
echo
|
||||
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
echo "Installing CUPS from scratch..."
|
||||
if ! sudo apt update; then
|
||||
log_error "apt update failed - check network/package sources and try again"
|
||||
return 1
|
||||
fi
|
||||
if ! sudo apt install -y cups cups-client cups-filters printer-driver-all \
|
||||
printer-driver-cups-pdf hplip printer-driver-gutenprint \
|
||||
foomatic-db-compressed-ppds openprinting-ppds; then
|
||||
log_error "CUPS package installation failed"
|
||||
return 1
|
||||
fi
|
||||
run_with_offline_fallback cups_apt_install || return 1
|
||||
|
||||
sudo systemctl enable cups 2>/dev/null || true
|
||||
sudo systemctl start cups 2>/dev/null || true
|
||||
@@ -132,7 +145,12 @@ EOF
|
||||
action_cups_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Completely remove CUPS, including all queues and settings (purge)?" "n" || { echo "Cancelled"; return; }
|
||||
cups_do_uninstall
|
||||
}
|
||||
|
||||
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||
# operation doesn't need to re-implement CUPS teardown a second time.
|
||||
cups_do_uninstall() {
|
||||
echo "Performing complete CUPS uninstall..."
|
||||
|
||||
sudo systemctl stop cups cups-browsed 2>/dev/null || true
|
||||
@@ -149,8 +167,8 @@ action_cups_uninstall() {
|
||||
sudo rm -rf /etc/cups /var/cache/cups /var/spool/cups /var/log/cups /usr/share/cups
|
||||
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
|
||||
|
||||
sudo apt autoremove -y
|
||||
sudo apt clean
|
||||
sudo apt autoremove -y 2>/dev/null || true
|
||||
sudo apt clean 2>/dev/null || true
|
||||
|
||||
log_success "CUPS completely removed"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
|
||||
#
|
||||
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
|
||||
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
|
||||
# - MariaDB.
|
||||
# Docker itself is installed on demand (lib/docker.sh).
|
||||
#
|
||||
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
|
||||
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
|
||||
# all, because Koha's own search permission also allows editing label layouts); the page
|
||||
# list stores that guest login like any other Basic Auth site. The admin login never
|
||||
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
|
||||
#
|
||||
# Whether other devices on the LAN can reach the library site is the "Network access"
|
||||
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
|
||||
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
|
||||
# (BIND_ADDR in library/.env), not by firewall rules.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
|
||||
|
||||
################################################################################
|
||||
# Detection / state
|
||||
################################################################################
|
||||
|
||||
library_env_get() {
|
||||
sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
|
||||
}
|
||||
|
||||
library_is_installed() {
|
||||
[[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1
|
||||
[[ "$(docker_container_state library-koha)" != "missing" ]]
|
||||
}
|
||||
|
||||
library_is_running() {
|
||||
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||
}
|
||||
|
||||
# The page the kiosk shows: Quick Add's Find a book view on the library site.
|
||||
library_catalogue_url() {
|
||||
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
|
||||
}
|
||||
|
||||
# Pages from earlier versions of this add-on pointed at the public catalogue.
|
||||
library_old_catalogue_url() {
|
||||
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||
}
|
||||
|
||||
# True when the kiosk's page list already shows the catalogue.
|
||||
library_tab_exists() {
|
||||
local url idx
|
||||
url="$(library_catalogue_url)"
|
||||
for idx in "${!URLS[@]}"; do
|
||||
[[ "${URLS[$idx]}" == "$url" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
addon_library_status() {
|
||||
if ! library_is_installed; then
|
||||
echo "Library Catalog: not installed"
|
||||
return
|
||||
fi
|
||||
local state health ip
|
||||
state="$(docker_container_state library-koha)"
|
||||
health="$(docker_container_health library-koha)"
|
||||
ip="$(get_ip_address)"
|
||||
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
|
||||
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||
echo " Network: this kiosk only"
|
||||
else
|
||||
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_library_menu_builder() {
|
||||
if library_is_installed; then
|
||||
load_existing_config
|
||||
local tab_label="Show Find a book on the kiosk"
|
||||
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
|
||||
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||
else
|
||||
MENU_LABELS=("Install Library Catalog")
|
||||
MENU_HANDLERS=(action_library_install)
|
||||
fi
|
||||
}
|
||||
|
||||
addon_library_menu() {
|
||||
run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Helpers
|
||||
################################################################################
|
||||
|
||||
# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the
|
||||
# deployed .env (passwords) even if a stray one exists in the checkout.
|
||||
library_sync_files() {
|
||||
local src="${SCRIPT_DIR}/library"
|
||||
if [[ ! -f "$src/docker-compose.yml" ]]; then
|
||||
log_error "library/ not found in this checkout ($src) - re-clone or git pull"
|
||||
return 1
|
||||
fi
|
||||
sudo mkdir -p "$LIBRARY_DIR" || return 1
|
||||
tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1
|
||||
sudo chmod 755 "$LIBRARY_DIR/install.sh"
|
||||
}
|
||||
|
||||
library_add_tab() {
|
||||
load_existing_config
|
||||
if library_tab_exists; then
|
||||
log_info "Find a book is already one of the kiosk's pages."
|
||||
return 0
|
||||
fi
|
||||
URLS+=("$(library_catalogue_url)")
|
||||
DURS+=("0")
|
||||
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
|
||||
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
|
||||
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||
save_config
|
||||
}
|
||||
|
||||
# Drop every page that points at the catalogue's local address.
|
||||
library_remove_tab() {
|
||||
load_existing_config
|
||||
local url old idx
|
||||
url="$(library_catalogue_url)"
|
||||
old="$(library_old_catalogue_url)"
|
||||
local -a nu=() nd=() nus=() np=() nn=()
|
||||
for idx in "${!URLS[@]}"; do
|
||||
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
|
||||
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||
done
|
||||
if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}")
|
||||
HOME_TAB_INDEX=-1
|
||||
save_config
|
||||
}
|
||||
|
||||
# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers.
|
||||
library_set_bind() {
|
||||
local addr="$1"
|
||||
if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then
|
||||
sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env"
|
||||
else
|
||||
echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null
|
||||
fi
|
||||
docker_compose "$LIBRARY_DIR" up -d
|
||||
}
|
||||
|
||||
library_warn_if_low_memory() {
|
||||
local total_mb
|
||||
total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then
|
||||
log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow."
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_library_install() {
|
||||
echo
|
||||
echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label"
|
||||
echo "printing and a scan-to-add page. It needs Docker (installed for you if"
|
||||
echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes"
|
||||
echo "5-10 minutes."
|
||||
echo
|
||||
library_warn_if_low_memory
|
||||
|
||||
local library_name
|
||||
library_name=$(ask_text "Library name (shown on the catalogue)" "My Library")
|
||||
[[ -z "$library_name" ]] && library_name="My Library"
|
||||
|
||||
local bind="0.0.0.0"
|
||||
echo
|
||||
echo "Who should be able to open the catalogue, staff interface and Quick Add"
|
||||
echo "page besides this kiosk?"
|
||||
echo " - Yes: any device on your network (use it from your own laptop/phone;"
|
||||
echo " the staff pages are protected by a generated password)."
|
||||
echo " - No: only this kiosk. Safest, but you can only manage the library here."
|
||||
if ! ask_yes_no "Allow other devices on the network?" "y"; then
|
||||
bind="127.0.0.1"
|
||||
fi
|
||||
|
||||
docker_ensure_installed || { pause; return 1; }
|
||||
library_sync_files || { pause; return 1; }
|
||||
|
||||
echo
|
||||
log_info "Building and starting the stack - this takes a while on the first run..."
|
||||
if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then
|
||||
log_error "The library stack failed to start - see: sudo docker logs library-koha"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
|
||||
library_add_tab
|
||||
fi
|
||||
echo
|
||||
log_success "Library Catalog installed"
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_show_access() {
|
||||
local ip user pass
|
||||
ip="$(get_ip_address)"
|
||||
user="$(library_env_get ADMIN_USER)"
|
||||
pass="$(library_env_get ADMIN_PASSWORD)"
|
||||
echo
|
||||
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||
ip="localhost"
|
||||
echo "(Network access is limited to this kiosk.)"
|
||||
fi
|
||||
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
||||
echo " Admin login: ${user} / ${pass}"
|
||||
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
|
||||
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
|
||||
echo " it cannot add or change anything (and it has no login on the staff site itself)."
|
||||
echo
|
||||
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_toggle_tab() {
|
||||
load_existing_config
|
||||
if library_tab_exists; then
|
||||
library_remove_tab
|
||||
else
|
||||
library_add_tab
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_network() {
|
||||
local current
|
||||
current="$(library_env_get BIND_ADDR)"
|
||||
echo
|
||||
echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")"
|
||||
echo
|
||||
if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then
|
||||
library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect"
|
||||
else
|
||||
library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now"
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_start() {
|
||||
docker_compose "$LIBRARY_DIR" start && log_success "Started"
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_stop() {
|
||||
docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)"
|
||||
pause
|
||||
}
|
||||
|
||||
action_library_update() {
|
||||
echo
|
||||
echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept."
|
||||
if ! ask_yes_no "Update now?" "y"; then
|
||||
return 0
|
||||
fi
|
||||
docker_ensure_installed || { pause; return 1; }
|
||||
library_sync_files || { pause; return 1; }
|
||||
if sudo "$LIBRARY_DIR/install.sh" --update; then
|
||||
log_success "Updated"
|
||||
else
|
||||
log_error "Update failed - see: sudo docker logs library-koha"
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
# library_do_uninstall [purge]
|
||||
# no argument: stop and remove the containers but KEEP the data volumes and
|
||||
# $LIBRARY_DIR/.env - the database password is baked into the
|
||||
# database volume, so a later reinstall needs the same .env.
|
||||
# purge: also delete the data (all books!), the images and $LIBRARY_DIR.
|
||||
library_do_uninstall() {
|
||||
local mode="${1:-}"
|
||||
library_remove_tab
|
||||
if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then
|
||||
if [[ "$mode" == "purge" ]]; then
|
||||
docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true
|
||||
else
|
||||
docker_compose "$LIBRARY_DIR" down 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if [[ "$mode" == "purge" ]]; then
|
||||
sudo rm -rf "$LIBRARY_DIR"
|
||||
log_success "Library Catalog and all its data removed"
|
||||
else
|
||||
log_success "Library Catalog stopped and removed (your books are kept)"
|
||||
fi
|
||||
}
|
||||
|
||||
action_library_uninstall() {
|
||||
echo
|
||||
echo "This stops and removes the catalogue containers."
|
||||
if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then
|
||||
local confirm
|
||||
confirm=$(ask_text "Type DELETE to confirm" "")
|
||||
if [[ "$confirm" != "DELETE" ]]; then
|
||||
echo "Cancelled"
|
||||
pause
|
||||
return 0
|
||||
fi
|
||||
library_do_uninstall purge
|
||||
else
|
||||
library_do_uninstall
|
||||
echo "Your books are kept; install again from this menu to bring them back."
|
||||
fi
|
||||
pause
|
||||
}
|
||||
@@ -22,10 +22,23 @@
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Also read by iso/offline/build-bundle.sh, to bundle the same versions
|
||||
# for offline installs (the .deb itself plus its apt dependencies).
|
||||
LMS_DEB_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb"
|
||||
SQUEEZELITE_APT_PACKAGES=(squeezelite)
|
||||
|
||||
# True on a kiosk installed from an --offline ISO that can't reach the
|
||||
# internet right now - the one case where LMS comes from the bundle.
|
||||
lms_offline_install_wanted() {
|
||||
grep -q '^Package: lyrionmusicserver$' "$KIOSK_OFFLINE_DIR/apt/Packages" 2>/dev/null && ! have_internet
|
||||
}
|
||||
|
||||
lms_service_name() {
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
|
||||
# `systemctl cat`, not `list-unit-files | grep -q`: grep -q exiting
|
||||
# early can SIGPIPE systemctl, failing the pipeline under pipefail.
|
||||
if systemctl cat lyrionmusicserver.service &>/dev/null; then
|
||||
echo "lyrionmusicserver"
|
||||
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then
|
||||
elif systemctl cat logitechmediaserver.service &>/dev/null; then
|
||||
echo "logitechmediaserver"
|
||||
fi
|
||||
}
|
||||
@@ -114,42 +127,53 @@ action_install_lms() {
|
||||
|
||||
echo "Installing Lyrion Music Server..."
|
||||
|
||||
# Try the repository method first.
|
||||
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
|
||||
# `|| true`: a bare, unguarded `apt update` failing here (bad
|
||||
# mirror, no network) would otherwise crash the whole session
|
||||
# under set -e instead of falling through to the direct-download
|
||||
# fallback below, which is exactly the degrade path this is
|
||||
# supposed to hit when the repository route doesn't work.
|
||||
sudo apt update 2>/dev/null || true
|
||||
if sudo apt install -y logitechmediaserver 2>/dev/null; then
|
||||
log_success "LMS installed via repository"
|
||||
else
|
||||
log_warning "Repository install failed, trying direct download..."
|
||||
if lms_offline_install_wanted; then
|
||||
# Kiosk installed from an --offline ISO with no internet right now:
|
||||
# the bundle carries the same LMS_DEB_URL .deb as a package.
|
||||
if ! run_with_offline_fallback sudo apt install -y lyrionmusicserver; then
|
||||
log_error "Failed to install LMS from the offline bundle"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to a direct .deb download if the repository didn't produce
|
||||
# either possible package.
|
||||
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
|
||||
local lms_deb="/tmp/lms.deb"
|
||||
echo "Downloading LMS v9.0.3..."
|
||||
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then
|
||||
echo "Installing LMS package..."
|
||||
if sudo apt install -y "$lms_deb"; then
|
||||
log_success "LMS installed via direct download"
|
||||
log_success "LMS installed from the offline bundle"
|
||||
else
|
||||
# Try the repository method first.
|
||||
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
|
||||
# `|| true`: a bare, unguarded `apt update` failing here (bad
|
||||
# mirror, no network) would otherwise crash the whole session
|
||||
# under set -e instead of falling through to the direct-download
|
||||
# fallback below, which is exactly the degrade path this is
|
||||
# supposed to hit when the repository route doesn't work.
|
||||
sudo apt update 2>/dev/null || true
|
||||
if sudo apt install -y logitechmediaserver 2>/dev/null; then
|
||||
log_success "LMS installed via repository"
|
||||
else
|
||||
log_error "Failed to install LMS package"
|
||||
log_warning "Repository install failed, trying direct download..."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to a direct .deb download if the repository didn't produce
|
||||
# either possible package.
|
||||
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
|
||||
local lms_deb="/tmp/lms.deb"
|
||||
echo "Downloading LMS ($(basename "$LMS_DEB_URL"))..."
|
||||
if wget -q "$LMS_DEB_URL" -O "$lms_deb"; then
|
||||
echo "Installing LMS package..."
|
||||
if sudo apt install -y "$lms_deb"; then
|
||||
log_success "LMS installed via direct download"
|
||||
else
|
||||
log_error "Failed to install LMS package"
|
||||
rm -f "$lms_deb"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
rm -f "$lms_deb"
|
||||
else
|
||||
log_error "Failed to download LMS from lms-community.org"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
rm -f "$lms_deb"
|
||||
else
|
||||
log_error "Failed to download LMS from lms-community.org"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -194,6 +218,16 @@ action_install_lms() {
|
||||
action_uninstall_lms() {
|
||||
echo
|
||||
ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; }
|
||||
lms_do_uninstall ask
|
||||
pause
|
||||
}
|
||||
|
||||
# The actual removal, no confirmation prompt - shared with Complete
|
||||
# Uninstall so that operation doesn't need to re-implement LMS teardown a
|
||||
# second time. $1: "ask" to prompt about data removal interactively (the
|
||||
# normal case), "purge" to remove data without asking (Complete Uninstall).
|
||||
lms_do_uninstall() {
|
||||
local data_choice="${1:-ask}"
|
||||
|
||||
local service_name
|
||||
service_name=$(lms_service_name)
|
||||
@@ -212,15 +246,20 @@ action_uninstall_lms() {
|
||||
sudo rm -f /etc/apt/sources.list.d/lms.list
|
||||
sudo rm -f /usr/share/keyrings/lms-keyring.gpg
|
||||
|
||||
if ask_yes_no "Remove LMS data and configuration?" "n"; then
|
||||
local purge_data=false
|
||||
if [[ "$data_choice" == "purge" ]]; then
|
||||
purge_data=true
|
||||
elif [[ "$data_choice" == "ask" ]] && ask_yes_no "Remove LMS data and configuration?" "n"; then
|
||||
purge_data=true
|
||||
fi
|
||||
|
||||
if $purge_data; then
|
||||
sudo rm -rf /var/lib/squeezeboxserver
|
||||
sudo rm -rf /etc/squeezeboxserver
|
||||
log_success "LMS and data removed"
|
||||
else
|
||||
log_success "LMS removed (data preserved)"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -235,7 +274,7 @@ action_install_squeezelite() {
|
||||
fi
|
||||
|
||||
if ! command -v squeezelite &>/dev/null; then
|
||||
if ! sudo apt install -y squeezelite; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${SQUEEZELITE_APT_PACKAGES[@]}"; then
|
||||
log_error "squeezelite package installation failed"
|
||||
pause
|
||||
return 1
|
||||
@@ -332,13 +371,16 @@ EOF
|
||||
action_uninstall_squeezelite() {
|
||||
echo
|
||||
ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; }
|
||||
squeezelite_do_uninstall
|
||||
pause
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as lms_do_uninstall.
|
||||
squeezelite_do_uninstall() {
|
||||
sudo systemctl stop squeezelite 2>/dev/null || true
|
||||
sudo systemctl disable squeezelite 2>/dev/null || true
|
||||
sudo rm -f "$SYSTEMD_DIR/squeezelite.service"
|
||||
sudo rm -f "$BIN_DIR/squeezelite-start.sh"
|
||||
sudo apt remove -y squeezelite 2>/dev/null || true
|
||||
log_success "Squeezelite removed"
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
+173
-10
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
|
||||
# Tailscale, Netbird).
|
||||
# Tailscale, Netbird) plus SSH Access (GitHub/Launchpad key import).
|
||||
#
|
||||
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
|
||||
# All four genuinely mutate real system state at fixed paths this project
|
||||
@@ -24,7 +24,14 @@
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline
|
||||
# installs. (Tailscale/Netbird aren't bundled: they install from their
|
||||
# vendors' scripts and need their vendors' servers to work anyway.)
|
||||
VNC_APT_PACKAGES=(x11vnc)
|
||||
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
|
||||
|
||||
remote_access_status() {
|
||||
echo "SSH: $(ssh_status_line)"
|
||||
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
|
||||
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
|
||||
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
|
||||
@@ -32,8 +39,8 @@ remote_access_status() {
|
||||
}
|
||||
|
||||
remote_access_menu_builder() {
|
||||
MENU_LABELS=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
||||
MENU_HANDLERS=(vnc_menu wireguard_menu tailscale_menu netbird_menu)
|
||||
MENU_LABELS=("SSH Access (GitHub/Launchpad keys)" "VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
||||
MENU_HANDLERS=(ssh_menu vnc_menu wireguard_menu tailscale_menu netbird_menu)
|
||||
}
|
||||
|
||||
remote_access_menu() {
|
||||
@@ -70,7 +77,7 @@ action_vnc_install() {
|
||||
echo
|
||||
ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
if ! sudo apt install -y x11vnc; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${VNC_APT_PACKAGES[@]}"; then
|
||||
log_error "x11vnc installation failed"
|
||||
return 1
|
||||
fi
|
||||
@@ -130,11 +137,15 @@ action_vnc_change_password() {
|
||||
action_vnc_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Remove VNC?" "n" || { echo "Cancelled"; return; }
|
||||
vnc_do_uninstall
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||
vnc_do_uninstall() {
|
||||
sudo systemctl stop x11vnc 2>/dev/null || true
|
||||
sudo systemctl disable x11vnc 2>/dev/null || true
|
||||
sudo rm -f "$SYSTEMD_DIR/x11vnc.service"
|
||||
sudo apt remove -y x11vnc
|
||||
sudo apt remove -y x11vnc 2>/dev/null || true
|
||||
log_success "VNC removed"
|
||||
}
|
||||
|
||||
@@ -178,7 +189,7 @@ action_wireguard_install() {
|
||||
echo
|
||||
ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
if ! sudo apt install -y wireguard wireguard-tools; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${WIREGUARD_APT_PACKAGES[@]}"; then
|
||||
log_error "WireGuard installation failed"
|
||||
return 1
|
||||
fi
|
||||
@@ -227,10 +238,14 @@ action_wireguard_paste_config() {
|
||||
action_wireguard_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Remove WireGuard?" "n" || { echo "Cancelled"; return; }
|
||||
wireguard_do_uninstall
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||
wireguard_do_uninstall() {
|
||||
sudo systemctl stop 'wg-quick@*' 2>/dev/null || true
|
||||
sudo systemctl disable 'wg-quick@*' 2>/dev/null || true
|
||||
sudo apt remove -y wireguard wireguard-tools
|
||||
sudo apt remove -y wireguard wireguard-tools 2>/dev/null || true
|
||||
log_success "WireGuard removed"
|
||||
}
|
||||
|
||||
@@ -328,9 +343,13 @@ action_tailscale_show_status() {
|
||||
action_tailscale_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Remove Tailscale?" "n" || { echo "Cancelled"; return; }
|
||||
tailscale_do_uninstall
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||
tailscale_do_uninstall() {
|
||||
sudo tailscale down 2>/dev/null || true
|
||||
sudo apt remove -y tailscale
|
||||
sudo apt remove -y tailscale 2>/dev/null || true
|
||||
log_success "Tailscale removed"
|
||||
}
|
||||
|
||||
@@ -396,7 +415,7 @@ action_netbird_connect() {
|
||||
return
|
||||
fi
|
||||
|
||||
if sudo netbird up --setup-key "$setup_key"; then
|
||||
if sudo netbird up --setup-key "$setup_key" --allow-server-ssh; then
|
||||
log_success "Netbird connected"
|
||||
else
|
||||
log_error "Netbird connection failed"
|
||||
@@ -411,8 +430,152 @@ action_netbird_show_status() {
|
||||
action_netbird_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Remove Netbird?" "n" || { echo "Cancelled"; return; }
|
||||
netbird_do_uninstall
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||
netbird_do_uninstall() {
|
||||
sudo netbird down 2>/dev/null || true
|
||||
sudo apt remove -y netbird
|
||||
sudo apt remove -y netbird 2>/dev/null || true
|
||||
log_success "Netbird removed"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SSH Access - openssh-server plus public keys imported from GitHub or
|
||||
# Launchpad with ssh-import-id (the same "Import SSH identity" choice
|
||||
# Ubuntu's own installer offers). Keys go to the admin user running this
|
||||
# menu (~/.ssh/authorized_keys), never to the restricted `kiosk` user.
|
||||
# The key sets published by those services are fetched over HTTPS and
|
||||
# are public data, so no secret is handled here.
|
||||
################################################################################
|
||||
|
||||
SSH_KEYS_ONLY_CONF="${SSHD_CONFIG_DIR:-/etc/ssh/sshd_config.d}/10-kiosk-keys-only.conf"
|
||||
SSH_AUTH_KEYS="${SSH_AUTH_KEYS:-$HOME/.ssh/authorized_keys}"
|
||||
|
||||
ssh_key_count() {
|
||||
[[ -f "$SSH_AUTH_KEYS" ]] && grep -cE '^(ssh-|ecdsa-|sk-)' "$SSH_AUTH_KEYS" || echo 0
|
||||
}
|
||||
|
||||
ssh_status_line() {
|
||||
local state="not installed"
|
||||
if is_service_active ssh || is_service_active sshd; then
|
||||
state="running"
|
||||
elif dpkg -s openssh-server &>/dev/null; then
|
||||
state="installed, not running"
|
||||
fi
|
||||
local pw="password login on"
|
||||
[[ -f "$SSH_KEYS_ONLY_CONF" ]] && pw="keys only"
|
||||
echo "$state, $(ssh_key_count) key(s), $pw"
|
||||
}
|
||||
|
||||
ssh_status() {
|
||||
echo "SSH: $(ssh_status_line)"
|
||||
echo " Keys go to: $SSH_AUTH_KEYS"
|
||||
}
|
||||
|
||||
ssh_menu_builder() {
|
||||
MENU_LABELS=("Enable SSH server" "Import keys from GitHub" "Import keys from Launchpad" "List authorized keys" "Require keys (disable password login)" "Allow password login again")
|
||||
MENU_HANDLERS=(action_ssh_enable action_ssh_import_github action_ssh_import_launchpad action_ssh_list_keys action_ssh_keys_only action_ssh_allow_passwords)
|
||||
}
|
||||
|
||||
ssh_menu() {
|
||||
run_menu "SSH ACCESS" ssh_menu_builder ssh_status
|
||||
}
|
||||
|
||||
action_ssh_enable() {
|
||||
echo
|
||||
sudo apt install -y openssh-server ssh-import-id || { log_error "Could not install openssh-server"; return 1; }
|
||||
sudo systemctl enable --now ssh || { log_error "Could not start the SSH server"; return 1; }
|
||||
# provision_configure_firewall already allows ssh; harmless if repeated.
|
||||
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
sudo ufw allow ssh >/dev/null
|
||||
fi
|
||||
log_success "SSH server enabled"
|
||||
}
|
||||
|
||||
# $1 = "gh" or "lp", $2 = label for messages.
|
||||
ssh_import_from() {
|
||||
local prefix="$1" label="$2" user
|
||||
echo
|
||||
if ! command -v ssh-import-id &>/dev/null; then
|
||||
sudo apt install -y ssh-import-id || { log_error "Could not install ssh-import-id"; return 1; }
|
||||
fi
|
||||
read -r -p "$label username (blank to cancel): " user
|
||||
[[ -z "$user" ]] && { echo "Cancelled"; return; }
|
||||
if [[ ! "$user" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
log_error "That doesn't look like a valid $label username"
|
||||
return 1
|
||||
fi
|
||||
if ssh-import-id "${prefix}:${user}"; then
|
||||
log_success "Imported $label keys for $user ($(ssh_key_count) key(s) now authorized)"
|
||||
else
|
||||
log_error "Import failed - check the username and that this machine can reach $label"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_ssh_import_github() { ssh_import_from gh "GitHub"; }
|
||||
action_ssh_import_launchpad() { ssh_import_from lp "Launchpad"; }
|
||||
|
||||
action_ssh_list_keys() {
|
||||
echo
|
||||
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||
echo "No authorized keys for $USER yet."
|
||||
else
|
||||
ssh-keygen -lf "$SSH_AUTH_KEYS"
|
||||
fi
|
||||
}
|
||||
|
||||
action_ssh_keys_only() {
|
||||
echo
|
||||
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||
log_error "No authorized keys yet - import some first, or you'd lock yourself out"
|
||||
return 1
|
||||
fi
|
||||
echo "Log in from another machine with a key BEFORE doing this; password"
|
||||
echo "logins over SSH will stop working."
|
||||
ask_yes_no "Disable SSH password login?" "n" || { echo "Cancelled"; return; }
|
||||
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' |
|
||||
sudo install -D -m 644 /dev/stdin "$SSH_KEYS_ONLY_CONF"
|
||||
if sudo sshd -t && sudo systemctl reload ssh; then
|
||||
log_success "SSH now requires a key"
|
||||
else
|
||||
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||
log_error "sshd rejected the config - reverted"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_ssh_allow_passwords() {
|
||||
echo
|
||||
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||
sudo systemctl reload ssh 2>/dev/null || true
|
||||
log_success "SSH password login allowed again"
|
||||
}
|
||||
|
||||
# First-time install hook (called from lib/provision.sh).
|
||||
ssh_first_time_setup() {
|
||||
echo
|
||||
echo "SSH lets you manage the kiosk remotely. Ubuntu can import your"
|
||||
echo "public keys from GitHub or Launchpad so you can log in with no password."
|
||||
ask_yes_no "Enable SSH and import keys now?" "y" || { log_info "Configure later: Addons -> Remote Access -> SSH Access"; return; }
|
||||
action_ssh_enable || return 0
|
||||
local choice
|
||||
while true; do
|
||||
echo
|
||||
echo "Import keys from:"
|
||||
echo " 1. GitHub"
|
||||
echo " 2. Launchpad"
|
||||
echo " 3. Done"
|
||||
choice=$(ask_integer "Choose" "3" 1 3)
|
||||
case "$choice" in
|
||||
1) action_ssh_import_github || true ;;
|
||||
2) action_ssh_import_launchpad || true ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$(ssh_key_count)" != "0" ]]; then
|
||||
echo "Tip: Remote Access -> SSH Access can switch SSH to key-only login"
|
||||
echo "once you've confirmed a key works."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/
|
||||
# Express app) as a systemd service running as $KIOSK_USER, giving a
|
||||
# browser-based editor for Sites & Page Timing, Display & Interaction,
|
||||
# and Password Protection & Lockout - config.json read/write only, no
|
||||
# sudo needed, since that file is already owned by $KIOSK_USER.
|
||||
#
|
||||
# Installed by default during first-time provisioning now
|
||||
# (lib/provision.sh's provision_configure_webui, which calls the same
|
||||
# install helpers this file defines) - this menu remains here for
|
||||
# reconfiguring the port, restarting the service, or reinstalling it on
|
||||
# a kiosk provisioned before this change.
|
||||
#
|
||||
# Also lets the web UI trigger a fixed, vetted set of privileged actions
|
||||
# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and
|
||||
# Update - through a narrow, allow-listed root helper
|
||||
# (webui_write_helper_script below), rather than by giving the service
|
||||
# itself any elevated privilege. The helper is reachable only via a
|
||||
# single-path passwordless sudo rule (webui_write_sudoers_file) and
|
||||
# re-checks its own fixed action allow-list before dispatching anything,
|
||||
# even though the sudoers rule alone already restricts which script can
|
||||
# run - defense in depth. Each allow-listed action is the exact same
|
||||
# interactive `action_*` function the terminal menu already uses,
|
||||
# driven by piping the right answers on stdin, the same technique this
|
||||
# project's own bash tests already use (see webui/lib/actions.js and
|
||||
# webui/test/*.test.js). No prompt/mutation refactor of any addon file
|
||||
# was needed for this to work. Everything else (WiFi, Timezone, Power/
|
||||
# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia,
|
||||
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
||||
# stays terminal-only for now.
|
||||
#
|
||||
# No login of its own, by design: this addon assumes it'll be put behind
|
||||
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
|
||||
# same way other self-hosted apps get protected - Authelia integration
|
||||
# is explicitly out of scope for this repo (Authelia runs elsewhere).
|
||||
# Direct LAN access with no proxy in front has no authentication at all -
|
||||
# treat it the same as SSH access to this kiosk, which is also the
|
||||
# access level the privileged helper effectively grants if reached
|
||||
# without a proxy in front: bounded to its fixed action list, not a
|
||||
# root shell, but real system mutation all the same.
|
||||
#
|
||||
# webui/'s own app-level logic (config.json schema/merge, API
|
||||
# validation, the action allow-list/stdin synthesis, the job/SSE
|
||||
# system) lives and is tested entirely under webui/ - this file only
|
||||
# wires it up as a system service plus the privileged helper, and never
|
||||
# touches config.json itself.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
webui_is_installed() {
|
||||
[[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]
|
||||
}
|
||||
|
||||
webui_is_active() {
|
||||
systemctl is-active --quiet kiosk-webui
|
||||
}
|
||||
|
||||
webui_port() {
|
||||
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then
|
||||
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090"
|
||||
else
|
||||
echo "8090"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_status() {
|
||||
if webui_is_installed && webui_is_active; then
|
||||
echo "Web UI: running at http://$(get_ip_address):$(webui_port)"
|
||||
elif webui_is_installed; then
|
||||
echo "Web UI: installed but not running"
|
||||
else
|
||||
echo "Web UI: not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_menu_builder() {
|
||||
if webui_is_installed; then
|
||||
MENU_LABELS=("Change port" "Restart service" "Uninstall")
|
||||
MENU_HANDLERS=(action_webui_reconfigure action_webui_restart action_webui_uninstall)
|
||||
else
|
||||
MENU_LABELS=("Install Web UI")
|
||||
MENU_HANDLERS=(action_webui_install)
|
||||
fi
|
||||
}
|
||||
|
||||
addon_webui_menu() {
|
||||
run_menu "WEB UI" addon_webui_menu_builder addon_webui_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Shared install helpers
|
||||
################################################################################
|
||||
|
||||
# Copies webui/ from this checkout onto the kiosk and installs its npm
|
||||
# dependencies as $KIOSK_USER - same shape as provision_install_app in
|
||||
# lib/provision.sh, but addon-scoped (opt-in) rather than core. Every
|
||||
# critical step is individually guarded (matches menus/addon_cups.sh's
|
||||
# action_install_cups) rather than relying on set -e to stop a bare
|
||||
# sequence - menu actions are always invoked via run_menu's
|
||||
# `"${MENU_HANDLERS[...]}" "$choice" || true` dispatch, which already
|
||||
# exempts everything they do from set -e for the whole call, so a bare
|
||||
# unguarded sequence here would silently continue past a real failure
|
||||
# (e.g. attempting npm install into a directory `cp` never populated).
|
||||
webui_install_app_files() {
|
||||
if ! sudo mkdir -p "$WEBUI_DIR"; then
|
||||
log_error "Could not create $WEBUI_DIR"
|
||||
return 1
|
||||
fi
|
||||
if ! sudo cp -r "$SCRIPT_DIR/webui/." "$WEBUI_DIR/"; then
|
||||
log_error "Could not copy Web UI app files"
|
||||
return 1
|
||||
fi
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR"
|
||||
if offline_mode_active; then
|
||||
offline_install_node_modules webui "$WEBUI_DIR" || return 1
|
||||
elif ! sudo -u "$KIOSK_USER" bash -lc "cd '$WEBUI_DIR' && npm install --omit=dev --unsafe-perm"; then
|
||||
log_error "npm install failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
webui_write_env_file() {
|
||||
local port="$1"
|
||||
sudo mkdir -p "$WEBUI_ENV_DIR"
|
||||
sudo tee "$WEBUI_ENV_DIR/webui.env" > /dev/null <<EOF
|
||||
PORT=${port}
|
||||
BIND_ADDR=0.0.0.0
|
||||
CONFIG_PATH=${CONFIG_PATH}
|
||||
HELPER_PATH=${WEBUI_HELPER_PATH}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Writes the one root-owned script the Web UI is ever allowed to reach
|
||||
# via sudo (see the sudoers rule webui_write_sudoers_file writes right
|
||||
# after this). Sources the exact same files install.sh does - full path
|
||||
# list baked in at generation time from $SCRIPT_DIR, so the generated
|
||||
# script has no runtime dependency on where it happens to be invoked
|
||||
# from. The fixed ALLOWED_ACTIONS allow-list inside the script itself is
|
||||
# the real gate (checked in addition to, not instead of, the sudoers
|
||||
# rule only permitting this one script path) - a request that reaches
|
||||
# this script can only ever trigger one of these exact, already-tested
|
||||
# interactive `action_*` functions, driven the same way this project's
|
||||
# own bash tests already drive them: real answers piped in on stdin, in
|
||||
# the exact order the function's own prompts expect them. No prompt/
|
||||
# mutation refactor of any addon file needed for this to work.
|
||||
webui_write_helper_script() {
|
||||
sudo mkdir -p "$(dirname "$WEBUI_HELPER_PATH")"
|
||||
sudo tee "$WEBUI_HELPER_PATH" > /dev/null <<EOF
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# lib/provision.sh reads \$SCRIPT_DIR directly at source time (no
|
||||
# fallback default, unlike the lib/config.sh path vars) - must be a real
|
||||
# exported variable here, not just used to interpolate the source paths
|
||||
# below, or it's unbound under set -u.
|
||||
export SCRIPT_DIR="$SCRIPT_DIR"
|
||||
|
||||
source "$SCRIPT_DIR/lib/menu.sh"
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
source "$SCRIPT_DIR/lib/electron.sh"
|
||||
source "$SCRIPT_DIR/menus/sites.sh"
|
||||
source "$SCRIPT_DIR/menus/display.sh"
|
||||
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||
source "$SCRIPT_DIR/menus/lockout.sh"
|
||||
source "$SCRIPT_DIR/menus/wifi.sh"
|
||||
source "$SCRIPT_DIR/menus/power_schedule.sh"
|
||||
source "$SCRIPT_DIR/menus/diagnostics.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_cups.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_authelia.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
|
||||
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
|
||||
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
|
||||
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
|
||||
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
|
||||
source "$SCRIPT_DIR/menus/clone_settings.sh"
|
||||
source "$SCRIPT_DIR/lib/provision.sh"
|
||||
|
||||
# Read-only status wrappers, not addon logic of their own - just a
|
||||
# single fixed-shape JSON line so the web UI can show "Install" vs.
|
||||
# "Reconfigure" per addon in one round trip. None of the underlying
|
||||
# checks (dpkg query, systemctl is-active/is-enabled, a file-existence
|
||||
# test under \$KIOSK_HOME) need root, but this script is still only
|
||||
# reachable via the same sudo-gated path as everything else here - one
|
||||
# access path is simpler to reason about than two, and the extra sudo
|
||||
# call for a read-only check is negligible.
|
||||
status_all() {
|
||||
echo "{\"cups\":\$(cups_is_installed && echo true || echo false),\"lms\":\$(lms_is_installed && echo true || echo false),\"squeezelite\":\$(squeezelite_is_installed && echo true || echo false),\"asterisk_intercom\":\$(baresip_is_installed && echo true || echo false)}"
|
||||
}
|
||||
|
||||
ALLOWED_ACTIONS=(
|
||||
action_install_cups
|
||||
action_reconfigure_cups
|
||||
action_install_lms
|
||||
action_install_squeezelite
|
||||
action_configure_asterisk_intercom
|
||||
action_upgrade
|
||||
status_all
|
||||
)
|
||||
|
||||
action="\${1:-}"
|
||||
allowed=false
|
||||
for a in "\${ALLOWED_ACTIONS[@]}"; do
|
||||
[[ "\$a" == "\$action" ]] && allowed=true && break
|
||||
done
|
||||
|
||||
if ! \$allowed; then
|
||||
echo "kiosk-webui-helper: action not permitted: \$action" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"\$action"
|
||||
EOF
|
||||
sudo chown root:root "$WEBUI_HELPER_PATH"
|
||||
sudo chmod 750 "$WEBUI_HELPER_PATH"
|
||||
}
|
||||
|
||||
# Grants $KIOSK_USER passwordless sudo on exactly this one script path -
|
||||
# no argument wildcarding at the sudoers level, since the script's own
|
||||
# ALLOWED_ACTIONS check above is the real gate. Validated with
|
||||
# `visudo -c -f` on a temp file before it's moved into place: a
|
||||
# malformed sudoers snippet can break sudo system-wide, so this step is
|
||||
# never skipped.
|
||||
webui_write_sudoers_file() {
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp"
|
||||
|
||||
if ! sudo visudo -c -f "$tmp" &>/dev/null; then
|
||||
log_error "Generated sudoers rule failed validation - not installed"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
webui_write_unit_file() {
|
||||
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Kiosk Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${KIOSK_USER}
|
||||
WorkingDirectory=${WEBUI_DIR}
|
||||
EnvironmentFile=${WEBUI_ENV_DIR}/webui.env
|
||||
ExecStart=/usr/bin/node ${WEBUI_DIR}/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_webui_install() {
|
||||
echo
|
||||
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
||||
echo "and Asterisk Intercom, and checking for updates. It has no login"
|
||||
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
|
||||
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
|
||||
echo
|
||||
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
local port
|
||||
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
|
||||
|
||||
echo "Installing app files and npm dependencies..."
|
||||
if ! webui_install_app_files; then
|
||||
log_error "Web UI install failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
webui_write_env_file "$port"
|
||||
webui_write_unit_file
|
||||
webui_write_helper_script
|
||||
if ! webui_write_sudoers_file; then
|
||||
log_error "Web UI install failed - could not grant the addon-install/Update helper permission"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if enable_and_start_units kiosk-webui; then
|
||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||
log_success "Web UI installed: http://$(get_ip_address):${port}"
|
||||
else
|
||||
log_error "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_webui_reconfigure() {
|
||||
echo
|
||||
local current_port
|
||||
current_port=$(webui_port)
|
||||
local port
|
||||
port=$(ask_integer "Port to listen on" "$current_port" 1024 65535)
|
||||
|
||||
if [[ "$port" == "$current_port" ]]; then
|
||||
echo "No change"
|
||||
return
|
||||
fi
|
||||
|
||||
webui_write_env_file "$port"
|
||||
sudo systemctl restart kiosk-webui
|
||||
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
|
||||
log_success "Web UI now listening on port ${port}"
|
||||
}
|
||||
|
||||
action_webui_restart() {
|
||||
sudo systemctl restart kiosk-webui
|
||||
sleep 1
|
||||
if webui_is_active; then
|
||||
log_success "Web UI restarted"
|
||||
else
|
||||
log_error "Web UI failed to restart - check: sudo journalctl -u kiosk-webui -n 50"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
action_webui_uninstall() {
|
||||
echo
|
||||
ask_yes_no "Uninstall the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||
webui_do_uninstall
|
||||
}
|
||||
|
||||
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||
# operation doesn't need to re-implement Web UI teardown a second time.
|
||||
# No `ufw delete` - matches every other addon's uninstall in this
|
||||
# codebase, which never removes its own firewall rule either.
|
||||
webui_do_uninstall() {
|
||||
sudo systemctl stop kiosk-webui 2>/dev/null || true
|
||||
sudo systemctl disable kiosk-webui 2>/dev/null || true
|
||||
sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service"
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH"
|
||||
sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR"
|
||||
log_success "Web UI removed"
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_electron.sh - "Electron Maintenance" (Advanced): the
|
||||
# legacy "Manual Electron Update" and "Fix Blank Screen" items, combined
|
||||
# under one submenu since both maintain the same Electron installation.
|
||||
# The binary-repair logic itself (electron_install_binary) now lives in
|
||||
# lib/electron.sh, shared with fresh provisioning (lib/provision.sh) -
|
||||
# the same repair sequence applies whether the binary never downloaded
|
||||
# during the initial `npm install` or went missing later.
|
||||
#
|
||||
# Real system state: $KIOSK_DIR/node_modules, package.json, lightdm.
|
||||
# Every write goes through `sudo`/`sudo -u "$KIOSK_USER"`, stubbed at the
|
||||
# command level in tests - there's no relocatable equivalent for another
|
||||
# project's (npm/Electron's) own directory layout.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
electron_installed_version() {
|
||||
local package_json="$KIOSK_DIR/package.json"
|
||||
if ! sudo test -f "$package_json" 2>/dev/null; then
|
||||
echo "not installed"
|
||||
return
|
||||
fi
|
||||
|
||||
local version
|
||||
version=$(sudo grep -oP '"electron"\s*:\s*"\^?\K[0-9.]+' "$package_json" 2>/dev/null || true)
|
||||
if [[ -z "$version" ]]; then
|
||||
local electron_pkg="$KIOSK_DIR/node_modules/electron/package.json"
|
||||
if sudo test -f "$electron_pkg" 2>/dev/null; then
|
||||
version=$(sudo grep -oP '"version"\s*:\s*"\K[0-9.]+' "$electron_pkg" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
echo "${version:-unknown}"
|
||||
}
|
||||
|
||||
electron_is_running() {
|
||||
pgrep -f "electron.*main.js" &>/dev/null || pgrep -f "node.*electron" &>/dev/null
|
||||
}
|
||||
|
||||
advanced_electron_status() {
|
||||
local ver
|
||||
ver=$(electron_installed_version)
|
||||
echo "Electron: v${ver}"
|
||||
if electron_is_running; then
|
||||
echo " Running"
|
||||
else
|
||||
echo " Not running"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_electron_menu_builder() {
|
||||
MENU_LABELS=(
|
||||
"Check for updates / update Electron"
|
||||
"Fix blank screen (repair Electron binary + sandbox)"
|
||||
)
|
||||
MENU_HANDLERS=(action_update_electron action_repair_electron)
|
||||
}
|
||||
|
||||
advanced_electron_menu() {
|
||||
run_menu "ELECTRON MAINTENANCE" advanced_electron_menu_builder advanced_electron_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_update_electron() {
|
||||
echo
|
||||
if ! sudo test -d "$KIOSK_DIR" 2>/dev/null; then
|
||||
log_error "Kiosk directory not found: $KIOSK_DIR"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_version
|
||||
current_version=$(electron_installed_version)
|
||||
log_info "Current Electron version: $current_version"
|
||||
|
||||
if electron_is_running; then
|
||||
log_success "Electron app is running"
|
||||
else
|
||||
log_warning "Electron app does not appear to be running"
|
||||
fi
|
||||
echo
|
||||
|
||||
ask_yes_no "Check for latest Electron version?" "y" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
local latest_version
|
||||
latest_version=$(npm view electron version 2>/dev/null || true)
|
||||
if [[ -z "$latest_version" ]]; then
|
||||
latest_version=$(curl -s https://registry.npmjs.org/electron/latest 2>/dev/null | grep -oP '"version"\s*:\s*"\K[0-9.]+' || true)
|
||||
fi
|
||||
if [[ -z "$latest_version" ]]; then
|
||||
latest_version=$(curl -s https://api.github.com/repos/electron/electron/releases/latest 2>/dev/null | grep -oP '"tag_name"\s*:\s*"v\K[0-9.]+' || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$latest_version" ]]; then
|
||||
log_error "Could not fetch latest Electron version - check your internet connection"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
log_success "Latest stable Electron version: $latest_version"
|
||||
echo
|
||||
|
||||
if [[ "$current_version" == "$latest_version" ]]; then
|
||||
log_success "Already running the latest version"
|
||||
ask_yes_no "Reinstall Electron $latest_version anyway?" "n" || { echo "Cancelled"; pause; return; }
|
||||
fi
|
||||
|
||||
echo "──────────────────────────────────────────────────────────"
|
||||
echo "UPDATE SUMMARY"
|
||||
echo "──────────────────────────────────────────────────────────"
|
||||
echo "Current version: $current_version"
|
||||
echo "Target version: $latest_version"
|
||||
echo "Installation: $KIOSK_DIR"
|
||||
echo
|
||||
|
||||
local current_major="${current_version%%.*}"
|
||||
local latest_major="${latest_version%%.*}"
|
||||
log_warning "Review breaking changes before updating:"
|
||||
echo " https://www.electronjs.org/docs/latest/breaking-changes"
|
||||
if [[ "$latest_major" != "$current_major" ]]; then
|
||||
log_warning "MAJOR VERSION CHANGE (v${current_major} -> v${latest_major})"
|
||||
fi
|
||||
echo
|
||||
|
||||
ask_yes_no "Reviewed breaking changes and want to proceed?" "n" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
echo
|
||||
log_info "Creating backup..."
|
||||
local kiosk_owner
|
||||
kiosk_owner=$(sudo stat -c '%U' "$KIOSK_DIR" 2>/dev/null || echo "$KIOSK_USER")
|
||||
local backup_dir="${KIOSK_DIR}/backups/electron_backup_$(date +%Y%m%d_%H%M%S)"
|
||||
sudo -u "$kiosk_owner" mkdir -p "$backup_dir"
|
||||
|
||||
if sudo test -f "$KIOSK_DIR/package.json" 2>/dev/null; then
|
||||
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package.json" "$backup_dir/"
|
||||
fi
|
||||
if sudo test -f "$KIOSK_DIR/package-lock.json" 2>/dev/null; then
|
||||
sudo -u "$kiosk_owner" cp "$KIOSK_DIR/package-lock.json" "$backup_dir/"
|
||||
fi
|
||||
echo "$current_version" | sudo -u "$kiosk_owner" tee "$backup_dir/electron_version.txt" > /dev/null
|
||||
log_success "Backup created at: $backup_dir"
|
||||
echo
|
||||
|
||||
ask_yes_no "Proceed with Electron update to $latest_version?" "n" || {
|
||||
log_info "Update cancelled - backup preserved at: $backup_dir"
|
||||
pause
|
||||
return
|
||||
}
|
||||
|
||||
echo
|
||||
log_info "Stopping kiosk display..."
|
||||
sudo systemctl stop lightdm 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
sudo -u "$KIOSK_USER" sed -i "s/\"electron\": \".*\"/\"electron\": \"^${latest_version}\"/" "$KIOSK_DIR/package.json"
|
||||
|
||||
if sudo test -d "$KIOSK_DIR/node_modules/electron" 2>/dev/null; then
|
||||
sudo -u "$KIOSK_USER" rm -rf "$KIOSK_DIR/node_modules/electron"
|
||||
fi
|
||||
|
||||
log_info "Installing Electron $latest_version (this may take a few minutes)..."
|
||||
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install electron@'$latest_version'"; then
|
||||
log_success "Electron updated to $latest_version"
|
||||
|
||||
local sandbox="$KIOSK_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||
if sudo test -f "$sandbox" 2>/dev/null; then
|
||||
sudo chown root:root "$sandbox"
|
||||
sudo chmod 4755 "$sandbox"
|
||||
fi
|
||||
|
||||
if ask_yes_no "Restart kiosk display now?" "y"; then
|
||||
sudo systemctl start lightdm
|
||||
sleep 3
|
||||
if systemctl is-active --quiet lightdm; then
|
||||
log_success "Kiosk display started"
|
||||
else
|
||||
log_error "Kiosk display failed to start - check: sudo journalctl -u lightdm -n 50"
|
||||
fi
|
||||
else
|
||||
log_info "Start manually with: sudo systemctl start lightdm"
|
||||
fi
|
||||
log_success "Backup preserved at: $backup_dir (delete once confirmed working)"
|
||||
else
|
||||
log_error "Electron install failed - restoring from backup..."
|
||||
if sudo test -f "$backup_dir/package.json" 2>/dev/null; then
|
||||
sudo -u "$KIOSK_USER" cp "$backup_dir/package.json" "$KIOSK_DIR/"
|
||||
fi
|
||||
if sudo -u "$KIOSK_USER" bash -c "cd '$KIOSK_DIR' && npm install"; then
|
||||
log_success "Restored original Electron installation"
|
||||
sudo systemctl start lightdm
|
||||
else
|
||||
log_error "Failed to restore - manual intervention required"
|
||||
fi
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_repair_electron() {
|
||||
echo
|
||||
echo "This will:"
|
||||
echo " 1. Check if the Electron binary is present"
|
||||
echo " 2. Download it if missing (~120MB)"
|
||||
echo " 3. Fix chrome-sandbox permissions (setuid root)"
|
||||
echo " 4. Restart the kiosk display"
|
||||
echo
|
||||
ask_yes_no "Continue?" "y" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
sudo systemctl stop lightdm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Bare call, not `if ! electron_install_binary; then`: testing a
|
||||
# multi-statement function as an if-condition exempts everything
|
||||
# inside it from set -e for the duration (e.g. the sandbox chown/
|
||||
# chmod below would silently continue past an earlier failure).
|
||||
# Capturing $? right after a bare call doesn't have that problem -
|
||||
# the exemption only affects whether a nonzero status halts the
|
||||
# script, never the actual value $? holds.
|
||||
electron_install_binary
|
||||
local electron_rc=$?
|
||||
if [[ $electron_rc -ne 0 ]]; then
|
||||
log_error "Could not install Electron. Check internet and retry."
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Restarting kiosk display..."
|
||||
sudo systemctl restart lightdm
|
||||
sleep 3
|
||||
if systemctl is-active --quiet lightdm && pgrep -f "electron.*main.js" &>/dev/null; then
|
||||
log_success "Kiosk display is running"
|
||||
else
|
||||
log_warning "LightDM started but Electron may still be loading."
|
||||
echo " Check: sudo tail -20 $KIOSK_DIR/../electron.log"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
|
||||
# auto-starts a WiFi hotspot if the kiosk finds itself cut off 60 seconds
|
||||
# after boot, so it can be reached and reconfigured remotely.
|
||||
# "Cut off" is chosen at setup (EMERGENCY_HOTSPOT_CHECK_MODE in the
|
||||
# generated script): by default the local network itself is unreachable -
|
||||
# neither the router nor the internet answers - so a kiosk on a LAN with
|
||||
# no internet (an offline install) doesn't start the hotspot on every
|
||||
# boot. The old internet-only check (ping 8.8.8.8) is still an option, as
|
||||
# is checking one specific address. Scripts generated before this choice
|
||||
# existed keep the internet-only check until Reconfigure is run.
|
||||
#
|
||||
# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot)
|
||||
# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both
|
||||
# of those paths are ours to place, so (like power_schedule and every
|
||||
# other addon) they're parameterized instead of hardcoded. hostapd/
|
||||
# dnsmasq/iptables themselves are real apt packages with their own fixed
|
||||
# config locations, stubbed at the command level in tests like CUPS.
|
||||
#
|
||||
# The runtime script itself is a template: everything written with `\$`
|
||||
# below stays literal and only resolves when the script actually runs at
|
||||
# boot (on the real machine, not in this tool); only the un-escaped
|
||||
# $wifi_iface/$hotspot_ssid/$hotspot_pass/$hotspot_ip/$KIOSK_USER/
|
||||
# $KIOSK_DIR are substituted once, at configuration time.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot"
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||
EMERGENCY_HOTSPOT_APT_PACKAGES=(hostapd dnsmasq iptables)
|
||||
|
||||
emergency_hotspot_is_configured() {
|
||||
[[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]]
|
||||
}
|
||||
|
||||
emergency_hotspot_ssid() {
|
||||
grep '^HOTSPOT_SSID=' "$EMERGENCY_HOTSPOT_SCRIPT" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true
|
||||
}
|
||||
|
||||
advanced_emergency_hotspot_status() {
|
||||
if emergency_hotspot_is_configured; then
|
||||
local ssid
|
||||
ssid=$(emergency_hotspot_ssid)
|
||||
echo "Emergency Hotspot: Configured (SSID: ${ssid:-unknown})"
|
||||
else
|
||||
echo "Emergency Hotspot: Not configured"
|
||||
fi
|
||||
echo "ℹ Auto-starts a WiFi hotspot if the kiosk can't reach its network"
|
||||
echo " 60 seconds after boot, so you can connect and reconfigure remotely."
|
||||
}
|
||||
|
||||
advanced_emergency_hotspot_menu_builder() {
|
||||
if emergency_hotspot_is_configured; then
|
||||
MENU_LABELS=("Reconfigure" "Disable")
|
||||
MENU_HANDLERS=(action_configure_emergency_hotspot action_disable_emergency_hotspot)
|
||||
else
|
||||
MENU_LABELS=("Enable emergency hotspot")
|
||||
MENU_HANDLERS=(action_configure_emergency_hotspot)
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_emergency_hotspot_menu() {
|
||||
run_menu "EMERGENCY HOTSPOT" advanced_emergency_hotspot_menu_builder advanced_emergency_hotspot_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_configure_emergency_hotspot() {
|
||||
echo
|
||||
if ! run_with_offline_fallback sudo apt install -y "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}"; then
|
||||
log_error "Failed to install hostapd/dnsmasq/iptables"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo systemctl stop hostapd dnsmasq 2>/dev/null || true
|
||||
sudo systemctl disable hostapd dnsmasq 2>/dev/null || true
|
||||
|
||||
local wifi_iface
|
||||
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1 || true)
|
||||
if [[ -z "$wifi_iface" ]]; then
|
||||
log_error "No WiFi interface found"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
echo "WiFi interface: $wifi_iface"
|
||||
echo
|
||||
|
||||
local hotspot_ssid
|
||||
hotspot_ssid=$(ask_text "Hotspot SSID" "Kiosk-Emergency")
|
||||
|
||||
local hotspot_pass=""
|
||||
while [[ ${#hotspot_pass} -lt 8 ]]; do
|
||||
read -r -s -p "Hotspot password (8+ chars): " hotspot_pass
|
||||
echo
|
||||
[[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "When should the hotspot start? (checked 60 seconds after boot)"
|
||||
echo " 1) The local network is unreachable - neither the router nor the"
|
||||
echo " internet answers. Recommended; right for networks without"
|
||||
echo " internet too."
|
||||
echo " 2) There's no internet. On a network without internet this starts"
|
||||
echo " the hotspot on every boot."
|
||||
echo " 3) A specific address on your network doesn't answer (e.g. a server)."
|
||||
local check_mode="network" check_host="" check_choice
|
||||
check_choice=$(ask_integer "Choice" 1 1 3)
|
||||
case "$check_choice" in
|
||||
2) check_mode="internet" ;;
|
||||
3)
|
||||
check_host=$(ask_text "Address to check (IP or hostname)" "")
|
||||
if [[ "$check_host" =~ ^[A-Za-z0-9.:-]+$ ]]; then
|
||||
check_mode="host"
|
||||
else
|
||||
log_warning "Not a valid address - using option 1 instead"
|
||||
check_host=""
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
local hotspot_ip="192.168.50.1"
|
||||
|
||||
sudo mkdir -p "$BIN_DIR"
|
||||
sudo tee "$EMERGENCY_HOTSPOT_SCRIPT" > /dev/null <<EOF
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### KIOSK EMERGENCY HOTSPOT
|
||||
### Auto-starts if the network check fails 60 seconds after boot
|
||||
################################################################################
|
||||
|
||||
# network = neither the default gateway (router) nor the internet answers
|
||||
# internet = no internet (pings 8.8.8.8 / 1.1.1.1)
|
||||
# host = CHECK_HOST doesn't answer
|
||||
CHECK_MODE="$check_mode"
|
||||
CHECK_HOST="$check_host"
|
||||
WIFI_IFACE="$wifi_iface"
|
||||
HOTSPOT_SSID="$hotspot_ssid"
|
||||
HOTSPOT_PASS="$hotspot_pass"
|
||||
HOTSPOT_IP="$hotspot_ip"
|
||||
KIOSK_USER="$KIOSK_USER"
|
||||
|
||||
# Wait 60 seconds after boot
|
||||
sleep 60
|
||||
|
||||
ping_ok() { ping -c 2 -W 3 "\$1" >/dev/null 2>&1; }
|
||||
internet_ok() { ping_ok 8.8.8.8 || ping_ok 1.1.1.1; }
|
||||
# The router counts as there if it answers ping - or, for routers set to
|
||||
# ignore ping, if it answered the ARP lookup that ping just triggered.
|
||||
gateway_ok() {
|
||||
local gw
|
||||
gw=\$(ip -4 route show default 2>/dev/null | awk '{print \$3; exit}')
|
||||
[[ -n "\$gw" ]] || return 1
|
||||
ping_ok "\$gw" && return 0
|
||||
ip neigh show "\$gw" 2>/dev/null | grep -qE 'REACHABLE|STALE|DELAY|PROBE'
|
||||
}
|
||||
connected() {
|
||||
case "\$CHECK_MODE" in
|
||||
internet) internet_ok ;;
|
||||
host) ping_ok "\$CHECK_HOST" ;;
|
||||
*) gateway_ok || internet_ok ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Three tries, 10 seconds apart, so a slow DHCP lease or WiFi association
|
||||
# isn't mistaken for being cut off.
|
||||
for attempt in 1 2 3; do
|
||||
if connected; then
|
||||
logger "KIOSK: Network check (\$CHECK_MODE) passed - emergency hotspot not needed"
|
||||
exit 0
|
||||
fi
|
||||
[[ \$attempt -lt 3 ]] && sleep 10
|
||||
done
|
||||
|
||||
logger "KIOSK: Network check (\$CHECK_MODE) failed - starting emergency hotspot"
|
||||
|
||||
# Stop any conflicting services
|
||||
systemctl stop wpa_supplicant 2>/dev/null || true
|
||||
ip link set \$WIFI_IFACE down 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Configure static IP for hotspot
|
||||
ip addr flush dev \$WIFI_IFACE
|
||||
ip addr add \${HOTSPOT_IP}/24 dev \$WIFI_IFACE
|
||||
ip link set \$WIFI_IFACE up
|
||||
|
||||
# Configure dnsmasq
|
||||
cat > /tmp/dnsmasq-hotspot.conf <<DNSMASQ
|
||||
interface=\$WIFI_IFACE
|
||||
dhcp-range=192.168.50.10,192.168.50.50,12h
|
||||
dhcp-option=3,\$HOTSPOT_IP
|
||||
dhcp-option=6,\$HOTSPOT_IP
|
||||
server=8.8.8.8
|
||||
log-queries
|
||||
log-dhcp
|
||||
DNSMASQ
|
||||
|
||||
# Start dnsmasq
|
||||
dnsmasq -C /tmp/dnsmasq-hotspot.conf
|
||||
|
||||
# Configure hostapd
|
||||
cat > /tmp/hostapd-hotspot.conf <<HOSTAPD
|
||||
interface=\$WIFI_IFACE
|
||||
driver=nl80211
|
||||
ssid=\$HOTSPOT_SSID
|
||||
hw_mode=g
|
||||
channel=6
|
||||
macaddr_acl=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
wpa=2
|
||||
wpa_passphrase=\$HOTSPOT_PASS
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=TKIP
|
||||
rsn_pairwise=CCMP
|
||||
HOSTAPD
|
||||
|
||||
# Start hostapd
|
||||
hostapd -B /tmp/hostapd-hotspot.conf
|
||||
|
||||
# Enable IP forwarding (optional - for internet sharing if wired connection exists)
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
|
||||
|
||||
# Show notification on kiosk display
|
||||
sudo -u \$KIOSK_USER DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$(id -u \$KIOSK_USER)/bus \\
|
||||
notify-send -u critical -t 0 "Emergency Hotspot Active" \\
|
||||
"SSID: \$HOTSPOT_SSID\\nPassword: \$HOTSPOT_PASS\\nConnect to: http://\$HOTSPOT_IP" 2>/dev/null || true
|
||||
|
||||
logger "KIOSK: Emergency hotspot started - SSID: \$HOTSPOT_SSID, IP: \$HOTSPOT_IP"
|
||||
|
||||
# Create on-screen notification HTML
|
||||
sudo -u \$KIOSK_USER tee /tmp/hotspot-notification.html > /dev/null <<'NOTIFY'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: rgba(0,0,0,0.95);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 { font-size: 48px; margin-bottom: 20px; }
|
||||
.icon { font-size: 72px; margin-bottom: 20px; }
|
||||
.info { font-size: 24px; margin: 20px 0; line-height: 1.6; }
|
||||
.credential {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
font-size: 20px;
|
||||
}
|
||||
.dismiss {
|
||||
margin-top: 30px;
|
||||
padding: 15px 40px;
|
||||
font-size: 18px;
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
.dismiss:hover { background: #ecf0f1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">📡</div>
|
||||
<h1>Emergency Hotspot Active</h1>
|
||||
<div class="info">No internet connection detected<br>Hotspot created for remote access</div>
|
||||
<div class="credential">SSID: <strong>\$HOTSPOT_SSID</strong></div>
|
||||
<div class="credential">Password: <strong>\$HOTSPOT_PASS</strong></div>
|
||||
<div class="credential">Connect to: <strong>http://\$HOTSPOT_IP</strong></div>
|
||||
<button class="dismiss" onclick="window.close()">Dismiss</button>
|
||||
</div>
|
||||
<script>
|
||||
// Auto-dismiss after 5 minutes
|
||||
setTimeout(() => window.close(), 300000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
NOTIFY
|
||||
|
||||
# Show notification window if Electron is running
|
||||
if pgrep -f "electron.*main.js" >/dev/null 2>&1; then
|
||||
sudo -u \$KIOSK_USER DISPLAY=:0 \\
|
||||
"$KIOSK_DIR/node_modules/electron/dist/electron" \\
|
||||
/tmp/hotspot-notification.html &
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
sudo chmod +x "$EMERGENCY_HOTSPOT_SCRIPT"
|
||||
|
||||
sudo mkdir -p "$SYSTEMD_DIR"
|
||||
sudo tee "$SYSTEMD_DIR/kiosk-emergency-hotspot.service" > /dev/null <<UNITEOF
|
||||
[Unit]
|
||||
Description=Kiosk Emergency Hotspot
|
||||
After=network.target lightdm.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=${EMERGENCY_HOTSPOT_SCRIPT}
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNITEOF
|
||||
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
# Enable only, not start: this is a boot-time oneshot that waits 60s
|
||||
# and checks connectivity - starting it right now would just run that
|
||||
# wait/check immediately, which isn't what "configure" means here.
|
||||
if ! sudo systemctl enable kiosk-emergency-hotspot.service 2>/dev/null; then
|
||||
log_warning "Hotspot files written, but 'systemctl enable' failed - check 'systemctl status kiosk-emergency-hotspot.service'"
|
||||
fi
|
||||
|
||||
echo
|
||||
log_success "Emergency hotspot configured"
|
||||
echo " SSID: $hotspot_ssid"
|
||||
echo " Password: $hotspot_pass"
|
||||
echo " IP: $hotspot_ip"
|
||||
echo
|
||||
echo "Hotspot auto-starts if no internet is detected 60 seconds after boot."
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_disable_emergency_hotspot() {
|
||||
echo
|
||||
ask_yes_no "Disable emergency hotspot?" "n" || { echo "Cancelled"; pause; return; }
|
||||
emergency_hotspot_do_disable
|
||||
pause
|
||||
}
|
||||
|
||||
# Shared with Complete Uninstall - same reasoning as cups_do_uninstall.
|
||||
emergency_hotspot_do_disable() {
|
||||
sudo systemctl stop kiosk-emergency-hotspot.service 2>/dev/null || true
|
||||
sudo systemctl disable kiosk-emergency-hotspot.service 2>/dev/null || true
|
||||
sudo rm -f "$SYSTEMD_DIR/kiosk-emergency-hotspot.service"
|
||||
sudo rm -f "$EMERGENCY_HOTSPOT_SCRIPT"
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
log_success "Emergency hotspot disabled"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_factory_reset.sh - "Factory Reset" (Advanced): wipe
|
||||
# config.json back to script defaults without touching anything else.
|
||||
#
|
||||
# Deliberately narrow - this only removes $CONFIG_PATH. Installed addons
|
||||
# (CUPS, LMS, VPNs, etc.), the kiosk user, and the system itself are left
|
||||
# alone; that's what Complete Uninstall is for.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
advanced_factory_reset_status() {
|
||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
echo "Config: $CONFIG_PATH exists"
|
||||
else
|
||||
echo "Config: not found (already at defaults)"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_factory_reset_menu_builder() {
|
||||
MENU_LABELS=("Reset configuration to defaults")
|
||||
MENU_HANDLERS=(action_factory_reset)
|
||||
}
|
||||
|
||||
advanced_factory_reset_menu() {
|
||||
run_menu "FACTORY RESET" advanced_factory_reset_menu_builder advanced_factory_reset_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_factory_reset() {
|
||||
echo
|
||||
echo "This resets $CONFIG_PATH to defaults - sites, schedules,"
|
||||
echo "password protection, and every other setting stored there are"
|
||||
echo "cleared. Installed addons (CUPS, LMS, VPNs, etc.) are not touched."
|
||||
echo
|
||||
ask_yes_no "Continue?" "n" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
sudo -u "$KIOSK_USER" rm -f "$CONFIG_PATH"
|
||||
log_success "Configuration reset - reconfigure via Core Settings"
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_upgrade.sh - "Upgrade" (Advanced): pull the latest code
|
||||
# from git and re-apply provisioning, plus an on-demand Electron update.
|
||||
#
|
||||
# ubuntu-based-kiosk.sh's Upgrade extracted fresh copies of main.js/
|
||||
# preload.js/etc from its own heredocs on every run - the modular tool
|
||||
# has no heredocs to extract from. kiosk-app/ and provision/files/ are
|
||||
# real files in this git checkout, so "get whatever's new" is just
|
||||
# `git pull` followed by re-running the same steps lib/provision.sh
|
||||
# already has for a fresh install - reused here, not reimplemented,
|
||||
# minus the interactive first-run settings wizard and the "reboot now"
|
||||
# prompt (upgrading shouldn't re-ask sites/hotspot/vconsoles or reboot
|
||||
# the whole machine). Electron itself isn't versioned by this repo, so
|
||||
# checking for a newer Electron is a separate step: the existing,
|
||||
# already-tested action_update_electron (menus/advanced_electron.sh),
|
||||
# reused as-is rather than duplicated.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh,
|
||||
# lib/provision.sh, menus/advanced_electron.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
advanced_upgrade_status() {
|
||||
if git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
local branch rev
|
||||
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
rev=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
echo "Installed from git: $branch @ $rev"
|
||||
else
|
||||
echo "Installed from git: not a git checkout (upgrade unavailable)"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_upgrade_menu_builder() {
|
||||
MENU_LABELS=("Check for and apply updates")
|
||||
MENU_HANDLERS=(action_upgrade)
|
||||
}
|
||||
|
||||
advanced_upgrade_menu() {
|
||||
run_menu "UPGRADE" advanced_upgrade_menu_builder advanced_upgrade_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_upgrade() {
|
||||
echo
|
||||
if ! command -v git &>/dev/null; then
|
||||
log_error "git is not installed - can't check for updates"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
log_error "$SCRIPT_DIR is not a git checkout - re-clone the repo to get this feature"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$SCRIPT_DIR" status --porcelain)" ]]; then
|
||||
log_error "Local changes in $SCRIPT_DIR - commit or discard them first, then retry"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Checking for updates..."
|
||||
if ! git -C "$SCRIPT_DIR" fetch origin; then
|
||||
log_error "Could not reach GitHub - check your internet connection"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local branch local_rev remote_rev
|
||||
branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD)
|
||||
local_rev=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
||||
remote_rev=$(git -C "$SCRIPT_DIR" rev-parse "origin/$branch" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$remote_rev" ]]; then
|
||||
log_error "Could not find origin/$branch - is this checkout tracking a real branch?"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$local_rev" == "$remote_rev" ]]; then
|
||||
log_success "Already up to date ($branch @ ${local_rev:0:8})"
|
||||
else
|
||||
echo
|
||||
echo "Changes available:"
|
||||
git -C "$SCRIPT_DIR" --no-pager log --oneline "${local_rev}..${remote_rev}"
|
||||
echo
|
||||
if ask_yes_no "Pull these changes and re-apply setup (packages, app files, hardware config)?" "y"; then
|
||||
if ! git -C "$SCRIPT_DIR" pull --ff-only origin "$branch"; then
|
||||
log_error "Pull failed (not a fast-forward) - resolve manually in $SCRIPT_DIR"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
log_success "Pulled latest code ($branch @ $(git -C "$SCRIPT_DIR" rev-parse --short HEAD))"
|
||||
|
||||
echo
|
||||
log_info "Re-applying setup with the updated code..."
|
||||
provision_install_packages
|
||||
provision_create_kiosk_user
|
||||
provision_install_nodejs
|
||||
|
||||
# Bare call, not `if ! provision_install_app; then`: testing a
|
||||
# multi-statement function as an if-condition exempts
|
||||
# everything inside it from set -e for the duration (see
|
||||
# lib/provision.sh's own call to this same function). $? is
|
||||
# captured right after instead - accurate either way, and
|
||||
# doesn't add a new exemption on top of the one this action
|
||||
# already has from being invoked through run_menu's dispatch.
|
||||
provision_install_app
|
||||
local app_rc=$?
|
||||
if [[ $app_rc -ne 0 ]]; then
|
||||
log_error "Upgrade stopped - app reinstall failed, see above"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
provision_configure_display
|
||||
provision_configure_firewall
|
||||
provision_configure_power_management
|
||||
log_success "Setup refreshed"
|
||||
|
||||
echo
|
||||
if ask_yes_no "Restart kiosk display now to apply changes?" "y"; then
|
||||
sudo systemctl restart lightdm
|
||||
else
|
||||
log_info "Restart later with: sudo systemctl restart lightdm"
|
||||
fi
|
||||
else
|
||||
echo "Cancelled"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
if ask_yes_no "Check for and install the latest Electron version too?" "y"; then
|
||||
action_update_electron
|
||||
return
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_virtual_consoles.sh - "Virtual Consoles" (Advanced): toggle
|
||||
# Ctrl+Alt+F1-F8 terminal login access for troubleshooting.
|
||||
#
|
||||
# Real system state: masks/unmasks the getty@ttyN systemd units and writes
|
||||
# a fixed-path X11 server-flags file. Neither is relocatable (X11 only
|
||||
# reads /etc/X11/xorg.conf.d/, and getty units are always system units),
|
||||
# so tests use full command-level `sudo` stubbing, same approach as CUPS.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
vconsoles_are_disabled() {
|
||||
local getty_masked=false
|
||||
local vt_switch_disabled=false
|
||||
|
||||
if systemctl is-masked --quiet getty@tty1.service 2>/dev/null; then
|
||||
getty_masked=true
|
||||
fi
|
||||
|
||||
if [[ -f /etc/X11/xorg.conf.d/10-serverflags.conf ]] && \
|
||||
grep -q 'Option.*"DontVTSwitch".*"true"' /etc/X11/xorg.conf.d/10-serverflags.conf 2>/dev/null; then
|
||||
vt_switch_disabled=true
|
||||
fi
|
||||
|
||||
[[ "$getty_masked" == "true" || "$vt_switch_disabled" == "true" ]]
|
||||
}
|
||||
|
||||
advanced_virtual_consoles_status() {
|
||||
if vconsoles_are_disabled; then
|
||||
echo "Virtual consoles: Disabled"
|
||||
else
|
||||
echo "Virtual consoles: Enabled"
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_virtual_consoles_menu_builder() {
|
||||
if vconsoles_are_disabled; then
|
||||
MENU_LABELS=("Enable virtual consoles (Ctrl+Alt+F1-F8 for manual login)")
|
||||
MENU_HANDLERS=(action_enable_virtual_consoles)
|
||||
else
|
||||
MENU_LABELS=("Disable virtual consoles (more secure, kiosk only)")
|
||||
MENU_HANDLERS=(action_disable_virtual_consoles)
|
||||
fi
|
||||
}
|
||||
|
||||
advanced_virtual_consoles_menu() {
|
||||
run_menu "VIRTUAL CONSOLES" advanced_virtual_consoles_menu_builder advanced_virtual_consoles_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_enable_virtual_consoles() {
|
||||
echo
|
||||
echo "Enabling virtual consoles..."
|
||||
|
||||
for i in {1..8}; do
|
||||
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
|
||||
done
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
|
||||
Section "ServerFlags"
|
||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||
Option "DontZap" "true"
|
||||
|
||||
# ALLOW VT switching (Ctrl+Alt+F1-F12)
|
||||
Option "DontVTSwitch" "false"
|
||||
|
||||
# Don't allow clients to disconnect on exit
|
||||
Option "AllowClosedownGrabs" "false"
|
||||
EndSection
|
||||
EOF
|
||||
|
||||
log_success "Virtual consoles enabled"
|
||||
echo " Access with Ctrl+Alt+F1 through Ctrl+Alt+F8"
|
||||
echo " (Ctrl+Alt+F7 typically returns to the kiosk)"
|
||||
echo
|
||||
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
|
||||
sudo systemctl restart lightdm
|
||||
else
|
||||
log_warning "Remember to restart: sudo systemctl restart lightdm"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_disable_virtual_consoles() {
|
||||
echo
|
||||
ask_yes_no "Disable all virtual consoles?" "n" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
echo "Disabling virtual consoles..."
|
||||
|
||||
for i in {1..8}; do
|
||||
sudo systemctl mask "getty@tty${i}.service" 2>/dev/null || true
|
||||
done
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
sudo tee /etc/X11/xorg.conf.d/10-serverflags.conf > /dev/null <<'EOF'
|
||||
Section "ServerFlags"
|
||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||
Option "DontZap" "true"
|
||||
|
||||
# DISABLE VT switching (Ctrl+Alt+F1-F12)
|
||||
Option "DontVTSwitch" "true"
|
||||
|
||||
# Don't allow clients to disconnect on exit
|
||||
Option "AllowClosedownGrabs" "false"
|
||||
EndSection
|
||||
EOF
|
||||
|
||||
log_success "Virtual consoles disabled"
|
||||
echo " You can re-enable them from this menu at any time."
|
||||
echo
|
||||
if ask_yes_no "Restart kiosk display now to apply?" "n"; then
|
||||
sudo systemctl restart lightdm
|
||||
else
|
||||
log_warning "Remember to restart: sudo systemctl restart lightdm"
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/clone_settings.sh - "Clone Settings" (Advanced): export the portable
|
||||
# parts of this kiosk's configuration to a JSON file, and apply that file
|
||||
# to other already-installed kiosks - for standing up several kiosks that
|
||||
# should share the same sites/settings.
|
||||
#
|
||||
# This is deliberately an MVP, not the legacy Export/Import Settings
|
||||
# redesigned 1:1. It moves only what's actually safe to copy between
|
||||
# machines automatically:
|
||||
# - config.json's portable fields (sites, display/touch, navigation,
|
||||
# lockout, password protection) - plain data, no machine binding.
|
||||
# - Which addons were present at export time, as an informational
|
||||
# checklist on apply - NOT automated installation. That's a
|
||||
# deliberately separate, bigger follow-up (each addon would need a
|
||||
# non-interactive install variant, mirroring the *_do_uninstall
|
||||
# helpers Complete Uninstall already composes).
|
||||
#
|
||||
# Explicitly NOT exported, because copying them would be actively wrong,
|
||||
# not just incomplete:
|
||||
# - Authelia credentials: encrypted with a key derived from this
|
||||
# machine's /etc/machine-id (addon_authelia.sh) - decrypts to
|
||||
# garbage on any other machine.
|
||||
# - WireGuard/VPN identity: a private key is that device's identity;
|
||||
# reusing one across machines is a peer conflict, not a saving.
|
||||
# - Asterisk Intercom's SIP extension: most PBXes reject two
|
||||
# simultaneous registrations to the same extension.
|
||||
# Apply prints all three as an explicit "needs a human" checklist rather
|
||||
# than silently skipping them.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh
|
||||
# being sourced first (for the *_is_installed detection helpers).
|
||||
################################################################################
|
||||
|
||||
clone_settings_status() {
|
||||
echo "Exports/applies sites, display, navigation, and lockout settings"
|
||||
echo "between already-installed kiosks. Addon credentials that are"
|
||||
echo "bound to one machine (Authelia, WireGuard, Asterisk Intercom)"
|
||||
echo "are never copied - see the checklist after applying a profile."
|
||||
}
|
||||
|
||||
clone_settings_menu_builder() {
|
||||
MENU_LABELS=("Export settings" "Apply settings (clone)")
|
||||
MENU_HANDLERS=(action_export_clone_settings action_apply_clone_settings)
|
||||
}
|
||||
|
||||
clone_settings_menu() {
|
||||
run_menu "CLONE SETTINGS" clone_settings_menu_builder clone_settings_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Helpers
|
||||
################################################################################
|
||||
|
||||
# JSON array of addon identifiers currently present on this machine.
|
||||
clone_detect_addons() {
|
||||
local addons=()
|
||||
|
||||
cups_is_installed 2>/dev/null && addons+=("cups")
|
||||
lms_is_installed 2>/dev/null && addons+=("lms")
|
||||
squeezelite_is_installed 2>/dev/null && addons+=("squeezelite")
|
||||
is_service_active x11vnc 2>/dev/null && addons+=("vnc")
|
||||
command -v wg &>/dev/null && addons+=("wireguard")
|
||||
command -v tailscale &>/dev/null && addons+=("tailscale")
|
||||
command -v netbird &>/dev/null && addons+=("netbird")
|
||||
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
||||
webui_is_installed 2>/dev/null && addons+=("webui")
|
||||
library_is_installed 2>/dev/null && addons+=("library")
|
||||
|
||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
local authelia_url
|
||||
authelia_url=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || true)
|
||||
[[ -n "$authelia_url" ]] && addons+=("authelia")
|
||||
fi
|
||||
|
||||
if [[ "${#addons[@]}" -eq 0 ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
printf '%s\n' "${addons[@]}" | jq -R . | jq -s . || echo "[]"
|
||||
fi
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_export_clone_settings() {
|
||||
echo
|
||||
if ! sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
log_error "config.json not found at $CONFIG_PATH - configure sites/settings first"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local out_path
|
||||
out_path=$(ask_text "Export profile to" "$HOME/kiosk-clone-settings.json")
|
||||
|
||||
local raw_config
|
||||
raw_config=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||
if ! echo "$raw_config" | jq empty 2>/dev/null; then
|
||||
log_error "config.json is not valid JSON - cannot export"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local settings
|
||||
settings=$(echo "$raw_config" | jq 'del(.autheliaURL, .autheliaUsername, .autheliaEncryptedPassword)')
|
||||
|
||||
local addons_present
|
||||
addons_present=$(clone_detect_addons)
|
||||
|
||||
jq -n \
|
||||
--argjson settings "$settings" \
|
||||
--argjson addons "$addons_present" \
|
||||
--arg script_version "${SCRIPT_VERSION:-unknown}" \
|
||||
'{profile_version: 1, script_version: $script_version, settings: $settings, addons_present: $addons}' \
|
||||
> "$out_path"
|
||||
|
||||
log_success "Settings exported to $out_path"
|
||||
echo
|
||||
echo "Included: sites, display/touch/navigation settings, lockout,"
|
||||
echo "password protection (SHA-256 hash only)."
|
||||
echo
|
||||
echo "NOT included (needs fresh setup on each new device):"
|
||||
echo " - Authelia credentials (encrypted per-machine, won't decrypt elsewhere)"
|
||||
echo " - WireGuard/VPN keys (each device needs its own identity)"
|
||||
echo " - Asterisk Intercom extension (most PBXes reject duplicate registrations)"
|
||||
|
||||
pause
|
||||
}
|
||||
|
||||
action_apply_clone_settings() {
|
||||
echo
|
||||
local in_path
|
||||
in_path=$(ask_text "Profile file to apply" "")
|
||||
if [[ -z "$in_path" ]]; then
|
||||
echo "Cancelled"
|
||||
pause
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "$in_path" ]]; then
|
||||
log_error "File not found: $in_path"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
if ! jq empty "$in_path" 2>/dev/null; then
|
||||
log_error "Not valid JSON: $in_path"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local settings addons_present
|
||||
settings=$(jq -c '.settings // {}' "$in_path" || echo '{}')
|
||||
addons_present=$(jq -r '.addons_present[]? // empty' "$in_path" || true)
|
||||
|
||||
echo "Profile summary:"
|
||||
echo " Sites: $(echo "$settings" | jq '.tabs | length // 0')"
|
||||
echo " Addons expected: $(echo "$addons_present" | tr '\n' ' ')"
|
||||
echo
|
||||
ask_yes_no "Apply this profile? This overwrites current sites/display/lockout settings." "n" || { echo "Cancelled"; pause; return; }
|
||||
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"
|
||||
|
||||
local existing="{}"
|
||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||
existing=$(sudo -u "$KIOSK_USER" cat "$CONFIG_PATH" 2>/dev/null)
|
||||
echo "$existing" | jq empty 2>/dev/null || existing="{}"
|
||||
fi
|
||||
|
||||
# Merge, not replace - same reasoning as save_config: this machine's
|
||||
# own Authelia fields (never in the exported settings blob) must
|
||||
# survive an apply untouched. Guarded: $settings came from an
|
||||
# external file - a corrupted/hand-edited profile whose "settings"
|
||||
# key isn't a JSON object must not be allowed to crash the session.
|
||||
local merged
|
||||
if ! merged=$(echo "$existing" | jq --argjson s "$settings" '. + $s' 2>/dev/null); then
|
||||
log_error "Profile's settings are not a valid JSON object - nothing was changed"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
echo "$merged" > "$tmp"
|
||||
sudo -u "$KIOSK_USER" bash -c "cat > '$CONFIG_PATH'" < "$tmp"
|
||||
sudo -u "$KIOSK_USER" chmod 644 "$CONFIG_PATH"
|
||||
rm -f "$tmp"
|
||||
|
||||
log_success "Settings applied"
|
||||
|
||||
echo
|
||||
echo "Addon checklist (from the exported profile):"
|
||||
local missing_any=false
|
||||
if [[ -z "$addons_present" ]]; then
|
||||
echo " (profile recorded no addons)"
|
||||
fi
|
||||
while IFS= read -r addon; do
|
||||
[[ -z "$addon" ]] && continue
|
||||
case "$addon" in
|
||||
cups)
|
||||
if cups_is_installed; then
|
||||
echo " [x] CUPS Printing - already installed"
|
||||
else
|
||||
echo " [ ] CUPS Printing - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
lms)
|
||||
if lms_is_installed; then
|
||||
echo " [x] LMS Server - already installed"
|
||||
else
|
||||
echo " [ ] LMS Server - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
squeezelite)
|
||||
if squeezelite_is_installed; then
|
||||
echo " [x] Squeezelite Player - already installed"
|
||||
else
|
||||
echo " [ ] Squeezelite Player - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
vnc)
|
||||
if is_service_active x11vnc; then
|
||||
echo " [x] VNC - already installed"
|
||||
else
|
||||
echo " [ ] VNC - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
wireguard)
|
||||
echo " [!] WireGuard - needs a NEW keypair/peer on this device, never clone the key" ;;
|
||||
tailscale)
|
||||
if command -v tailscale &>/dev/null; then
|
||||
echo " [x] Tailscale - installed (connect with a reusable auth key if not yet connected)"
|
||||
else
|
||||
echo " [ ] Tailscale - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
netbird)
|
||||
if command -v netbird &>/dev/null; then
|
||||
echo " [x] Netbird - installed (connect with a reusable setup key if not yet connected)"
|
||||
else
|
||||
echo " [ ] Netbird - not installed, install via Addons"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
asterisk_intercom)
|
||||
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
||||
library)
|
||||
if library_is_installed; then
|
||||
echo " [x] Library Catalog - already installed (the books themselves are not cloned)"
|
||||
else
|
||||
echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)"
|
||||
missing_any=true
|
||||
fi ;;
|
||||
authelia)
|
||||
echo " [!] Authelia - needs a fresh login on this device" ;;
|
||||
*)
|
||||
echo " [?] $addon - unrecognized entry in profile" ;;
|
||||
esac
|
||||
done <<< "$addons_present"
|
||||
|
||||
if $missing_any; then
|
||||
echo
|
||||
echo "Install anything marked \"not installed\" above via the Addons menu."
|
||||
fi
|
||||
|
||||
pause
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/complete_uninstall.sh - "Complete Uninstall" (Core Settings): full
|
||||
# teardown, returning the machine to its pre-kiosk state.
|
||||
#
|
||||
# Composed from every other addon's own silent uninstall helper
|
||||
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
||||
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
||||
# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all,
|
||||
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
||||
# re-implementing removal logic for each addon a second time here - if an
|
||||
# addon's uninstall logic changes, this picks it up automatically. Only
|
||||
# the pieces no single addon owns - the kiosk user/files, Node.js/
|
||||
# LightDM/Openbox, polkit rules, leftover systemd units - are handled
|
||||
# directly below, same as the legacy script.
|
||||
#
|
||||
# Ordering matters: every addon teardown runs before the kiosk user is
|
||||
# removed, because asterisk_intercom_do_uninstall still needs
|
||||
# `id -u "$KIOSK_USER"` to resolve that user's systemd --user session.
|
||||
#
|
||||
# After this runs, the kiosk user (and therefore is_kiosk_installed) is
|
||||
# gone - install.sh itself will refuse to start against this machine
|
||||
# again until a fresh install re-provisions it. That's intentional:
|
||||
# there is nothing left here for this tool to manage.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, and every menus/addon_*.sh /
|
||||
# menus/power_schedule.sh / menus/advanced_emergency_hotspot.sh being
|
||||
# sourced first (for the *_do_uninstall helpers above).
|
||||
################################################################################
|
||||
|
||||
complete_uninstall_status() {
|
||||
echo "⚠ Removes the kiosk user, every addon, and returns this machine"
|
||||
echo " to its pre-kiosk state. Cannot be undone."
|
||||
}
|
||||
|
||||
complete_uninstall_menu_builder() {
|
||||
MENU_LABELS=("Completely uninstall the kiosk")
|
||||
MENU_HANDLERS=(action_complete_uninstall)
|
||||
}
|
||||
|
||||
complete_uninstall_menu() {
|
||||
run_menu "COMPLETE UNINSTALL" complete_uninstall_menu_builder complete_uninstall_status
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_complete_uninstall() {
|
||||
echo
|
||||
echo "⚠️ This will COMPLETELY REMOVE:"
|
||||
echo " • Kiosk user and all data"
|
||||
echo " • All kiosk configuration and sites"
|
||||
echo " • All Electron/Node.js installations"
|
||||
echo " • All browser caches and data"
|
||||
echo " • CUPS printer system"
|
||||
echo " • Squeezelite and LMS (Lyrion Music Server)"
|
||||
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
||||
echo " • Asterisk Intercom (Baresip)"
|
||||
echo " • Web UI"
|
||||
echo " • Library Catalog (Koha) and ALL the books in it"
|
||||
echo " • LightDM and Openbox"
|
||||
echo " • All kiosk schedules and services"
|
||||
echo " • Emergency hotspot configuration"
|
||||
echo " • LAN time server setting (back to Ubuntu's defaults)"
|
||||
echo
|
||||
echo "⚠️ This CANNOT be undone!"
|
||||
echo
|
||||
local confirm
|
||||
confirm=$(ask_text "Type UNINSTALL to confirm" "")
|
||||
if [[ "$confirm" != "UNINSTALL" ]]; then
|
||||
echo "Cancelled"
|
||||
pause
|
||||
return
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Beginning complete uninstall..."
|
||||
|
||||
echo "[1/12] Stopping kiosk display..."
|
||||
sudo systemctl stop lightdm 2>/dev/null || true
|
||||
|
||||
echo "[2/12] Removing addons..."
|
||||
cups_do_uninstall
|
||||
vnc_do_uninstall
|
||||
wireguard_do_uninstall
|
||||
tailscale_do_uninstall
|
||||
netbird_do_uninstall
|
||||
lms_do_uninstall purge
|
||||
squeezelite_do_uninstall
|
||||
asterisk_intercom_do_uninstall purge
|
||||
webui_do_uninstall
|
||||
library_do_uninstall purge
|
||||
|
||||
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
||||
power_schedule_do_remove_all
|
||||
emergency_hotspot_do_disable
|
||||
time_sync_do_reset
|
||||
|
||||
# Must come after every addon teardown above - Asterisk Intercom's
|
||||
# helper still needs this user to resolve its systemd --user session.
|
||||
echo "[4/12] Removing kiosk user..."
|
||||
if id "$KIOSK_USER" &>/dev/null; then
|
||||
sudo pkill -u "$KIOSK_USER" 2>/dev/null || true
|
||||
sudo userdel -r "$KIOSK_USER" 2>/dev/null || true
|
||||
log_success "Kiosk user removed"
|
||||
fi
|
||||
|
||||
echo "[5/12] Removing kiosk files..."
|
||||
sudo rm -rf "$KIOSK_DIR"
|
||||
sudo rm -rf "$KIOSK_HOME"
|
||||
|
||||
echo "[6/12] Removing remaining systemd units..."
|
||||
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
|
||||
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
echo "[7/12] Removing remaining scripts..."
|
||||
sudo rm -f "$BIN_DIR"/kiosk-*
|
||||
sudo rm -f "$SUDOERS_D_DIR/kiosk-wifi"
|
||||
sudo rm -f /etc/udev/rules.d/99-kiosk-hotplug.rules
|
||||
sudo udevadm control --reload-rules 2>/dev/null || true
|
||||
|
||||
echo "[8/12] Removing Node.js..."
|
||||
sudo apt-get purge -y nodejs npm 2>/dev/null || true
|
||||
sudo rm -rf /usr/local/lib/node_modules
|
||||
sudo rm -rf /usr/local/bin/node
|
||||
sudo rm -rf /usr/local/bin/npm
|
||||
|
||||
echo "[9/12] Removing LightDM and Openbox..."
|
||||
sudo systemctl disable lightdm 2>/dev/null || true
|
||||
sudo apt-get purge -y lightdm openbox 2>/dev/null || true
|
||||
|
||||
echo "[10/12] Removing polkit rules..."
|
||||
sudo rm -f "$POLKIT_DIR/kiosk-power.pkla"
|
||||
sudo rm -f "$POLKIT_DIR/kiosk-printing.pkla"
|
||||
|
||||
echo "[11/12] Re-enabling virtual consoles..."
|
||||
for i in {1..8}; do
|
||||
sudo systemctl unmask "getty@tty${i}.service" 2>/dev/null || true
|
||||
done
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
echo "[12/12] Cleaning up packages..."
|
||||
sudo apt-get autoremove -y 2>/dev/null || true
|
||||
sudo apt-get autoclean 2>/dev/null || true
|
||||
|
||||
echo
|
||||
log_success "Kiosk completely uninstalled"
|
||||
echo "The system has been returned to its pre-kiosk state."
|
||||
echo "You may want to reboot to ensure all changes take effect."
|
||||
echo
|
||||
if ask_yes_no "Reboot now?" "n"; then
|
||||
echo "Rebooting in 3 seconds..."
|
||||
sleep 3
|
||||
sudo reboot
|
||||
fi
|
||||
}
|
||||
+19
-4
@@ -6,6 +6,12 @@
|
||||
# flat PIN file rather than config.json - a fourth shape for the framework
|
||||
# to prove out (plain file, not JSON at all).
|
||||
#
|
||||
# The file holds "NOPIN", or "sha256:<hex>" - the PIN is never stored as
|
||||
# plain digits (a file from before that is converted by the kiosk app at
|
||||
# startup; plain digits are still accepted until then). No file = the
|
||||
# default PIN 1234. The kiosk app reads it on every check, so changes
|
||||
# apply immediately, and slows down repeated wrong guesses.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
@@ -22,8 +28,10 @@ hidden_pin_status() {
|
||||
current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null)
|
||||
if [[ "$current_pin" == "NOPIN" ]]; then
|
||||
echo "Current: no PIN (hidden pages open to anyone)"
|
||||
elif [[ "$current_pin" == "sha256:$(hidden_pin_hash 1234)" || "$current_pin" == "1234" ]]; then
|
||||
echo "Current: default PIN (1234) - set your own"
|
||||
else
|
||||
echo "Current: PIN set (${#current_pin} digits)"
|
||||
echo "Current: PIN set"
|
||||
fi
|
||||
else
|
||||
echo "Current: not configured (default: 1234)"
|
||||
@@ -43,29 +51,36 @@ hidden_pin_menu() {
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
hidden_pin_hash() {
|
||||
echo -n "$1" | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# write_pin VALUE - VALUE is "NOPIN" or the PIN digits (stored hashed).
|
||||
write_pin() {
|
||||
local value="$1"
|
||||
local pin_file
|
||||
pin_file=$(hidden_pin_file)
|
||||
|
||||
[[ "$value" == "NOPIN" ]] || value="sha256:$(hidden_pin_hash "$value")"
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
|
||||
sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
|
||||
log_warning "Restart the kiosk display for this to take effect"
|
||||
}
|
||||
|
||||
action_set_pin() {
|
||||
echo
|
||||
local new_pin confirm_pin
|
||||
while true; do
|
||||
read -r -p "Enter new PIN (4-8 digits): " new_pin
|
||||
read -r -s -p "Enter new PIN (4-8 digits): " new_pin
|
||||
echo
|
||||
|
||||
if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then
|
||||
echo "❌ PIN must be 4-8 digits"
|
||||
continue
|
||||
fi
|
||||
|
||||
read -r -p "Confirm PIN: " confirm_pin
|
||||
read -r -s -p "Confirm PIN: " confirm_pin
|
||||
echo
|
||||
|
||||
if [[ "$new_pin" == "$confirm_pin" ]]; then
|
||||
write_pin "$new_pin"
|
||||
|
||||
+109
-24
@@ -2,6 +2,11 @@
|
||||
################################################################################
|
||||
# menus/lockout.sh - "Password Protection & Lockout" menu.
|
||||
#
|
||||
# The unlock code and the (optional, separate) boot code are each either
|
||||
# a password or a 4-8 digit PIN - the kiosk's own codes, independent of
|
||||
# the admin account's sudo password. A PIN gets an on-screen number pad
|
||||
# on the lock screen, so it works on a touch-only kiosk.
|
||||
#
|
||||
# Fifth menu migrated. Back to config.json (like Display), but with a
|
||||
# sensitive field: the lockout password is SHA-256 hashed before it's
|
||||
# ever written to disk (matching the Electron app's comparison logic in
|
||||
@@ -31,7 +36,11 @@ lockout_status() {
|
||||
else
|
||||
echo "Daily lock time: not set"
|
||||
fi
|
||||
echo "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
|
||||
echo "Unlock code: $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
|
||||
echo "Lock on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
|
||||
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
|
||||
echo "Boot code: $(lockout_boot_code_label)"
|
||||
fi
|
||||
else
|
||||
echo "Password protection: disabled"
|
||||
fi
|
||||
@@ -40,10 +49,11 @@ lockout_status() {
|
||||
lockout_menu_builder() {
|
||||
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
|
||||
MENU_LABELS=(
|
||||
"Change lockout password"
|
||||
"Change unlock password/PIN (currently: $(lockout_code_label "$LOCKOUT_CODE_TYPE"))"
|
||||
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
|
||||
"Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})"
|
||||
"Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
|
||||
"Toggle lock on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
|
||||
"Set boot password/PIN (currently: $(lockout_boot_code_label))"
|
||||
"Disable password protection"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
@@ -51,6 +61,7 @@ lockout_menu_builder() {
|
||||
action_change_timeout
|
||||
action_change_daily_lock
|
||||
action_toggle_boot_password
|
||||
action_set_boot_code
|
||||
action_disable_protection
|
||||
)
|
||||
else
|
||||
@@ -68,31 +79,74 @@ lockout_menu() {
|
||||
# Shared helpers
|
||||
################################################################################
|
||||
|
||||
# Prompts for a new password twice, hashes it, and assigns to
|
||||
# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up.
|
||||
prompt_and_hash_password() {
|
||||
local pass1 pass2
|
||||
# "PIN" / "password" for menus and status lines.
|
||||
lockout_code_label() {
|
||||
[[ "$1" == "pin" ]] && echo "PIN" || echo "password"
|
||||
}
|
||||
|
||||
lockout_boot_code_label() {
|
||||
if [[ -z "$BOOT_PASSWORD" ]]; then
|
||||
echo "same as unlock"
|
||||
else
|
||||
echo "separate $(lockout_code_label "$BOOT_CODE_TYPE")"
|
||||
fi
|
||||
}
|
||||
|
||||
# True if $1 is a valid kiosk PIN: 4-8 digits.
|
||||
lockout_valid_pin() {
|
||||
[[ "$1" =~ ^[0-9]{4,8}$ ]]
|
||||
}
|
||||
|
||||
# prompt_code WHAT - asks whether WHAT (e.g. "unlock") is a password or a
|
||||
# 4-8 digit PIN, then for the code itself twice. Sets CODE_TYPE ("password"
|
||||
# or "pin") and CODE_HASH (SHA-256 hex, matching the Electron app's check
|
||||
# in main.js - never plaintext). These are the kiosk's own codes, not
|
||||
# any Linux account's password.
|
||||
prompt_code() {
|
||||
local what="$1" choice
|
||||
echo "How should the $what code be entered?"
|
||||
echo " 1) Password (letters, numbers, anything - needs a keyboard)"
|
||||
echo " 2) PIN (4-8 digits - on-screen number pad, works on a touch screen)"
|
||||
while true; do
|
||||
read -r -s -p "Enter password: " pass1
|
||||
echo
|
||||
read -r -s -p "Confirm password: " pass2
|
||||
echo
|
||||
read -r -p "Choose [1-2]: " choice
|
||||
case "$choice" in
|
||||
1) CODE_TYPE="password"; break ;;
|
||||
2) CODE_TYPE="pin"; break ;;
|
||||
*) echo "❌ Enter 1 or 2" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local label pass1 pass2
|
||||
label=$(lockout_code_label "$CODE_TYPE")
|
||||
while true; do
|
||||
read -r -s -p "Enter $what $label: " pass1
|
||||
echo
|
||||
if [[ -z "$pass1" ]]; then
|
||||
echo "❌ Password cannot be empty"
|
||||
echo "❌ The $label cannot be empty"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$CODE_TYPE" == "pin" ]] && ! lockout_valid_pin "$pass1"; then
|
||||
echo "❌ A PIN is 4-8 digits (0-9 only)"
|
||||
continue
|
||||
fi
|
||||
read -r -s -p "Confirm $what $label: " pass2
|
||||
echo
|
||||
if [[ "$pass1" != "$pass2" ]]; then
|
||||
echo "❌ Passwords don't match, try again"
|
||||
echo "❌ They don't match, try again"
|
||||
continue
|
||||
fi
|
||||
|
||||
LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||
CODE_HASH=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
# Prompts for the unlock code into LOCKOUT_PASSWORD/LOCKOUT_CODE_TYPE.
|
||||
prompt_and_hash_password() {
|
||||
prompt_code "unlock"
|
||||
LOCKOUT_CODE_TYPE="$CODE_TYPE"
|
||||
LOCKOUT_PASSWORD="$CODE_HASH"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
@@ -101,13 +155,14 @@ action_enable_protection() {
|
||||
echo
|
||||
echo "Add password protection with automatic lockout:"
|
||||
echo " • Blank screen after an inactivity period"
|
||||
echo " • Password required to unlock"
|
||||
echo " • Password required after display schedule wake-up"
|
||||
echo " • Password or PIN required to unlock"
|
||||
echo " • Required after display schedule wake-up"
|
||||
echo " • Optional: lock at a specific time daily"
|
||||
echo " • Optional: require password on system boot"
|
||||
echo " • Optional: locked on system boot, with the same or its own code"
|
||||
echo
|
||||
echo "These are the kiosk's own codes - not your admin (sudo) password."
|
||||
echo
|
||||
|
||||
echo "Set lockout password:"
|
||||
prompt_and_hash_password
|
||||
|
||||
echo
|
||||
@@ -123,8 +178,16 @@ action_enable_protection() {
|
||||
fi
|
||||
|
||||
echo
|
||||
if ask_yes_no "Require password on system boot/power on?" "y"; then
|
||||
BOOT_PASSWORD=""
|
||||
BOOT_CODE_TYPE="password"
|
||||
if ask_yes_no "Lock the kiosk on system boot/power on?" "y"; then
|
||||
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||
echo
|
||||
if ask_yes_no "Use a different password/PIN at boot than for unlocking?" "n"; then
|
||||
prompt_code "boot"
|
||||
BOOT_CODE_TYPE="$CODE_TYPE"
|
||||
BOOT_PASSWORD="$CODE_HASH"
|
||||
fi
|
||||
else
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
fi
|
||||
@@ -140,6 +203,9 @@ action_disable_protection() {
|
||||
LOCKOUT_TIMEOUT=0
|
||||
LOCKOUT_AT_TIME=""
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
LOCKOUT_CODE_TYPE="password"
|
||||
BOOT_PASSWORD=""
|
||||
BOOT_CODE_TYPE="password"
|
||||
log_success "Password protection disabled"
|
||||
save_config
|
||||
}
|
||||
@@ -147,7 +213,7 @@ action_disable_protection() {
|
||||
action_change_password() {
|
||||
echo
|
||||
prompt_and_hash_password
|
||||
log_success "Password updated"
|
||||
log_success "Unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE") updated"
|
||||
save_config
|
||||
}
|
||||
|
||||
@@ -178,10 +244,29 @@ action_change_daily_lock() {
|
||||
action_toggle_boot_password() {
|
||||
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
log_warning "Password on boot disabled"
|
||||
log_warning "Lock on boot disabled"
|
||||
else
|
||||
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||
log_success "Password on boot enabled"
|
||||
log_success "Lock on boot enabled (boot code: $(lockout_boot_code_label))"
|
||||
fi
|
||||
save_config
|
||||
}
|
||||
|
||||
action_set_boot_code() {
|
||||
echo
|
||||
echo "The boot lock screen can use its own password/PIN, or the unlock one."
|
||||
echo "Currently: $(lockout_boot_code_label)"
|
||||
echo
|
||||
if ask_yes_no "Use a separate password/PIN at boot?" "y"; then
|
||||
prompt_code "boot"
|
||||
BOOT_CODE_TYPE="$CODE_TYPE"
|
||||
BOOT_PASSWORD="$CODE_HASH"
|
||||
REQUIRE_PASSWORD_ON_BOOT="true"
|
||||
log_success "Boot $(lockout_code_label "$BOOT_CODE_TYPE") set (lock on boot: on)"
|
||||
else
|
||||
BOOT_PASSWORD=""
|
||||
BOOT_CODE_TYPE="password"
|
||||
log_success "Boot uses the unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
|
||||
fi
|
||||
save_config
|
||||
}
|
||||
|
||||
@@ -628,7 +628,12 @@ action_disable_electron_reload() {
|
||||
action_remove_all_schedules() {
|
||||
echo
|
||||
ask_yes_no "Remove ALL power/display/quiet/reload schedules?" "n" || { echo "Cancelled"; return; }
|
||||
power_schedule_do_remove_all
|
||||
}
|
||||
|
||||
# The actual removal, no prompt - shared with Complete Uninstall so that
|
||||
# operation doesn't need to re-implement schedule teardown a second time.
|
||||
power_schedule_do_remove_all() {
|
||||
for timer in kiosk-shutdown kiosk-display-off kiosk-display-on kiosk-quiet-start kiosk-quiet-end kiosk-electron-reload; do
|
||||
sudo systemctl stop "${timer}.timer" 2>/dev/null || true
|
||||
sudo systemctl disable "${timer}.timer" 2>/dev/null || true
|
||||
|
||||
+17
-2
@@ -18,11 +18,23 @@ SITE_EDIT_IDX=""
|
||||
################################################################################
|
||||
|
||||
# Normalize whatever the user typed into a URL, same rules the old
|
||||
# installer used: bare host -> https://, bare IP -> http://.
|
||||
# installer used: bare host -> https://, bare IP -> http://. Plus local
|
||||
# files on the kiosk itself (pages, PDFs, images): an absolute path ->
|
||||
# file://, percent-encoded the same way webui/server.js's parseUrl does.
|
||||
sites_parse_url() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^https?:// ]]; then
|
||||
if [[ "$raw" =~ ^(https?|file):// ]]; then
|
||||
echo "$raw"
|
||||
elif [[ "$raw" == /* ]]; then
|
||||
local LC_ALL=C out="" c i
|
||||
for (( i = 0; i < ${#raw}; i++ )); do
|
||||
c="${raw:i:1}"
|
||||
case "$c" in
|
||||
[A-Za-z0-9._~/!\'\(\)*-]) out+="$c" ;;
|
||||
*) out+="$(printf '%%%02X' "'$c")" ;;
|
||||
esac
|
||||
done
|
||||
echo "file://$out"
|
||||
elif [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "http://${raw}"
|
||||
else
|
||||
@@ -124,6 +136,9 @@ action_add_page() {
|
||||
echo " 0 = manual only (swipe/nav menu to reach it)"
|
||||
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
|
||||
echo
|
||||
echo "URL: a web address, or a path on this kiosk - a file (page, PDF,"
|
||||
echo "image, Word .docx) like /home/kiosk/docs/menu.pdf, or a folder of"
|
||||
echo "images ending in / like /home/kiosk/photos/ for a slideshow."
|
||||
|
||||
local raw_url url dur name needs_auth user pass
|
||||
read -r -p "URL: " raw_url
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/time_sync.sh - "Time Server (NTP)" (Core Settings).
|
||||
#
|
||||
# The kiosk keeps its clock right with systemd-timesyncd, which by default
|
||||
# asks Ubuntu's time servers on the internet. On a network with no
|
||||
# internet that never works and the clock slowly drifts - which matters
|
||||
# here, because lockout times, quiet hours and power schedules all go by
|
||||
# it. This menu points timesyncd at a time server on the local network
|
||||
# instead (most routers, Windows domain controllers and NAS boxes can
|
||||
# serve NTP), or, with no time server at all, sets the clock by hand.
|
||||
#
|
||||
# Writes one drop-in, $TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf - removing it
|
||||
# (the "internet time servers" option, or Complete Uninstall via
|
||||
# time_sync_do_reset) puts Ubuntu's defaults back untouched.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
TIMESYNC_DROPIN="$TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf"
|
||||
|
||||
# The time server(s) this menu configured, or empty for Ubuntu's defaults.
|
||||
time_sync_servers() {
|
||||
sed -n 's/^NTP=//p' "$TIMESYNC_DROPIN" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
time_sync_status() {
|
||||
local servers synced
|
||||
servers=$(time_sync_servers)
|
||||
synced=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo "unknown")
|
||||
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
echo "Time servers: ${servers:-Ubuntu defaults (internet)}"
|
||||
echo "Synchronized: $([[ "$synced" == "yes" ]] && echo "yes" || echo "no")"
|
||||
}
|
||||
|
||||
time_sync_menu_builder() {
|
||||
MENU_LABELS=(
|
||||
"Use a time server on my network"
|
||||
"Use the internet time servers (Ubuntu default)"
|
||||
"Set the date and time manually"
|
||||
"Show sync details"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
action_time_sync_lan
|
||||
action_time_sync_default
|
||||
action_time_sync_manual
|
||||
action_time_sync_details
|
||||
)
|
||||
}
|
||||
|
||||
time_sync_menu() {
|
||||
run_menu "TIME SERVER (NTP)" time_sync_menu_builder time_sync_status
|
||||
}
|
||||
|
||||
# Validate a space-separated list of hostnames / IPv4 / IPv6 addresses.
|
||||
time_sync_valid_servers() {
|
||||
local s
|
||||
[[ -n "$1" ]] || return 1
|
||||
for s in $1; do
|
||||
[[ "$s" =~ ^[A-Za-z0-9.:-]+$ ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
time_sync_apply() {
|
||||
sudo systemctl enable systemd-timesyncd 2>/dev/null || true
|
||||
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||
sudo systemctl restart systemd-timesyncd 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Remove this menu's drop-in, back to Ubuntu's default time servers.
|
||||
# Also used by Complete Uninstall.
|
||||
time_sync_do_reset() {
|
||||
sudo rm -f "$TIMESYNC_DROPIN"
|
||||
time_sync_apply
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_time_sync_lan() {
|
||||
echo
|
||||
echo "Enter the address of a time (NTP) server on your network - often"
|
||||
echo "the router (e.g. 192.168.1.1), a Windows domain controller, or a"
|
||||
echo "NAS. Several can be given, separated by spaces."
|
||||
local current servers
|
||||
current=$(time_sync_servers)
|
||||
servers=$(ask_text "Time server(s)" "${current:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}")
|
||||
if ! time_sync_valid_servers "$servers"; then
|
||||
log_error "Not a valid address list: '$servers'"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$TIMESYNCD_CONF_DIR"
|
||||
sudo tee "$TIMESYNC_DROPIN" > /dev/null <<EOF
|
||||
# Written by ubuntu-based-kiosk (Core Settings -> Time Server).
|
||||
# Delete this file to go back to Ubuntu's default time servers.
|
||||
[Time]
|
||||
NTP=$servers
|
||||
EOF
|
||||
time_sync_apply
|
||||
|
||||
echo "Checking the time server (up to 15 seconds)..."
|
||||
local i
|
||||
for i in {1..15}; do
|
||||
if [[ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" == "yes" ]]; then
|
||||
log_success "Clock synchronized with $servers"
|
||||
pause
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log_warning "Saved, but no sync yet. timesyncd keeps retrying in the background."
|
||||
echo "If it never syncs, check the address and that the server answers"
|
||||
echo "NTP (UDP port 123): Core Settings -> Time Server -> Show sync details."
|
||||
pause
|
||||
}
|
||||
|
||||
action_time_sync_default() {
|
||||
echo
|
||||
if [[ ! -f "$TIMESYNC_DROPIN" ]]; then
|
||||
echo "Already using Ubuntu's default (internet) time servers."
|
||||
pause
|
||||
return
|
||||
fi
|
||||
time_sync_do_reset
|
||||
log_success "Back on Ubuntu's default (internet) time servers"
|
||||
pause
|
||||
}
|
||||
|
||||
# For a network with no time server at all: set the clock by hand.
|
||||
# Automatic sync stays on afterwards - it just has nothing to sync with
|
||||
# until a server becomes reachable, and then takes over again.
|
||||
action_time_sync_manual() {
|
||||
echo
|
||||
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
echo "Enter the correct local date and time (timezone: $(timedatectl show -p Timezone --value 2>/dev/null))."
|
||||
local when
|
||||
when=$(ask_text "Date and time (YYYY-MM-DD HH:MM)" "$(date '+%Y-%m-%d %H:%M')")
|
||||
if [[ ! "$when" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}(:[0-9]{2})?$ ]] || ! date -d "$when" &>/dev/null; then
|
||||
log_error "Not a valid date/time: '$when' (expected e.g. 2026-10-03 14:30)"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
[[ "$when" =~ :[0-9]{2}:[0-9]{2}$ ]] || when="$when:00"
|
||||
|
||||
# timedatectl refuses set-time while automatic sync is on.
|
||||
sudo timedatectl set-ntp false 2>/dev/null || true
|
||||
if sudo timedatectl set-time "$when"; then
|
||||
# Keep the hardware clock in step so the time survives a reboot.
|
||||
sudo hwclock --systohc 2>/dev/null || true
|
||||
log_success "Clock set to $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
else
|
||||
log_error "Couldn't set the clock"
|
||||
fi
|
||||
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||
pause
|
||||
}
|
||||
|
||||
action_time_sync_details() {
|
||||
echo
|
||||
timedatectl status 2>/dev/null || true
|
||||
echo
|
||||
timedatectl timesync-status 2>/dev/null || echo "(timesync-status not available)"
|
||||
pause
|
||||
}
|
||||
+300
-40
@@ -20,17 +20,249 @@
|
||||
# is exercised by hand against real hardware only.
|
||||
#
|
||||
# Unlike the other migrated menus, this one has no sub-options - it's a
|
||||
# single linear wizard, same as the legacy configure_wifi - so wifi_menu
|
||||
# IS the action, not a run_menu wrapper.
|
||||
# single linear wizard, same as the legacy configure_wifi. wifi_menu runs
|
||||
# it (wifi_configure) and then pauses: run_menu clears the screen as soon
|
||||
# as a handler returns, which used to wipe every error message here
|
||||
# before it could be read - the menu just seemed to bounce back.
|
||||
#
|
||||
# The WiFi config goes in its own file, $NETPLAN_DIR/60-kiosk-wifi.yaml,
|
||||
# which netplan merges with the rest - not over the first *.yaml it finds,
|
||||
# which on a fresh install is the installer's wired config.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||
|
||||
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
|
||||
# in every file and waits for that device - 90s, then the whole apply
|
||||
# fails - if one names an interface this machine doesn't have (the
|
||||
# installer's own config can carry e.g. "wlan0" while the adapter here is
|
||||
# "wlp1s0"). Take such entries out of the other netplan files first
|
||||
# (originals kept as *.kiosk-bak, which netplan ignores).
|
||||
wifi_drop_stale_entries() {
|
||||
local f
|
||||
for f in "$NETPLAN_DIR"/*.yaml; do
|
||||
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
|
||||
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
|
||||
import os, shutil, sys, yaml
|
||||
p = sys.argv[1]
|
||||
try:
|
||||
d = yaml.safe_load(open(p)) or {}
|
||||
w = (d.get("network") or {}).get("wifis") or {}
|
||||
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
|
||||
if stale:
|
||||
shutil.copy2(p, p + ".kiosk-bak")
|
||||
for n in stale:
|
||||
del w[n]
|
||||
if not w:
|
||||
del d["network"]["wifis"]
|
||||
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
|
||||
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
|
||||
for n in opt:
|
||||
w[n]["optional"] = True
|
||||
if stale or opt:
|
||||
if not stale:
|
||||
shutil.copy2(p, p + ".kiosk-bak")
|
||||
mode = os.stat(p).st_mode & 0o777
|
||||
with open(p, "w") as fh:
|
||||
yaml.safe_dump(d, fh, default_flow_style=False)
|
||||
os.chmod(p, mode)
|
||||
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
done
|
||||
}
|
||||
|
||||
# Why a connect attempt failed, from wpa_supplicant's own log lines in
|
||||
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
|
||||
# short human-readable reason, or nothing if the log says nothing useful.
|
||||
wifi_diagnose() {
|
||||
local log
|
||||
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
|
||||
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
|
||||
echo "wrong password (the network rejected the key)"
|
||||
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
|
||||
echo "network not found - wrong name, or out of range of this adapter"
|
||||
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
|
||||
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Every wireless interface, one per line.
|
||||
wifi_list_ifaces() {
|
||||
local d
|
||||
for d in /sys/class/net/*; do
|
||||
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||
basename "$d"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# "USB" / "PCI" / "" - what an interface hangs off, to tell a dongle
|
||||
# from a built-in card when there are two.
|
||||
wifi_iface_bus() {
|
||||
local sub
|
||||
sub=$(readlink -f "/sys/class/net/$1/device/subsystem" 2>/dev/null) || return 0
|
||||
case "$sub" in
|
||||
*/usb) echo "USB" ;;
|
||||
*/pci) echo "PCI" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The interface to configure: the only one, or the user's pick when there
|
||||
# are several (e.g. built-in card plus a USB dongle).
|
||||
wifi_choose_iface() {
|
||||
local -a ifaces
|
||||
mapfile -t ifaces < <(wifi_list_ifaces)
|
||||
if (( ${#ifaces[@]} == 0 )); then
|
||||
wifi_detect_iface
|
||||
return
|
||||
fi
|
||||
if (( ${#ifaces[@]} == 1 )); then
|
||||
echo "${ifaces[0]}"
|
||||
return 0
|
||||
fi
|
||||
{
|
||||
echo "Several WiFi adapters found:"
|
||||
local i bus
|
||||
for i in "${!ifaces[@]}"; do
|
||||
bus=$(wifi_iface_bus "${ifaces[$i]}")
|
||||
printf ' %d. %s%s\n' "$((i + 1))" "${ifaces[$i]}" "${bus:+ ($bus)}"
|
||||
done
|
||||
} >&2
|
||||
local choice
|
||||
read -r -p "Which one? [1]: " choice </dev/tty >&2
|
||||
choice=${choice:-1}
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ifaces[@]} )); then
|
||||
echo "${ifaces[$((choice - 1))]}"
|
||||
else
|
||||
echo "${ifaces[0]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# What the adapter is actually doing, printed after a failed connect so the
|
||||
# cause (regulatory domain, weak signal, DHCP, driver) is visible, not guessed.
|
||||
wifi_debug_dump() {
|
||||
local i="$1"
|
||||
echo "--- link ---"
|
||||
iw dev "$i" link 2>&1 | head -8
|
||||
echo "--- regulatory domain ---"
|
||||
iw reg get 2>&1 | grep -m2 "country\|global" || true
|
||||
echo "--- wpa_supplicant log (last 15) ---"
|
||||
journalctl -b -u "netplan-wpa-$i" -n 15 --no-pager -o cat 2>&1 | cut -c1-160
|
||||
echo "--- networkd ---"
|
||||
networkctl status "$i" --no-pager 2>&1 | head -12
|
||||
echo "(full log: /var/lib/kiosk-firstboot/install.log, or journalctl -b -u 'netplan-wpa-*')"
|
||||
}
|
||||
|
||||
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||
# the adapter can't join it.
|
||||
wifi_default_country() {
|
||||
local c=""
|
||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||
fi
|
||||
if [[ -z "$c" && -r /etc/modprobe.d/kiosk-regdom.conf ]]; then
|
||||
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' /etc/modprobe.d/kiosk-regdom.conf | head -1)
|
||||
fi
|
||||
if [[ -z "$c" ]]; then
|
||||
local loc="${LANG:-}"
|
||||
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
||||
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "${c^^}"
|
||||
}
|
||||
|
||||
wifi_menu() {
|
||||
local rc=0
|
||||
wifi_configure || rc=$?
|
||||
echo
|
||||
pause
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The first wireless interface, by what it is (it has a wireless/ or
|
||||
# phy80211 entry in sysfs) rather than by name - adapters aren't always
|
||||
# named wl*. Falls back to the wl* naming convention.
|
||||
wifi_detect_iface() {
|
||||
local d
|
||||
for d in /sys/class/net/*; do
|
||||
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||
basename "$d"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
local name
|
||||
for d in /sys/class/net/wl*; do
|
||||
[[ -e "$d" ]] || continue
|
||||
name=$(basename "$d")
|
||||
echo "$name"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Clear a software WiFi block (the rfkill "soft" switch) - with it set,
|
||||
# `ip link set up` fails with "RF-kill". The rfkill tool isn't installed
|
||||
# on a server, so via sysfs. A hard block (physical switch / BIOS) can't
|
||||
# be cleared from here and is reported instead.
|
||||
wifi_unblock() {
|
||||
local r type
|
||||
for r in /sys/class/rfkill/rfkill*; do
|
||||
[[ -e "$r/type" ]] || continue
|
||||
type=$(cat "$r/type" 2>/dev/null)
|
||||
[[ "$type" == "wlan" ]] || continue
|
||||
if [[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]]; then
|
||||
echo "WiFi is switched off in software (rfkill) - switching it on..."
|
||||
echo 0 | sudo tee "$r/soft" >/dev/null || true
|
||||
fi
|
||||
if [[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]]; then
|
||||
log_warning "WiFi is switched off by a hardware switch or the BIOS/UEFI - turn it on there"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# YAML single-quoted scalar: the only special character inside is the
|
||||
# quote itself, doubled. Safe for any SSID/password (", \, #, : ...) -
|
||||
# double quotes broke on a password containing " or \.
|
||||
wifi_yaml_quote() {
|
||||
local v="$1"
|
||||
printf "'%s'" "${v//\'/\'\'}"
|
||||
}
|
||||
|
||||
# IFACE's first IPv4 address (without the /prefix), or nothing.
|
||||
wifi_iface_ip() {
|
||||
ip -4 -o addr show dev "$1" 2>/dev/null \
|
||||
| awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
|
||||
}
|
||||
|
||||
# True if IFACE has an IPv4 address.
|
||||
wifi_iface_has_ip() {
|
||||
ip -4 addr show dev "$1" 2>/dev/null | grep -q "inet "
|
||||
}
|
||||
|
||||
wifi_configure() {
|
||||
echo
|
||||
echo " ═══ WIFI CONFIGURATION ═══"
|
||||
echo
|
||||
|
||||
# netplan needs wpa_supplicant to join WiFi at all; iw/wpa_cli scan.
|
||||
# Missing on a server install that skipped WiFi in the installer -
|
||||
# install them here (from the offline bundle if there's no internet,
|
||||
# which is exactly the situation this menu is usually opened in).
|
||||
if ! command -v wpa_supplicant &>/dev/null || ! command -v iw &>/dev/null; then
|
||||
echo "WiFi support (wpasupplicant, iw) isn't installed yet - installing..."
|
||||
if ! run_with_offline_fallback sudo apt install -y wpasupplicant iw; then
|
||||
log_error "Couldn't install WiFi support (needs internet, wired, or an --offline install ISO)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local has_tools=false
|
||||
if command -v nmcli &>/dev/null || command -v iw &>/dev/null || command -v wpa_cli &>/dev/null; then
|
||||
has_tools=true
|
||||
@@ -43,11 +275,11 @@ wifi_menu() {
|
||||
fi
|
||||
|
||||
local wifi_iface
|
||||
wifi_iface=$(ls /sys/class/net 2>/dev/null | grep -E "^wl" | head -1)
|
||||
|
||||
if [[ -z "$wifi_iface" ]]; then
|
||||
if ! wifi_iface=$(wifi_choose_iface); then
|
||||
log_warning "No WiFi hardware detected"
|
||||
echo "If you have a USB WiFi adapter, ensure it's plugged in."
|
||||
echo "Network interfaces found: $(ls /sys/class/net 2>/dev/null | tr '\n' ' ')"
|
||||
echo "If you have a USB WiFi adapter, make sure it's plugged in. A built-in"
|
||||
echo "adapter with no driver shows up in 'lspci -k' / 'lsusb' but not above."
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -62,9 +294,11 @@ wifi_menu() {
|
||||
|
||||
ask_yes_no "Configure WiFi?" "n" || return 0
|
||||
|
||||
wifi_unblock
|
||||
echo "Bringing up interface..."
|
||||
if ! sudo ip link set "$wifi_iface" up 2>/dev/null; then
|
||||
log_error "Failed to bring up interface"
|
||||
local link_err
|
||||
if ! link_err=$(sudo ip link set "$wifi_iface" up 2>&1); then
|
||||
log_error "Failed to bring up $wifi_iface: ${link_err:-unknown error}"
|
||||
return 1
|
||||
fi
|
||||
sleep 3
|
||||
@@ -138,7 +372,17 @@ wifi_menu() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
apply_wifi_config "$wifi_iface" "$ssid" "$password"
|
||||
local country c_in
|
||||
country=$(wifi_default_country)
|
||||
read -r -p "Country code for WiFi regulations (2 letters, e.g. US) [${country:-none}]: " c_in
|
||||
c_in=${c_in:-$country}
|
||||
c_in=${c_in^^}
|
||||
if [[ -n "$c_in" && ! "$c_in" =~ ^[A-Z]{2}$ ]]; then
|
||||
log_warning "'$c_in' isn't a 2-letter country code - skipping it"
|
||||
c_in=""
|
||||
fi
|
||||
|
||||
apply_wifi_config "$wifi_iface" "$ssid" "$password" "$c_in"
|
||||
}
|
||||
|
||||
# apply_wifi_config IFACE SSID PASSWORD
|
||||
@@ -148,16 +392,11 @@ apply_wifi_config() {
|
||||
local wifi_iface="$1"
|
||||
local ssid="$2"
|
||||
local password="$3"
|
||||
local country="${4:-}"
|
||||
|
||||
# `|| true`: under set -e + pipefail (this whole tool runs under both),
|
||||
# `ls` matching nothing exits non-zero even with stderr silenced, which
|
||||
# would abort this function outright instead of falling through to the
|
||||
# default filename below. Same class of bug as the run_menu fix in
|
||||
# lib/menu.sh - masked here in practice because cloud-init almost
|
||||
# always leaves a *.yaml file behind, but not guaranteed.
|
||||
local netplan_file
|
||||
netplan_file=$(ls "$NETPLAN_DIR"/*.yaml 2>/dev/null | head -1) || true
|
||||
[[ -z "$netplan_file" ]] && netplan_file="$NETPLAN_DIR/50-cloud-init.yaml"
|
||||
# Our own file, merged by netplan with the others (wired config etc).
|
||||
local netplan_file="$WIFI_NETPLAN_FILE"
|
||||
sudo mkdir -p "$NETPLAN_DIR"
|
||||
|
||||
local backup=""
|
||||
if [[ -f "$netplan_file" ]]; then
|
||||
@@ -177,57 +416,78 @@ network:
|
||||
dhcp4: true
|
||||
dhcp6: false
|
||||
optional: true
|
||||
access-points:
|
||||
"$ssid":
|
||||
password: "$password"
|
||||
${country:+ regulatory-domain: \"$country\"
|
||||
} access-points:
|
||||
$(wifi_yaml_quote "$ssid"):
|
||||
password: $(wifi_yaml_quote "$password")
|
||||
EOF
|
||||
|
||||
if [[ -n "${SSH_CONNECTION:-}" ]] && [[ -n "$backup" ]]; then
|
||||
if [[ -n "${SSH_CONNECTION:-}" ]]; then
|
||||
# Reverts if the WiFi interface still has no address after 60s -
|
||||
# not "can't ping 8.8.8.8", which on a network without internet
|
||||
# would undo a perfectly good connection every time. No backup
|
||||
# means there was no WiFi file before: revert = remove ours.
|
||||
local watchdog
|
||||
watchdog=$(mktemp --suffix=.sh)
|
||||
cat > "$watchdog" <<'WATCHEOF'
|
||||
#!/bin/bash
|
||||
# $1 = backup ("" if none), $2 = netplan file, $3 = WiFi interface
|
||||
sleep 60
|
||||
if [[ -f "$1" && -f "$2" ]]; then
|
||||
ip=$(hostname -I | awk '{print $1}')
|
||||
if [[ -z "$ip" ]] || ! ping -c 2 8.8.8.8 >/dev/null 2>&1; then
|
||||
cp "$1" "$2"
|
||||
netplan apply 2>/dev/null
|
||||
echo "WiFi config reverted - connection failed" | wall
|
||||
fi
|
||||
if ! ip -4 addr show dev "$3" 2>/dev/null | grep -q "inet "; then
|
||||
if [[ -n "$1" && -f "$1" ]]; then cp "$1" "$2"; else rm -f "$2"; fi
|
||||
netplan apply 2>/dev/null
|
||||
echo "WiFi config reverted - $3 got no address" | wall
|
||||
fi
|
||||
rm -f "$0"
|
||||
WATCHEOF
|
||||
chmod +x "$watchdog"
|
||||
nohup sudo bash "$watchdog" "$backup" "$netplan_file" >/dev/null 2>&1 &
|
||||
echo "Watchdog started - will revert in 60s if the connection fails"
|
||||
nohup sudo bash "$watchdog" "$backup" "$netplan_file" "$wifi_iface" >/dev/null 2>&1 &
|
||||
echo "Watchdog started - will revert in 60s if $wifi_iface gets no address"
|
||||
fi
|
||||
|
||||
sudo cp "$temp_plan" "$netplan_file"
|
||||
sudo chmod 0600 "$netplan_file"
|
||||
rm -f "$temp_plan"
|
||||
|
||||
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
|
||||
[[ -n "$country" ]] && sudo iw reg set "$country" 2>/dev/null
|
||||
echo "Applying configuration..."
|
||||
local netplan_log
|
||||
netplan_log=$(mktemp)
|
||||
if sudo netplan apply > "$netplan_log" 2>&1; then
|
||||
cat "$netplan_log"
|
||||
sleep 10
|
||||
local new_ip
|
||||
new_ip=$(get_ip_address)
|
||||
if [[ -n "$new_ip" && "$new_ip" != "No IP" ]]; then
|
||||
log_success "Connected: $ssid ($new_ip)"
|
||||
echo "Waiting for $wifi_iface to connect (up to 45 seconds)..."
|
||||
local _
|
||||
for _ in {1..45}; do
|
||||
wifi_iface_has_ip "$wifi_iface" && break
|
||||
sleep 1
|
||||
done
|
||||
if wifi_iface_has_ip "$wifi_iface"; then
|
||||
log_success "Connected to $ssid: $(wifi_iface_ip "$wifi_iface")"
|
||||
[[ -n "${SSH_CONNECTION:-}" ]] && echo "Connection successful - watchdog will not revert"
|
||||
else
|
||||
log_warning "Config applied but no IP yet"
|
||||
echo "Check: sudo journalctl -u systemd-networkd -f"
|
||||
log_warning "Config applied but $wifi_iface has no address yet"
|
||||
local why
|
||||
why=$(wifi_diagnose "$wifi_iface")
|
||||
if [[ -n "$why" ]]; then
|
||||
log_error "Couldn't connect to $ssid: $why"
|
||||
elif iw dev "$wifi_iface" link 2>/dev/null | grep -q "^Connected"; then
|
||||
log_error "Joined the network but got no address - the router's DHCP isn't answering (or is slow)"
|
||||
else
|
||||
echo "Not joined, and the log doesn't say why - not necessarily the password."
|
||||
fi
|
||||
wifi_debug_dump "$wifi_iface"
|
||||
fi
|
||||
else
|
||||
log_error "netplan apply failed"
|
||||
echo "Error log:"
|
||||
cat "$netplan_log"
|
||||
if [[ -n "$backup" ]] && ask_yes_no "Restore backup?" "y"; then
|
||||
sudo cp "$backup" "$netplan_file"
|
||||
if ask_yes_no "Undo this WiFi change?" "y"; then
|
||||
if [[ -n "$backup" ]]; then
|
||||
sudo cp "$backup" "$netplan_file"
|
||||
else
|
||||
sudo rm -f "$netplan_file"
|
||||
fi
|
||||
# Last resort after everything else failed: report, don't crash
|
||||
# the session if even the restore-and-reapply doesn't work.
|
||||
if sudo netplan apply; then
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Section "ServerFlags"
|
||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||
Option "DontZap" "true"
|
||||
|
||||
# Disable VT switching (Ctrl+Alt+F1-F12)
|
||||
Option "DontVTSwitch" "true"
|
||||
|
||||
# Don't allow clients to disconnect on exit
|
||||
Option "AllowClosedownGrabs" "false"
|
||||
EndSection
|
||||
@@ -0,0 +1,7 @@
|
||||
Section "Device"
|
||||
Identifier "Intel Graphics"
|
||||
Driver "intel"
|
||||
Option "AccelMethod" "sna"
|
||||
Option "TearFree" "true"
|
||||
Option "DRI" "3"
|
||||
EndSection
|
||||
@@ -0,0 +1,5 @@
|
||||
Section "InputClass"
|
||||
Identifier "Touch screen use libinput"
|
||||
MatchIsTouchscreen "on"
|
||||
Driver "libinput"
|
||||
EndSection
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power.*
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power PBTN
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,2 @@
|
||||
event=button/power PWRF
|
||||
action=/usr/local/bin/kiosk-power-button.sh
|
||||
@@ -0,0 +1,6 @@
|
||||
[Allow kiosk power operations]
|
||||
Identity=unix-user:kiosk
|
||||
Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.suspend;org.freedesktop.login1.suspend-multiple-sessions
|
||||
ResultAny=yes
|
||||
ResultInactive=yes
|
||||
ResultActive=yes
|
||||
@@ -0,0 +1,6 @@
|
||||
[Login]
|
||||
HandlePowerKey=ignore
|
||||
HandlePowerKeyLongPress=poweroff
|
||||
HandleSuspendKey=ignore
|
||||
HandleHibernateKey=ignore
|
||||
HandleLidSwitch=ignore
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Kiosk Display Hotplug Handler
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/kiosk-hotplug.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1,11 @@
|
||||
# Keep WiFi adapters awake. Power saving (the adapter dozing between
|
||||
# packets, and the USB port suspending an idle dongle) is what makes
|
||||
# cheap dongles like the Realtek RTL8188CUS drop or stall a connection.
|
||||
# Both are ordinary software settings, so nothing here can harm the
|
||||
# hardware: the cost is a fraction of a watt.
|
||||
|
||||
# Radio power save off on every WiFi interface as it appears.
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="wl*", RUN+="/usr/sbin/iw dev $name set power_save off"
|
||||
|
||||
# No USB autosuspend for the common RTL8188/8192 CU/CUS/RU dongles.
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="8176|8177|8178|8179|817a|817b|817c|817d|817e|8191", TEST=="power/control", ATTR{power/control}="on"
|
||||
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="drm", ACTION=="change", TAG+="systemd", ENV{SYSTEMD_WANTS}="kiosk-hotplug.service"
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mirror any connected external display (e.g. HDMI-out to a monitor/TV) onto
|
||||
# the primary display, forcing it to the primary's exact resolution.
|
||||
/usr/local/bin/kiosk-mirror-display.sh
|
||||
|
||||
# AGGRESSIVE DPMS disable - multiple methods
|
||||
xset s off
|
||||
xset s noblank
|
||||
xset -dpms
|
||||
xset s 0 0
|
||||
xset dpms 0 0 0
|
||||
xset dpms force on
|
||||
|
||||
# Keep screen on forever - watchdog (schedule-aware)
|
||||
(
|
||||
while true; do
|
||||
sleep 300 # Every 5 minutes
|
||||
|
||||
# Check if display schedule is active
|
||||
schedule_active=false
|
||||
if systemctl is-active --quiet kiosk-display-off.timer && systemctl is-active --quiet kiosk-display-on.timer; then
|
||||
# Get display off and on times from systemd timers
|
||||
doff=$(systemctl cat kiosk-display-off.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
don=$(systemctl cat kiosk-display-on.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
|
||||
if [ -n "$doff" ] && [ -n "$don" ]; then
|
||||
# Get current time in HH:MM format
|
||||
current_time=$(date +%H:%M)
|
||||
|
||||
# Convert times to minutes since midnight for comparison
|
||||
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||
off_mins=$(( 10#$(echo "$doff" | cut -d: -f1) * 60 + 10#$(echo "$doff" | cut -d: -f2) ))
|
||||
on_mins=$(( 10#$(echo "$don" | cut -d: -f1) * 60 + 10#$(echo "$don" | cut -d: -f2) ))
|
||||
|
||||
# Check if we're in the "display off" window
|
||||
if [ "$off_mins" -lt "$on_mins" ]; then
|
||||
# Normal case: off time is before on time (e.g., 22:00 to 06:00 next day)
|
||||
if [ "$current_mins" -ge "$off_mins" ] && [ "$current_mins" -lt "$on_mins" ]; then
|
||||
schedule_active=true
|
||||
fi
|
||||
else
|
||||
# Overnight case: off time is after on time (e.g., 06:00 to 22:00)
|
||||
if [ "$current_mins" -ge "$off_mins" ] || [ "$current_mins" -lt "$on_mins" ]; then
|
||||
schedule_active=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only force display on if NOT in scheduled off period
|
||||
if [ "$schedule_active" = "false" ]; then
|
||||
xset s reset 2>/dev/null
|
||||
xset dpms force on 2>/dev/null
|
||||
fi
|
||||
done
|
||||
) &
|
||||
|
||||
# Start PipeWire user services
|
||||
systemctl --user start pipewire pipewire-pulse wireplumber
|
||||
sleep 3
|
||||
|
||||
# Wait for PipeWire to be ready
|
||||
for i in {1..15}; do
|
||||
pactl info >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Wait for ALSA devices
|
||||
for i in {1..10}; do
|
||||
pactl list sinks short | grep -q alsa && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Route audio to HDMI if an external display is connected/mirrored, else built-in
|
||||
/usr/local/bin/kiosk-audio-route.sh
|
||||
|
||||
# Set audio levels (speakers 100%, mic 100%, mic unmuted)
|
||||
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||
|
||||
# Audio watchdog - checks every 30 seconds (quiet hours aware)
|
||||
(
|
||||
while true; do
|
||||
sleep 30
|
||||
|
||||
# Check if quiet hours is active
|
||||
quiet_active=false
|
||||
if systemctl is-active --quiet kiosk-quiet-start.timer && systemctl is-active --quiet kiosk-quiet-end.timer; then
|
||||
# Get quiet hours start and end times from systemd timers
|
||||
qstart=$(systemctl cat kiosk-quiet-start.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
qend=$(systemctl cat kiosk-quiet-end.timer 2>/dev/null | grep "^OnCalendar=" | sed 's/.*\*-\*-\* //' | sed 's/:00$//')
|
||||
|
||||
if [ -n "$qstart" ] && [ -n "$qend" ]; then
|
||||
# Convert times to minutes since midnight for comparison
|
||||
# Using 10# prefix to force decimal interpretation (fixes octal bug for 08:xx and 09:xx times)
|
||||
current_mins=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
|
||||
start_mins=$(( 10#$(echo "$qstart" | cut -d: -f1) * 60 + 10#$(echo "$qstart" | cut -d: -f2) ))
|
||||
end_mins=$(( 10#$(echo "$qend" | cut -d: -f1) * 60 + 10#$(echo "$qend" | cut -d: -f2) ))
|
||||
|
||||
# Check if we're in quiet hours window
|
||||
if [ "$start_mins" -lt "$end_mins" ]; then
|
||||
# Normal case: start time is before end time (e.g., 08:00 to 17:00)
|
||||
if [ "$current_mins" -ge "$start_mins" ] && [ "$current_mins" -lt "$end_mins" ]; then
|
||||
quiet_active=true
|
||||
fi
|
||||
else
|
||||
# Overnight case: start time is after end time (e.g., 22:00 to 07:00)
|
||||
if [ "$current_mins" -ge "$start_mins" ] || [ "$current_mins" -lt "$end_mins" ]; then
|
||||
quiet_active=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if PipeWire is running
|
||||
if ! pactl info >/dev/null 2>&1; then
|
||||
logger "KIOSK: Audio dead, restarting PipeWire"
|
||||
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||
sleep 5
|
||||
# Only restore audio levels if NOT in quiet hours
|
||||
if [ "$quiet_active" = "false" ]; then
|
||||
pactl set-sink-volume @DEFAULT_SINK@ 100%
|
||||
pactl set-source-volume @DEFAULT_SOURCE@ 100%
|
||||
pactl set-source-mute @DEFAULT_SOURCE@ 0
|
||||
fi
|
||||
fi
|
||||
|
||||
done
|
||||
) &
|
||||
|
||||
# Other services
|
||||
unclutter -idle 0.1 -root &
|
||||
XDG_RUNTIME_DIR=/run/user/$(id -u) xbindkeys &
|
||||
|
||||
# Create boot flag for password requirement on boot
|
||||
touch /home/kiosk/kiosk-app/.boot-flag
|
||||
|
||||
# Start kiosk app AFTER audio is ready
|
||||
sleep 2
|
||||
/home/kiosk/kiosk-app/start.sh &
|
||||
@@ -0,0 +1,33 @@
|
||||
# PipeWire noise cancellation configuration for kiosk microphone
|
||||
# This creates a virtual source with echo cancellation and noise suppression
|
||||
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-echo-cancel
|
||||
args = {
|
||||
# audio.channels = 1
|
||||
# capture.props = {
|
||||
# node.name = "Echo Cancellation Capture"
|
||||
# }
|
||||
# source.props = {
|
||||
# node.name = "Echo Cancellation Source"
|
||||
# node.description = "Noise-Cancelled Microphone"
|
||||
# }
|
||||
# sink.props = {
|
||||
# node.name = "Echo Cancellation Sink"
|
||||
# }
|
||||
# playback.props = {
|
||||
# node.name = "Echo Cancellation Playback"
|
||||
# }
|
||||
aec.method = webrtc
|
||||
aec.args = {
|
||||
# WebRTC audio processing settings
|
||||
webrtc.gain_control = true
|
||||
webrtc.extended_filter = true
|
||||
webrtc.high_pass_filter = true
|
||||
webrtc.noise_suppression = true
|
||||
webrtc.noise_suppression_level = 3
|
||||
webrtc.voice_detection = true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Assumes DISPLAY/XAUTHORITY are set (for xrandr) and pactl already has a
|
||||
# working PipeWire/pulse socket for the invoking context.
|
||||
|
||||
QUERY=$(xrandr --query 2>/dev/null)
|
||||
[ -z "$QUERY" ] && exit 0
|
||||
|
||||
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||
|
||||
EXTERNAL_CONNECTED=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '/ connected/ && $1!=p{f=1} END{print (f==1)?"yes":"no"}')
|
||||
|
||||
HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) ~ /hdmi/{print $2; exit}')
|
||||
NON_HDMI_SINK=$(pactl list sinks short 2>/dev/null | awk 'tolower($2) !~ /hdmi/{print $2; exit}')
|
||||
|
||||
route_to() {
|
||||
local sink="$1" label="$2"
|
||||
if [ -z "$sink" ]; then
|
||||
logger "KIOSK: no $label audio sink found, leaving routing unchanged"
|
||||
return
|
||||
fi
|
||||
pactl set-default-sink "$sink" 2>/dev/null \
|
||||
&& logger "KIOSK: audio routed to $label sink ($sink)" \
|
||||
|| logger "KIOSK: failed to route audio to $label sink ($sink)"
|
||||
pactl list sink-inputs short 2>/dev/null | awk '{print $1}' | while read -r sid; do
|
||||
pactl move-sink-input "$sid" "$sink" 2>/dev/null
|
||||
done
|
||||
pactl set-sink-volume "$sink" 100% 2>/dev/null
|
||||
pactl set-sink-mute "$sink" 0 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "$EXTERNAL_CONNECTED" = "yes" ]; then
|
||||
route_to "$HDMI_SINK" "HDMI"
|
||||
else
|
||||
route_to "$NON_HDMI_SINK" "built-in"
|
||||
fi
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Give X a moment to finish enumerating the output after the hotplug event
|
||||
sleep 2
|
||||
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority /usr/local/bin/kiosk-mirror-display.sh
|
||||
|
||||
kiosk_uid=$(id -u kiosk)
|
||||
sudo -u kiosk DISPLAY=:0 XAUTHORITY=/home/kiosk/.Xauthority XDG_RUNTIME_DIR="/run/user/${kiosk_uid}" /usr/local/bin/kiosk-audio-route.sh
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Assumes it's run with DISPLAY/XAUTHORITY already set for the kiosk user's
|
||||
# X session (either inherited, as from Openbox autostart, or exported by the
|
||||
# caller). Mirrors every connected non-primary output at the primary's exact
|
||||
# current resolution so kiosk content isn't cropped/letterboxed/blank on a
|
||||
# TV/monitor with a different native resolution than the kiosk panel.
|
||||
|
||||
QUERY=$(xrandr --query 2>/dev/null)
|
||||
[ -z "$QUERY" ] && exit 0
|
||||
|
||||
PRIMARY_OUTPUT=$(echo "$QUERY" | awk '/ primary/{print $1; exit}')
|
||||
[ -z "$PRIMARY_OUTPUT" ] && exit 0
|
||||
|
||||
PRIMARY_RES=$(echo "$QUERY" | awk -v p="$PRIMARY_OUTPUT" '$1==p{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+x[0-9]+\+/){split($i,a,"+"); print a[1]; exit}}')
|
||||
[ -z "$PRIMARY_RES" ] && exit 0
|
||||
|
||||
for OUT in $(echo "$QUERY" | awk '/ connected/{print $1}'); do
|
||||
[ "$OUT" = "$PRIMARY_OUTPUT" ] && continue
|
||||
|
||||
HAS_NATIVE=$(echo "$QUERY" | awk -v out="$OUT" -v res="$PRIMARY_RES" '
|
||||
$0 ~ "^"out" " {infound=1; next}
|
||||
/^[^ \t]/ {infound=0}
|
||||
infound && $1==res {print "yes"; exit}
|
||||
')
|
||||
|
||||
if [ "$HAS_NATIVE" = "yes" ]; then
|
||||
xrandr --output "$OUT" --mode "$PRIMARY_RES" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||
&& logger "KIOSK: mirrored $OUT at native $PRIMARY_RES" \
|
||||
|| logger "KIOSK: mirror of $OUT at $PRIMARY_RES failed"
|
||||
continue
|
||||
fi
|
||||
|
||||
# $OUT doesn't natively list the primary's resolution - force a matching mode
|
||||
CVT_LINE=$(cvt "${PRIMARY_RES%x*}" "${PRIMARY_RES#*x}" 2>/dev/null | grep Modeline)
|
||||
MODENAME=$(echo "$CVT_LINE" | sed -n 's/^Modeline "\([^"]*\)".*/\1/p')
|
||||
TIMINGS=$(echo "$CVT_LINE" | sed -n 's/^Modeline "[^"]*" *//p')
|
||||
|
||||
if [ -z "$MODENAME" ] || [ -z "$TIMINGS" ]; then
|
||||
logger "KIOSK: could not generate a $PRIMARY_RES mode for $OUT (cvt failed or missing)"
|
||||
continue
|
||||
fi
|
||||
|
||||
xrandr --newmode "$MODENAME" $TIMINGS 2>/dev/null
|
||||
xrandr --addmode "$OUT" "$MODENAME" 2>/dev/null
|
||||
xrandr --output "$OUT" --mode "$MODENAME" --same-as "$PRIMARY_OUTPUT" 2>/dev/null \
|
||||
&& logger "KIOSK: mirrored $OUT at forced $PRIMARY_RES ($MODENAME)" \
|
||||
|| logger "KIOSK: mirror of $OUT at forced $PRIMARY_RES failed"
|
||||
done
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Power button handler - sends SIGUSR1 to Electron to show power menu
|
||||
# This runs as ROOT from acpid, so it can signal any process
|
||||
|
||||
logger "KIOSK POWER: Button pressed"
|
||||
|
||||
# Find the Electron main process (runs as kiosk user)
|
||||
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||
|
||||
if [ -z "$PIDS" ]; then
|
||||
logger "KIOSK POWER: No Electron process found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Send SIGUSR1 to all Electron processes (the main one will handle it)
|
||||
for PID in $PIDS; do
|
||||
logger "KIOSK POWER: Sending SIGUSR1 to PID $PID"
|
||||
kill -USR1 $PID 2>/dev/null
|
||||
done
|
||||
|
||||
logger "KIOSK POWER: Signal sent"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||
NEW=$((CURRENT - 5))
|
||||
[[ $NEW -lt 0 ]] && NEW=0
|
||||
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
CURRENT=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oE '[0-9]+%' | head -1 | tr -d '%')
|
||||
NEW=$((CURRENT + 5))
|
||||
[[ $NEW -gt 100 ]] && NEW=100
|
||||
pactl set-sink-volume @DEFAULT_SINK@ ${NEW}%
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen
|
||||
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
|
||||
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
|
||||
#
|
||||
# The kiosk user may run this one script via passwordless sudo
|
||||
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
|
||||
# fixed set of things and nothing else:
|
||||
#
|
||||
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
||||
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
||||
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
||||
# kiosk-wifi-helper region-status
|
||||
# {"country","locale","keyboard","locales":[],"keyboards":[]}
|
||||
# kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
|
||||
# WiFi country (regulatory domain), system
|
||||
# locale and keyboard layout; every field optional
|
||||
#
|
||||
# Every reply is one JSON line on stdout, exit 0 on success. connect
|
||||
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
||||
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
|
||||
# gets no address - a typo in the password doesn't lose a WiFi network
|
||||
# that was working. The password comes in on stdin, never argv (argv is
|
||||
# visible to every user in ps).
|
||||
################################################################################
|
||||
set -uo pipefail
|
||||
|
||||
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
||||
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
||||
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
|
||||
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
|
||||
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
||||
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
||||
|
||||
reply_error() {
|
||||
jq -cn --arg e "$1" '{ok:false,error:$e}'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The first wireless interface, by what it is (sysfs wireless/ or
|
||||
# phy80211), falling back to the wl* naming convention. Same as
|
||||
# menus/wifi.sh's wifi_detect_iface.
|
||||
detect_iface() {
|
||||
local d
|
||||
for d in $SYS_CLASS/net/*; do
|
||||
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||
basename "$d"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for d in $SYS_CLASS/net/wl*; do
|
||||
[[ -e "$d" ]] || continue
|
||||
basename "$d"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Clear an rfkill soft block (a hard block can't be cleared from software).
|
||||
unblock() {
|
||||
local r
|
||||
for r in $SYS_CLASS/rfkill/rfkill*; do
|
||||
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
|
||||
[[ "$(cat "$r/soft" 2>/dev/null)" == "1" ]] && echo 0 > "$r/soft" 2>/dev/null
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
hard_blocked() {
|
||||
local r
|
||||
for r in $SYS_CLASS/rfkill/rfkill*; do
|
||||
[[ "$(cat "$r/type" 2>/dev/null)" == "wlan" ]] || continue
|
||||
[[ "$(cat "$r/hard" 2>/dev/null)" == "1" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
iface_ip() {
|
||||
ip -4 -o addr show dev "$1" 2>/dev/null \
|
||||
| awk '{for (i = 1; i < NF; i++) if ($i == "inet") { sub("/.*", "", $(i+1)); print $(i+1); exit }}'
|
||||
}
|
||||
|
||||
# YAML single-quoted scalar - the quote itself doubled is the only escape.
|
||||
yaml_quote() {
|
||||
local v="$1"
|
||||
printf "'%s'" "${v//\'/\'\'}"
|
||||
}
|
||||
|
||||
# iw prints a backslash, a leading/trailing space and anything
|
||||
# unprintable in an SSID as \xNN (a backslash itself is always \x5c,
|
||||
# so there are no other escapes) - %b turns those back into the bytes.
|
||||
unescape_ssid() {
|
||||
printf '%b' "$1"
|
||||
}
|
||||
|
||||
# The SSID the interface is associated with right now, if any.
|
||||
current_ssid() {
|
||||
unescape_ssid "$(iw dev "$1" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -1)"
|
||||
}
|
||||
|
||||
# The SSID in our netplan file, if any (the first access point's key).
|
||||
configured_ssid() {
|
||||
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 0
|
||||
python3 -I -c '
|
||||
import sys, yaml
|
||||
try:
|
||||
d = yaml.safe_load(open(sys.argv[1]))
|
||||
for w in (d.get("network", {}).get("wifis") or {}).values():
|
||||
for ssid in (w.get("access-points") or {}):
|
||||
print(ssid); sys.exit(0)
|
||||
except Exception:
|
||||
pass' "$WIFI_NETPLAN_FILE" 2>/dev/null
|
||||
}
|
||||
|
||||
# Why a connect attempt failed, from wpa_supplicant's own log lines in
|
||||
# the journal (netplan runs it as netplan-wpa-<iface>.service). Prints a
|
||||
# short human-readable reason, or nothing if the log says nothing useful.
|
||||
diagnose_failure() {
|
||||
local log
|
||||
log=$(journalctl -b -u "netplan-wpa-$1" -n 150 --no-pager 2>/dev/null) || return 0
|
||||
if grep -q "reason=WRONG_KEY\|pre-shared key may be incorrect" <<< "$log"; then
|
||||
echo "wrong password (the network rejected the key)"
|
||||
elif grep -q "CTRL-EVENT-NETWORK-NOT-FOUND" <<< "$log"; then
|
||||
echo "network not found - wrong name, or out of range of this adapter"
|
||||
elif grep -q "CTRL-EVENT-ASSOC-REJECT\|CTRL-EVENT-AUTH-REJECT" <<< "$log"; then
|
||||
echo "the access point refused the connection (MAC filter, or WPA3-only/unsupported security)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Prefer the adapter our netplan file already configures (if it's still
|
||||
# there) over "first one found", so a built-in card plus a USB dongle
|
||||
# don't swap places between calls.
|
||||
prefer_configured_iface() {
|
||||
[[ -f "$WIFI_NETPLAN_FILE" ]] || return 1
|
||||
python3 -I -c '
|
||||
import sys, os, yaml
|
||||
try:
|
||||
d = yaml.safe_load(open(sys.argv[1]))
|
||||
for n in (d.get("network", {}).get("wifis") or {}):
|
||||
if os.path.exists(sys.argv[2] + "/net/" + str(n)):
|
||||
print(n); sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||
# the adapter can't join it.
|
||||
default_country() {
|
||||
local c=""
|
||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||
fi
|
||||
if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
|
||||
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
|
||||
fi
|
||||
if [[ -z "$c" ]]; then
|
||||
local loc="${LANG:-}"
|
||||
[[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
|
||||
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "${c^^}"
|
||||
}
|
||||
|
||||
need_iface() {
|
||||
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
|
||||
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
|
||||
}
|
||||
|
||||
# ---- Region: WiFi country, system locale, keyboard layout -----------------
|
||||
|
||||
current_locale() {
|
||||
[[ -r "$LOCALE_FILE" ]] || return 0
|
||||
sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
|
||||
}
|
||||
|
||||
current_keyboard() {
|
||||
localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
|
||||
}
|
||||
|
||||
# Locales that are generated on this system (setting any other one would
|
||||
# leave the whole system on a locale that doesn't exist).
|
||||
list_locales() {
|
||||
locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
|
||||
}
|
||||
|
||||
list_keyboards() {
|
||||
localectl list-x11-keymap-layouts 2>/dev/null
|
||||
}
|
||||
|
||||
do_region_status() {
|
||||
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||
--argjson ls "$(list_locales | jq -R . | jq -cs .)" \
|
||||
--argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
|
||||
'{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
|
||||
}
|
||||
|
||||
# Persist the WiFi country: kernel option for the regulatory domain (so it
|
||||
# applies from boot, before any WiFi config exists), the netplan file if
|
||||
# there is one (so wpa_supplicant gets it too), and now via iw.
|
||||
set_country() {
|
||||
local c="$1"
|
||||
printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
|
||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||
sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
|
||||
sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
|
||||
fi
|
||||
iw reg set "$c" 2>/dev/null || true
|
||||
}
|
||||
|
||||
do_region_set() {
|
||||
local input country locale keyboard
|
||||
input=$(head -c 4096)
|
||||
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||
country=${country^^}
|
||||
|
||||
if [[ -n "$country" ]]; then
|
||||
[[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
|
||||
# Reject codes that aren't real countries when the tz database is here.
|
||||
if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
|
||||
reply_error "$country isn't a known country code"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$locale" ]]; then
|
||||
list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
|
||||
fi
|
||||
if [[ -n "$keyboard" ]]; then
|
||||
list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
|
||||
fi
|
||||
|
||||
local err
|
||||
[[ -n "$country" ]] && set_country "$country"
|
||||
if [[ -n "$locale" ]]; then
|
||||
err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
|
||||
fi
|
||||
if [[ -n "$keyboard" ]]; then
|
||||
err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
|
||||
# Console layout too, when there's a console map of the same name.
|
||||
localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
|
||||
fi
|
||||
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||
--argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
|
||||
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local iface ssid="" ip="" configured
|
||||
configured=$(configured_ssid)
|
||||
if ! iface=$(detect_iface); then
|
||||
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
|
||||
return 0
|
||||
fi
|
||||
ssid=$(current_ssid "$iface")
|
||||
ip=$(iface_ip "$iface")
|
||||
jq -cn --arg i "$iface" --arg s "$ssid" --arg a "$ip" --arg c "$configured" \
|
||||
'{ok:true,iface:$i,connected:($s != "" and $a != ""),ssid:$s,ip:$a,configured:$c}'
|
||||
}
|
||||
|
||||
do_scan() {
|
||||
need_iface
|
||||
unblock
|
||||
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
||||
ip link set "$IFACE" up 2>/dev/null || reply_error "Couldn't switch on $IFACE"
|
||||
|
||||
# "Device or resource busy" while wpa_supplicant is mid-scan - retry.
|
||||
local out="" try
|
||||
for try in 1 2 3; do
|
||||
out=$(iw dev "$IFACE" scan 2>/dev/null) && break
|
||||
out=""
|
||||
sleep 2
|
||||
done
|
||||
# wpa_supplicant owns the interface and refuses iw: use its last scan.
|
||||
if [[ -z "$out" ]] && command -v wpa_cli >/dev/null; then
|
||||
wpa_cli -i "$IFACE" scan >/dev/null 2>&1
|
||||
sleep 4
|
||||
wpa_cli -i "$IFACE" scan_results 2>/dev/null | awk -F'\t' '
|
||||
NR > 1 && $5 != "" { secure = ($4 ~ /WPA|WEP|RSN|SAE/) ? 1 : 0; print $3 "\t" secure "\t" $5 }' \
|
||||
> "$TMP_SCAN"
|
||||
else
|
||||
# BSS blocks: signal, SSID, and RSN/WPA/Privacy for "secured".
|
||||
printf '%s\n' "$out" | awk '
|
||||
function flush() { if (ssid != "") print sig "\t" sec "\t" ssid; ssid = ""; sig = -100; sec = 0 }
|
||||
/^BSS / { flush() }
|
||||
/^\tsignal:/ { sig = int($2) }
|
||||
/^\tSSID: / { sub(/^\tSSID: /, ""); ssid = $0 }
|
||||
/^\t(RSN|WPA):/ { sec = 1 }
|
||||
/^\tcapability:.*Privacy/ { sec = 1 }
|
||||
END { flush() }' \
|
||||
| while IFS=$'\t' read -r sig sec ssid; do
|
||||
printf '%s\t%s\t%s\n' "$sig" "$sec" "$(unescape_ssid "$ssid")"
|
||||
done > "$TMP_SCAN"
|
||||
fi
|
||||
|
||||
# Strongest first, one entry per SSID.
|
||||
jq -cR --arg i "$IFACE" -n '
|
||||
[inputs | split("\t") | select(length >= 3)
|
||||
| {signal: (.[0] | tonumber? // -100), secure: (.[1] == "1"), ssid: (.[2:] | join("\t"))}]
|
||||
| group_by(.ssid) | map(max_by(.signal)) | sort_by(-.signal)
|
||||
| {ok: true, iface: $i, networks: .}' < "$TMP_SCAN"
|
||||
}
|
||||
|
||||
# netplan apply starts a netplan-wpa-<iface> unit for every wifis: entry
|
||||
# in every file and waits for that device - 90s, then the whole apply
|
||||
# fails - if one names an interface this machine doesn't have (the
|
||||
# installer's own config can carry e.g. "wlan0" while the adapter here is
|
||||
# "wlp1s0"). Take such entries out of the other netplan files first
|
||||
# (originals kept as *.kiosk-bak, which netplan ignores).
|
||||
drop_stale_entries() {
|
||||
local f
|
||||
for f in "$NETPLAN_DIR"/*.yaml; do
|
||||
[[ -f "$f" && "$f" != "$WIFI_NETPLAN_FILE" ]] || continue
|
||||
python3 -I - "$f" <<'PYEOF' 2>/dev/null || true
|
||||
import os, shutil, sys, yaml
|
||||
p = sys.argv[1]
|
||||
try:
|
||||
d = yaml.safe_load(open(p)) or {}
|
||||
w = (d.get("network") or {}).get("wifis") or {}
|
||||
stale = [n for n in w if not os.path.exists("/sys/class/net/" + str(n))]
|
||||
if stale:
|
||||
shutil.copy2(p, p + ".kiosk-bak")
|
||||
for n in stale:
|
||||
del w[n]
|
||||
if not w:
|
||||
del d["network"]["wifis"]
|
||||
# Not "optional" = the boot waits (up to ~2 min) for it to get an address.
|
||||
opt = [n for n, v in w.items() if isinstance(v, dict) and not v.get("optional")]
|
||||
for n in opt:
|
||||
w[n]["optional"] = True
|
||||
if stale or opt:
|
||||
if not stale:
|
||||
shutil.copy2(p, p + ".kiosk-bak")
|
||||
mode = os.stat(p).st_mode & 0o777
|
||||
with open(p, "w") as fh:
|
||||
yaml.safe_dump(d, fh, default_flow_style=False)
|
||||
os.chmod(p, mode)
|
||||
print("Fixed WiFi entries in %s (removed: %s)" % (p, ", ".join(map(str, stale)) or "none"))
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
done
|
||||
}
|
||||
|
||||
do_connect() {
|
||||
need_iface
|
||||
local input ssid password
|
||||
input=$(head -c 4096)
|
||||
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
|
||||
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||
|
||||
local ssid_bytes
|
||||
ssid_bytes=$(printf '%s' "$ssid" | wc -c)
|
||||
(( ssid_bytes >= 1 && ssid_bytes <= 32 )) || reply_error "A network name is 1-32 characters"
|
||||
# No control characters (newlines etc.) in either - they can't be in a
|
||||
# real SSID/passphrase and would only serve to break the YAML.
|
||||
[[ "$ssid$password" == *[[:cntrl:]]* ]] && reply_error "The name or password contains an invalid character"
|
||||
if [[ -n "$password" ]]; then
|
||||
local plen=${#password}
|
||||
if ! (( plen >= 8 && plen <= 63 )) && ! [[ "$password" =~ ^[0-9A-Fa-f]{64}$ ]]; then
|
||||
reply_error "A WiFi password is 8-63 characters"
|
||||
fi
|
||||
fi
|
||||
|
||||
unblock
|
||||
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
||||
|
||||
local country backup="" new
|
||||
country=$(default_country)
|
||||
[[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
|
||||
mkdir -p "$NETPLAN_DIR"
|
||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
cp -p "$WIFI_NETPLAN_FILE" "$backup"
|
||||
fi
|
||||
|
||||
new=$(mktemp "$NETPLAN_DIR/.kiosk-wifi.XXXXXX")
|
||||
{
|
||||
echo "network:"
|
||||
echo " version: 2"
|
||||
echo " renderer: networkd"
|
||||
echo " wifis:"
|
||||
echo " $IFACE:"
|
||||
echo " dhcp4: true"
|
||||
echo " dhcp6: false"
|
||||
echo " optional: true"
|
||||
[[ -n "$country" ]] && echo " regulatory-domain: \"$country\""
|
||||
echo " access-points:"
|
||||
if [[ -n "$password" ]]; then
|
||||
echo " $(yaml_quote "$ssid"):"
|
||||
echo " password: $(yaml_quote "$password")"
|
||||
else
|
||||
echo " $(yaml_quote "$ssid"): {}"
|
||||
fi
|
||||
} > "$new"
|
||||
chmod 0600 "$new"
|
||||
mv -f "$new" "$WIFI_NETPLAN_FILE"
|
||||
|
||||
drop_stale_entries >/dev/null
|
||||
local err
|
||||
if ! err=$(netplan apply 2>&1); then
|
||||
restore "$backup"
|
||||
reply_error "netplan apply failed: ${err:0:300}"
|
||||
fi
|
||||
|
||||
local i addr="" now
|
||||
for (( i = 0; i < CONNECT_WAIT; i++ )); do
|
||||
now=$(current_ssid "$IFACE")
|
||||
addr=$(iface_ip "$IFACE")
|
||||
[[ "$now" == "$ssid" && -n "$addr" ]] && break
|
||||
addr=""
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -z "$addr" ]]; then
|
||||
restore "$backup"
|
||||
local why
|
||||
why=$(diagnose_failure "$IFACE")
|
||||
if [[ -n "$why" ]]; then
|
||||
reply_error "Couldn't connect to \"$ssid\": $why."
|
||||
fi
|
||||
if [[ -n "$backup" ]]; then
|
||||
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range. The previous WiFi setting was put back."
|
||||
fi
|
||||
reply_error "Couldn't connect to \"$ssid\" - wrong password, or out of range."
|
||||
fi
|
||||
jq -cn --arg s "$ssid" --arg a "$addr" --arg i "$IFACE" '{ok:true,ssid:$s,ip:$a,iface:$i}'
|
||||
}
|
||||
|
||||
# Put the previous netplan WiFi file back (or remove ours if there was
|
||||
# none) and re-apply, after a failed connect.
|
||||
restore() {
|
||||
if [[ -n "$1" && -f "$1" ]]; then
|
||||
mv -f "$1" "$WIFI_NETPLAN_FILE"
|
||||
else
|
||||
rm -f "$WIFI_NETPLAN_FILE"
|
||||
fi
|
||||
netplan apply >/dev/null 2>&1
|
||||
}
|
||||
|
||||
[[ $EUID -eq 0 ]] || reply_error "must run as root (sudo)"
|
||||
command -v jq >/dev/null || { echo '{"ok":false,"error":"jq is not installed"}'; exit 1; }
|
||||
|
||||
TMP_SCAN=$(mktemp)
|
||||
trap 'rm -f "$TMP_SCAN"' EXIT
|
||||
|
||||
# One at a time - two overlapping netplan applies/backups would race.
|
||||
exec 9> "$LOCK_FILE"
|
||||
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
|
||||
|
||||
case "${1:-}" in
|
||||
status) do_status ;;
|
||||
scan) do_scan ;;
|
||||
connect) do_connect ;;
|
||||
region-status) do_region_status ;;
|
||||
region-set) do_region_set ;;
|
||||
*) reply_error "unknown action: ${1:-}" ;;
|
||||
esac
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
echo "Testing power button configuration..."
|
||||
echo
|
||||
|
||||
echo "1. Checking acpid service..."
|
||||
if systemctl is-active --quiet acpid; then
|
||||
echo " ✓ acpid is running"
|
||||
else
|
||||
echo " ✗ acpid is NOT running"
|
||||
echo " Fix: sudo systemctl enable --now acpid"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "2. Checking ACPI event handler..."
|
||||
if [ -f /etc/acpi/events/kiosk-power-button ]; then
|
||||
echo " ✓ Event handler exists"
|
||||
cat /etc/acpi/events/kiosk-power-button
|
||||
else
|
||||
echo " ✗ Event handler not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "3. Checking power button script..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " ✓ Script exists and is executable"
|
||||
else
|
||||
echo " ✗ Script not found or not executable"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "4. Checking Electron process..."
|
||||
PIDS=$(pgrep -u kiosk -f "electron" 2>/dev/null)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo " ✓ Found Electron PIDs: $PIDS"
|
||||
else
|
||||
echo " ✗ No Electron process found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "5. Testing power button trigger NOW..."
|
||||
if [ -x /usr/local/bin/kiosk-power-button.sh ]; then
|
||||
echo " Running: /usr/local/bin/kiosk-power-button.sh"
|
||||
/usr/local/bin/kiosk-power-button.sh
|
||||
echo " Check if power menu appeared!"
|
||||
else
|
||||
echo " Script not found"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "6. To watch ACPI events: sudo acpi_listen"
|
||||
echo " Then press power button and look for 'button/power' events"
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh
|
||||
#
|
||||
# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and
|
||||
# a fixture stack standing in for library/, so it needs no root, no Docker and
|
||||
# no network - and cannot touch a real kiosk. (The real stack is exercised by
|
||||
# library/install.sh itself.)
|
||||
#
|
||||
# bash tests/addon_library.test.sh
|
||||
################################################################################
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
T="$(mktemp -d)"
|
||||
trap 'rm -rf "$T"' EXIT
|
||||
|
||||
PASS=0 FAIL=0
|
||||
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
|
||||
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
|
||||
check() { # check "description" command...
|
||||
local d="$1"; shift
|
||||
if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi
|
||||
}
|
||||
|
||||
# ---- stubs --------------------------------------------------------------------
|
||||
mkdir -p "$T/bin"
|
||||
cat > "$T/bin/sudo" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Stub sudo: drop "-u USER" and run the command as-is.
|
||||
if [[ "$1" == "-u" ]]; then shift 2; fi
|
||||
exec "$@"
|
||||
EOF
|
||||
cat > "$T/bin/docker" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Stub docker: logs every call; container state comes from $STUB_STATE.
|
||||
echo "$*" >> "$STUB_LOG"
|
||||
case "$1" in
|
||||
--version) echo "Docker version 99.0.0, build stub"; exit 0 ;;
|
||||
info) exit 0 ;;
|
||||
compose)
|
||||
[[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; }
|
||||
exit 0 ;;
|
||||
inspect)
|
||||
st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)"
|
||||
[[ "$st" == "missing" ]] && exit 1
|
||||
case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$T/bin/sudo" "$T/bin/docker"
|
||||
export PATH="$T/bin:$PATH"
|
||||
export STUB_LOG="$T/docker.log" STUB_STATE="$T/state"
|
||||
echo missing > "$STUB_STATE"; : > "$STUB_LOG"
|
||||
|
||||
# ---- fixture "checkout" with a stub library stack ----------------------------
|
||||
FIX="$T/checkout"
|
||||
mkdir -p "$FIX/library/koha/certs"
|
||||
echo "name: library" > "$FIX/library/docker-compose.yml"
|
||||
echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env"
|
||||
echo "cert" > "$FIX/library/koha/certs/proxy.crt"
|
||||
echo "koha-file" > "$FIX/library/koha/Dockerfile"
|
||||
cat > "$FIX/library/install.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Stub of library/install.sh: records how it was called and writes a .env.
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG"
|
||||
[[ "${1:-}" == "--update" ]] && exit 0
|
||||
cat > "$HERE/.env" << ENV
|
||||
LIBRARY_NAME="${LIBRARY_NAME:-}"
|
||||
OPAC_PORT=8080
|
||||
STAFF_PORT=8081
|
||||
QUICKADD_PORT=8082
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=s3cretPASS
|
||||
VIEWER_USER=guest
|
||||
VIEWER_PASSWORD=guestPASS
|
||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||
ENV
|
||||
echo running > "$STUB_STATE"
|
||||
EOF
|
||||
|
||||
# ---- environment the addon expects -------------------------------------------
|
||||
export KIOSK_USER="$(id -un)"
|
||||
export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json"
|
||||
export LIBRARY_DIR="$T/opt-library"
|
||||
SCRIPT_DIR="$FIX"
|
||||
mkdir -p "$KIOSK_DIR"
|
||||
cat > "$CONFIG_PATH" << 'EOF'
|
||||
{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}],
|
||||
"homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"}
|
||||
EOF
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$REPO/lib/menu.sh"
|
||||
source "$REPO/lib/config.sh"
|
||||
source "$REPO/lib/docker.sh"
|
||||
source "$REPO/menus/addon_library.sh"
|
||||
export KIOSK_UNATTENDED=0
|
||||
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
||||
|
||||
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
|
||||
|
||||
# ============================================================================
|
||||
echo "== lib/docker.sh"
|
||||
check "docker_is_installed sees the stub" docker_is_installed
|
||||
check "container state: missing" test "$(docker_container_state library-koha)" = missing
|
||||
echo running > "$STUB_STATE"
|
||||
check "container state: running" test "$(docker_container_state library-koha)" = running
|
||||
check "container health: healthy" test "$(docker_container_health library-koha)" = healthy
|
||||
echo missing > "$STUB_STATE"
|
||||
check "docker_free_port returns a number" test -n "$(docker_free_port 20000)"
|
||||
|
||||
# ============================================================================
|
||||
echo "== sync files"
|
||||
mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env"
|
||||
library_sync_files
|
||||
check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile"
|
||||
check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV
|
||||
check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt"
|
||||
rm -rf "$LIBRARY_DIR"
|
||||
|
||||
# ============================================================================
|
||||
echo "== not installed"
|
||||
if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi
|
||||
addon_library_menu_builder
|
||||
check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog"
|
||||
check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed"
|
||||
|
||||
# ============================================================================
|
||||
echo "== install action (kiosk-only network, show on kiosk)"
|
||||
# answers: library name, allow-network=n, show-on-kiosk=y, <Enter> for pause
|
||||
printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1
|
||||
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
||||
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
||||
check "is_installed is now true" library_is_installed
|
||||
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
|
||||
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
|
||||
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
|
||||
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
||||
addon_library_status > "$T/status.out"
|
||||
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
||||
|
||||
# ============================================================================
|
||||
echo "== menu while installed"
|
||||
addon_library_menu_builder
|
||||
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
|
||||
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
||||
|
||||
echo "== tab toggling is idempotent"
|
||||
library_add_tab >/dev/null 2>&1
|
||||
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
||||
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
addon_library_menu_builder
|
||||
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
||||
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||
|
||||
# ============================================================================
|
||||
echo "== an earlier version's tab (public catalogue address) is cleaned up"
|
||||
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
|
||||
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
|
||||
library_remove_tab >/dev/null 2>&1
|
||||
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
|
||||
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
library_add_tab >/dev/null 2>&1
|
||||
|
||||
echo "== network access"
|
||||
: > "$STUB_LOG"
|
||||
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
||||
check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0
|
||||
check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG"
|
||||
printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only
|
||||
check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1
|
||||
|
||||
# ============================================================================
|
||||
echo "== access screen"
|
||||
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
||||
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
|
||||
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
||||
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
||||
|
||||
# ============================================================================
|
||||
echo "== update"
|
||||
: > "$STUB_LOG"
|
||||
printf 'y\n\n' | action_library_update >/dev/null 2>&1
|
||||
check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG"
|
||||
check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS
|
||||
|
||||
# ============================================================================
|
||||
echo "== uninstall keeps the books by default"
|
||||
: > "$STUB_LOG"
|
||||
printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
||||
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
||||
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
||||
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
|
||||
echo "== purge needs the typed confirmation"
|
||||
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env"
|
||||
: > "$STUB_LOG"
|
||||
library_add_tab >/dev/null 2>&1
|
||||
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
||||
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
||||
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
|
||||
echo
|
||||
echo "passed: $PASS failed: $FAIL"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
+288
-4
@@ -1,8 +1,292 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### Ubuntu Based Kiosk v2.9.0 ###
|
||||
### Ubuntu Based Kiosk v2.17.0 ###
|
||||
################################################################################
|
||||
#
|
||||
# RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/
|
||||
# Reconfigure Addons + Check for Updates
|
||||
# - Web UI (Addons -> Web UI) now installs by default during first-time
|
||||
# provisioning (lib/provision.sh's provision_configure_webui), not
|
||||
# opt-in - fixed port 8090, no prompt (matches every other core step).
|
||||
# The Addons menu entry still works standalone for reconfiguring the
|
||||
# port or reinstalling it on a kiosk provisioned before this change.
|
||||
# - The web UI can now install/reconfigure CUPS Printing, LMS Server,
|
||||
# Squeezelite Player, and Asterisk Intercom, and check for updates -
|
||||
# the same four addons plus Update this project's user asked for by
|
||||
# name. Every one of these is the exact same interactive action_*
|
||||
# function the terminal menu already uses (action_install_cups,
|
||||
# action_install_lms, action_install_squeezelite,
|
||||
# action_configure_asterisk_intercom, action_upgrade) - no
|
||||
# prompt/mutation refactor of any addon file, driven instead by
|
||||
# piping the right answers on stdin, the same technique this
|
||||
# project's own bash tests already use to drive these functions.
|
||||
# - Privilege model: the web service itself still runs as $KIOSK_USER
|
||||
# with zero ambient sudo. A new narrow, allow-listed root helper
|
||||
# (menus/addon_webui.sh's webui_write_helper_script, reachable only
|
||||
# via a single-path passwordless sudo rule generated and validated
|
||||
# with `visudo -c -f` before being installed) is the only way the web
|
||||
# UI ever gains privilege, and it re-checks its own fixed action
|
||||
# allow-list before dispatching anything - a request that reaches it
|
||||
# can only ever trigger one of five vetted actions, never a root
|
||||
# shell. Chosen over running the whole service as root after asking
|
||||
# directly: this repo has no login of its own by design (Authelia
|
||||
# runs elsewhere), so a request that reaches it with no reverse proxy
|
||||
# in front is effectively unauthenticated - the allow-list bounds
|
||||
# what that can actually do.
|
||||
# - Long-running installs stream live output to the browser via
|
||||
# Server-Sent Events (webui/lib/jobs.js), with only one action
|
||||
# running at a time (a second request while one is in flight gets a
|
||||
# clear 409, not silently queued or dropped).
|
||||
# - Full visual redesign: a sidebar shell (Sites/Display/Lockout/
|
||||
# Addons/Update) replacing the single scrolling page of three cards,
|
||||
# both light and dark themes via prefers-color-scheme, no external
|
||||
# font/CDN dependency.
|
||||
# - A real bug was found and fixed by actually driving the redesigned
|
||||
# UI in a headless browser, not just by reading the code: refreshing
|
||||
# an addon's pill/button after a successful install used to rebuild
|
||||
# the whole card, which raced (and usually lost to) the success
|
||||
# status/log that same job had just written a moment earlier. Fixed
|
||||
# to update pill/buttons in place, leaving the completed job's log
|
||||
# exactly as the user left it.
|
||||
# - Uninstall-via-web is deliberately still not offered, for any addon -
|
||||
# flagged as needing its own double-confirmation design, not bundled
|
||||
# into this pass. WiFi, Timezone, Power/Display/Quiet Hours,
|
||||
# Diagnostics, Remote Access, Authelia, Factory Reset, Virtual
|
||||
# Consoles, Emergency Hotspot, Clone Settings, and the fleet/
|
||||
# multi-kiosk dashboard all remain out of scope for the web UI too,
|
||||
# each a named, sequenced follow-up rather than an oversight.
|
||||
#
|
||||
# RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh ->
|
||||
# Addons -> Web UI)
|
||||
# - New: a small Node/Express app (webui/) installable via ./install.sh's
|
||||
# Addons menu, giving a browser-based editor for Sites & Page Timing,
|
||||
# Display & Interaction, and Password Protection & Lockout - the three
|
||||
# Core Settings menus that are pure config.json read/write with no
|
||||
# privileged system mutation involved. Runs as a systemd service under
|
||||
# $KIOSK_USER (the same user Electron runs as), so it never needs sudo
|
||||
# - it can read/write config.json directly with normal filesystem
|
||||
# permissions. webui/lib/config.js re-implements lib/config.sh's exact
|
||||
# schema and merge-on-save contract in JS (kiosk-app/main.js already
|
||||
# reads the same file directly in JS - established precedent, not a
|
||||
# new pattern), so it can never silently clobber fields it doesn't
|
||||
# track (Authelia's credentials, quiet-hours fields, etc) - the same
|
||||
# failure mode previously fixed in lib/config.sh's own history.
|
||||
# - No login of its own, by design - Authelia runs elsewhere, and the
|
||||
# expectation is a reverse proxy (e.g. Caddy) with Authelia forward-
|
||||
# auth in front of it, the same way other self-hosted apps get
|
||||
# protected. Direct LAN access with no proxy in front has no
|
||||
# authentication at all - treat it like SSH access to the kiosk.
|
||||
# - Deliberately narrow scope for this first pass: WiFi, Timezone,
|
||||
# Power/Display/Quiet Hours, Complete Uninstall, every other addon,
|
||||
# and everything in Advanced remain terminal-only - a network-facing
|
||||
# process shouldn't be handed sudo-level system mutation (netplan,
|
||||
# timedatectl, apt, systemd timers) without a lot more thought than
|
||||
# this pass gives it. A "restart kiosk display" action was left out
|
||||
# for the same reason - would need a narrow polkit grant, follow-up.
|
||||
# - Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings
|
||||
# (webui addon-presence detection) the same way every other addon is.
|
||||
# - This is the single-kiosk piece of the planned web-based GUI
|
||||
# (mentioned in this repo's "Modular Management" notes for a while) -
|
||||
# a central multi-kiosk fleet dashboard is an intentional follow-up,
|
||||
# not part of this pass.
|
||||
#
|
||||
# RELEASE v2.15.0 - Upgrade Migrated to install.sh (Advanced -> Upgrade)
|
||||
# - New in ./install.sh's Advanced menu: Upgrade. Not a port of this
|
||||
# script's Upgrade - that one re-extracted main.js/preload.js/etc from
|
||||
# its own heredocs on every run, a mechanism that has no equivalent
|
||||
# here now that kiosk-app/ and provision/files/ are real files in the
|
||||
# git checkout. The modular Upgrade is `git pull` (only after
|
||||
# confirming the working tree is clean and the pull is a fast-forward
|
||||
# - never an automatic merge) followed by re-running the same
|
||||
# packages/kiosk-app/display/firewall/power-management steps
|
||||
# lib/provision.sh already has for a fresh install, reused rather than
|
||||
# reimplemented. Skips the interactive first-run settings wizard and
|
||||
# the "reboot now" prompt - those don't belong in a routine upgrade.
|
||||
# - Also offers an on-demand Electron version check/update regardless of
|
||||
# whether there was any code to pull, since Electron isn't versioned
|
||||
# by this repo - reuses the existing, already-tested
|
||||
# action_update_electron (menus/advanced_electron.sh) as-is.
|
||||
# - Requires a git checkout (not the no-git ZIP download option) and a
|
||||
# clean working tree; a diverged local history fails the pull cleanly
|
||||
# with a clear message instead of attempting an automatic merge.
|
||||
# - Full Reinstall dropped, not carried forward - it never worked
|
||||
# reliably in this script either, and the modular tool already covers
|
||||
# the same outcome more reliably as two already-tested pieces run back
|
||||
# to back: Complete Uninstall (Core Settings), then ./install.sh again
|
||||
# to provision fresh. No dedicated combined action needed.
|
||||
#
|
||||
# RELEASE v2.14.0 - install.sh Now Provisions a Kiosk From Scratch,
|
||||
# Not Just Manages an Existing One
|
||||
# - Until now, ./install.sh only worked against an already-installed
|
||||
# kiosk (this script was still the only path from a bare Ubuntu
|
||||
# Server box to a running one). It now provisions too: on a machine
|
||||
# with no kiosk-app directory yet, it installs packages, creates the
|
||||
# kiosk user, installs Node.js/Electron, sets up LightDM+Openbox
|
||||
# autologin, audio/video/HDMI/power-button hardware handling, the
|
||||
# firewall, then hands off to the same Core Settings menus below for
|
||||
# initial configuration - matching this script's own install-then-
|
||||
# configure flow, on the new modular codebase.
|
||||
# - New: lib/provision.sh (the provisioning steps, built almost
|
||||
# entirely by calling already-migrated menus - core_settings_menu,
|
||||
# action_configure_emergency_hotspot, action_disable_virtual_consoles
|
||||
# - rather than reimplementing that logic a third time), lib/electron.sh
|
||||
# (electron_install_binary, extracted out of menus/advanced_electron.sh
|
||||
# so both fresh provisioning and the existing "Fix blank screen"
|
||||
# action share one implementation), kiosk-app/ (the Electron app
|
||||
# source - main.js, preload.js, the dialog HTML files, package.json,
|
||||
# start.sh - extracted byte-for-byte out of this script's heredocs
|
||||
# into real files), provision/files/ (every other system template
|
||||
# file - X11 configs, udev rules, systemd units, the power-button and
|
||||
# HDMI-mirroring scripts, polkit rules - laid out mirroring their real
|
||||
# destination paths, e.g. provision/files/etc/X11/xorg.conf.d/foo.conf
|
||||
# installs to /etc/X11/xorg.conf.d/foo.conf).
|
||||
# - Reusing the already-migrated menus instead of reimplementing
|
||||
# first-time configuration cut lib/provision.sh down to roughly 300
|
||||
# lines against this script's ~4,000-line first_time_install().
|
||||
# - Fixed along the way: a bash `set -e` gotcha where testing a
|
||||
# multi-statement function as an if-condition (`if ! some_func; then`)
|
||||
# silently exempts everything inside that function from set -e for
|
||||
# the duration - found via direct testing while writing the new
|
||||
# provisioning code, then swept for elsewhere and also fixed in
|
||||
# menus/advanced_electron.sh's existing "Fix blank screen" action
|
||||
# (its electron_install_binary call had the same shape).
|
||||
# - Known, deliberate limitation carried over unchanged from this
|
||||
# script: a few of the extracted system scripts (start.sh,
|
||||
# kiosk-hotplug.sh, the power-button handler) hardcode the username
|
||||
# "kiosk" rather than substituting $KIOSK_USER, exactly as the
|
||||
# quoted heredocs here always did. Fine unless $KIOSK_USER is
|
||||
# overridden from its default, which in practice it almost never is.
|
||||
# - Still not ported to ./install.sh: Upgrade and Full Reinstall, both
|
||||
# coupled to this script's own heredoc self-extraction - a different
|
||||
# mechanism than the new provisioning (which copies real files from
|
||||
# kiosk-app/ and provision/files/, not heredocs). This script remains
|
||||
# the way to upgrade/reinstall an existing install for now.
|
||||
#
|
||||
# RELEASE v2.13.0 - Clone Settings: New MVP for Standing Up Several
|
||||
# Kiosks with the Same Settings
|
||||
# - New in ./install.sh's Advanced menu: Clone Settings
|
||||
# (menus/clone_settings.sh). Not a port of the legacy Export/Import
|
||||
# Settings - a narrower, deliberately-scoped feature for the "set up
|
||||
# one kiosk, then stamp out a dozen more like it" use case: export the
|
||||
# portable parts of config.json (sites, display/touch/navigation,
|
||||
# lockout, password protection) to a JSON file, apply that file to any
|
||||
# other already-installed kiosk.
|
||||
# - Explicitly does NOT copy machine-bound credentials, because copying
|
||||
# them would be actively wrong, not just incomplete: Authelia's
|
||||
# encrypted password is keyed off /etc/machine-id and decrypts to
|
||||
# garbage elsewhere; a WireGuard private key is a device identity and
|
||||
# reusing one across machines is a peer conflict; most Asterisk PBXes
|
||||
# reject two simultaneous registrations to the same extension. Apply
|
||||
# prints these as an explicit "needs a human" checklist instead of
|
||||
# silently skipping them or (worse) cloning them.
|
||||
# - Does not install missing addons - only records which addons were
|
||||
# present at export time and reports which of those are/aren't
|
||||
# present on the machine being applied to. Non-interactive addon
|
||||
# installation (so applying a profile needs zero prompts, scriptable
|
||||
# over SSH to a whole fleet) is deliberately left as a follow-up, not
|
||||
# bundled into this MVP.
|
||||
#
|
||||
# RELEASE v2.12.0 - Complete Uninstall Migrated (Last of the
|
||||
# "Destructive Trio"); Composed, Not Re-Implemented
|
||||
# - New in ./install.sh's Core Settings menu: Complete Uninstall
|
||||
# (menus/complete_uninstall.sh). Rather than re-implementing every
|
||||
# addon's teardown a second time (the shape this function had in the
|
||||
# legacy script - CUPS/VNC/WireGuard/Tailscale/Netbird/LMS/Squeezelite
|
||||
# removal logic all inlined again, independently of the same logic in
|
||||
# each addon's own uninstall action), it composes the *_do_uninstall
|
||||
# helpers each addon already has. If an addon's removal logic changes,
|
||||
# Complete Uninstall picks it up automatically instead of silently
|
||||
# drifting out of sync.
|
||||
# - Every addon menu that had an uninstall action (CUPS, VNC, WireGuard,
|
||||
# Tailscale, Netbird, LMS, Squeezelite, Asterisk Intercom) plus
|
||||
# power_schedule's "remove all schedules" and the Emergency Hotspot
|
||||
# disable action were each split into a confirm-and-call wrapper (the
|
||||
# existing interactive action, unchanged from the user's perspective)
|
||||
# and a silent do-the-removal helper that both the wrapper and
|
||||
# Complete Uninstall call - no duplicated removal logic anywhere.
|
||||
# - IMPORTANT bug found and fixed while composing these: several
|
||||
# *_do_uninstall helpers (CUPS's `apt autoremove`/`apt clean`, and
|
||||
# VNC/WireGuard/Tailscale/Netbird's `apt remove`) had a bare, unguarded
|
||||
# `apt` call as their second-to-last statement. Previously this only
|
||||
# risked aborting that one menu action if the package was already
|
||||
# gone (silently caught by run_menu's own guard) - a minor UX
|
||||
# blemish. Composed together as bare sequential calls inside Complete
|
||||
# Uninstall, the same failure would have silently truncated the
|
||||
# *entire* uninstall sequence partway through - e.g. the kiosk user
|
||||
# might never get removed because an already-uninstalled VPN client's
|
||||
# `apt remove` failed first. Guarded all of them with `|| true`,
|
||||
# fixing the risk in both the standalone action and the composition.
|
||||
# - Non-addon teardown (kiosk user/files, Node.js, LightDM/Openbox,
|
||||
# remaining systemd units/scripts, polkit rules, re-enabling virtual
|
||||
# consoles, final package cleanup) stays inline in
|
||||
# menus/complete_uninstall.sh, same as the legacy script, since no
|
||||
# single addon owns those paths.
|
||||
# - Upgrade and Full Reinstall remain in ubuntu-based-kiosk.sh only -
|
||||
# both are fundamentally coupled to this file's own heredoc self-
|
||||
# extraction of main.js/preload.js/etc, which has no equivalent in the
|
||||
# modular system yet. This closes out the "destructive trio."
|
||||
#
|
||||
# RELEASE v2.11.0 - 4 More Advanced Items Migrated (Electron Maintenance,
|
||||
# Factory Reset, Virtual Consoles, Emergency Hotspot)
|
||||
# - New in ./install.sh's Advanced menu, alongside Diagnostics:
|
||||
# - menus/advanced_electron.sh - "Electron Maintenance": the legacy
|
||||
# "Manual Electron Update" and "Fix Blank Screen" combined under one
|
||||
# submenu, since both maintain the same installation and share the
|
||||
# binary-repair logic (electron_install_binary).
|
||||
# - menus/advanced_factory_reset.sh - "Factory Reset": wipes
|
||||
# config.json back to defaults only - addons are untouched.
|
||||
# - menus/advanced_virtual_consoles.sh - "Virtual Consoles": toggles
|
||||
# Ctrl+Alt+F1-F8 terminal login access.
|
||||
# - menus/advanced_emergency_hotspot.sh - "Emergency Hotspot": auto-
|
||||
# starts a WiFi hotspot if no internet is detected 60 seconds after
|
||||
# boot. Its own runtime script and systemd unit now go through
|
||||
# $BIN_DIR/$SYSTEMD_DIR like every other addon's own files, instead
|
||||
# of the legacy's hardcoded /usr/local/bin and /etc/systemd/system.
|
||||
# - That leaves Diagnostics' original 4 items plus these 4 covering 8 of
|
||||
# the legacy Advanced menu's 12 entries. Not migrated this round:
|
||||
# Export/Import Settings (kept in the legacy script pending a decision
|
||||
# on whether it's worth rebuilding around actual paths instead of a
|
||||
# hardcoded per-addon step list, or whether the future web UI replaces
|
||||
# the need for it) and Fix Squeezelite Audio (small and specific
|
||||
# enough that it may fold into menus/addon_lms_squeezelite.sh instead
|
||||
# of staying a standalone Advanced entry - not decided yet).
|
||||
# - Complete Uninstall (the last of the "destructive trio") is next,
|
||||
# composed from each addon's own uninstall action plus core teardown
|
||||
# rather than rewriting removal logic a second time. Upgrade and Full
|
||||
# Reinstall stay in this script for now: both are fundamentally
|
||||
# coupled to this file's own heredoc self-extraction of main.js/
|
||||
# preload.js/etc, which has no equivalent yet in the modular system.
|
||||
#
|
||||
# RELEASE v2.10.0 - Asterisk Intercom Migrated, Redesigned as a SIP
|
||||
# Extension Client (No More PBX Server Install)
|
||||
# - New in ./install.sh: Asterisk Intercom (menus/addon_asterisk_intercom.sh).
|
||||
# The legacy addon offered three options: Client Only (a Baresip SIP
|
||||
# client), Server Only, and Full (server + client) - the latter two
|
||||
# downloaded and ran a third-party installer from a separate "Easy
|
||||
# Asterisk" repository to stand up a whole Asterisk PBX. That
|
||||
# repository has since gone through a major rework upstream, so this
|
||||
# migration drops the PBX-install path entirely rather than carrying
|
||||
# a dependency on code that's moved on without it. The addon now does
|
||||
# only the client/endpoint piece: install Baresip and register it as
|
||||
# one SIP extension against an Asterisk server the user already has
|
||||
# running somewhere else. It never installs or manages Asterisk
|
||||
# itself. The legacy script's own three-option version is untouched -
|
||||
# both copies coexist deliberately, same as every other migrated menu.
|
||||
# - Dropped the legacy client path's dependency on the (now-reworked)
|
||||
# Easy Asterisk repo's GitHub API for version tracking. It now reads
|
||||
# the real installed `baresip` package version via dpkg instead - one
|
||||
# less network dependency and one less thing to keep in sync with an
|
||||
# external repo.
|
||||
# - New capability: an uninstall option for the Baresip client, which
|
||||
# the legacy addon never had at all.
|
||||
# - Bug fix (found while porting): `baresip_installed_version()`'s
|
||||
# `dpkg-query` call fails (as expected) when the package isn't
|
||||
# installed, and the unguarded `ver=$(...)` assignment around it would
|
||||
# have crashed the whole session under this tool's `set -e` the first
|
||||
# time status was checked before Baresip was installed. Guarded with
|
||||
# `|| true` - the same class of bug hunted throughout this migration,
|
||||
# caught by testing before it shipped.
|
||||
#
|
||||
# RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated;
|
||||
# is_service_enabled() Dead Pre-Check Fixed
|
||||
# - New in ./install.sh: LMS Server / Squeezelite Player
|
||||
@@ -349,7 +633,7 @@ set -euo pipefail
|
||||
### SECTION 1: CONSTANTS & GLOBALS
|
||||
################################################################################
|
||||
|
||||
SCRIPT_VERSION="2.9.0"
|
||||
SCRIPT_VERSION="2.17.0"
|
||||
|
||||
# Resolve the real path to this script file.
|
||||
# When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,
|
||||
@@ -9039,7 +9323,7 @@ addon_netbird() {
|
||||
echo "Get setup key from Netbird dashboard"
|
||||
read -r -p "Enter setup key: " setup_key
|
||||
if [[ -n "$setup_key" ]]; then
|
||||
sudo netbird up --setup-key "$setup_key"
|
||||
sudo netbird up --setup-key "$setup_key" --allow-server-ssh
|
||||
log_success "Netbird connected"
|
||||
fi
|
||||
pause
|
||||
@@ -9070,7 +9354,7 @@ addon_netbird() {
|
||||
if [[ "$do_connect" =~ ^[Yy]$ ]]; then
|
||||
echo "Get setup key from Netbird dashboard"
|
||||
read -r -p "Enter setup key: " setup_key
|
||||
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key"
|
||||
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key" --allow-server-ssh
|
||||
fi
|
||||
fi
|
||||
pause
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
|
||||
// and how to turn a web form's fields into the exact stdin sequence the
|
||||
// real bash `action_*` function expects.
|
||||
//
|
||||
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
|
||||
// generated kiosk-webui-helper script) - kept in sync by hand rather
|
||||
// than shared/generated, since both lists are short and deliberately
|
||||
// curated. A mismatch between the two just means an action fails closed
|
||||
// on whichever side is missing it, never open on both: server.js checks
|
||||
// this list before spawning anything, and the helper script re-checks
|
||||
// its own list before dispatching regardless of what server.js sent.
|
||||
//
|
||||
// Every buildStdin() below was verified against the real menus/*.sh
|
||||
// source (prompt order, defaults, and which fields reject a blank
|
||||
// answer and re-prompt) - see webui/test/actions.test.js, which drives
|
||||
// the actual bash functions with this exact output and checks the real
|
||||
// resulting state (cups_is_installed, lms_is_installed, etc), not just
|
||||
// that the process exits 0.
|
||||
|
||||
const ACTIONS = {
|
||||
install_cups: {
|
||||
helperAction: 'action_install_cups',
|
||||
label: 'Install CUPS Printing',
|
||||
fields: [],
|
||||
// action_install_cups's only prompt is "Install CUPS printing?
|
||||
// (n)" - the web UI's own install button is the confirmation,
|
||||
// so this always answers yes. No pause() in this function.
|
||||
buildStdin() {
|
||||
return 'y\n';
|
||||
},
|
||||
},
|
||||
|
||||
reconfigure_cups: {
|
||||
helperAction: 'action_reconfigure_cups',
|
||||
label: 'Reconfigure CUPS for network access',
|
||||
fields: [],
|
||||
// No prompts at all, no pause().
|
||||
buildStdin() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
install_lms: {
|
||||
helperAction: 'action_install_lms',
|
||||
label: 'Install / reconfigure LMS Server',
|
||||
// fields.alreadyInstalled must reflect real current state
|
||||
// (server.js fills this in from lms_is_installed via the
|
||||
// helper's own status check before offering the reconfigure
|
||||
// fields) - action_install_lms branches on it internally and a
|
||||
// wrong guess here desyncs the stdin sequence from what the
|
||||
// real function actually prompts for.
|
||||
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled) {
|
||||
if (!f.reconfigurePort) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const port = Number(f.newPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('newPort must be an integer 1-65535');
|
||||
}
|
||||
return `y\n${port}\n\n`; // accept, new port, pause()
|
||||
}
|
||||
return '\n'; // fresh install: fully automated except pause()
|
||||
},
|
||||
},
|
||||
|
||||
install_squeezelite: {
|
||||
helperAction: 'action_install_squeezelite',
|
||||
label: 'Install / reconfigure Squeezelite Player',
|
||||
// If already installed, the real function first asks
|
||||
// "Reconfigure?" (default n) and, if declined, returns
|
||||
// immediately after just the pause() - it does NOT fall through
|
||||
// to the player-name/server prompts. f.reconfigure must be
|
||||
// explicit (not inferred from other fields) so the web UI can
|
||||
// offer "leave it as-is" without also having to resend the
|
||||
// current values.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
|
||||
lines.push(f.playerName || ''); // blank -> "Kiosk" default
|
||||
lines.push(f.lmsServer || ''); // blank -> auto-discovery
|
||||
// "Reboot now?" is always answered "n" here regardless of
|
||||
// what the UI shows - triggering a real `sudo reboot` from
|
||||
// inside a one-click addon-install action is out of scope
|
||||
// for this pass (see webui phase-2 plan). The UI surfaces
|
||||
// "reboot required to start Squeezelite" as an info banner
|
||||
// instead of a real remote reboot trigger.
|
||||
lines.push('n');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
configure_asterisk_intercom: {
|
||||
helperAction: 'action_configure_asterisk_intercom',
|
||||
label: 'Configure Asterisk Intercom',
|
||||
// Same shape as Squeezelite's reconfigure gate: if already
|
||||
// installed, the real function asks "Reconfigure with a
|
||||
// different server/extension?" (default n) and returns after
|
||||
// just the pause() if declined - the rest of this sequence is
|
||||
// never reached in that case.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
// Only present at all when baresip_is_installed is already
|
||||
// true - a fresh install has no "Reconfigure?" prompt.
|
||||
if (f.alreadyInstalled) lines.push('y');
|
||||
|
||||
// Server IP and extension reject a blank answer and
|
||||
// re-prompt (a `while [[ -z ... ]]` loop in the real
|
||||
// function) - sending an empty line here would desync the
|
||||
// rest of the sequence by consuming a second prompt cycle,
|
||||
// so these are validated up front instead.
|
||||
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
|
||||
lines.push(String(f.serverIp).trim());
|
||||
|
||||
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
|
||||
|
||||
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
|
||||
lines.push(String(f.extension).trim());
|
||||
|
||||
// Password also rejects blank and re-prompts, same reason.
|
||||
if (!f.password) throw new Error('password is required');
|
||||
lines.push(f.password);
|
||||
|
||||
lines.push(f.autoAnswer ? 'y' : 'n');
|
||||
lines.push(f.useTls ? 'y' : 'n');
|
||||
// "Proceed with installation?" (default y) - already
|
||||
// confirmed by the web click that got us here.
|
||||
lines.push('y');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
upgrade: {
|
||||
helperAction: 'action_upgrade',
|
||||
label: 'Check for and apply updates',
|
||||
fields: [],
|
||||
buildStdin() {
|
||||
// action_upgrade's own flow: "Pull these changes...?" (y),
|
||||
// then - only if there was anything to pull -
|
||||
// "Restart kiosk display now...?" (y), then always
|
||||
// "Check for and install the latest Electron...?", answered
|
||||
// n here. That sub-flow's own prompts default to declining
|
||||
// and aren't a good fit for one-click automation yet (see
|
||||
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
|
||||
// to a prompt that never actually gets shown (nothing to
|
||||
// pull, or the display-restart question) is harmless - a
|
||||
// synthesized line bash never reads is simply left unread,
|
||||
// not an error.
|
||||
return 'y\ny\nn\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getAction(name) {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS, getAction };
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/config.js - config.json read/write for the web UI.
|
||||
//
|
||||
// Mirrors lib/config.sh's load_existing_config()/save_config() contract
|
||||
// exactly, in JS instead of jq, so the web UI and the terminal menus stay
|
||||
// in sync against the same file without either one going through the
|
||||
// other. kiosk-app/main.js already reads this same config.json directly
|
||||
// in JS (its own fs.readFileSync/JSON.parse, no bash involved) - this is
|
||||
// established precedent in this repo, not a new pattern.
|
||||
//
|
||||
// Tracks exactly the fields Sites & Page Timing (menus/sites.sh),
|
||||
// Display & Interaction (menus/display.sh), and Password Protection &
|
||||
// Lockout (menus/lockout.sh) track. lockoutActiveStart/lockoutActiveEnd
|
||||
// are deliberately excluded, matching lockout.sh's own header comment:
|
||||
// "the app doesn't act on them... lib/config.sh just carries whatever is
|
||||
// already in config.json through unchanged." Anything else present in an
|
||||
// existing file (autheliaURL, autheliaUsername,
|
||||
// autheliaEncryptedPassword, lockoutActiveStart/End, or any future field)
|
||||
// is opaque passthrough data - saveConfig() merges onto it, never
|
||||
// rebuilds from nothing, so none of it is ever silently deleted. That
|
||||
// exact failure mode was a real, previously-fixed bug in lib/config.sh's
|
||||
// own history (see its header/body comments) and must not be
|
||||
// reintroduced here.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH;
|
||||
if (!CONFIG_PATH) {
|
||||
throw new Error("CONFIG_PATH environment variable is required (path to the kiosk app's config.json)");
|
||||
}
|
||||
|
||||
const SCALAR_DEFAULTS = {
|
||||
swipeMode: 'dual',
|
||||
allowNavigation: 'same-origin',
|
||||
homeTabIndex: -1,
|
||||
inactivityTimeout: 120,
|
||||
enablePauseButton: true,
|
||||
enableKeyboardButton: true,
|
||||
enableNavButton: true,
|
||||
enablePasswordProtection: false,
|
||||
lockoutTimeout: 0,
|
||||
lockoutAtTime: '',
|
||||
requirePasswordOnBoot: false,
|
||||
// 'password' or 'pin' (4-8 digits, entered on the lock screen's
|
||||
// number pad) - for the unlock code and the optional separate boot code.
|
||||
lockoutCodeType: 'password',
|
||||
bootCodeType: 'password',
|
||||
};
|
||||
|
||||
function readExisting() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// Missing file or invalid JSON - same fallback save_config() uses
|
||||
// in lib/config.sh ("{}" when the file is absent/unparsable).
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeTab(t) {
|
||||
return {
|
||||
url: typeof t.url === 'string' ? t.url : '',
|
||||
duration: Number.isFinite(Number(t.duration)) ? Number(t.duration) : 0,
|
||||
username: typeof t.username === 'string' ? t.username : '',
|
||||
password: typeof t.password === 'string' ? t.password : '',
|
||||
name: typeof t.name === 'string' ? t.name : '',
|
||||
};
|
||||
}
|
||||
|
||||
function hashPassword(plaintext) {
|
||||
return crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
// Mirrors load_existing_config(). Never returns a site's Basic Auth
|
||||
// password or the lockout password hash - both are write-only from here,
|
||||
// same as the terminal menus, which never display a stored password back
|
||||
// either (sites.sh's edit_page_status only ever shows the username;
|
||||
// lockout.sh has no "show current password" path at all).
|
||||
function loadConfig() {
|
||||
const existing = readExisting();
|
||||
const tabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
|
||||
const out = {
|
||||
tabs: tabs.map((t) => ({
|
||||
url: t.url,
|
||||
duration: t.duration,
|
||||
username: t.username,
|
||||
hasPassword: t.password.length > 0,
|
||||
name: t.name,
|
||||
})),
|
||||
};
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
out[key] = key in existing ? existing[key] : def;
|
||||
}
|
||||
out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0;
|
||||
// A boot code of its own (empty bootPassword = the unlock code is used at boot).
|
||||
out.hasBootPassword = typeof existing.bootPassword === 'string' && existing.bootPassword.length > 0;
|
||||
out.dualSwipe = out.swipeMode === 'dual';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mirrors save_config(): merge known fields onto whatever's already on
|
||||
// disk (see file header). `patch` fields are applied only when present -
|
||||
// omitting a field means "leave it as it is", so each frontend section
|
||||
// (Sites / Display / Lockout) can PUT just the fields it owns.
|
||||
function saveConfig(patch) {
|
||||
const existing = readExisting();
|
||||
const merged = { ...existing };
|
||||
|
||||
for (const [key, def] of Object.entries(SCALAR_DEFAULTS)) {
|
||||
if (!(key in merged)) merged[key] = def;
|
||||
}
|
||||
for (const key of Object.keys(SCALAR_DEFAULTS)) {
|
||||
if (key in patch) merged[key] = patch[key];
|
||||
}
|
||||
|
||||
// Password: only touched when the caller explicitly provides a new
|
||||
// plaintext value to hash. Disabling protection clears the whole
|
||||
// lockout state, mirroring lockout.sh's action_disable_protection()
|
||||
// exactly (not just the enabled flag - the password/timeout/daily
|
||||
// lock time too).
|
||||
if (!('lockoutPassword' in merged)) merged.lockoutPassword = '';
|
||||
if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) {
|
||||
merged.lockoutPassword = hashPassword(patch.newLockoutPassword);
|
||||
}
|
||||
// Separate boot code: a new one is hashed in, clearBootPassword goes
|
||||
// back to "boot uses the unlock code".
|
||||
if (!('bootPassword' in merged)) merged.bootPassword = '';
|
||||
if (typeof patch.newBootPassword === 'string' && patch.newBootPassword.length > 0) {
|
||||
merged.bootPassword = hashPassword(patch.newBootPassword);
|
||||
}
|
||||
if (patch.clearBootPassword === true) {
|
||||
merged.bootPassword = '';
|
||||
merged.bootCodeType = 'password';
|
||||
}
|
||||
if (patch.enablePasswordProtection === false) {
|
||||
merged.lockoutPassword = '';
|
||||
merged.lockoutTimeout = 0;
|
||||
merged.lockoutAtTime = '';
|
||||
merged.requirePasswordOnBoot = false;
|
||||
merged.lockoutCodeType = 'password';
|
||||
merged.bootPassword = '';
|
||||
merged.bootCodeType = 'password';
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.tabs)) {
|
||||
const existingTabs = Array.isArray(existing.tabs) ? existing.tabs.map(normalizeTab) : [];
|
||||
merged.tabs = patch.tabs.map((t, i) => {
|
||||
const norm = normalizeTab(t);
|
||||
if (typeof t.password !== 'string') {
|
||||
// No new password supplied for this tab - keep whatever
|
||||
// was already stored at this position (tabs are
|
||||
// positional, not ID-based, matching the bash arrays).
|
||||
norm.password = existingTabs[i] ? existingTabs[i].password : '';
|
||||
}
|
||||
return norm;
|
||||
});
|
||||
} else if (!Array.isArray(merged.tabs)) {
|
||||
merged.tabs = [];
|
||||
}
|
||||
|
||||
merged.autoswitch = true;
|
||||
merged.enableTouch = true;
|
||||
merged.dualSwipe = merged.swipeMode === 'dual';
|
||||
|
||||
const dir = path.dirname(CONFIG_PATH);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.config.json.tmp-${process.pid}-${Date.now()}`);
|
||||
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o644 });
|
||||
fs.renameSync(tmp, CONFIG_PATH);
|
||||
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/jobs.js - runs one privileged action at a time via the
|
||||
// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper),
|
||||
// and keeps an in-memory log so /api/actions/:name/run's caller and any
|
||||
// number of SSE stream reconnects all see the same output. No database -
|
||||
// this tool manages one kiosk, a Map is plenty.
|
||||
//
|
||||
// HELPER_PATH and SUDO_CMD are both overridable via environment (see
|
||||
// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake
|
||||
// script and clear SUDO_CMD, so the real test suite never needs actual
|
||||
// root or a real addon install - the same principle as every stubbed
|
||||
// bash test in this project, just on the Node side.
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { getAction } = require('./actions');
|
||||
|
||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||
|
||||
const jobs = new Map();
|
||||
let activeJobId = null;
|
||||
|
||||
function startJob(actionName, fields) {
|
||||
const action = getAction(actionName);
|
||||
if (!action) {
|
||||
const err = new Error(`Unknown action: ${actionName}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (activeJobId) {
|
||||
const err = new Error('Another action is already running - wait for it to finish first');
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// buildStdin() validates its own required fields and throws a plain
|
||||
// Error with a human-readable message on bad input - treated as a
|
||||
// 400 here, before anything is spawned.
|
||||
let stdin;
|
||||
try {
|
||||
stdin = action.buildStdin(fields || {});
|
||||
} catch (e) {
|
||||
e.status = 400;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const job = {
|
||||
id: jobId,
|
||||
name: actionName,
|
||||
label: action.label,
|
||||
status: 'running',
|
||||
log: [],
|
||||
exitCode: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
jobs.set(jobId, job);
|
||||
activeJobId = jobId;
|
||||
|
||||
const child = SUDO_CMD
|
||||
? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction])
|
||||
: spawn(HELPER_PATH, [action.helperAction]);
|
||||
|
||||
const appendLine = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
job.log.push(text);
|
||||
for (const listener of job.listeners) listener(text);
|
||||
};
|
||||
child.stdout.on('data', appendLine);
|
||||
child.stderr.on('data', appendLine);
|
||||
|
||||
const finish = (status, exitCode) => {
|
||||
if (job.status !== 'running') return; // 'error' and 'close' can both fire
|
||||
job.status = status;
|
||||
job.exitCode = exitCode;
|
||||
for (const listener of job.listeners) listener(null);
|
||||
if (activeJobId === jobId) activeJobId = null;
|
||||
};
|
||||
child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code));
|
||||
child.on('error', (err) => {
|
||||
job.log.push(`\n[error] ${err.message}\n`);
|
||||
finish('failed', null);
|
||||
});
|
||||
|
||||
child.stdin.write(stdin);
|
||||
child.stdin.end();
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
function getJob(jobId) {
|
||||
return jobs.get(jobId);
|
||||
}
|
||||
|
||||
module.exports = { startJob, getJob };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user