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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
+82
-39
@@ -2,20 +2,29 @@
|
||||
//
|
||||
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
|
||||
// in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a
|
||||
// full-screen slideshow of the images in it, instead of Chromium's
|
||||
// directory listing. main.js swaps the folder URL for slideshow.html via
|
||||
// resolveSiteUrl(), then hands the page its image list with attach() -
|
||||
// the page itself has no filesystem access (contextIsolation, no Node).
|
||||
// The folder is re-read every minute, so images added or removed show up
|
||||
// without restarting the kiosk.
|
||||
// full-screen slideshow of the images 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 };
|
||||
|
||||
Reference in New Issue
Block a user