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. 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
@@ -100,8 +103,7 @@ Useful options (`iso/build-iso.sh --help` for all):
### Offline installs (`--offline`) ### Offline installs (`--offline`)
```bash ```bash
sudo apt install apt-utils python3 gnupg ubuntu-keyring # once, on top of the tools above iso/build-iso.sh --offline # also offers to install apt-utils, python3, gnupg, ubuntu-keyring
iso/build-iso.sh --offline
# -> iso/build/ubuntu-26.04.x-kiosk-offline-amd64.iso (~3.2 GB) # -> 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, 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 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 stays on the kiosk at `/opt/kiosk-offline`, so **Addons → CUPS Printing**
with no internet too: (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
```bash switches apt back to normal.
sudo kiosk-offline-apt on # apt uses the bundle only
./install.sh # Addons -> CUPS Printing
sudo kiosk-offline-apt off # back to normal updates
```
Notes: Notes:
- Build it on an **amd64** Ubuntu/Debian machine, or let the GitHub - 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 - **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.
### Touch Controls ### Touch Controls
- **2-finger horizontal swipe** - Switch between sites - **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); # (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): # plus, for --offline (amd64 build machine only):
# sudo apt install apt-utils python3 gnupg ubuntu-keyring # sudo apt install apt-utils python3 gnupg ubuntu-keyring
@@ -33,6 +35,7 @@
# 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)
@@ -74,8 +77,8 @@
# With internet it still installs online as usual; # With internet it still installs online as usual;
# offline it waits 90s, then uses the bundle. The # offline it waits 90s, then uses the bundle. The
# bundle stays on the kiosk at /opt/kiosk-offline # bundle stays on the kiosk at /opt/kiosk-offline
# (see iso/firstboot/kiosk-offline-apt for using it # - Addons -> CUPS Printing then installs from it too
# later, e.g. adding CUPS while offline). # 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).
@@ -103,6 +106,7 @@ AUTO_NETWORK=0
NO_CONFIRM=0 NO_CONFIRM=0
UNATTENDED=0 UNATTENDED=0
OFFLINE=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
@@ -149,6 +153,7 @@ while [[ $# -gt 0 ]]; do
--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 ;; --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
@@ -158,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"
+4 -4
View File
@@ -8,10 +8,10 @@
# #
# Installed by the custom ISO (iso/build-iso.sh); the bundle itself is # 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 # 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 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 # so does the CUPS addon install (lib/config.sh's
# CUPS addon: `sudo kiosk-offline-apt on`, ./install.sh -> Addons -> CUPS, # run_with_offline_fallback). By hand it's for installing anything else
# then `sudo kiosk-offline-apt off`. While it's on, install.sh also # from the bundle with no internet. While it's on, install.sh also
# unpacks the bundle's prebuilt node_modules instead of running npm # unpacks the bundle's prebuilt node_modules instead of running npm
# (lib/config.sh's offline_mode_active checks for the file this writes). # (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" 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
} }
+14 -6
View File
@@ -22,7 +22,11 @@ CUPS_APT_PACKAGES=(
) )
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() {
@@ -60,11 +64,7 @@ addon_cups_menu() {
# Actions # Actions
################################################################################ ################################################################################
action_install_cups() { cups_apt_install() {
echo
ask_yes_no "Install CUPS printing?" "n" || { echo "Cancelled"; return; }
echo "Installing CUPS from scratch..."
if ! sudo apt update; then if ! sudo apt update; then
log_error "apt update failed - check network/package sources and try again" log_error "apt update failed - check network/package sources and try again"
return 1 return 1
@@ -73,6 +73,14 @@ action_install_cups() {
log_error "CUPS package installation failed" log_error "CUPS package installation failed"
return 1 return 1
fi 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 enable cups 2>/dev/null || true
sudo systemctl start 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 # 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
+1 -1
View File
@@ -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
View File
@@ -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}`;
} }
+4
View File
@@ -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 () => {