Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab76325c9b | ||
|
|
d4b75ca1b9 | ||
|
|
5792e201b6 | ||
|
|
0b5d958fcc | ||
|
|
96adf6a5e2 | ||
|
|
a170dc6228 |
@@ -39,6 +39,13 @@ on:
|
|||||||
description: Optional SSH public key for the admin user (ssh-ed25519 AAAA...)
|
description: Optional SSH public key for the admin user (ssh-ed25519 AAAA...)
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
|
offline:
|
||||||
|
description: >-
|
||||||
|
Bundle everything install.sh downloads (~420 MB: all packages incl.
|
||||||
|
CUPS printing, Node.js, Electron) so the kiosk installs with no
|
||||||
|
internet on first boot.
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
retention_days:
|
retention_days:
|
||||||
description: Days to keep the ISO artifact
|
description: Days to keep the ISO artifact
|
||||||
type: number
|
type: number
|
||||||
@@ -57,11 +64,12 @@ jobs:
|
|||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -q
|
sudo apt-get update -q
|
||||||
sudo apt-get install -y -q xorriso
|
sudo apt-get install -y -q xorriso apt-utils ubuntu-keyring
|
||||||
|
|
||||||
- name: Build ISO
|
- name: Build ISO
|
||||||
env:
|
env:
|
||||||
MODE: ${{ inputs.mode }}
|
MODE: ${{ inputs.mode }}
|
||||||
|
OFFLINE: ${{ inputs.offline }}
|
||||||
ADMIN_USERNAME: ${{ inputs.username }}
|
ADMIN_USERNAME: ${{ inputs.username }}
|
||||||
ADMIN_HOSTNAME: ${{ inputs.hostname }}
|
ADMIN_HOSTNAME: ${{ inputs.hostname }}
|
||||||
SSH_PUBLIC_KEY: ${{ inputs.ssh_public_key }}
|
SSH_PUBLIC_KEY: ${{ inputs.ssh_public_key }}
|
||||||
@@ -84,19 +92,22 @@ jobs:
|
|||||||
printf '%s\n' "$SSH_PUBLIC_KEY" > "$RUNNER_TEMP/ssh_key.pub"
|
printf '%s\n' "$SSH_PUBLIC_KEY" > "$RUNNER_TEMP/ssh_key.pub"
|
||||||
args+=(--ssh-key "$RUNNER_TEMP/ssh_key.pub")
|
args+=(--ssh-key "$RUNNER_TEMP/ssh_key.pub")
|
||||||
fi
|
fi
|
||||||
|
[[ "$OFFLINE" == "true" ]] && args+=(--offline)
|
||||||
iso/build-iso.sh "${args[@]}"
|
iso/build-iso.sh "${args[@]}"
|
||||||
# Name the file after the Ubuntu point release the script picked.
|
# Name the file after the Ubuntu point release the script picked.
|
||||||
src="$(find iso/build/cache -name "*.iso" -print -quit)"
|
src="$(find iso/build/cache -name "*.iso" -print -quit)"
|
||||||
name="$(basename "$src" .iso | sed 's/-live-server/-kiosk/')-${MODE}-${GITHUB_SHA::7}.iso"
|
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"
|
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.
|
# Free the ~3 GB source ISO before the upload step needs scratch space.
|
||||||
rm -rf iso/build/cache
|
rm -rf iso/build/cache iso/build/offline-cache
|
||||||
(cd "$RUNNER_TEMP/iso" && sha256sum "$name" > "$name.sha256")
|
(cd "$RUNNER_TEMP/iso" && sha256sum "$name" > "$name.sha256")
|
||||||
echo "ISO_NAME=$name" >> "$GITHUB_ENV"
|
echo "ISO_NAME=$name" >> "$GITHUB_ENV"
|
||||||
{
|
{
|
||||||
echo "### $name"
|
echo "### $name"
|
||||||
echo
|
echo
|
||||||
echo "- Mode: \`$MODE\`"
|
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 "- Admin user: ${ADMIN_USERNAME:-chosen during install}"
|
||||||
echo "- Built from: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA::7}\`"
|
echo "- Built from: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA::7}\`"
|
||||||
echo "- SHA-256: \`$(cut -d' ' -f1 "$RUNNER_TEMP/iso/$name.sha256")\`"
|
echo "- SHA-256: \`$(cut -d' ' -f1 "$RUNNER_TEMP/iso/$name.sha256")\`"
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ boot of the installed system it runs `./install.sh` automatically on the
|
|||||||
console.
|
console.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install xorriso curl git openssl
|
|
||||||
git clone https://github.com/outis1one/ubuntu-based-kiosk.git
|
git clone https://github.com/outis1one/ubuntu-based-kiosk.git
|
||||||
cd ubuntu-based-kiosk
|
cd ubuntu-based-kiosk
|
||||||
iso/build-iso.sh # downloads + verifies the latest 26.04.x server ISO
|
iso/build-iso.sh # downloads + verifies the latest 26.04.x server ISO
|
||||||
@@ -77,6 +76,10 @@ iso/build-iso.sh # downloads + verifies the latest 26.04.x server ISO
|
|||||||
sudo dd if=iso/build/ubuntu-26.04.*-kiosk-amd64.iso of=/dev/sdX bs=4M status=progress oflag=sync
|
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),
|
By default the installer only asks for **network** (WiFi works here),
|
||||||
**disk**, and your **admin username/password** — language, keyboard and the
|
**disk**, and your **admin username/password** — language, keyboard and the
|
||||||
SSH server are answered for you. On first boot, `install.sh` runs
|
SSH server are answered for you. On first boot, `install.sh` runs
|
||||||
@@ -94,8 +97,41 @@ Useful options (`iso/build-iso.sh --help` for all):
|
|||||||
| `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
|
| `--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. |
|
| `--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.** |
|
| `--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. |
|
| `--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 ~420 MB bundle to the ISO:
|
||||||
|
- Every Ubuntu package the kiosk install and the **CUPS printing addon**
|
||||||
|
need (all printer drivers included), 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 **Addons → CUPS Printing**
|
||||||
|
(in `./install.sh` or the Web UI) works with no internet too: when it can't
|
||||||
|
reach the Ubuntu archive, it installs from 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.
|
||||||
|
- Addons that download their own software (LMS/Squeezelite, Tailscale,
|
||||||
|
Netbird, Asterisk Intercom, VNC) still need internet.
|
||||||
|
- `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it.
|
||||||
|
|
||||||
**No Linux machine handy?** Build it on GitHub instead: **Actions → Build
|
**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
|
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).
|
**Artifacts** once it finishes (about 10 minutes; kept for 3 days by default).
|
||||||
@@ -107,9 +143,10 @@ because its inputs show up in the run log.
|
|||||||
Notes:
|
Notes:
|
||||||
- The ISO carries a copy of this checkout's current commit (uncommitted
|
- The ISO carries a copy of this checkout's current commit (uncommitted
|
||||||
changes are not included).
|
changes are not included).
|
||||||
- The kiosk machine still needs internet on first boot (packages, Node.js,
|
- Without `--offline`, the kiosk machine needs internet on first boot
|
||||||
Electron). If there's none, the console waits for it and offers a shell to
|
(packages, Node.js, Electron). If there's none, the console waits for it
|
||||||
fix networking (`/etc/netplan/*.yaml`, then `sudo netplan apply`).
|
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
|
- A failed or interrupted first-boot install runs again on the next boot, or
|
||||||
now with `sudo systemctl start kiosk-firstboot`. Its log is
|
now with `sudo systemctl start kiosk-firstboot`. Its log is
|
||||||
`/var/lib/kiosk-firstboot/install.log`.
|
`/var/lib/kiosk-firstboot/install.log`.
|
||||||
@@ -120,7 +157,7 @@ Notes:
|
|||||||
|
|
||||||
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.
|
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:**
|
**On a machine with internet access:**
|
||||||
|
|
||||||
@@ -156,6 +193,18 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
|||||||
- **Home URL** - Auto-return after inactivity on manual or hidden sites
|
- **Home URL** - Auto-return after inactivity on manual or hidden sites
|
||||||
- **Pause functionality** - Temporarily pause rotation (configurable per-site)
|
- **Pause functionality** - Temporarily pause rotation (configurable per-site)
|
||||||
- **Navigation menu** - Quick access to all sites via key icon (top-left hot corner)
|
- **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), and images. Works offline. The file
|
||||||
|
must be readable by the `kiosk` user.
|
||||||
|
- **Image slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`)
|
||||||
|
and its images (jpg, png, gif, webp, bmp, svg, avif) play full-screen in
|
||||||
|
file-name order with a crossfade, 10 seconds each. Images added to or
|
||||||
|
removed from the folder are picked up within a minute, no restart
|
||||||
|
needed. Optional `slideshow.json` in the folder:
|
||||||
|
`{"interval": 10, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
|
||||||
|
(`fit`: `contain` shows the whole image, `cover` fills the screen and
|
||||||
|
crops; `recursive` includes subfolders).
|
||||||
|
|
||||||
### Touch Controls
|
### Touch Controls
|
||||||
- **2-finger horizontal swipe** - Switch between sites
|
- **2-finger horizontal swipe** - Switch between sites
|
||||||
@@ -355,7 +404,7 @@ Both can be used at the same time — they serve different purposes:
|
|||||||
## What This Script Installs
|
## What This Script Installs
|
||||||
|
|
||||||
### Core Components
|
### Core Components
|
||||||
- **Electron** v42.x (Chromium-based app framework)
|
- **Electron** v44.x (Chromium-based app framework)
|
||||||
- **Node.js** v20.x with npm
|
- **Node.js** v20.x with npm
|
||||||
- **Openbox** - Lightweight window manager
|
- **Openbox** - Lightweight window manager
|
||||||
- **LightDM** - Display manager with autologin
|
- **LightDM** - Display manager with autologin
|
||||||
@@ -1412,9 +1461,16 @@ full migration pass.
|
|||||||
|
|
||||||
## Project Status & Future Plans
|
## Project Status & Future Plans
|
||||||
|
|
||||||
**Current Version:** 2.17.0
|
**Current Version:** 2.18.0
|
||||||
|
|
||||||
**Recent Updates (v2.17.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.
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+61
-5
@@ -24,13 +24,18 @@
|
|||||||
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
|
||||||
# boot records (BIOS + UEFI) are replayed from the original as-is.
|
# boot records (BIOS + UEFI) are replayed from the original as-is.
|
||||||
#
|
#
|
||||||
# Requirements (on the build machine, any recent Ubuntu/Debian):
|
# 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
|
# sudo apt install xorriso curl git openssl
|
||||||
|
# plus, for --offline (amd64 build machine only):
|
||||||
|
# sudo apt install apt-utils python3 gnupg ubuntu-keyring
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# iso/build-iso.sh [options]
|
# iso/build-iso.sh [options]
|
||||||
#
|
#
|
||||||
# --iso PATH Use this Ubuntu Server ISO instead of downloading.
|
# --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
|
# --release VER Ubuntu release to download (default: 26.04 - picks
|
||||||
# the newest 26.04.x live-server-amd64 point release).
|
# the newest 26.04.x live-server-amd64 point release).
|
||||||
# --output PATH Output ISO (default: iso/build/ubuntu-<ver>-kiosk-amd64.iso)
|
# --output PATH Output ISO (default: iso/build/ubuntu-<ver>-kiosk-amd64.iso)
|
||||||
@@ -65,6 +70,15 @@
|
|||||||
# --repo-url URL git remote recorded in the copied repo, used by
|
# --repo-url URL git remote recorded in the copied repo, used by
|
||||||
# Advanced -> Upgrade (default: this checkout's
|
# Advanced -> Upgrade (default: this checkout's
|
||||||
# origin, else the upstream GitHub repo).
|
# origin, else the upstream GitHub repo).
|
||||||
|
# --offline Also bundle everything install.sh downloads (~420 MB:
|
||||||
|
# every apt package incl. the CUPS printing addon,
|
||||||
|
# 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
|
# The repo copied onto the ISO is a clone of this checkout's current HEAD
|
||||||
# (committed changes only - uncommitted edits are NOT included).
|
# (committed changes only - uncommitted edits are NOT included).
|
||||||
@@ -91,6 +105,8 @@ AUTO_STORAGE=0
|
|||||||
AUTO_NETWORK=0
|
AUTO_NETWORK=0
|
||||||
NO_CONFIRM=0
|
NO_CONFIRM=0
|
||||||
UNATTENDED=0
|
UNATTENDED=0
|
||||||
|
OFFLINE=0
|
||||||
|
INSTALL_DEPS=0
|
||||||
REPO_URL=""
|
REPO_URL=""
|
||||||
|
|
||||||
# Usernames the Ubuntu installer rejects (system users/groups) - copied
|
# Usernames the Ubuntu installer rejects (system users/groups) - copied
|
||||||
@@ -136,6 +152,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--unattended) UNATTENDED=1; shift ;;
|
--unattended) UNATTENDED=1; shift ;;
|
||||||
--fully-automatic) AUTO_STORAGE=1; AUTO_NETWORK=1; NO_CONFIRM=1; UNATTENDED=1; shift ;;
|
--fully-automatic) AUTO_STORAGE=1; AUTO_NETWORK=1; NO_CONFIRM=1; UNATTENDED=1; shift ;;
|
||||||
--repo-url) REPO_URL="$2"; shift 2 ;;
|
--repo-url) REPO_URL="$2"; shift 2 ;;
|
||||||
|
--offline) OFFLINE=1; shift ;;
|
||||||
|
--install-deps) INSTALL_DEPS=1; shift ;;
|
||||||
-h|--help) usage 0 ;;
|
-h|--help) usage 0 ;;
|
||||||
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
*) echo "Unknown option: $1" >&2; usage 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -145,9 +163,34 @@ done
|
|||||||
# Validate
|
# Validate
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
for cmd in xorriso curl git openssl sha256sum; do
|
# Build tools: command -> the apt package that provides it.
|
||||||
command -v "$cmd" &>/dev/null || die "'$cmd' not found - install it: sudo apt install xorriso curl git openssl"
|
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
|
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
|
if [[ -n "$ADMIN_USER" ]]; then
|
||||||
[[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid username: $ADMIN_USER"
|
[[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid username: $ADMIN_USER"
|
||||||
@@ -212,8 +255,9 @@ fi
|
|||||||
|
|
||||||
if [[ -z "$OUTPUT" ]]; then
|
if [[ -z "$OUTPUT" ]]; then
|
||||||
src_base="$(basename "$SRC_ISO" .iso)"
|
src_base="$(basename "$SRC_ISO" .iso)"
|
||||||
OUTPUT="$BUILD_DIR/${src_base/-live-server/-kiosk}.iso"
|
kind="kiosk"; (( OFFLINE )) && kind="kiosk-offline"
|
||||||
[[ "$OUTPUT" != "$BUILD_DIR/$src_base.iso" ]] || OUTPUT="$BUILD_DIR/${src_base}-kiosk.iso"
|
OUTPUT="$BUILD_DIR/${src_base/-live-server/-$kind}.iso"
|
||||||
|
[[ "$OUTPUT" != "$BUILD_DIR/$src_base.iso" ]] || OUTPUT="$BUILD_DIR/${src_base}-$kind.iso"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -244,12 +288,18 @@ git -C "$stage_repo" config "branch.$BRANCH.merge" "refs/heads/$BRANCH"
|
|||||||
|
|
||||||
install -m 755 "$SCRIPT_DIR/firstboot/kiosk-firstboot" "$WORK/kiosk/firstboot/"
|
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 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
|
cat > "$WORK/kiosk/firstboot/kiosk-firstboot.conf" <<EOF
|
||||||
# Read by /usr/local/sbin/kiosk-firstboot. Written by iso/build-iso.sh.
|
# 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.
|
# 1 = take every install.sh default and reboot into the kiosk; 0 = interactive.
|
||||||
KIOSK_UNATTENDED=$UNATTENDED
|
KIOSK_UNATTENDED=$UNATTENDED
|
||||||
EOF
|
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
|
# autoinstall.yaml
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -305,9 +355,14 @@ interactive=()
|
|||||||
- install -m 755 /cdrom/kiosk/firstboot/kiosk-firstboot /target/usr/local/sbin/kiosk-firstboot
|
- 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.service /target/etc/systemd/system/kiosk-firstboot.service
|
||||||
- install -m 644 /cdrom/kiosk/firstboot/kiosk-firstboot.conf /target/etc/kiosk-firstboot.conf
|
- 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
|
- touch /target/var/lib/kiosk-firstboot/pending
|
||||||
- curtin in-target --target=/target -- systemctl enable kiosk-firstboot.service
|
- curtin in-target --target=/target -- systemctl enable kiosk-firstboot.service
|
||||||
EOF
|
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"
|
} > "$WORK/autoinstall.yaml"
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -345,6 +400,7 @@ echo
|
|||||||
echo "Installer asks for : ${interactive[*]:-nothing}$( (( NO_CONFIRM )) || echo " (plus a 'Continue with autoinstall?' confirmation)")"
|
echo "Installer asks for : ${interactive[*]:-nothing}$( (( NO_CONFIRM )) || echo " (plus a 'Continue with autoinstall?' confirmation)")"
|
||||||
echo "Admin user : ${ADMIN_USER:-chosen during install}"
|
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 "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
|
||||||
echo "Write it to a USB stick (replace /dev/sdX - this erases it):"
|
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"
|
echo " sudo dd if='$OUTPUT' of=/dev/sdX bs=4M status=progress oflag=sync"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
# KIOSK_UNATTENDED=1 take every prompt's default, skip the Core
|
# KIOSK_UNATTENDED=1 take every prompt's default, skip the Core
|
||||||
# Settings menu, reboot into the kiosk when done.
|
# Settings menu, reboot into the kiosk when done.
|
||||||
# KIOSK_UNATTENDED=0 (default) normal interactive install.sh on tty1.
|
# 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).
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -31,13 +34,23 @@ SUDOERS_DROPIN=/etc/sudoers.d/90-kiosk-firstboot
|
|||||||
APT_DROPIN=/etc/apt/apt.conf.d/90-kiosk-firstboot
|
APT_DROPIN=/etc/apt/apt.conf.d/90-kiosk-firstboot
|
||||||
REPO_SRC=/opt/ubuntu-based-kiosk
|
REPO_SRC=/opt/ubuntu-based-kiosk
|
||||||
LOG="$STATE_DIR/install.log"
|
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_UNATTENDED=0
|
||||||
|
KIOSK_OFFLINE_WAIT=90
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
[[ -f "$CONF" ]] && source "$CONF"
|
[[ -f "$CONF" ]] && source "$CONF"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$SUDOERS_DROPIN" "$APT_DROPIN"
|
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
|
if [[ -f "$DONE_MARKER" ]]; then
|
||||||
rm -f "$PENDING"
|
rm -f "$PENDING"
|
||||||
fi
|
fi
|
||||||
@@ -86,6 +99,8 @@ offer_retry() {
|
|||||||
done
|
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() {
|
wait_for_internet() {
|
||||||
local waited=0
|
local waited=0
|
||||||
until curl -fsS --max-time 10 -o /dev/null https://deb.nodesource.com/ 2>/dev/null \
|
until curl -fsS --max-time 10 -o /dev/null https://deb.nodesource.com/ 2>/dev/null \
|
||||||
@@ -93,9 +108,17 @@ wait_for_internet() {
|
|||||||
if (( waited == 0 )); then
|
if (( waited == 0 )); then
|
||||||
echo "Waiting for an internet connection (needed for packages,"
|
echo "Waiting for an internet connection (needed for packages,"
|
||||||
echo "Node.js and Electron)..."
|
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
|
fi
|
||||||
sleep 10
|
sleep 10
|
||||||
waited=$((waited + 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
|
if (( waited % 120 == 0 )); then
|
||||||
echo "Still no internet after ${waited}s. Network status:"
|
echo "Still no internet after ${waited}s. Network status:"
|
||||||
networkctl list --no-pager 2>/dev/null || ip -brief addr
|
networkctl list --no-pager 2>/dev/null || ip -brief addr
|
||||||
@@ -153,7 +176,22 @@ fi
|
|||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_DROPIN"
|
printf 'DPkg::Lock::Timeout "900";\n' > "$APT_DROPIN"
|
||||||
|
|
||||||
wait_for_internet || { release_tty; exit 1; }
|
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.
|
# install.sh's own preflight needs jq; Advanced -> Upgrade needs git.
|
||||||
echo "Installing installer prerequisites (jq, git)..."
|
echo "Installing installer prerequisites (jq, git)..."
|
||||||
|
|||||||
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
+220
@@ -0,0 +1,220 @@
|
|||||||
|
#!/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 CUPS addon
|
||||||
|
# install ask for, 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/addon_cups.sh (PROVISION_APT_PACKAGES, PROVISION_INTEL_APT_PACKAGES,
|
||||||
|
# CUPS_APT_PACKAGES), 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
|
||||||
|
mapfile -t packages < <(
|
||||||
|
SCRIPT_DIR="$REPO_DIR"
|
||||||
|
source "$REPO_DIR/lib/provision.sh"
|
||||||
|
source "$REPO_DIR/menus/addon_cups.sh"
|
||||||
|
printf '%s\n' "${PROVISION_APT_PACKAGES[@]}" "${PROVISION_INTEL_APT_PACKAGES[@]}" \
|
||||||
|
"${CUPS_APT_PACKAGES[@]}" "${EXTRA_APT_PACKAGES[@]}"
|
||||||
|
)
|
||||||
|
(( ${#packages[@]} > 20 )) || die "couldn't read the package lists from $REPO_DIR"
|
||||||
|
|
||||||
|
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 '{print $2}')
|
||||||
|
(( ${#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
|
||||||
|
(
|
||||||
|
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()
|
||||||
+5
-1
@@ -4,6 +4,8 @@ const fs=require('fs');
|
|||||||
const path=require('path');
|
const path=require('path');
|
||||||
const os=require('os');
|
const os=require('os');
|
||||||
const crypto=require('crypto');
|
const crypto=require('crypto');
|
||||||
|
// Sites that are a local folder show as an image slideshow (slideshow.js).
|
||||||
|
const slideshow=require('./slideshow');
|
||||||
|
|
||||||
// Suppress EPIPE errors (happen when no terminal attached)
|
// Suppress EPIPE errors (happen when no terminal attached)
|
||||||
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
|
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
|
||||||
@@ -782,7 +784,7 @@ function attachView(i){
|
|||||||
|
|
||||||
const tabIdx=viewIndexToTabIndex(i);
|
const tabIdx=viewIndexToTabIndex(i);
|
||||||
if(tabIdx>=0&&tabs[tabIdx]){
|
if(tabIdx>=0&&tabs[tabIdx]){
|
||||||
const configuredUrl=tabs[tabIdx].url;
|
const configuredUrl=slideshow.resolveSiteUrl(tabs[tabIdx].url);
|
||||||
const currentUrl=views[i].webContents.getURL();
|
const currentUrl=views[i].webContents.getURL();
|
||||||
|
|
||||||
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
|
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
|
||||||
@@ -1334,6 +1336,7 @@ async function createWindow(){
|
|||||||
url=u.toString();
|
url=u.toString();
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
}
|
}
|
||||||
|
url=slideshow.resolveSiteUrl(url);
|
||||||
|
|
||||||
const initialOrigin=new URL(t.url).origin;
|
const initialOrigin=new URL(t.url).origin;
|
||||||
|
|
||||||
@@ -1367,6 +1370,7 @@ async function createWindow(){
|
|||||||
});
|
});
|
||||||
|
|
||||||
view.webContents.setAudioMuted(false);
|
view.webContents.setAudioMuted(false);
|
||||||
|
slideshow.attach(view.webContents);
|
||||||
view.webContents.loadURL(url);
|
view.webContents.loadURL(url);
|
||||||
|
|
||||||
view.webContents.on('did-finish-load',()=>{
|
view.webContents.on('did-finish-load',()=>{
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron": "^42.0.0"
|
"electron": "^44.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Slideshow</title>
|
||||||
|
<!-- Shown for a site that's a local folder - see slideshow.js. The image
|
||||||
|
list arrives from main.js via window.kioskSlideshow.update(). -->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
||||||
|
.slide {
|
||||||
|
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||||
|
object-fit: contain; opacity: 0; transition-property: opacity;
|
||||||
|
}
|
||||||
|
.slide.show { opacity: 1; }
|
||||||
|
#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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img class="slide" id="a" alt="">
|
||||||
|
<img class="slide" id="b" alt="">
|
||||||
|
<div id="msg">Loading slideshow…</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
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 state = null;
|
||||||
|
|
||||||
|
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 showMessage(text) {
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.style.display = 'flex';
|
||||||
|
layers.forEach((l) => { l.classList.remove('show'); l.removeAttribute('src'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the next image into the hidden layer, then crossfade to it.
|
||||||
|
// Unreadable/corrupt images are skipped rather than stalling the show.
|
||||||
|
function next(attempts = 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!order.length) return;
|
||||||
|
if (attempts >= order.length) { showMessage('None of the images in this folder could be shown.'); return; }
|
||||||
|
pos = (pos + 1) % order.length;
|
||||||
|
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(state.images);
|
||||||
|
const back = layers[1 - front];
|
||||||
|
back.onload = () => {
|
||||||
|
msg.style.display = 'none';
|
||||||
|
back.classList.add('show');
|
||||||
|
layers[front].classList.remove('show');
|
||||||
|
front = 1 - front;
|
||||||
|
if (order.length > 1) timer = setTimeout(next, state.options.interval * 1000);
|
||||||
|
};
|
||||||
|
back.onerror = () => next(attempts + 1);
|
||||||
|
back.src = order[pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
window.kioskSlideshow = {
|
||||||
|
update(s) {
|
||||||
|
const first = !state;
|
||||||
|
const sameImages = state && JSON.stringify(state.images) === JSON.stringify(s.images);
|
||||||
|
const current = order[pos];
|
||||||
|
state = s;
|
||||||
|
layers.forEach((l) => {
|
||||||
|
l.style.objectFit = s.options.fit;
|
||||||
|
l.style.transitionDuration = s.options.transition + 's';
|
||||||
|
});
|
||||||
|
if (!s.images.length) {
|
||||||
|
order = [];
|
||||||
|
clearTimeout(timer);
|
||||||
|
showMessage(s.exists ? `No images in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameImages && !first) return;
|
||||||
|
// Folder changed: keep showing the current picture if it's still
|
||||||
|
// there and carry on from it, otherwise restart from the top.
|
||||||
|
order = s.options.shuffle ? shuffled(s.images) : s.images.slice();
|
||||||
|
const keep = current ? order.indexOf(current) : -1;
|
||||||
|
if (keep >= 0 && !first) {
|
||||||
|
pos = keep;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (order.length > 1) timer = setTimeout(next, s.options.interval * 1000);
|
||||||
|
} else {
|
||||||
|
pos = -1;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// 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 in it, instead of Chromium's
|
||||||
|
// directory listing. main.js swaps the folder URL for slideshow.html via
|
||||||
|
// resolveSiteUrl(), then hands the page its image list with attach() -
|
||||||
|
// the page itself has no filesystem access (contextIsolation, no Node).
|
||||||
|
// The folder is re-read every minute, so images added or removed show up
|
||||||
|
// without restarting the kiosk.
|
||||||
|
//
|
||||||
|
// Optional slideshow.json in the folder (all keys optional):
|
||||||
|
// { "interval": 10, seconds per image (default 10, minimum 2)
|
||||||
|
// "shuffle": false, random order instead of by file name
|
||||||
|
// "fit": "contain", "contain" (whole image, letterboxed) or
|
||||||
|
// "cover" (fill the screen, cropped)
|
||||||
|
// "transition": 1, crossfade seconds (0 = instant cut)
|
||||||
|
// "recursive": false } include images 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 REFRESH_MS = 60000;
|
||||||
|
const DEFAULTS = { interval: 10, 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 "no images" message, not an error page).
|
||||||
|
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 };
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
|
||||||
|
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
|
||||||
|
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;
|
||||||
|
} catch (e) {
|
||||||
|
// No (or unreadable) slideshow.json - defaults.
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listImages(dir, recursive, depth = 0) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.')) continue;
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (recursive && depth < 8) out.push(...listImages(full, recursive, depth + 1));
|
||||||
|
} else if (IMAGE_EXT.has(path.extname(e.name).toLowerCase())) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(dir) {
|
||||||
|
const options = readOptions(dir);
|
||||||
|
const files = listImages(dir, options.recursive)
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
exists: fs.existsSync(dir),
|
||||||
|
options,
|
||||||
|
images: files.map((f) => pathToFileURL(f).href),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed a view's slideshow page its image list now and every REFRESH_MS
|
||||||
|
// while it stays on a slideshow. Call once per view (main.js does, right
|
||||||
|
// after creating it); safe on views that never show a slideshow.
|
||||||
|
function attach(webContents) {
|
||||||
|
let lastSent = '';
|
||||||
|
const send = (force) => {
|
||||||
|
if (webContents.isDestroyed()) return;
|
||||||
|
const current = webContents.getURL();
|
||||||
|
if (!current.startsWith(pathToFileURL(SLIDESHOW_PAGE).href)) return;
|
||||||
|
const dir = new URL(current).searchParams.get('dir');
|
||||||
|
if (!dir) return;
|
||||||
|
const payload = JSON.stringify(snapshot(dir));
|
||||||
|
if (!force && payload === lastSent) return;
|
||||||
|
lastSent = payload;
|
||||||
|
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
webContents.on('did-finish-load', () => send(true));
|
||||||
|
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||||
|
webContents.once('destroyed', () => clearInterval(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot };
|
||||||
@@ -41,6 +41,16 @@
|
|||||||
# other addon's own scripts.
|
# other addon's own scripts.
|
||||||
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
||||||
|
|
||||||
|
# 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 admin account actually running this tool (as opposed to $KIOSK_USER,
|
||||||
# the kiosk's own restricted account) - used where an addon needs to grant
|
# the kiosk's own restricted account) - used where an addon needs to grant
|
||||||
# *this* user a group membership (e.g. lpadmin for CUPS).
|
# *this* user a group membership (e.g. lpadmin for CUPS).
|
||||||
@@ -74,6 +84,56 @@ AUTHELIA_URL=""
|
|||||||
AUTHELIA_USERNAME=""
|
AUTHELIA_USERNAME=""
|
||||||
AUTHELIA_ENCRYPTED_PASSWORD=""
|
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() {
|
kiosk_user_exists() {
|
||||||
id "$KIOSK_USER" &>/dev/null
|
id "$KIOSK_USER" &>/dev/null
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-24
@@ -54,23 +54,31 @@ provision_install_file() {
|
|||||||
sudo install -D -m "$mode" "$PROVISION_FILES/$src_rel" "$dest"
|
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
|
||||||
|
)
|
||||||
|
PROVISION_INTEL_APT_PACKAGES=(
|
||||||
|
intel-gpu-tools xserver-xorg-video-intel i965-va-driver intel-media-va-driver
|
||||||
|
)
|
||||||
|
|
||||||
provision_install_packages() {
|
provision_install_packages() {
|
||||||
echo "[1/10] Installing packages..."
|
echo "[1/10] Installing packages..."
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y \
|
sudo apt install -y "${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
|
|
||||||
|
|
||||||
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
if lspci | grep -i "VGA.*Intel" >/dev/null 2>&1; then
|
||||||
sudo apt install -y intel-gpu-tools xserver-xorg-video-intel \
|
sudo apt install -y "${PROVISION_INTEL_APT_PACKAGES[@]}"
|
||||||
i965-va-driver intel-media-va-driver
|
|
||||||
provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf
|
provision_install_file "etc/X11/xorg.conf.d/20-intel.conf" /etc/X11/xorg.conf.d/20-intel.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -101,8 +109,13 @@ provision_create_kiosk_user() {
|
|||||||
provision_install_nodejs() {
|
provision_install_nodejs() {
|
||||||
echo "[3/10] Installing Node.js..."
|
echo "[3/10] Installing Node.js..."
|
||||||
if ! command -v node &>/dev/null; then
|
if ! command -v node &>/dev/null; then
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
if offline_mode_active; then
|
||||||
sudo apt install -y nodejs
|
# The bundle carries NodeSource's own nodejs package.
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
else
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "Node.js: $(node -v)"
|
echo "Node.js: $(node -v)"
|
||||||
}
|
}
|
||||||
@@ -113,16 +126,20 @@ provision_install_app() {
|
|||||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||||
|
|
||||||
echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..."
|
if offline_mode_active; then
|
||||||
sudo -u "$KIOSK_USER" bash -lc "
|
offline_install_node_modules kiosk-app "$KIOSK_DIR"
|
||||||
npm config set fetch-timeout 600000
|
else
|
||||||
npm config set fetch-retries 5
|
echo "Installing npm dependencies (Electron ~120MB - may take several minutes)..."
|
||||||
npm config set fetch-retry-mintimeout 30000
|
sudo -u "$KIOSK_USER" bash -lc "
|
||||||
npm config set fetch-retry-maxtimeout 300000
|
npm config set fetch-timeout 600000
|
||||||
"
|
npm config set fetch-retries 5
|
||||||
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then
|
npm config set fetch-retry-mintimeout 30000
|
||||||
log_error "npm install failed"
|
npm config set fetch-retry-maxtimeout 300000
|
||||||
return 1
|
"
|
||||||
|
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$KIOSK_DIR' && npm install --unsafe-perm"; then
|
||||||
|
log_error "npm install failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
electron_install_binary
|
electron_install_binary
|
||||||
|
|||||||
+24
-11
@@ -14,8 +14,19 @@
|
|||||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
# 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() {
|
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() {
|
cups_is_active() {
|
||||||
@@ -53,21 +64,23 @@ addon_cups_menu() {
|
|||||||
# Actions
|
# 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() {
|
action_install_cups() {
|
||||||
echo
|
echo
|
||||||
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
|
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
|
||||||
|
|
||||||
echo "Installing CUPS from scratch..."
|
echo "Installing CUPS from scratch..."
|
||||||
if ! sudo apt update; then
|
run_with_offline_fallback cups_apt_install || return 1
|
||||||
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
|
|
||||||
|
|
||||||
sudo systemctl enable cups 2>/dev/null || true
|
sudo systemctl enable cups 2>/dev/null || true
|
||||||
sudo systemctl start cups 2>/dev/null || true
|
sudo systemctl start cups 2>/dev/null || true
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ webui_install_app_files() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR"
|
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$WEBUI_DIR"
|
||||||
if ! sudo -u "$KIOSK_USER" bash -lc "cd '$WEBUI_DIR' && npm install --omit=dev --unsafe-perm"; then
|
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"
|
log_error "npm install failed"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+17
-2
@@ -18,11 +18,23 @@ SITE_EDIT_IDX=""
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
# Normalize whatever the user typed into a URL, same rules the old
|
# 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() {
|
sites_parse_url() {
|
||||||
local raw="$1"
|
local raw="$1"
|
||||||
if [[ "$raw" =~ ^https?:// ]]; then
|
if [[ "$raw" =~ ^(https?|file):// ]]; then
|
||||||
echo "$raw"
|
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
|
elif [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||||
echo "http://${raw}"
|
echo "http://${raw}"
|
||||||
else
|
else
|
||||||
@@ -124,6 +136,9 @@ action_add_page() {
|
|||||||
echo " 0 = manual only (swipe/nav menu to reach it)"
|
echo " 0 = manual only (swipe/nav menu to reach it)"
|
||||||
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
|
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
|
||||||
echo
|
echo
|
||||||
|
echo "URL: a web address, or a path on this kiosk - a file (page, PDF,"
|
||||||
|
echo "image) like /home/kiosk/docs/menu.pdf, or a folder of images ending"
|
||||||
|
echo "in / like /home/kiosk/photos/ for a slideshow."
|
||||||
|
|
||||||
local raw_url url dur name needs_auth user pass
|
local raw_url url dur name needs_auth user pass
|
||||||
read -r -p "URL: " raw_url
|
read -r -p "URL: " raw_url
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
<div class="site-row-grid">
|
<div class="site-row-grid">
|
||||||
<label>
|
<label>
|
||||||
URL
|
URL
|
||||||
<input type="text" class="site-url" placeholder="example.com or https://example.com">
|
<input type="text" class="site-url" placeholder="example.com, https://example.com or /path/to/file.pdf">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Name (optional)
|
Name (optional)
|
||||||
|
|||||||
+4
-2
@@ -35,9 +35,11 @@ const NAV_MODES = ['restricted', 'same-origin', 'open'];
|
|||||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
// Same normalization rule as menus/sites.sh's sites_parse_url(): bare
|
// Same normalization rule as menus/sites.sh's sites_parse_url(): bare
|
||||||
// host -> https://, bare IPv4 -> http://, else passed through as-is.
|
// host -> https://, bare IPv4 -> http://, absolute path -> file:// (local
|
||||||
|
// pages/PDFs/images on the kiosk itself), else passed through as-is.
|
||||||
function parseUrl(raw) {
|
function parseUrl(raw) {
|
||||||
if (/^https?:\/\//.test(raw)) return raw;
|
if (/^(https?|file):\/\//.test(raw)) return raw;
|
||||||
|
if (raw.startsWith('/')) return `file://${raw.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`;
|
if (/^\d+\.\d+\.\d+\.\d+/.test(raw)) return `http://${raw}`;
|
||||||
return `https://${raw}`;
|
return `https://${raw}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ async function main() {
|
|||||||
{ url: 'example.com', duration: 30, name: 'bare host' },
|
{ url: 'example.com', duration: 30, name: 'bare host' },
|
||||||
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
|
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
|
||||||
{ url: 'https://already.example.com', duration: 30, name: 'already a url' },
|
{ url: 'https://already.example.com', duration: 30, name: 'already a url' },
|
||||||
|
{ url: '/home/kiosk/docs/menu #2.pdf', duration: 30, name: 'local path' },
|
||||||
|
{ url: 'file:///srv/kiosk/index.html', duration: 30, name: 'already a file url' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -88,6 +90,8 @@ async function main() {
|
|||||||
assert.strictEqual(body.tabs[0].url, 'https://example.com');
|
assert.strictEqual(body.tabs[0].url, 'https://example.com');
|
||||||
assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50');
|
assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50');
|
||||||
assert.strictEqual(body.tabs[2].url, 'https://already.example.com');
|
assert.strictEqual(body.tabs[2].url, 'https://already.example.com');
|
||||||
|
assert.strictEqual(body.tabs[3].url, 'file:///home/kiosk/docs/menu%20%232.pdf');
|
||||||
|
assert.strictEqual(body.tabs[4].url, 'file:///srv/kiosk/index.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
await check('PUT rejects homeTabIndex out of range', async () => {
|
await check('PUT rejects homeTabIndex out of range', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user