diff --git a/Readme.md b/Readme.md index 7a06719..aad83cb 100644 --- a/Readme.md +++ b/Readme.md @@ -197,6 +197,14 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c 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 - **2-finger horizontal swipe** - Switch between sites @@ -396,7 +404,7 @@ Both can be used at the same time — they serve different purposes: ## What This Script Installs ### Core Components -- **Electron** v42.x (Chromium-based app framework) +- **Electron** v44.x (Chromium-based app framework) - **Node.js** v20.x with npm - **Openbox** - Lightweight window manager - **LightDM** - Display manager with autologin @@ -1453,9 +1461,16 @@ full migration pass. ## 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. - **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. diff --git a/iso/offline/build-bundle.sh b/iso/offline/build-bundle.sh index 2859881..b834a77 100755 --- a/iso/offline/build-bundle.sh +++ b/iso/offline/build-bundle.sh @@ -185,7 +185,14 @@ build_modules() { # 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 - (cd "$tmp" && node node_modules/electron/install.js) || die "Electron binary download failed" + # 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" diff --git a/kiosk-app/main.js b/kiosk-app/main.js index 3108da5..d0acd51 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -4,6 +4,8 @@ const fs=require('fs'); const path=require('path'); const os=require('os'); 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) process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;}); @@ -782,7 +784,7 @@ function attachView(i){ const tabIdx=viewIndexToTabIndex(i); if(tabIdx>=0&&tabs[tabIdx]){ - const configuredUrl=tabs[tabIdx].url; + const configuredUrl=slideshow.resolveSiteUrl(tabs[tabIdx].url); const currentUrl=views[i].webContents.getURL(); if(currentUrl&&!currentUrl.startsWith(configuredUrl)){ @@ -1334,6 +1336,7 @@ async function createWindow(){ url=u.toString(); }catch(e){} } + url=slideshow.resolveSiteUrl(url); const initialOrigin=new URL(t.url).origin; @@ -1367,6 +1370,7 @@ async function createWindow(){ }); view.webContents.setAudioMuted(false); + slideshow.attach(view.webContents); view.webContents.loadURL(url); view.webContents.on('did-finish-load',()=>{ diff --git a/kiosk-app/package.json b/kiosk-app/package.json index 0b31721..0c2ca6c 100644 --- a/kiosk-app/package.json +++ b/kiosk-app/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "main": "main.js", "dependencies": { - "electron": "^42.0.0" + "electron": "^44.0.0" } } diff --git a/kiosk-app/slideshow.html b/kiosk-app/slideshow.html new file mode 100644 index 0000000..6e76f50 --- /dev/null +++ b/kiosk-app/slideshow.html @@ -0,0 +1,104 @@ + + + + +Slideshow + + + + + + +
Loading slideshow…
+ + + diff --git a/kiosk-app/slideshow.js b/kiosk-app/slideshow.js new file mode 100644 index 0000000..c515b6a --- /dev/null +++ b/kiosk-app/slideshow.js @@ -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 }; diff --git a/menus/sites.sh b/menus/sites.sh index 60454eb..06f27e4 100644 --- a/menus/sites.sh +++ b/menus/sites.sh @@ -136,6 +136,9 @@ action_add_page() { echo " 0 = manual only (swipe/nav menu to reach it)" echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)" echo + echo "URL: a web address, or a path on this kiosk - a file (page, PDF," + echo "image) 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 read -r -p "URL: " raw_url