Merge pull request #116 from outis1one/claude/happy-knuth-hwnvbn
Claude/happy knuth hwnvbn
This commit is contained in:
@@ -41,9 +41,10 @@ on:
|
||||
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.
|
||||
Bundle everything install.sh downloads (~520 MB: all packages incl.
|
||||
the CUPS, LMS/Squeezelite, Intercom, VNC, WireGuard and hotspot
|
||||
addons, Node.js, Electron) so the kiosk and those addons install
|
||||
with no internet.
|
||||
type: boolean
|
||||
default: true
|
||||
retention_days:
|
||||
|
||||
@@ -107,19 +107,21 @@ iso/build-iso.sh --offline # also offers to install apt-utils, python3, gnupg,
|
||||
# -> 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.
|
||||
This adds a ~520 MB bundle to the ISO (~3.3 GB total):
|
||||
- Every Ubuntu package the kiosk install needs, plus the addons that make
|
||||
sense on a network without internet: **CUPS printing** (all printer
|
||||
drivers), **LMS (Lyrion) music server** and **Squeezelite** player,
|
||||
**Asterisk Intercom**, **VNC**, **WireGuard** and the **Emergency
|
||||
Hotspot** - with their dependencies, as a local package repository.
|
||||
- Node.js 22 (NodeSource).
|
||||
- Prebuilt Electron and Web UI modules.
|
||||
|
||||
On first boot it still installs online when there's internet. With none,
|
||||
it waits 90 seconds and then installs entirely from the bundle. The bundle
|
||||
stays on the kiosk at `/opt/kiosk-offline`, so **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.
|
||||
stays on the kiosk at `/opt/kiosk-offline`, so those addons install from
|
||||
the normal menus (`./install.sh` or the Web UI) with no internet too: when
|
||||
an install can't reach the Ubuntu archive, it uses the bundle by itself and
|
||||
then switches apt back to normal.
|
||||
|
||||
Notes:
|
||||
- Build it on an **amd64** Ubuntu/Debian machine, or let the GitHub
|
||||
@@ -128,8 +130,12 @@ Notes:
|
||||
- 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.
|
||||
- Tailscale and Netbird aren't bundled: they install from their vendors'
|
||||
scripts and only work with their vendors' servers on the internet.
|
||||
- On a network with no internet, set **Core Settings → Time Server (NTP)**
|
||||
to a time server on your network (most routers can serve time), or set
|
||||
the clock by hand there - otherwise the clock slowly drifts, and lockout
|
||||
times, quiet hours and power schedules drift with it.
|
||||
- `sudo rm -rf /opt/kiosk-offline` frees the space if you don't need it.
|
||||
|
||||
**No Linux machine handy?** Build it on GitHub instead: **Actions → Build
|
||||
@@ -195,16 +201,26 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
||||
- **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
|
||||
HTML pages, PDFs (built-in viewer), images, and Word documents (below). 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).
|
||||
- **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and
|
||||
its images (jpg, png, gif, webp, bmp, svg, avif), Word documents
|
||||
(`.docx`) and PDFs play full-screen in file-name order with a crossfade -
|
||||
images 10 seconds each, documents and PDFs 20. A one-page document or
|
||||
PDF is shown whole; a longer one fills the width and scrolls slowly top
|
||||
to bottom during its time (PDFs: first 30 pages). Files added, removed or changed are picked up within a minute, no
|
||||
restart needed. Optional `slideshow.json` in the folder:
|
||||
`{"interval": 10, "docInterval": 20, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
|
||||
(`docInterval`: seconds per document/PDF; `fit`: `contain` shows the whole
|
||||
image, `cover` fills the screen and crops; `recursive` includes
|
||||
subfolders).
|
||||
- **Word documents** - A `.docx` path (e.g. `/home/kiosk/docs/menu.docx`)
|
||||
is rendered as pages (text, tables, images, lists), scaled to the screen
|
||||
width, via the bundled [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||
library - no LibreOffice needed, works offline. Replacing the file on
|
||||
disk updates the kiosk within a minute. Old `.doc` and LibreOffice
|
||||
`.odt` aren't supported; save those as `.docx` or PDF. Layout is close
|
||||
to Word's but not pixel-exact - for exact layout, export to PDF.
|
||||
|
||||
### Touch Controls
|
||||
- **2-finger horizontal swipe** - Switch between sites
|
||||
@@ -1095,6 +1111,12 @@ Automatically creates a WiFi access point when internet connectivity is lost.
|
||||
- Optional configuration with custom SSID and password
|
||||
- Can be deferred and configured later
|
||||
|
||||
**What counts as "cut off"** (chosen when you set it up; `./install.sh`
|
||||
version): by default, *the local network itself* is unreachable - neither
|
||||
the router nor the internet answers - so a kiosk on a network without
|
||||
internet doesn't start the hotspot on every boot. "No internet" (the old
|
||||
check) and "a specific address doesn't answer" are options too.
|
||||
|
||||
**How It Works:**
|
||||
1. Service monitors internet connectivity every 30 seconds
|
||||
2. When internet is lost, automatically:
|
||||
@@ -1467,6 +1489,10 @@ full migration pass.
|
||||
- **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.
|
||||
- **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet.
|
||||
- **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure.
|
||||
- **New: Core Settings → Time Server (NTP)** - use a time server on your network, go back to Ubuntu's, or set the clock by hand; for networks without internet.
|
||||
- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes. Slideshow folders can mix `.docx` documents and PDFs (rendered with pdf.js) in with images.
|
||||
- **Electron upgrade:** v42.x → v44.x (42 leaves Electron's support window when 45 ships). Existing kiosks move to it via Advanced → Upgrade.
|
||||
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
||||
|
||||
|
||||
+6
-1
@@ -24,7 +24,8 @@
|
||||
#
|
||||
# Migrated so far, grouped the same way the legacy menu groups them:
|
||||
# Core Settings: Sites & Page Timing, Display & Interaction, Timezone,
|
||||
# Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||
# Time Server (NTP - menus/time_sync.sh, a LAN time server or manual
|
||||
# clock for networks without internet), Hidden Site PIN, Password Protection & Lockout, WiFi,
|
||||
# Power/Display/Quiet Hours, Complete Uninstall
|
||||
# (menus/complete_uninstall.sh - composed from every addon's own
|
||||
# uninstall helper rather than re-implementing removal a second time).
|
||||
@@ -72,6 +73,8 @@ source "$SCRIPT_DIR/menus/sites.sh"
|
||||
source "$SCRIPT_DIR/menus/display.sh"
|
||||
# shellcheck source=menus/timezone.sh
|
||||
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||
# shellcheck source=menus/time_sync.sh
|
||||
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||
# shellcheck source=menus/hidden_pin.sh
|
||||
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||
# shellcheck source=menus/lockout.sh
|
||||
@@ -151,6 +154,7 @@ core_settings_menu_builder() {
|
||||
"Sites & Page Timing"
|
||||
"Display & Interaction"
|
||||
"Timezone"
|
||||
"Time Server (NTP)"
|
||||
"Hidden Site PIN"
|
||||
"Password Protection & Lockout"
|
||||
"WiFi"
|
||||
@@ -161,6 +165,7 @@ core_settings_menu_builder() {
|
||||
sites_menu
|
||||
display_menu
|
||||
timezone_menu
|
||||
time_sync_menu
|
||||
hidden_pin_menu
|
||||
lockout_menu
|
||||
wifi_menu
|
||||
|
||||
+3
-2
@@ -70,8 +70,9 @@
|
||||
# --repo-url URL git remote recorded in the copied repo, used by
|
||||
# Advanced -> Upgrade (default: this checkout's
|
||||
# origin, else the upstream GitHub repo).
|
||||
# --offline Also bundle everything install.sh downloads (~420 MB:
|
||||
# every apt package incl. the CUPS printing addon,
|
||||
# --offline Also bundle everything install.sh downloads (~520 MB:
|
||||
# every apt package incl. the CUPS, LMS/Squeezelite,
|
||||
# Asterisk Intercom, VNC, WireGuard and hotspot addons,
|
||||
# Node.js, prebuilt Electron/Web UI node_modules), so
|
||||
# first boot can install the kiosk with NO internet.
|
||||
# With internet it still installs online as usual;
|
||||
|
||||
@@ -8,17 +8,20 @@
|
||||
#
|
||||
# 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
|
||||
# package install.sh's provisioning and the addons ask
|
||||
# for (CUPS, LMS + Squeezelite, Asterisk Intercom, VNC,
|
||||
# WireGuard, Emergency Hotspot - not Tailscale/Netbird,
|
||||
# which need their vendors' servers anyway), Lyrion's
|
||||
# own .deb, NodeSource's nodejs, and all their
|
||||
# dependencies that a fresh Ubuntu Server install from
|
||||
# SOURCE_ISO doesn't already have.
|
||||
# npm/kiosk-app-node_modules.tar.gz Prebuilt node_modules (Electron
|
||||
# npm/webui-node_modules.tar.gz included) in place of `npm install`.
|
||||
# bundle-info.txt What was bundled, from where, when.
|
||||
#
|
||||
# Package lists are read from REPO_DIR's own lib/provision.sh and
|
||||
# menus/addon_cups.sh (PROVISION_APT_PACKAGES, PROVISION_INTEL_APT_PACKAGES,
|
||||
# CUPS_APT_PACKAGES), so the bundle always matches what install.sh asks for.
|
||||
# Package lists are read from REPO_DIR's own lib/provision.sh and menus/
|
||||
# (PROVISION_APT_PACKAGES, CUPS_APT_PACKAGES, SQUEEZELITE_APT_PACKAGES,
|
||||
# LMS_DEB_URL, ...), so the bundle always matches what install.sh asks for.
|
||||
#
|
||||
# Dependencies are resolved against SOURCE_ISO's own package manifest - a
|
||||
# fake dpkg status built from it (fake-dpkg-status.py) - so apt only
|
||||
@@ -127,21 +130,45 @@ python3 "$HERE/fake-dpkg-status.py" "$manifest" "$R/var/lib/apt/lists" > "$R/var
|
||||
################################################################################
|
||||
|
||||
# shellcheck disable=SC1091,SC2034
|
||||
# Each list lives next to the code that installs it; lib/config.sh first
|
||||
# for the path defaults those files use at the top level.
|
||||
mapfile -t packages < <(
|
||||
SCRIPT_DIR="$REPO_DIR"
|
||||
source "$REPO_DIR/lib/config.sh"
|
||||
source "$REPO_DIR/lib/provision.sh"
|
||||
source "$REPO_DIR/menus/addon_cups.sh"
|
||||
source "$REPO_DIR/menus/addon_lms_squeezelite.sh"
|
||||
source "$REPO_DIR/menus/addon_asterisk_intercom.sh"
|
||||
source "$REPO_DIR/menus/addon_remote_access.sh"
|
||||
source "$REPO_DIR/menus/advanced_emergency_hotspot.sh"
|
||||
printf '%s\n' "${PROVISION_APT_PACKAGES[@]}" "${PROVISION_INTEL_APT_PACKAGES[@]}" \
|
||||
"${CUPS_APT_PACKAGES[@]}" "${EXTRA_APT_PACKAGES[@]}"
|
||||
"${CUPS_APT_PACKAGES[@]}" "${SQUEEZELITE_APT_PACKAGES[@]}" \
|
||||
"${ASTERISK_INTERCOM_APT_PACKAGES[@]}" "${VNC_APT_PACKAGES[@]}" \
|
||||
"${WIREGUARD_APT_PACKAGES[@]}" "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}" \
|
||||
"${EXTRA_APT_PACKAGES[@]}"
|
||||
)
|
||||
(( ${#packages[@]} > 20 )) || die "couldn't read the package lists from $REPO_DIR"
|
||||
|
||||
# Lyrion Music Server isn't in Ubuntu's archive: bundle the exact .deb the
|
||||
# LMS addon downloads online, and let apt resolve its dependencies from it
|
||||
# (a local .deb path in the install list).
|
||||
# shellcheck disable=SC1091,SC2034
|
||||
lms_url="$(SCRIPT_DIR="$REPO_DIR"; source "$REPO_DIR/lib/config.sh"; source "$REPO_DIR/menus/addon_lms_squeezelite.sh"; echo "$LMS_DEB_URL")"
|
||||
lms_deb="$CACHE_DIR/$(basename "$lms_url")"
|
||||
if [[ ! -s "$lms_deb" ]]; then
|
||||
info "Downloading $(basename "$lms_url")..."
|
||||
curl -fL --retry 3 -o "$lms_deb.part" "$lms_url" || die "couldn't download $lms_url"
|
||||
mv "$lms_deb.part" "$lms_deb"
|
||||
fi
|
||||
dpkg-deb -I "$lms_deb" >/dev/null 2>&1 || { rm -f "$lms_deb"; die "$lms_deb isn't a valid .deb - deleted, re-run to retry"; }
|
||||
packages+=("$lms_deb")
|
||||
|
||||
info "Resolving ${#packages[@]} packages (with recommends, as install.sh installs them)..."
|
||||
# --print-uris against an empty archive dir: it leaves out anything already
|
||||
# in the cache, which on a rebuild would be everything.
|
||||
rm -rf "$R/empty-archives"; mkdir -p "$R/empty-archives/partial"
|
||||
mapfile -t debs < <("${APT[@]}" -qq -o "Dir::Cache::archives=$R/empty-archives" \
|
||||
install --print-uris -y "${packages[@]}" | awk '{print $2}')
|
||||
install --print-uris -y "${packages[@]}" | awk '$1 !~ /^.file:/ {print $2}') # the local LMS .deb is copied below
|
||||
(( ${#debs[@]} )) || die "apt resolved nothing to download - check the output above"
|
||||
|
||||
info "Downloading ${#debs[@]} .debs (cached between builds)..."
|
||||
@@ -150,6 +177,7 @@ info "Downloading ${#debs[@]} .debs (cached between builds)..."
|
||||
for f in "${debs[@]}"; do
|
||||
cp "$R/var/cache/apt/archives/$f" "$OUT_DIR/apt/"
|
||||
done
|
||||
cp "$lms_deb" "$OUT_DIR/apt/"
|
||||
(
|
||||
cd "$OUT_DIR/apt"
|
||||
apt-ftparchive packages . > Packages 2>/dev/null
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Document</title>
|
||||
<!-- Shown for a site that's a local .docx file - see docview.js. The
|
||||
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #666; }
|
||||
#msg {
|
||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
<script src="docx-render.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="msg">Loading document…</div>
|
||||
<div id="doc"></div>
|
||||
<script>
|
||||
(() => {
|
||||
const docEl = document.getElementById('doc');
|
||||
const msg = document.getElementById('msg');
|
||||
let cls = null;
|
||||
|
||||
function showMessage(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
docEl.innerHTML = '';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth));
|
||||
|
||||
window.kioskDocView = {
|
||||
async show(d) {
|
||||
if (d.error) { showMessage(d.error); return; }
|
||||
if (!window.KioskDocx || !KioskDocx.available()) {
|
||||
showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl);
|
||||
msg.style.display = 'none';
|
||||
KioskDocx.fit(docEl, cls, window.innerWidth);
|
||||
// Only called on first load or when the file changed on disk -
|
||||
// start a changed document from the top.
|
||||
window.scrollTo(0, 0);
|
||||
} catch (e) {
|
||||
showMessage(`Couldn't show ${d.file}: ${e.message || e}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Word document (.docx) support for main.js.
|
||||
//
|
||||
// A site whose URL is a local .docx file (file:///home/kiosk/docs/menu.docx,
|
||||
// entered in the Sites menu or Web UI as /home/kiosk/docs/menu.docx) is
|
||||
// rendered with docx-preview (an npm dependency of this app - pure
|
||||
// JavaScript, works offline) instead of Chromium offering to download it.
|
||||
// main.js swaps the file URL for docview.html via resolveSiteUrl(), then
|
||||
// attach() hands the page the file's bytes - the page itself has no
|
||||
// filesystem access (contextIsolation, no Node). The file is re-checked
|
||||
// every minute and re-rendered if it changed, so replacing the document
|
||||
// on disk updates the kiosk without a restart.
|
||||
//
|
||||
// Only .docx (Word 2007+). Old binary .doc and LibreOffice .odt aren't
|
||||
// supported by docx-preview - save those as .docx or PDF.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { fileURLToPath, pathToFileURL } = require('url');
|
||||
|
||||
const VIEWER_PAGE = path.join(__dirname, 'docview.html');
|
||||
const REFRESH_MS = 60000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
// The .docx path a site URL points at, or null.
|
||||
function docxPath(url) {
|
||||
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||
let p;
|
||||
try {
|
||||
p = fileURLToPath(url);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return path.extname(p).toLowerCase() === '.docx' ? p : null;
|
||||
}
|
||||
|
||||
// What a view should actually load for a configured site URL.
|
||||
function resolveSiteUrl(url) {
|
||||
const file = docxPath(url);
|
||||
if (!file) return url;
|
||||
const u = pathToFileURL(VIEWER_PAGE);
|
||||
u.searchParams.set('file', file);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function readDoc(file) {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (st.size > MAX_BYTES) return { file, error: `Document is too large to show (${Math.round(st.size / 1048576)} MB).` };
|
||||
return { file, mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||
} catch (e) {
|
||||
return { file, error: e.code === 'ENOENT' ? `File not found: ${file}` : `Can't read ${file}: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Feed a view's viewer page the document now and again whenever the file
|
||||
// changes. Call once per view; safe on views that never show a document.
|
||||
function attach(webContents) {
|
||||
let lastKey = '';
|
||||
const send = (force) => {
|
||||
if (webContents.isDestroyed()) return;
|
||||
const current = webContents.getURL();
|
||||
if (!current.startsWith(pathToFileURL(VIEWER_PAGE).href)) return;
|
||||
const file = new URL(current).searchParams.get('file');
|
||||
if (!file) return;
|
||||
let key;
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
key = `${st.mtimeMs}:${st.size}`;
|
||||
} catch (e) {
|
||||
key = 'missing';
|
||||
}
|
||||
if (!force && key === lastKey) return;
|
||||
lastKey = key;
|
||||
webContents.executeJavaScript(`window.kioskDocView&&window.kioskDocView.show(${JSON.stringify(readDoc(file))})`)
|
||||
.catch(() => {});
|
||||
};
|
||||
webContents.on('did-finish-load', () => send(true));
|
||||
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||
webContents.once('destroyed', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
module.exports = { resolveSiteUrl, docxPath, attach };
|
||||
@@ -0,0 +1,110 @@
|
||||
// Browser-side .docx rendering shared by docview.html (one document as a
|
||||
// site) and slideshow.html (documents mixed into a folder slideshow).
|
||||
// Needs node_modules/jszip and node_modules/docx-preview loaded first.
|
||||
//
|
||||
// Each render gets its own class prefix and keeps its generated styles
|
||||
// inside its own container, so two documents on screen at once (a
|
||||
// slideshow crossfade) can't restyle each other, and removing the
|
||||
// container removes its styles.
|
||||
(() => {
|
||||
let counter = 0;
|
||||
|
||||
// Word draws most bullets with private-use characters from the Symbol
|
||||
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
|
||||
// render as blanks. docx-preview passes them through into its generated
|
||||
// list CSS; swap the common ones for real Unicode equivalents.
|
||||
const SYMBOL_BULLETS = {
|
||||
'': '•', // Symbol bullet -> •
|
||||
'': '▪', // Wingdings square -> ▪
|
||||
'': '➢', // Wingdings arrowhead -> ➢
|
||||
'': '❖', // Wingdings diamond -> ❖
|
||||
'': '✓', // Wingdings check -> ✓
|
||||
'': '■', // Wingdings black square -> ■
|
||||
'': '❏', // Wingdings box -> ❏
|
||||
};
|
||||
|
||||
function fixSymbolBullets(container) {
|
||||
container.querySelectorAll('style').forEach((st) => {
|
||||
let css = st.textContent;
|
||||
const before = css;
|
||||
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
|
||||
css = css.split(from).join(to);
|
||||
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 ").
|
||||
const hex = from.charCodeAt(0).toString(16);
|
||||
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
|
||||
}
|
||||
if (css !== before) st.textContent = css;
|
||||
});
|
||||
}
|
||||
|
||||
window.KioskDocx = {
|
||||
available() {
|
||||
return !!(window.docx && window.JSZip);
|
||||
},
|
||||
|
||||
base64ToBytes(b64) {
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
},
|
||||
|
||||
// Render into `container` (emptied first). Returns the class prefix
|
||||
// used, for fit().
|
||||
async render(bytes, container) {
|
||||
const cls = `kdocx${++counter}`;
|
||||
container.innerHTML = '';
|
||||
await window.docx.renderAsync(bytes, container, container, {
|
||||
className: cls,
|
||||
inWrapper: true,
|
||||
ignoreLastRenderedPageBreak: true,
|
||||
experimental: true,
|
||||
useBase64URL: true,
|
||||
});
|
||||
const wrapper = container.querySelector(`.${cls}-wrapper`);
|
||||
if (wrapper) {
|
||||
wrapper.style.background = 'transparent';
|
||||
wrapper.style.padding = '16px 0';
|
||||
}
|
||||
container.querySelectorAll(`section.${cls}`).forEach((s) => {
|
||||
s.style.marginBottom = '16px';
|
||||
s.style.boxShadow = '0 2px 8px rgba(0,0,0,.4)';
|
||||
});
|
||||
fixSymbolBullets(container);
|
||||
return cls;
|
||||
},
|
||||
|
||||
// Scale `zoomEl` so the first page fills `width` (capped at maxZoom),
|
||||
// so a portrait page is readable on a landscape screen without
|
||||
// sideways scrolling. CSS zoom keeps scrolling and touch coordinates
|
||||
// consistent.
|
||||
fit(zoomEl, cls, width, maxZoom = 1.6) {
|
||||
const page = zoomEl.querySelector(`section.${cls}`);
|
||||
if (!page) return;
|
||||
zoomEl.style.zoom = 1;
|
||||
const pageWidth = page.getBoundingClientRect().width + 32;
|
||||
zoomEl.style.zoom = Math.min(width / pageWidth, maxZoom);
|
||||
},
|
||||
|
||||
// Scale `zoomEl` so the whole first page fits in width x height
|
||||
// (letterboxed, like an image) - for one-page documents in a slideshow,
|
||||
// where scrolling through the page's empty bottom would be pointless.
|
||||
fitPage(zoomEl, cls, width, height) {
|
||||
const page = zoomEl.querySelector(`section.${cls}`);
|
||||
if (!page) return;
|
||||
zoomEl.style.zoom = 1;
|
||||
const r = page.getBoundingClientRect();
|
||||
zoomEl.style.zoom = Math.min(width / (r.width + 32), height / (r.height + 32));
|
||||
},
|
||||
|
||||
// True when the whole document fits on one physical page. Not just
|
||||
// "one <section>": docx-preview doesn't paginate by itself - it only
|
||||
// starts a new section at an explicit page break - so a long document
|
||||
// without breaks is one very tall section. Each section's min-height
|
||||
// is the paper height, so content that fits leaves it at that height.
|
||||
isSinglePage(zoomEl, cls) {
|
||||
const pages = zoomEl.querySelectorAll(`section.${cls}`);
|
||||
if (pages.length !== 1) return false;
|
||||
const paper = parseFloat(getComputedStyle(pages[0]).minHeight);
|
||||
if (!paper) return false;
|
||||
return pages[0].getBoundingClientRect().height <= paper * 1.02;
|
||||
},
|
||||
};
|
||||
})();
|
||||
+7
-3
@@ -4,8 +4,11 @@ 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).
|
||||
// Local-file sites with their own viewer pages: a folder plays as an image
|
||||
// slideshow (slideshow.js), a .docx renders as a Word document (docview.js).
|
||||
const slideshow=require('./slideshow');
|
||||
const docview=require('./docview');
|
||||
function resolveLocalSite(url){return docview.resolveSiteUrl(slideshow.resolveSiteUrl(url));}
|
||||
|
||||
// Suppress EPIPE errors (happen when no terminal attached)
|
||||
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
|
||||
@@ -784,7 +787,7 @@ function attachView(i){
|
||||
|
||||
const tabIdx=viewIndexToTabIndex(i);
|
||||
if(tabIdx>=0&&tabs[tabIdx]){
|
||||
const configuredUrl=slideshow.resolveSiteUrl(tabs[tabIdx].url);
|
||||
const configuredUrl=resolveLocalSite(tabs[tabIdx].url);
|
||||
const currentUrl=views[i].webContents.getURL();
|
||||
|
||||
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
|
||||
@@ -1336,7 +1339,7 @@ async function createWindow(){
|
||||
url=u.toString();
|
||||
}catch(e){}
|
||||
}
|
||||
url=slideshow.resolveSiteUrl(url);
|
||||
url=resolveLocalSite(url);
|
||||
|
||||
const initialOrigin=new URL(t.url).origin;
|
||||
|
||||
@@ -1371,6 +1374,7 @@ async function createWindow(){
|
||||
|
||||
view.webContents.setAudioMuted(false);
|
||||
slideshow.attach(view.webContents);
|
||||
docview.attach(view.webContents);
|
||||
view.webContents.loadURL(url);
|
||||
|
||||
view.webContents.on('did-finish-load',()=>{
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"dependencies": {
|
||||
"electron": "^44.0.0"
|
||||
"electron": "^44.0.0",
|
||||
"docx-preview": "^0.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^6.3.289"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Browser-side PDF rendering for slideshow.html, via pdf.js (pdfjs-dist,
|
||||
// an npm dependency of this app). A module because pdf.js only ships as
|
||||
// one; it sets window.KioskPdf for slideshow.html's classic script.
|
||||
//
|
||||
// Pages are drawn to canvases (no viewer toolbar, and the slideshow can
|
||||
// scroll them like a Word document). pdf.js >= 4.2.67 is required - older
|
||||
// versions could run JavaScript embedded in a crafted PDF (CVE-2024-4367);
|
||||
// isEvalSupported:false is set as well, belt and braces.
|
||||
import * as pdfjs from './node_modules/pdfjs-dist/build/pdf.min.mjs';
|
||||
|
||||
const base = new URL('./node_modules/pdfjs-dist/', import.meta.url).href;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = base + 'build/pdf.worker.min.mjs';
|
||||
|
||||
// Rendering every page of a huge PDF would take a long time and a lot of
|
||||
// memory for something shown for seconds - stop after this many.
|
||||
const MAX_PAGES = 30;
|
||||
|
||||
window.KioskPdf = {
|
||||
// Render `bytes` into `container` (emptied first), sized for a screen of
|
||||
// width x height: a one-page PDF fits entirely (like an image); a longer
|
||||
// one fits the width, pages stacked, for the slideshow to scroll.
|
||||
async render(bytes, container, width, height) {
|
||||
container.innerHTML = '';
|
||||
const task = pdfjs.getDocument({
|
||||
// A copy: pdf.js transfers (empties) the buffer it's given, and the
|
||||
// slideshow caches these bytes to show the PDF again next round.
|
||||
data: bytes.slice(),
|
||||
isEvalSupported: false,
|
||||
cMapUrl: base + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: base + 'standard_fonts/',
|
||||
});
|
||||
try {
|
||||
const doc = await task.promise;
|
||||
const count = Math.min(doc.numPages, MAX_PAGES);
|
||||
const single = doc.numPages === 1;
|
||||
const gap = 16;
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.gap = gap + 'px';
|
||||
container.style.padding = (single ? 0 : gap) + 'px 0';
|
||||
container.style.minHeight = single ? '100%' : '';
|
||||
container.style.justifyContent = single ? 'center' : '';
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const natural = page.getViewport({ scale: 1 });
|
||||
const scale = single
|
||||
? Math.min(width / natural.width, height / natural.height)
|
||||
: Math.min((width - 2 * gap) / natural.width, 1.6 * (96 / 72));
|
||||
const vp = page.getViewport({ scale: scale * dpr });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||
canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||
canvas.style.background = '#fff';
|
||||
canvas.style.boxShadow = single ? '' : '0 2px 8px rgba(0,0,0,.4)';
|
||||
container.appendChild(canvas);
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||
page.cleanup();
|
||||
}
|
||||
return { pages: doc.numPages, rendered: count };
|
||||
} finally {
|
||||
// Frees the document and its worker-side data (pdf.js 6 has this on
|
||||
// the loading task, not the document).
|
||||
task.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
+171
-42
@@ -3,34 +3,50 @@
|
||||
<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(). -->
|
||||
<!-- Shown for a site that's a local folder - see slideshow.js. The item
|
||||
list arrives from main.js via window.kioskSlideshow.update(); a Word
|
||||
document's bytes are requested by setting the page title (see
|
||||
TITLE_REQUEST in slideshow.js) and arrive via kioskSlideshow.docData(). -->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
||||
.slide {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: contain; opacity: 0; transition-property: opacity;
|
||||
position: absolute; inset: 0; overflow: hidden;
|
||||
opacity: 0; transition-property: opacity;
|
||||
}
|
||||
.slide.show { opacity: 1; }
|
||||
.slide img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.slide.doc { background: #666; }
|
||||
#msg {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #888; font: 24px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
<script src="docx-render.js"></script>
|
||||
<script type="module" src="pdf-render.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<img class="slide" id="a" alt="">
|
||||
<img class="slide" id="b" alt="">
|
||||
<div class="slide" id="a"></div>
|
||||
<div class="slide" id="b"></div>
|
||||
<div id="msg">Loading slideshow…</div>
|
||||
<script>
|
||||
(() => {
|
||||
const TITLE = 'Slideshow';
|
||||
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||
const layers = [document.getElementById('a'), document.getElementById('b')];
|
||||
const msg = document.getElementById('msg');
|
||||
let front = 0;
|
||||
let order = [];
|
||||
let pos = -1;
|
||||
let timer = null;
|
||||
let scrollAnim = null;
|
||||
let state = null;
|
||||
let generation = 0; // bumps on every (re)start, cancels stale work
|
||||
const docCache = new Map(); // url -> { mtime, bytes }
|
||||
const pendingDocs = new Map(); // url -> { resolve, reject, timeout }
|
||||
|
||||
const keyOf = (it) => it && `${it.type}:${it.url}:${it.mtime || ''}`;
|
||||
|
||||
function shuffled(list) {
|
||||
const a = list.slice();
|
||||
@@ -41,62 +57,175 @@
|
||||
return a;
|
||||
}
|
||||
|
||||
function showMessage(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
layers.forEach((l) => { l.classList.remove('show'); l.removeAttribute('src'); });
|
||||
function stopTimers() {
|
||||
clearTimeout(timer);
|
||||
cancelAnimationFrame(scrollAnim);
|
||||
scrollAnim = null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
function showMessage(text) {
|
||||
stopTimers();
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
layers.forEach((l) => { l.classList.remove('show'); l.innerHTML = ''; });
|
||||
}
|
||||
|
||||
// Ask main.js for a document's bytes (see slideshow.js); cached by mtime
|
||||
// so a document is only re-read when it changed on disk.
|
||||
function getDocBytes(item) {
|
||||
const hit = docCache.get(item.url);
|
||||
if (hit && hit.mtime === item.mtime) return Promise.resolve(hit.bytes);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingDocs.delete(item.url);
|
||||
document.title = TITLE;
|
||||
reject(new Error('timed out reading the file'));
|
||||
}, 20000);
|
||||
pendingDocs.set(item.url, { resolve, reject, timeout, mtime: item.mtime });
|
||||
// main.js notices title *changes*; every answer (or timeout) puts it
|
||||
// back to TITLE, so each request is a change.
|
||||
document.title = TITLE_REQUEST + item.url;
|
||||
});
|
||||
}
|
||||
|
||||
// Fill `layer` with `item`, resolving once it's ready to fade in.
|
||||
async function prepare(layer, item) {
|
||||
layer.innerHTML = '';
|
||||
layer.scrollTop = 0;
|
||||
layer.classList.toggle('doc', item.type !== 'image');
|
||||
if (item.type === 'image') {
|
||||
const img = new Image();
|
||||
img.style.objectFit = state.options.fit;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error('unreadable image'));
|
||||
img.src = item.url;
|
||||
});
|
||||
layer.appendChild(img);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'pdf') {
|
||||
if (!window.KioskPdf) throw new Error('PDF renderer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const box = document.createElement('div');
|
||||
layer.appendChild(box);
|
||||
await KioskPdf.render(bytes, box, window.innerWidth, window.innerHeight);
|
||||
return;
|
||||
}
|
||||
if (!KioskDocx.available()) throw new Error('Word document viewer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const zoomEl = document.createElement('div');
|
||||
layer.appendChild(zoomEl);
|
||||
const cls = await KioskDocx.render(bytes, zoomEl);
|
||||
// One page: show all of it at once. Longer: fit the width and let
|
||||
// autoScroll() move through it.
|
||||
if (KioskDocx.isSinglePage(zoomEl, cls)) {
|
||||
KioskDocx.fitPage(zoomEl, cls, window.innerWidth, window.innerHeight);
|
||||
zoomEl.style.display = 'flex';
|
||||
zoomEl.style.justifyContent = 'center';
|
||||
} else {
|
||||
KioskDocx.fit(zoomEl, cls, window.innerWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// A document taller than the screen scrolls smoothly top to bottom
|
||||
// during its time on screen: holds at the top for the first 15%,
|
||||
// reaches the bottom at 85%, then holds.
|
||||
function autoScroll(layer, seconds) {
|
||||
const distance = layer.scrollHeight - layer.clientHeight;
|
||||
if (distance <= 4) return;
|
||||
const start = performance.now();
|
||||
const total = seconds * 1000;
|
||||
const step = (now) => {
|
||||
const t = Math.min(Math.max(((now - start) / total - 0.15) / 0.7, 0), 1);
|
||||
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
layer.scrollTop = distance * eased;
|
||||
if (t < 1) scrollAnim = requestAnimationFrame(step);
|
||||
};
|
||||
back.onerror = () => next(attempts + 1);
|
||||
back.src = order[pos];
|
||||
scrollAnim = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
async function next(attempts = 0) {
|
||||
stopTimers();
|
||||
if (!order.length) return;
|
||||
if (attempts >= order.length) { showMessage('None of the files in this folder could be shown.'); return; }
|
||||
const gen = generation;
|
||||
pos = (pos + 1) % order.length;
|
||||
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(order);
|
||||
const item = order[pos];
|
||||
const back = layers[1 - front];
|
||||
try {
|
||||
await prepare(back, item);
|
||||
} catch (e) {
|
||||
console.warn('[SLIDESHOW] Skipping', item.url, '-', e.message || e);
|
||||
if (gen === generation) next(attempts + 1);
|
||||
return;
|
||||
}
|
||||
if (gen !== generation) return; // folder changed while loading
|
||||
msg.style.display = 'none';
|
||||
const old = layers[front];
|
||||
back.classList.add('show');
|
||||
old.classList.remove('show');
|
||||
front = 1 - front;
|
||||
// Free the old slide (a rendered document can be large) once faded out.
|
||||
setTimeout(() => { if (!old.classList.contains('show')) old.innerHTML = ''; },
|
||||
state.options.transition * 1000 + 100);
|
||||
const isDoc = item.type !== 'image';
|
||||
const seconds = isDoc ? state.options.docInterval : state.options.interval;
|
||||
if (isDoc) autoScroll(back, seconds);
|
||||
// A lone document still cycles, so its scroll starts over from the top.
|
||||
if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000);
|
||||
}
|
||||
|
||||
window.kioskSlideshow = {
|
||||
update(s) {
|
||||
const first = !state;
|
||||
const sameImages = state && JSON.stringify(state.images) === JSON.stringify(s.images);
|
||||
const current = order[pos];
|
||||
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
|
||||
const currentKey = keyOf(order[pos]);
|
||||
state = s;
|
||||
layers.forEach((l) => {
|
||||
l.style.objectFit = s.options.fit;
|
||||
l.style.transitionDuration = s.options.transition + 's';
|
||||
});
|
||||
if (!s.images.length) {
|
||||
layers.forEach((l) => { l.style.transitionDuration = s.options.transition + 's'; });
|
||||
layers.forEach((l) => l.querySelectorAll('img').forEach((img) => { img.style.objectFit = s.options.fit; }));
|
||||
// Drop cached documents that left the folder.
|
||||
for (const url of docCache.keys()) {
|
||||
if (!s.items.some((it) => it.url === url)) docCache.delete(url);
|
||||
}
|
||||
if (!s.items.length) {
|
||||
order = [];
|
||||
clearTimeout(timer);
|
||||
showMessage(s.exists ? `No images in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||
generation++;
|
||||
showMessage(s.exists ? `No images, documents or PDFs 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 (sameItems && !first) return;
|
||||
// Folder changed: keep showing the current item if it's still there
|
||||
// unchanged and carry on from it, otherwise restart from the top.
|
||||
generation++;
|
||||
order = s.options.shuffle ? shuffled(s.items) : s.items.slice();
|
||||
const keep = currentKey ? order.findIndex((it) => keyOf(it) === currentKey) : -1;
|
||||
if (keep >= 0 && !first) {
|
||||
pos = keep;
|
||||
clearTimeout(timer);
|
||||
if (order.length > 1) timer = setTimeout(next, s.options.interval * 1000);
|
||||
const it = order[pos];
|
||||
const seconds = it.type !== 'image' ? s.options.docInterval : s.options.interval;
|
||||
if (order.length > 1 || it.type !== 'image') timer = setTimeout(next, seconds * 1000);
|
||||
} else {
|
||||
pos = -1;
|
||||
next();
|
||||
}
|
||||
},
|
||||
|
||||
docData(url, d) {
|
||||
const p = pendingDocs.get(url);
|
||||
if (!p) return;
|
||||
pendingDocs.delete(url);
|
||||
clearTimeout(p.timeout);
|
||||
document.title = TITLE;
|
||||
if (d.error) { p.reject(new Error(d.error)); return; }
|
||||
const bytes = KioskDocx.base64ToBytes(d.data);
|
||||
docCache.set(url, { mtime: p.mtime, bytes });
|
||||
// Keep at most 10 documents' bytes around.
|
||||
while (docCache.size > 10) docCache.delete(docCache.keys().next().value);
|
||||
p.resolve(bytes);
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
+82
-39
@@ -2,20 +2,29 @@
|
||||
//
|
||||
// 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.
|
||||
// full-screen slideshow of the images, Word documents (.docx) and PDFs in it,
|
||||
// instead of Chromium's directory listing. main.js swaps the folder URL
|
||||
// for slideshow.html via resolveSiteUrl(), then attach() hands the page
|
||||
// its item list - the page itself has no filesystem access
|
||||
// (contextIsolation, no Node). The folder is re-read every minute, so
|
||||
// files added, removed or changed show up without restarting the kiosk.
|
||||
//
|
||||
// Images load directly by file:// URL. A document's bytes are fetched on
|
||||
// demand: the page sets its title to TITLE_REQUEST + the document's URL,
|
||||
// and attach() answers via kioskSlideshow.docData() - but only for a
|
||||
// document (.docx/.pdf) in that slideshow's own current item list, so a page can't use
|
||||
// this to read anything else on disk.
|
||||
//
|
||||
// Optional slideshow.json in the folder (all keys optional):
|
||||
// { "interval": 10, seconds per image (default 10, minimum 2)
|
||||
// "docInterval": 20, seconds per document - .docx or PDF (default 2x interval,
|
||||
// minimum 5); a document longer than the
|
||||
// screen scrolls slowly top to bottom in it
|
||||
// "shuffle": false, random order instead of by file name
|
||||
// "fit": "contain", "contain" (whole image, letterboxed) or
|
||||
// "cover" (fill the screen, cropped)
|
||||
// "fit": "contain", images: "contain" (whole image, letterboxed)
|
||||
// or "cover" (fill the screen, cropped)
|
||||
// "transition": 1, crossfade seconds (0 = instant cut)
|
||||
// "recursive": false } include images in subfolders
|
||||
// "recursive": false } include files in subfolders
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -23,13 +32,16 @@ const { fileURLToPath, pathToFileURL } = require('url');
|
||||
|
||||
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
||||
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
||||
const DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' };
|
||||
const REFRESH_MS = 60000;
|
||||
const DEFAULTS = { interval: 10, shuffle: false, fit: 'contain', transition: 1, recursive: false };
|
||||
const MAX_DOC_BYTES = 50 * 1024 * 1024;
|
||||
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||
const DEFAULTS = { interval: 10, docInterval: null, shuffle: false, fit: 'contain', transition: 1, recursive: false };
|
||||
|
||||
// The folder path a site URL points at, or null if it isn't a slideshow.
|
||||
// A file:// URL counts when it's an existing directory, or ends in "/"
|
||||
// (so a folder that isn't there yet - e.g. a USB stick not plugged in -
|
||||
// still shows the slideshow's "no images" message, not an error page).
|
||||
// still shows the slideshow's "nothing to show" message, not an error).
|
||||
function slideshowDir(url) {
|
||||
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||
let p;
|
||||
@@ -57,20 +69,22 @@ function resolveSiteUrl(url) {
|
||||
|
||||
function readOptions(dir) {
|
||||
const opts = { ...DEFAULTS };
|
||||
let user = {};
|
||||
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;
|
||||
user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
|
||||
} catch (e) {
|
||||
// No (or unreadable) slideshow.json - defaults.
|
||||
}
|
||||
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
|
||||
opts.docInterval = Number(user.docInterval) > 0 ? Math.max(5, Number(user.docInterval)) : opts.interval * 2;
|
||||
if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle;
|
||||
if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit;
|
||||
if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2);
|
||||
if (typeof user.recursive === 'boolean') opts.recursive = user.recursive;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function listImages(dir, recursive, depth = 0) {
|
||||
function listItems(dir, recursive, depth = 0) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
@@ -79,12 +93,18 @@ function listImages(dir, recursive, depth = 0) {
|
||||
}
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
// Skip hidden files and Word's "~$name.docx" lock files.
|
||||
if (e.name.startsWith('.') || e.name.startsWith('~$')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (e.isDirectory()) {
|
||||
if (recursive && depth < 8) out.push(...listImages(full, recursive, depth + 1));
|
||||
} else if (IMAGE_EXT.has(path.extname(e.name).toLowerCase())) {
|
||||
out.push(full);
|
||||
if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1));
|
||||
} else if (IMAGE_EXT.has(ext)) {
|
||||
out.push({ path: full, type: 'image' });
|
||||
} else if (DOC_TYPES[ext]) {
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ }
|
||||
out.push({ path: full, type: DOC_TYPES[ext], mtime });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -92,36 +112,59 @@ function listImages(dir, recursive, depth = 0) {
|
||||
|
||||
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),
|
||||
};
|
||||
const items = listItems(dir, options.recursive)
|
||||
.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) }));
|
||||
return { dir, exists: fs.existsSync(dir), options, items };
|
||||
}
|
||||
|
||||
// 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 readDoc(fileUrl) {
|
||||
try {
|
||||
const file = fileURLToPath(fileUrl);
|
||||
const st = fs.statSync(file);
|
||||
if (st.size > MAX_DOC_BYTES) return { error: `too large (${Math.round(st.size / 1048576)} MB)` };
|
||||
return { mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
|
||||
} catch (e) {
|
||||
return { error: e.code === 'ENOENT' ? 'file not found' : e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Feed a view's slideshow page its item list now and every REFRESH_MS
|
||||
// while it stays on a slideshow, and answer its document requests. Call
|
||||
// once per view (main.js does, right after creating it); safe on views
|
||||
// that never show a slideshow.
|
||||
function attach(webContents) {
|
||||
const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href;
|
||||
let lastSent = '';
|
||||
let current = null; // latest snapshot sent to the page
|
||||
|
||||
const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix);
|
||||
|
||||
const send = (force) => {
|
||||
if (webContents.isDestroyed()) return;
|
||||
const current = webContents.getURL();
|
||||
if (!current.startsWith(pathToFileURL(SLIDESHOW_PAGE).href)) return;
|
||||
const dir = new URL(current).searchParams.get('dir');
|
||||
if (!onSlideshow()) return;
|
||||
const dir = new URL(webContents.getURL()).searchParams.get('dir');
|
||||
if (!dir) return;
|
||||
const payload = JSON.stringify(snapshot(dir));
|
||||
const snap = snapshot(dir);
|
||||
const payload = JSON.stringify(snap);
|
||||
if (!force && payload === lastSent) return;
|
||||
lastSent = payload;
|
||||
current = snap;
|
||||
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
webContents.on('did-finish-load', () => send(true));
|
||||
webContents.on('page-title-updated', (event, title) => {
|
||||
if (!title.startsWith(TITLE_REQUEST) || !onSlideshow() || !current) return;
|
||||
const url = title.slice(TITLE_REQUEST.length);
|
||||
if (!current.items.some((it) => it.type !== 'image' && it.url === url)) return;
|
||||
const doc = readDoc(url);
|
||||
webContents.executeJavaScript(
|
||||
`window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`,
|
||||
).catch(() => {});
|
||||
});
|
||||
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||
webContents.once('destroyed', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot };
|
||||
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
: "${CRON_D_DIR:=/etc/cron.d}"
|
||||
: "${BIN_DIR:=/usr/local/bin}"
|
||||
: "${NETPLAN_DIR:=/etc/netplan}"
|
||||
: "${TIMESYNCD_CONF_DIR:=/etc/systemd/timesyncd.conf.d}"
|
||||
: "${POLKIT_DIR:=/etc/polkit-1/localauthority/50-local.d}"
|
||||
: "${WIREGUARD_DIR:=/etc/wireguard}"
|
||||
: "${WEBUI_DIR:=/opt/kiosk-webui}"
|
||||
|
||||
+3
-2
@@ -122,8 +122,9 @@ provision_install_nodejs() {
|
||||
|
||||
provision_install_app() {
|
||||
echo "[4/10] Installing kiosk app..."
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||
# *.mjs: pdf-render.mjs (pdf.js only ships as a JavaScript module).
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.mjs "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.mjs "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||
|
||||
if offline_mode_active; then
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
################################################################################
|
||||
|
||||
BARESIP_CONFIG_DIR="${KIOSK_HOME}/.baresip"
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||
# pulseaudio-utils/pipewire-pulse: pactl, for audio routing.
|
||||
ASTERISK_INTERCOM_APT_PACKAGES=(baresip pulseaudio-utils pipewire-pulse)
|
||||
BARESIP_USER_SERVICE_DIR="${KIOSK_HOME}/.config/systemd/user"
|
||||
|
||||
# Runs `systemctl --user ...` as $KIOSK_USER with the runtime dir/D-Bus
|
||||
@@ -169,14 +172,13 @@ action_configure_asterisk_intercom() {
|
||||
|
||||
echo
|
||||
echo "Installing Baresip..."
|
||||
if ! command -v baresip &>/dev/null; then
|
||||
if ! sudo apt install -y baresip; then
|
||||
if ! command -v baresip &>/dev/null || ! command -v pactl &>/dev/null; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${ASTERISK_INTERCOM_APT_PACKAGES[@]}"; then
|
||||
log_error "Failed to install baresip package"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
sudo apt install -y pulseaudio-utils pipewire-pulse 2>/dev/null || true
|
||||
|
||||
sudo mkdir -p "$BARESIP_CONFIG_DIR"
|
||||
|
||||
|
||||
@@ -22,10 +22,23 @@
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Also read by iso/offline/build-bundle.sh, to bundle the same versions
|
||||
# for offline installs (the .deb itself plus its apt dependencies).
|
||||
LMS_DEB_URL="https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb"
|
||||
SQUEEZELITE_APT_PACKAGES=(squeezelite)
|
||||
|
||||
# True on a kiosk installed from an --offline ISO that can't reach the
|
||||
# internet right now - the one case where LMS comes from the bundle.
|
||||
lms_offline_install_wanted() {
|
||||
grep -q '^Package: lyrionmusicserver$' "$KIOSK_OFFLINE_DIR/apt/Packages" 2>/dev/null && ! have_internet
|
||||
}
|
||||
|
||||
lms_service_name() {
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
|
||||
# `systemctl cat`, not `list-unit-files | grep -q`: grep -q exiting
|
||||
# early can SIGPIPE systemctl, failing the pipeline under pipefail.
|
||||
if systemctl cat lyrionmusicserver.service &>/dev/null; then
|
||||
echo "lyrionmusicserver"
|
||||
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then
|
||||
elif systemctl cat logitechmediaserver.service &>/dev/null; then
|
||||
echo "logitechmediaserver"
|
||||
fi
|
||||
}
|
||||
@@ -114,42 +127,53 @@ action_install_lms() {
|
||||
|
||||
echo "Installing Lyrion Music Server..."
|
||||
|
||||
# Try the repository method first.
|
||||
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
|
||||
# `|| true`: a bare, unguarded `apt update` failing here (bad
|
||||
# mirror, no network) would otherwise crash the whole session
|
||||
# under set -e instead of falling through to the direct-download
|
||||
# fallback below, which is exactly the degrade path this is
|
||||
# supposed to hit when the repository route doesn't work.
|
||||
sudo apt update 2>/dev/null || true
|
||||
if sudo apt install -y logitechmediaserver 2>/dev/null; then
|
||||
log_success "LMS installed via repository"
|
||||
else
|
||||
log_warning "Repository install failed, trying direct download..."
|
||||
if lms_offline_install_wanted; then
|
||||
# Kiosk installed from an --offline ISO with no internet right now:
|
||||
# the bundle carries the same LMS_DEB_URL .deb as a package.
|
||||
if ! run_with_offline_fallback sudo apt install -y lyrionmusicserver; then
|
||||
log_error "Failed to install LMS from the offline bundle"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to a direct .deb download if the repository didn't produce
|
||||
# either possible package.
|
||||
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
|
||||
local lms_deb="/tmp/lms.deb"
|
||||
echo "Downloading LMS v9.0.3..."
|
||||
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then
|
||||
echo "Installing LMS package..."
|
||||
if sudo apt install -y "$lms_deb"; then
|
||||
log_success "LMS installed via direct download"
|
||||
log_success "LMS installed from the offline bundle"
|
||||
else
|
||||
# Try the repository method first.
|
||||
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
|
||||
# `|| true`: a bare, unguarded `apt update` failing here (bad
|
||||
# mirror, no network) would otherwise crash the whole session
|
||||
# under set -e instead of falling through to the direct-download
|
||||
# fallback below, which is exactly the degrade path this is
|
||||
# supposed to hit when the repository route doesn't work.
|
||||
sudo apt update 2>/dev/null || true
|
||||
if sudo apt install -y logitechmediaserver 2>/dev/null; then
|
||||
log_success "LMS installed via repository"
|
||||
else
|
||||
log_error "Failed to install LMS package"
|
||||
log_warning "Repository install failed, trying direct download..."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to a direct .deb download if the repository didn't produce
|
||||
# either possible package.
|
||||
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
|
||||
local lms_deb="/tmp/lms.deb"
|
||||
echo "Downloading LMS ($(basename "$LMS_DEB_URL"))..."
|
||||
if wget -q "$LMS_DEB_URL" -O "$lms_deb"; then
|
||||
echo "Installing LMS package..."
|
||||
if sudo apt install -y "$lms_deb"; then
|
||||
log_success "LMS installed via direct download"
|
||||
else
|
||||
log_error "Failed to install LMS package"
|
||||
rm -f "$lms_deb"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
rm -f "$lms_deb"
|
||||
else
|
||||
log_error "Failed to download LMS from lms-community.org"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
rm -f "$lms_deb"
|
||||
else
|
||||
log_error "Failed to download LMS from lms-community.org"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -250,7 +274,7 @@ action_install_squeezelite() {
|
||||
fi
|
||||
|
||||
if ! command -v squeezelite &>/dev/null; then
|
||||
if ! sudo apt install -y squeezelite; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${SQUEEZELITE_APT_PACKAGES[@]}"; then
|
||||
log_error "squeezelite package installation failed"
|
||||
pause
|
||||
return 1
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline
|
||||
# installs. (Tailscale/Netbird aren't bundled: they install from their
|
||||
# vendors' scripts and need their vendors' servers to work anyway.)
|
||||
VNC_APT_PACKAGES=(x11vnc)
|
||||
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
|
||||
|
||||
remote_access_status() {
|
||||
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
|
||||
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
|
||||
@@ -70,7 +76,7 @@ action_vnc_install() {
|
||||
echo
|
||||
ask_yes_no "Install x11vnc?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
if ! sudo apt install -y x11vnc; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${VNC_APT_PACKAGES[@]}"; then
|
||||
log_error "x11vnc installation failed"
|
||||
return 1
|
||||
fi
|
||||
@@ -182,7 +188,7 @@ action_wireguard_install() {
|
||||
echo
|
||||
ask_yes_no "Install WireGuard?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
if ! sudo apt install -y wireguard wireguard-tools; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${WIREGUARD_APT_PACKAGES[@]}"; then
|
||||
log_error "WireGuard installation failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -163,6 +163,7 @@ source "$SCRIPT_DIR/lib/electron.sh"
|
||||
source "$SCRIPT_DIR/menus/sites.sh"
|
||||
source "$SCRIPT_DIR/menus/display.sh"
|
||||
source "$SCRIPT_DIR/menus/timezone.sh"
|
||||
source "$SCRIPT_DIR/menus/time_sync.sh"
|
||||
source "$SCRIPT_DIR/menus/hidden_pin.sh"
|
||||
source "$SCRIPT_DIR/menus/lockout.sh"
|
||||
source "$SCRIPT_DIR/menus/wifi.sh"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/advanced_emergency_hotspot.sh - "Emergency Hotspot" (Advanced):
|
||||
# auto-starts a WiFi hotspot if no internet is detected 60 seconds after
|
||||
# boot, so the kiosk can be reached and reconfigured remotely.
|
||||
# auto-starts a WiFi hotspot if the kiosk finds itself cut off 60 seconds
|
||||
# after boot, so it can be reached and reconfigured remotely.
|
||||
# "Cut off" is chosen at setup (EMERGENCY_HOTSPOT_CHECK_MODE in the
|
||||
# generated script): by default the local network itself is unreachable -
|
||||
# neither the router nor the internet answers - so a kiosk on a LAN with
|
||||
# no internet (an offline install) doesn't start the hotspot on every
|
||||
# boot. The old internet-only check (ping 8.8.8.8) is still an option, as
|
||||
# is checking one specific address. Scripts generated before this choice
|
||||
# existed keep the internet-only check until Reconfigure is run.
|
||||
#
|
||||
# Writes a standalone runtime script ($BIN_DIR/kiosk-emergency-hotspot)
|
||||
# plus a oneshot systemd unit ($SYSTEMD_DIR) that runs it at boot - both
|
||||
@@ -21,6 +28,8 @@
|
||||
################################################################################
|
||||
|
||||
EMERGENCY_HOTSPOT_SCRIPT="$BIN_DIR/kiosk-emergency-hotspot"
|
||||
# Also read by iso/offline/build-bundle.sh to bundle them for offline installs.
|
||||
EMERGENCY_HOTSPOT_APT_PACKAGES=(hostapd dnsmasq iptables)
|
||||
|
||||
emergency_hotspot_is_configured() {
|
||||
[[ -f "$EMERGENCY_HOTSPOT_SCRIPT" ]]
|
||||
@@ -38,8 +47,8 @@ advanced_emergency_hotspot_status() {
|
||||
else
|
||||
echo "Emergency Hotspot: Not configured"
|
||||
fi
|
||||
echo "ℹ Auto-starts a WiFi hotspot if no internet is detected 60"
|
||||
echo " seconds after boot, so you can connect and reconfigure remotely."
|
||||
echo "ℹ Auto-starts a WiFi hotspot if the kiosk can't reach its network"
|
||||
echo " 60 seconds after boot, so you can connect and reconfigure remotely."
|
||||
}
|
||||
|
||||
advanced_emergency_hotspot_menu_builder() {
|
||||
@@ -62,7 +71,7 @@ advanced_emergency_hotspot_menu() {
|
||||
|
||||
action_configure_emergency_hotspot() {
|
||||
echo
|
||||
if ! sudo apt install -y hostapd dnsmasq iptables; then
|
||||
if ! run_with_offline_fallback sudo apt install -y "${EMERGENCY_HOTSPOT_APT_PACKAGES[@]}"; then
|
||||
log_error "Failed to install hostapd/dnsmasq/iptables"
|
||||
pause
|
||||
return 1
|
||||
@@ -91,6 +100,29 @@ action_configure_emergency_hotspot() {
|
||||
[[ ${#hotspot_pass} -lt 8 ]] && log_error "Password must be at least 8 characters"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "When should the hotspot start? (checked 60 seconds after boot)"
|
||||
echo " 1) The local network is unreachable - neither the router nor the"
|
||||
echo " internet answers. Recommended; right for networks without"
|
||||
echo " internet too."
|
||||
echo " 2) There's no internet. On a network without internet this starts"
|
||||
echo " the hotspot on every boot."
|
||||
echo " 3) A specific address on your network doesn't answer (e.g. a server)."
|
||||
local check_mode="network" check_host="" check_choice
|
||||
check_choice=$(ask_integer "Choice" 1 1 3)
|
||||
case "$check_choice" in
|
||||
2) check_mode="internet" ;;
|
||||
3)
|
||||
check_host=$(ask_text "Address to check (IP or hostname)" "")
|
||||
if [[ "$check_host" =~ ^[A-Za-z0-9.:-]+$ ]]; then
|
||||
check_mode="host"
|
||||
else
|
||||
log_warning "Not a valid address - using option 1 instead"
|
||||
check_host=""
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
local hotspot_ip="192.168.50.1"
|
||||
|
||||
sudo mkdir -p "$BIN_DIR"
|
||||
@@ -98,9 +130,14 @@ action_configure_emergency_hotspot() {
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
### KIOSK EMERGENCY HOTSPOT
|
||||
### Auto-starts if no internet connection 60 seconds after boot
|
||||
### Auto-starts if the network check fails 60 seconds after boot
|
||||
################################################################################
|
||||
|
||||
# network = neither the default gateway (router) nor the internet answers
|
||||
# internet = no internet (pings 8.8.8.8 / 1.1.1.1)
|
||||
# host = CHECK_HOST doesn't answer
|
||||
CHECK_MODE="$check_mode"
|
||||
CHECK_HOST="$check_host"
|
||||
WIFI_IFACE="$wifi_iface"
|
||||
HOTSPOT_SSID="$hotspot_ssid"
|
||||
HOTSPOT_PASS="$hotspot_pass"
|
||||
@@ -110,13 +147,36 @@ KIOSK_USER="$KIOSK_USER"
|
||||
# Wait 60 seconds after boot
|
||||
sleep 60
|
||||
|
||||
# Check for internet connectivity
|
||||
if ping -c 3 -W 5 8.8.8.8 >/dev/null 2>&1; then
|
||||
logger "KIOSK: Internet connected - emergency hotspot not needed"
|
||||
exit 0
|
||||
fi
|
||||
ping_ok() { ping -c 2 -W 3 "\$1" >/dev/null 2>&1; }
|
||||
internet_ok() { ping_ok 8.8.8.8 || ping_ok 1.1.1.1; }
|
||||
# The router counts as there if it answers ping - or, for routers set to
|
||||
# ignore ping, if it answered the ARP lookup that ping just triggered.
|
||||
gateway_ok() {
|
||||
local gw
|
||||
gw=\$(ip -4 route show default 2>/dev/null | awk '{print \$3; exit}')
|
||||
[[ -n "\$gw" ]] || return 1
|
||||
ping_ok "\$gw" && return 0
|
||||
ip neigh show "\$gw" 2>/dev/null | grep -qE 'REACHABLE|STALE|DELAY|PROBE'
|
||||
}
|
||||
connected() {
|
||||
case "\$CHECK_MODE" in
|
||||
internet) internet_ok ;;
|
||||
host) ping_ok "\$CHECK_HOST" ;;
|
||||
*) gateway_ok || internet_ok ;;
|
||||
esac
|
||||
}
|
||||
|
||||
logger "KIOSK: No internet detected - starting emergency hotspot"
|
||||
# Three tries, 10 seconds apart, so a slow DHCP lease or WiFi association
|
||||
# isn't mistaken for being cut off.
|
||||
for attempt in 1 2 3; do
|
||||
if connected; then
|
||||
logger "KIOSK: Network check (\$CHECK_MODE) passed - emergency hotspot not needed"
|
||||
exit 0
|
||||
fi
|
||||
[[ \$attempt -lt 3 ]] && sleep 10
|
||||
done
|
||||
|
||||
logger "KIOSK: Network check (\$CHECK_MODE) failed - starting emergency hotspot"
|
||||
|
||||
# Stop any conflicting services
|
||||
systemctl stop wpa_supplicant 2>/dev/null || true
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
||||
# webui_do_uninstall, power_schedule_do_remove_all,
|
||||
# emergency_hotspot_do_disable) instead of
|
||||
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
||||
# re-implementing removal logic for each addon a second time here - if an
|
||||
# addon's uninstall logic changes, this picks it up automatically. Only
|
||||
# the pieces no single addon owns - the kiosk user/files, Node.js/
|
||||
@@ -62,6 +62,7 @@ action_complete_uninstall() {
|
||||
echo " • LightDM and Openbox"
|
||||
echo " • All kiosk schedules and services"
|
||||
echo " • Emergency hotspot configuration"
|
||||
echo " • LAN time server setting (back to Ubuntu's defaults)"
|
||||
echo
|
||||
echo "⚠️ This CANNOT be undone!"
|
||||
echo
|
||||
@@ -90,9 +91,10 @@ action_complete_uninstall() {
|
||||
asterisk_intercom_do_uninstall purge
|
||||
webui_do_uninstall
|
||||
|
||||
echo "[3/12] Removing schedules and emergency hotspot..."
|
||||
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
||||
power_schedule_do_remove_all
|
||||
emergency_hotspot_do_disable
|
||||
time_sync_do_reset
|
||||
|
||||
# Must come after every addon teardown above - Asterisk Intercom's
|
||||
# helper still needs this user to resolve its systemd --user session.
|
||||
|
||||
+2
-2
@@ -137,8 +137,8 @@ action_add_page() {
|
||||
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."
|
||||
echo "image, Word .docx) like /home/kiosk/docs/menu.pdf, or a folder of"
|
||||
echo "images ending in / like /home/kiosk/photos/ for a slideshow."
|
||||
|
||||
local raw_url url dur name needs_auth user pass
|
||||
read -r -p "URL: " raw_url
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/time_sync.sh - "Time Server (NTP)" (Core Settings).
|
||||
#
|
||||
# The kiosk keeps its clock right with systemd-timesyncd, which by default
|
||||
# asks Ubuntu's time servers on the internet. On a network with no
|
||||
# internet that never works and the clock slowly drifts - which matters
|
||||
# here, because lockout times, quiet hours and power schedules all go by
|
||||
# it. This menu points timesyncd at a time server on the local network
|
||||
# instead (most routers, Windows domain controllers and NAS boxes can
|
||||
# serve NTP), or, with no time server at all, sets the clock by hand.
|
||||
#
|
||||
# Writes one drop-in, $TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf - removing it
|
||||
# (the "internet time servers" option, or Complete Uninstall via
|
||||
# time_sync_do_reset) puts Ubuntu's defaults back untouched.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
TIMESYNC_DROPIN="$TIMESYNCD_CONF_DIR/50-kiosk-ntp.conf"
|
||||
|
||||
# The time server(s) this menu configured, or empty for Ubuntu's defaults.
|
||||
time_sync_servers() {
|
||||
sed -n 's/^NTP=//p' "$TIMESYNC_DROPIN" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
time_sync_status() {
|
||||
local servers synced
|
||||
servers=$(time_sync_servers)
|
||||
synced=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo "unknown")
|
||||
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
echo "Time servers: ${servers:-Ubuntu defaults (internet)}"
|
||||
echo "Synchronized: $([[ "$synced" == "yes" ]] && echo "yes" || echo "no")"
|
||||
}
|
||||
|
||||
time_sync_menu_builder() {
|
||||
MENU_LABELS=(
|
||||
"Use a time server on my network"
|
||||
"Use the internet time servers (Ubuntu default)"
|
||||
"Set the date and time manually"
|
||||
"Show sync details"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
action_time_sync_lan
|
||||
action_time_sync_default
|
||||
action_time_sync_manual
|
||||
action_time_sync_details
|
||||
)
|
||||
}
|
||||
|
||||
time_sync_menu() {
|
||||
run_menu "TIME SERVER (NTP)" time_sync_menu_builder time_sync_status
|
||||
}
|
||||
|
||||
# Validate a space-separated list of hostnames / IPv4 / IPv6 addresses.
|
||||
time_sync_valid_servers() {
|
||||
local s
|
||||
[[ -n "$1" ]] || return 1
|
||||
for s in $1; do
|
||||
[[ "$s" =~ ^[A-Za-z0-9.:-]+$ ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
time_sync_apply() {
|
||||
sudo systemctl enable systemd-timesyncd 2>/dev/null || true
|
||||
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||
sudo systemctl restart systemd-timesyncd 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Remove this menu's drop-in, back to Ubuntu's default time servers.
|
||||
# Also used by Complete Uninstall.
|
||||
time_sync_do_reset() {
|
||||
sudo rm -f "$TIMESYNC_DROPIN"
|
||||
time_sync_apply
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Actions
|
||||
################################################################################
|
||||
|
||||
action_time_sync_lan() {
|
||||
echo
|
||||
echo "Enter the address of a time (NTP) server on your network - often"
|
||||
echo "the router (e.g. 192.168.1.1), a Windows domain controller, or a"
|
||||
echo "NAS. Several can be given, separated by spaces."
|
||||
local current servers
|
||||
current=$(time_sync_servers)
|
||||
servers=$(ask_text "Time server(s)" "${current:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}")
|
||||
if ! time_sync_valid_servers "$servers"; then
|
||||
log_error "Not a valid address list: '$servers'"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$TIMESYNCD_CONF_DIR"
|
||||
sudo tee "$TIMESYNC_DROPIN" > /dev/null <<EOF
|
||||
# Written by ubuntu-based-kiosk (Core Settings -> Time Server).
|
||||
# Delete this file to go back to Ubuntu's default time servers.
|
||||
[Time]
|
||||
NTP=$servers
|
||||
EOF
|
||||
time_sync_apply
|
||||
|
||||
echo "Checking the time server (up to 15 seconds)..."
|
||||
local i
|
||||
for i in {1..15}; do
|
||||
if [[ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" == "yes" ]]; then
|
||||
log_success "Clock synchronized with $servers"
|
||||
pause
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log_warning "Saved, but no sync yet. timesyncd keeps retrying in the background."
|
||||
echo "If it never syncs, check the address and that the server answers"
|
||||
echo "NTP (UDP port 123): Core Settings -> Time Server -> Show sync details."
|
||||
pause
|
||||
}
|
||||
|
||||
action_time_sync_default() {
|
||||
echo
|
||||
if [[ ! -f "$TIMESYNC_DROPIN" ]]; then
|
||||
echo "Already using Ubuntu's default (internet) time servers."
|
||||
pause
|
||||
return
|
||||
fi
|
||||
time_sync_do_reset
|
||||
log_success "Back on Ubuntu's default (internet) time servers"
|
||||
pause
|
||||
}
|
||||
|
||||
# For a network with no time server at all: set the clock by hand.
|
||||
# Automatic sync stays on afterwards - it just has nothing to sync with
|
||||
# until a server becomes reachable, and then takes over again.
|
||||
action_time_sync_manual() {
|
||||
echo
|
||||
echo "Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
echo "Enter the correct local date and time (timezone: $(timedatectl show -p Timezone --value 2>/dev/null))."
|
||||
local when
|
||||
when=$(ask_text "Date and time (YYYY-MM-DD HH:MM)" "$(date '+%Y-%m-%d %H:%M')")
|
||||
if [[ ! "$when" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}(:[0-9]{2})?$ ]] || ! date -d "$when" &>/dev/null; then
|
||||
log_error "Not a valid date/time: '$when' (expected e.g. 2026-10-03 14:30)"
|
||||
pause
|
||||
return 1
|
||||
fi
|
||||
[[ "$when" =~ :[0-9]{2}:[0-9]{2}$ ]] || when="$when:00"
|
||||
|
||||
# timedatectl refuses set-time while automatic sync is on.
|
||||
sudo timedatectl set-ntp false 2>/dev/null || true
|
||||
if sudo timedatectl set-time "$when"; then
|
||||
# Keep the hardware clock in step so the time survives a reboot.
|
||||
sudo hwclock --systohc 2>/dev/null || true
|
||||
log_success "Clock set to $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
else
|
||||
log_error "Couldn't set the clock"
|
||||
fi
|
||||
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||
pause
|
||||
}
|
||||
|
||||
action_time_sync_details() {
|
||||
echo
|
||||
timedatectl status 2>/dev/null || true
|
||||
echo
|
||||
timedatectl timesync-status 2>/dev/null || echo "(timesync-status not available)"
|
||||
pause
|
||||
}
|
||||
Reference in New Issue
Block a user