From c54ae352a1478db65a9907f7ee418eea5bce90b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:57:10 +0000 Subject: [PATCH 1/4] Word (.docx) documents as sites, via docx-preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site whose path is a local .docx renders as pages in the kiosk instead of Chromium offering a download. kiosk-app/docview.js maps the file URL to docview.html (alongside slideshow.js, through main.js's new resolveLocalSite) and pushes the file's bytes into the page from the main process; the page renders them with docx-preview + jszip, both pure-JS npm dependencies of kiosk-app (so online installs, Advanced -> Upgrade and the offline bundle all pick them up - no LibreOffice). - Scaled to the screen width (capped at 160%), scrolls normally. - Re-checked every minute; a changed file re-renders from the top. - Word's Symbol/Wingdings private-use bullet glyphs (U+F0B7 etc.), which render blank without Microsoft fonts, are mapped to Unicode (•, ▪, ...). - Missing/unreadable/oversized (>50 MB) files and a missing library show a message instead of a blank page. .doc/.odt aren't supported. Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox: heading, coloured bold run, image, bordered table, bullets and a page break rendered; wheel scrolling works; replacing the file while running re-rendered the new document within the refresh. Offline bundle rebuilt and contains docx-preview and jszip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 10 +++- kiosk-app/docview.html | 106 +++++++++++++++++++++++++++++++++++++++++ kiosk-app/docview.js | 82 +++++++++++++++++++++++++++++++ kiosk-app/main.js | 10 ++-- kiosk-app/package.json | 4 +- menus/sites.sh | 4 +- 6 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 kiosk-app/docview.html create mode 100644 kiosk-app/docview.js diff --git a/Readme.md b/Readme.md index aad83cb..2fad438 100644 --- a/Readme.md +++ b/Readme.md @@ -195,7 +195,7 @@ 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 @@ -205,6 +205,13 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c `{"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). +- **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 @@ -1467,6 +1474,7 @@ 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. +- **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. - **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`). diff --git a/kiosk-app/docview.html b/kiosk-app/docview.html new file mode 100644 index 0000000..a28f805 --- /dev/null +++ b/kiosk-app/docview.html @@ -0,0 +1,106 @@ + + + + +Document + + + + + + +
Loading document…
+
+ + + diff --git a/kiosk-app/docview.js b/kiosk-app/docview.js new file mode 100644 index 0000000..668d67c --- /dev/null +++ b/kiosk-app/docview.js @@ -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 }; diff --git a/kiosk-app/main.js b/kiosk-app/main.js index d0acd51..84deff2 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -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',()=>{ diff --git a/kiosk-app/package.json b/kiosk-app/package.json index 0c2ca6c..c472b2b 100644 --- a/kiosk-app/package.json +++ b/kiosk-app/package.json @@ -3,6 +3,8 @@ "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" } } diff --git a/menus/sites.sh b/menus/sites.sh index 06f27e4..b47660b 100644 --- a/menus/sites.sh +++ b/menus/sites.sh @@ -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 From 4b85ae59fca3135880f30a414ab3cf9b292c8e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:07:24 +0000 Subject: [PATCH 2/4] Slideshow folders can mix Word documents in with images A slideshow folder now plays .docx files alongside images: a one-page document is shown whole (letterboxed like a photo); a longer one fills the width and scrolls smoothly top to bottom during its docInterval (new slideshow.json key, default 2x interval). Word's ~$ lock files are skipped; changed documents (by mtime) are re-read on the minute refresh. - kiosk-app/docx-render.js: the docx-preview rendering now shared by docview.html and slideshow.html. Each render gets its own class prefix and keeps its styles in its own container, so two documents crossfading can't restyle each other. "One page" is judged by content fitting the paper height, not section count - docx-preview only starts a new section at explicit page breaks, so a long unbroken document is one tall section. - Document bytes are fetched on demand: the page sets its title to a request, slideshow.js answers via executeJavaScript - only for a .docx in that slideshow's own current listing (no file-read API exposed to pages; the preload is shared by every site). Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox on a folder of 2 images + a 1-page and a long .docx (+ a ~$ lock file): order, crossfades, whole-page fit, scroll to the end, lock file skipped; the single-.docx site still renders. The request handler answered a listed .docx and refused /etc/passwd, a listed image, and a .docx outside the folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 21 ++-- kiosk-app/docview.html | 60 ++---------- kiosk-app/docx-render.js | 110 +++++++++++++++++++++ kiosk-app/slideshow.html | 203 +++++++++++++++++++++++++++++++-------- kiosk-app/slideshow.js | 121 +++++++++++++++-------- 5 files changed, 371 insertions(+), 144 deletions(-) create mode 100644 kiosk-app/docx-render.js diff --git a/Readme.md b/Readme.md index 2fad438..9733385 100644 --- a/Readme.md +++ b/Readme.md @@ -197,14 +197,17 @@ 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), 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) and Word documents + (`.docx`) play full-screen in file-name order with a crossfade - images + 10 seconds each, documents 20. A one-page document is shown whole; a + longer one fills the width and scrolls slowly top to bottom during its + time. 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; `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) @@ -1474,7 +1477,7 @@ 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. -- **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. +- **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 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`). diff --git a/kiosk-app/docview.html b/kiosk-app/docview.html index a28f805..9a8f88e 100644 --- a/kiosk-app/docview.html +++ b/kiosk-app/docview.html @@ -7,10 +7,6 @@ document's bytes arrive from main.js via window.kioskDocView.show(). --> +
Loading document…
@@ -26,6 +23,7 @@ (() => { const docEl = document.getElementById('doc'); const msg = document.getElementById('msg'); + let cls = null; function showMessage(text) { msg.textContent = text; @@ -33,65 +31,19 @@ docEl.innerHTML = ''; } - // Scale pages to the screen width (never above 100% + a little), so a - // portrait page is readable on a landscape kiosk without sideways - // scrolling. CSS zoom keeps scrolling and touch coordinates consistent. - function fitWidth() { - const page = docEl.querySelector('section.docx'); - if (!page) return; - docEl.style.zoom = 1; - const pageWidth = page.getBoundingClientRect().width + 32; - docEl.style.zoom = Math.min(window.innerWidth / pageWidth, 1.6); - } - window.addEventListener('resize', fitWidth); - - // 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 = { - '\uf0b7': '\u2022', // Symbol bullet -> • - '\uf0a7': '\u25aa', // Wingdings square -> ▪ - '\uf0d8': '\u27a2', // Wingdings arrowhead -> ➢ - '\uf076': '\u2756', // Wingdings diamond -> ❖ - '\uf0fc': '\u2713', // Wingdings check -> ✓ - '\uf06e': '\u25a0', // Wingdings black square -> ■ - '\uf071': '\u274f', // Wingdings box -> ❏ - }; - function fixSymbolBullets() { - document.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 "), in case it's - // emitted that way. - const hex = from.charCodeAt(0).toString(16); - css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to); - } - if (css !== before) st.textContent = css; - }); - } + window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth)); window.kioskDocView = { async show(d) { if (d.error) { showMessage(d.error); return; } - if (!window.docx || !window.JSZip) { + if (!window.KioskDocx || !KioskDocx.available()) { showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.'); return; } try { - const bytes = Uint8Array.from(atob(d.data), (c) => c.charCodeAt(0)); - docEl.innerHTML = ''; - await window.docx.renderAsync(bytes, docEl, null, { - inWrapper: true, - ignoreLastRenderedPageBreak: true, - experimental: true, - useBase64URL: true, - }); + cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl); msg.style.display = 'none'; - fixSymbolBullets(); - fitWidth(); + 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); diff --git a/kiosk-app/docx-render.js b/kiosk-app/docx-render.js new file mode 100644 index 0000000..714418a --- /dev/null +++ b/kiosk-app/docx-render.js @@ -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
": 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; + }, + }; +})(); diff --git a/kiosk-app/slideshow.html b/kiosk-app/slideshow.html index 6e76f50..8b03460 100644 --- a/kiosk-app/slideshow.html +++ b/kiosk-app/slideshow.html @@ -3,34 +3,49 @@ Slideshow - + + + + - - +
+
Loading slideshow…
diff --git a/kiosk-app/slideshow.js b/kiosk-app/slideshow.js index c515b6a..30acf6f 100644 --- a/kiosk-app/slideshow.js +++ b/kiosk-app/slideshow.js @@ -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 and Word documents (.docx) 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 +// .docx 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 (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_EXT = new Set(['.docx']); 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_EXT.has(ext)) { + let mtime = 0; + try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ } + out.push({ path: full, type: 'docx', 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 === 'docx' && 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 }; From fb4ee6fa62b70dfc51754637835f3d8082990fae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:43:05 +0000 Subject: [PATCH 3/4] Slideshow folders can include PDFs PDFs in a slideshow folder now play alongside images and Word documents: a one-page PDF is shown whole, a longer one fits the width and scrolls through its pages during docInterval (first 30 pages - rendering a huge PDF for a few seconds on screen isn't worth the time and memory). - kiosk-app/pdf-render.mjs renders pages to canvases with pdf.js (pdfjs-dist ^6.3.289, new kiosk-app dependency): no viewer toolbar, and the slideshow's existing auto-scroll works on it. pdf.js >= 4.2.67 is required (CVE-2024-4367: older versions could run script embedded in a crafted PDF); isEvalSupported:false too. It's a module because pdf.js only ships as one - confirmed to load (with its worker) from file:// in Electron 44. - Hands pdf.js a copy of the bytes: it transfers the buffer it's given, which would break the cached copy the next time round. - slideshow.js lists .pdf as a document type; the title-request handler serves any listed non-image item (still only from that folder). - provision_install_app now copies kiosk-app/*.mjs as well - without it pdf-render.mjs would never reach an installed kiosk. Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox, app files copied with provision_install_app's own glob: a photo, a one-page PDF, a three-page PDF and a .docx played in order, twice round; the one-page PDF shown whole, the three-page one scrolled through all pages. Offline bundle rebuilt with pdfjs-dist (437 MB total, +22 MB). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 14 ++++---- kiosk-app/package.json | 3 +- kiosk-app/pdf-render.mjs | 71 ++++++++++++++++++++++++++++++++++++++++ kiosk-app/slideshow.html | 24 ++++++++++---- kiosk-app/slideshow.js | 14 ++++---- lib/provision.sh | 5 +-- 6 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 kiosk-app/pdf-render.mjs diff --git a/Readme.md b/Readme.md index 9733385..25d58ff 100644 --- a/Readme.md +++ b/Readme.md @@ -198,14 +198,14 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file must be readable by the `kiosk` user. - **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and - its images (jpg, png, gif, webp, bmp, svg, avif) and Word documents - (`.docx`) play full-screen in file-name order with a crossfade - images - 10 seconds each, documents 20. A one-page document is shown whole; a - longer one fills the width and scrolls slowly top to bottom during its - time. Files added, removed or changed are picked up within a minute, no + 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; `fit`: `contain` shows the whole + (`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`) @@ -1477,7 +1477,7 @@ 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. -- **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 in with images. +- **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`). diff --git a/kiosk-app/package.json b/kiosk-app/package.json index c472b2b..9324094 100644 --- a/kiosk-app/package.json +++ b/kiosk-app/package.json @@ -5,6 +5,7 @@ "dependencies": { "electron": "^44.0.0", "docx-preview": "^0.4.1", - "jszip": "^3.10.1" + "jszip": "^3.10.1", + "pdfjs-dist": "^6.3.289" } } diff --git a/kiosk-app/pdf-render.mjs b/kiosk-app/pdf-render.mjs new file mode 100644 index 0000000..63472ec --- /dev/null +++ b/kiosk-app/pdf-render.mjs @@ -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(); + } + }, +}; diff --git a/kiosk-app/slideshow.html b/kiosk-app/slideshow.html index 8b03460..bb83664 100644 --- a/kiosk-app/slideshow.html +++ b/kiosk-app/slideshow.html @@ -24,6 +24,7 @@ +
@@ -91,7 +92,7 @@ async function prepare(layer, item) { layer.innerHTML = ''; layer.scrollTop = 0; - layer.classList.toggle('doc', item.type === 'docx'); + layer.classList.toggle('doc', item.type !== 'image'); if (item.type === 'image') { const img = new Image(); img.style.objectFit = state.options.fit; @@ -103,6 +104,14 @@ 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'); @@ -161,10 +170,11 @@ // 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 seconds = item.type === 'docx' ? state.options.docInterval : state.options.interval; - if (item.type === 'docx') autoScroll(back, seconds); + 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 || item.type === 'docx') timer = setTimeout(next, seconds * 1000); + if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000); } window.kioskSlideshow = { @@ -182,7 +192,7 @@ if (!s.items.length) { order = []; generation++; - showMessage(s.exists ? `No images or documents in ${s.dir}` : `Folder not found: ${s.dir}`); + showMessage(s.exists ? `No images, documents or PDFs in ${s.dir}` : `Folder not found: ${s.dir}`); return; } if (sameItems && !first) return; @@ -195,8 +205,8 @@ pos = keep; clearTimeout(timer); const it = order[pos]; - const seconds = it.type === 'docx' ? s.options.docInterval : s.options.interval; - if (order.length > 1 || it.type === 'docx') timer = setTimeout(next, seconds * 1000); + 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(); diff --git a/kiosk-app/slideshow.js b/kiosk-app/slideshow.js index 30acf6f..708d5d3 100644 --- a/kiosk-app/slideshow.js +++ b/kiosk-app/slideshow.js @@ -2,7 +2,7 @@ // // 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 and Word documents (.docx) in it, +// 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 @@ -12,12 +12,12 @@ // 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 -// .docx in that slideshow's own current item list, so a page can't use +// 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 (default 2x interval, +// "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 @@ -32,7 +32,7 @@ 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_EXT = new Set(['.docx']); +const DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' }; const REFRESH_MS = 60000; const MAX_DOC_BYTES = 50 * 1024 * 1024; const TITLE_REQUEST = 'kiosk-slideshow-need:'; @@ -101,10 +101,10 @@ function listItems(dir, recursive, depth = 0) { 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_EXT.has(ext)) { + } else if (DOC_TYPES[ext]) { let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ } - out.push({ path: full, type: 'docx', mtime }); + out.push({ path: full, type: DOC_TYPES[ext], mtime }); } } return out; @@ -157,7 +157,7 @@ function attach(webContents) { 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 === 'docx' && it.url === url)) return; + 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)})`, diff --git a/lib/provision.sh b/lib/provision.sh index 85067ec..bf774b9 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -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 From fcbb890dbfb229e1afcc5ccacf64ff7773aa88a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:08:46 +0000 Subject: [PATCH 4/4] Offline addons, LAN-aware emergency hotspot, LAN time server Offline bundle now carries every addon that works on a network without internet: LMS (Lyrion's own .deb, from the addon's LMS_DEB_URL) + Squeezelite, Asterisk Intercom (baresip), VNC, WireGuard and the Emergency Hotspot packages - each list in a variable next to the code that installs it (SQUEEZELITE_APT_PACKAGES, ASTERISK_INTERCOM_APT_PACKAGES, VNC_APT_PACKAGES, WIREGUARD_APT_PACKAGES, EMERGENCY_HOTSPOT_APT_PACKAGES), read by build-bundle.sh. Each install goes through run_with_offline_fallback like CUPS, so they install offline from the normal menus / Web UI. LMS installs as a bundle package when offline (lms_offline_install_wanted); its online repo/direct-download paths are unchanged. Tailscale/Netbird deliberately not bundled (vendor scripts, vendor servers). Bundle: 524 MB (+87 MB). Emergency hotspot: the boot check pinged only 8.8.8.8, so on a LAN with no internet the hotspot started on every boot. Now chosen at setup: "network" (default - router or internet must answer; a router that ignores ping still counts if it answered ARP), "internet" (old check), or "host" (a specific address). Three tries 10s apart. Existing scripts keep the old check until Reconfigure. New Core Settings -> Time Server (NTP) (menus/time_sync.sh): point systemd-timesyncd at LAN time server(s) via a timesyncd.conf.d drop-in, back to Ubuntu's defaults, or set the clock by hand (and the RTC) for a network with no time server. Complete Uninstall resets it. Also: lms_service_name used `systemctl list-unit-files | grep -q`, the same pipefail/SIGPIPE pattern as the earlier cups_is_installed bug; now `systemctl cat`. Tested in a network-less chroot of the 26.04.1 server filesystem: kiosk base, then action_wireguard_install and action_install_lms (real functions) and the Squeezelite/Intercom/VNC/hotspot package sets all installed from the bundle, apt left on normal sources after each. Hotspot script generated for all three modes (plus an invalid address, which falls back to "network") and its check run against simulated networks: LAN-only with router answering ping or only ARP -> no hotspot in "network" mode; nothing reachable -> hotspot. Time Server actions driven with timedatectl stubbed: drop-in contents, input validation, manual set-time sequence, reset removes the drop-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- .github/workflows/build-iso.yml | 7 +- Readme.md | 35 ++++-- install.sh | 7 +- iso/build-iso.sh | 5 +- iso/offline/build-bundle.sh | 42 +++++-- lib/config.sh | 1 + menus/addon_asterisk_intercom.sh | 8 +- menus/addon_lms_squeezelite.sh | 90 +++++++++------ menus/addon_remote_access.sh | 10 +- menus/addon_webui.sh | 1 + menus/advanced_emergency_hotspot.sh | 84 ++++++++++++-- menus/complete_uninstall.sh | 6 +- menus/time_sync.sh | 167 ++++++++++++++++++++++++++++ 13 files changed, 388 insertions(+), 75 deletions(-) create mode 100644 menus/time_sync.sh diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 9717d0d..fe2e5f8 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -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: diff --git a/Readme.md b/Readme.md index 25d58ff..cacf298 100644 --- a/Readme.md +++ b/Readme.md @@ -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 @@ -1105,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: @@ -1477,6 +1489,9 @@ 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`). diff --git a/install.sh b/install.sh index 4034e87..1ac658a 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/iso/build-iso.sh b/iso/build-iso.sh index 822815f..43630e6 100755 --- a/iso/build-iso.sh +++ b/iso/build-iso.sh @@ -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; diff --git a/iso/offline/build-bundle.sh b/iso/offline/build-bundle.sh index b834a77..7cced65 100755 --- a/iso/offline/build-bundle.sh +++ b/iso/offline/build-bundle.sh @@ -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 diff --git a/lib/config.sh b/lib/config.sh index 01f196f..3b9fd50 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -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}" diff --git a/menus/addon_asterisk_intercom.sh b/menus/addon_asterisk_intercom.sh index 7a54274..c70a418 100644 --- a/menus/addon_asterisk_intercom.sh +++ b/menus/addon_asterisk_intercom.sh @@ -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" diff --git a/menus/addon_lms_squeezelite.sh b/menus/addon_lms_squeezelite.sh index ac779a3..dfadcda 100644 --- a/menus/addon_lms_squeezelite.sh +++ b/menus/addon_lms_squeezelite.sh @@ -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 diff --git a/menus/addon_remote_access.sh b/menus/addon_remote_access.sh index d535b95..4c3fe0d 100644 --- a/menus/addon_remote_access.sh +++ b/menus/addon_remote_access.sh @@ -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 diff --git a/menus/addon_webui.sh b/menus/addon_webui.sh index 520ada1..ec01bec 100644 --- a/menus/addon_webui.sh +++ b/menus/addon_webui.sh @@ -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" diff --git a/menus/advanced_emergency_hotspot.sh b/menus/advanced_emergency_hotspot.sh index 315cf0c..b4c7684 100644 --- a/menus/advanced_emergency_hotspot.sh +++ b/menus/advanced_emergency_hotspot.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 diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index 53d83a8..0230d45 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -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. diff --git a/menus/time_sync.sh b/menus/time_sync.sh new file mode 100644 index 0000000..9ea34a8 --- /dev/null +++ b/menus/time_sync.sh @@ -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 < 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 +}