Offline CUPS from the normal menu, local-file sites, build-dep install

- CUPS (Addons -> CUPS Printing, from ./install.sh or the Web UI) now
  installs from the offline bundle by itself when the kiosk can't reach
  the Ubuntu archive: lib/config.sh's run_with_offline_fallback switches
  apt to the bundle (kiosk-offline-apt on) around the install and back
  afterwards. No-op on manual installs or when online.
- Fix cups_is_installed reporting "not installed" for an installed CUPS:
  `dpkg -l | grep -q` under install.sh's pipefail fails when grep exits
  early and dpkg takes SIGPIPE. Uses dpkg-query on the one package now.
- Sites: an absolute path (/home/kiosk/docs/menu.pdf) is accepted and
  turned into a percent-encoded file:// URL, identically in the Sites
  menu (sites_parse_url) and the Web UI (parseUrl, with tests). Electron
  42 shows local HTML, PDFs (built-in viewer) and images as-is.
- iso/build-iso.sh checks its build tools (plus the --offline ones) and
  offers to `sudo apt install` what's missing; --install-deps does it
  without asking. --offline now refuses non-amd64 hosts up front.

Tested: offline chroot of the 26.04.1 server filesystem - action_install_cups
installed CUPS + printer-driver-all/hplip/gutenprint from the bundle with
no network and left apt back on normal sources; webui test suite passes;
Electron 42 rendered a file:// PDF and PNG with spaces/# in the path;
build-iso.sh stopped with the install command when apt-utils was missing
and no TTY, and installed it and built the ISO with --install-deps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-03 13:29:04 +00:00
parent 0b5d958fcc
commit 5792e201b6
9 changed files with 119 additions and 31 deletions
+13 -11
View File
@@ -69,7 +69,6 @@ boot of the installed system it runs `./install.sh` automatically on the
console.
```bash
sudo apt install xorriso curl git openssl
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
@@ -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
```
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
@@ -100,8 +103,7 @@ Useful options (`iso/build-iso.sh --help` for all):
### Offline installs (`--offline`)
```bash
sudo apt install apt-utils python3 gnupg ubuntu-keyring # once, on top of the tools above
iso/build-iso.sh --offline
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)
```
@@ -114,14 +116,10 @@ This adds a ~420 MB bundle to the ISO:
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 CUPS can be added later
with no internet too:
```bash
sudo kiosk-offline-apt on # apt uses the bundle only
./install.sh # Addons -> CUPS Printing
sudo kiosk-offline-apt off # back to normal updates
```
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
@@ -195,6 +193,10 @@ 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), and images. Works offline. The file
must be readable by the `kiosk` user.
### Touch Controls
- **2-finger horizontal swipe** - Switch between sites
+35 -5
View File
@@ -24,7 +24,9 @@
# (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):
# 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
@@ -33,6 +35,7 @@
# 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)
@@ -74,8 +77,8 @@
# 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
# (see iso/firstboot/kiosk-offline-apt for using it
# later, e.g. adding CUPS while 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).
@@ -103,6 +106,7 @@ AUTO_NETWORK=0
NO_CONFIRM=0
UNATTENDED=0
OFFLINE=0
INSTALL_DEPS=0
REPO_URL=""
# Usernames the Ubuntu installer rejects (system users/groups) - copied
@@ -149,6 +153,7 @@ while [[ $# -gt 0 ]]; do
--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
@@ -158,9 +163,34 @@ done
# Validate
################################################################################
for cmd in xorriso curl git openssl sha256sum; do
command -v "$cmd" &>/dev/null || die "'$cmd' not found - install it: sudo apt install xorriso curl git openssl"
# 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"
+4 -4
View File
@@ -8,10 +8,10 @@
#
# 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.
# By hand it's for installing things later with no internet - e.g. the
# CUPS addon: `sudo kiosk-offline-apt on`, ./install.sh -> Addons -> CUPS,
# then `sudo kiosk-offline-apt off`. While it's on, install.sh also
# 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).
#
+30
View File
@@ -104,6 +104,36 @@ offline_install_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
}
+14 -6
View File
@@ -22,7 +22,11 @@ CUPS_APT_PACKAGES=(
)
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() {
@@ -60,11 +64,7 @@ addon_cups_menu() {
# Actions
################################################################################
action_install_cups() {
echo
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
echo "Installing CUPS from scratch..."
cups_apt_install() {
if ! sudo apt update; then
log_error "apt update failed - check network/package sources and try again"
return 1
@@ -73,6 +73,14 @@ action_install_cups() {
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..."
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
+14 -2
View File
@@ -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
+1 -1
View File
@@ -184,7 +184,7 @@
<div class="site-row-grid">
<label>
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>
Name (optional)
+4 -2
View File
@@ -35,9 +35,11 @@ const NAV_MODES = ['restricted', 'same-origin', 'open'];
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
// 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) {
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}`;
return `https://${raw}`;
}
+4
View File
@@ -80,6 +80,8 @@ async function main() {
{ url: 'example.com', duration: 30, name: 'bare host' },
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
{ 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[1].url, 'http://192.168.1.50');
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 () => {