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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
171 lines
7.3 KiB
JavaScript
171 lines
7.3 KiB
JavaScript
// Folder slideshow support for main.js.
|
|
//
|
|
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
|
|
// in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a
|
|
// full-screen slideshow of the images, 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", images: "contain" (whole image, letterboxed)
|
|
// or "cover" (fill the screen, cropped)
|
|
// "transition": 1, crossfade seconds (0 = instant cut)
|
|
// "recursive": false } include files in subfolders
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { fileURLToPath, pathToFileURL } = require('url');
|
|
|
|
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
|
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
|
const DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' };
|
|
const REFRESH_MS = 60000;
|
|
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 "nothing to show" message, not an error).
|
|
function slideshowDir(url) {
|
|
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
|
let p;
|
|
try {
|
|
p = fileURLToPath(url);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (url.endsWith('/')) return p;
|
|
try {
|
|
return fs.statSync(p).isDirectory() ? p : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// What a view should actually load for a configured site URL.
|
|
function resolveSiteUrl(url) {
|
|
const dir = slideshowDir(url);
|
|
if (!dir) return url;
|
|
const u = pathToFileURL(SLIDESHOW_PAGE);
|
|
u.searchParams.set('dir', dir);
|
|
return u.href;
|
|
}
|
|
|
|
function readOptions(dir) {
|
|
const opts = { ...DEFAULTS };
|
|
let user = {};
|
|
try {
|
|
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 listItems(dir, recursive, depth = 0) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
for (const e of entries) {
|
|
// 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(...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;
|
|
}
|
|
|
|
function snapshot(dir) {
|
|
const options = readOptions(dir);
|
|
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 };
|
|
}
|
|
|
|
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 (!onSlideshow()) return;
|
|
const dir = new URL(webContents.getURL()).searchParams.get('dir');
|
|
if (!dir) return;
|
|
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, TITLE_REQUEST };
|