Add kiosk launcher downloads and player URL resolution

This commit is contained in:
2026-08-06 18:51:33 +01:00
parent 59730837ad
commit a4f8a807ff
23 changed files with 621 additions and 71 deletions
+2
View File
@@ -2,3 +2,5 @@
!package.json
!src/
!src/**
!scripts/
!scripts/**
+11
View File
@@ -2,6 +2,17 @@
All notable changes to this project will be documented in this file.
## 2.5.14 - 2026-08-06
### Added
- Added branded Windows and Linux kiosk launcher downloads on the dashboard, with updated copy that explains the launcher behavior more clearly.
- Kiosk launchers now start the browser in kiosk mode, suppress notifications for Chromium-based browsers, and use the correct Firefox kiosk flag.
### Changed
- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback.
## 2.5.13 - 2026-08-05
### Fixed
+1
View File
@@ -8,6 +8,7 @@ COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.5.13",
"version": "2.5.14",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+61
View File
@@ -0,0 +1,61 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "TARGET_URL=%~1"
if not defined TARGET_URL set "TARGET_URL=http://localhost:8081"
set "BROWSER_PATH="
set "BROWSER_KIND="
for %%B in (chrome.exe msedge.exe firefox.exe) do if not defined BROWSER_PATH (
for /f "delims=" %%I in ('where %%B 2^>nul') do if not defined BROWSER_PATH (
set "BROWSER_PATH=%%I"
set "BROWSER_KIND=%%~nB"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Google\Chrome\Application\chrome.exe"
"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"
"%LocalAppData%\Google\Chrome\Application\chrome.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=chrome"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"
"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"
"%LocalAppData%\Microsoft\Edge\Application\msedge.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=edge"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Mozilla Firefox\firefox.exe"
"%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe"
"%LocalAppData%\Mozilla Firefox\firefox.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=firefox"
)
)
if not defined BROWSER_PATH (
echo No supported browser was found.
echo Tried Chrome, Edge, and Firefox in PATH and common install locations.
exit /b 1
)
echo Launching !BROWSER_KIND! in kiosk mode: !TARGET_URL!
if /I "!BROWSER_KIND!"=="firefox" (
start "" "!BROWSER_PATH!" -kiosk "!TARGET_URL!"
) else (
start "" "!BROWSER_PATH!" --disable-notifications --kiosk "!TARGET_URL!"
)
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
target_url="${1:-http://localhost:8081}"
find_browser() {
local candidate
for candidate in "$@"; do
if command -v "$candidate" >/dev/null 2>&1; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
browser=""
browser_kind=""
if browser="$(find_browser google-chrome-stable google-chrome chromium chromium-browser msedge microsoft-edge firefox)"; then
case "$browser" in
firefox)
browser_kind="firefox"
;;
msedge|microsoft-edge)
browser_kind="edge"
;;
*)
browser_kind="chrome"
;;
esac
else
echo "No supported browser was found."
echo "Tried Chrome, Chromium, Edge, and Firefox in PATH."
exit 1
fi
echo "Launching ${browser_kind} in kiosk mode: ${target_url}"
case "$browser_kind" in
firefox)
exec "$browser" -kiosk "$target_url"
;;
edge|chrome)
exec "$browser" --disable-notifications --kiosk "$target_url"
;;
esac
+1
View File
@@ -86,6 +86,7 @@ module.exports = {
fetchScreenById: data.fetchScreenById,
fetchScreenEditData: data.fetchScreenEditData,
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
fetchTemplateById: data.fetchTemplateById,
fetchSlideById: data.fetchSlideById,
+2 -1
View File
@@ -7,7 +7,7 @@ const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord } = require('./screens');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
@@ -61,6 +61,7 @@ module.exports = {
fetchScreenById,
fetchScreenEditData,
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord,
fetchTemplateById,
fetchSlideById,
+12
View File
@@ -50,6 +50,17 @@ async function fetchScreenPlayerUrls(pool) {
return playerUrls;
}
async function fetchPlayerPublicBaseUrl(pool) {
const [rows] = await pool.query(`
SELECT public_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1
`);
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
}
async function fetchScreenPlayerRecord(pool, slug) {
const [rows] = await pool.query(`
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
@@ -103,6 +114,7 @@ module.exports = {
normalizePlayerBaseUrl,
buildScreenPlayerUrl,
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord,
uniqueScreenSlug,
fetchScreenById,
-1
View File
@@ -71,7 +71,6 @@ async function start() {
pool: pool,
common: common,
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
playerPublicBaseUrl: webConfig.playerPublicBaseUrl,
uploadDir: webConfig.uploadsDir,
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens,
-2
View File
@@ -9,7 +9,6 @@ function createWebBootstrap(options) {
const pool = options && options.pool;
const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = 5000;
const formatDashboardDate = options && options.formatDashboardDate;
@@ -155,7 +154,6 @@ function createWebBootstrap(options) {
playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
playerPublicBaseUrl: playerPublicBaseUrl,
formatDashboardDate: formatDashboardDate
});
const buildDashboardState = dashboardStateService.buildDashboardState;
+5 -1
View File
@@ -58,7 +58,11 @@ function registerUrlHelpers(Handlebars) {
});
Handlebars.registerHelper('playerUrl', function (slug) {
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const base = String(arguments[1] || '').trim().replace(/\/$/, '');
if (!base) {
return '';
}
return `${base}/screen/${encodeURIComponent(slug)}`;
});
}
-2
View File
@@ -6,7 +6,6 @@ function createWebConfig() {
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
const assetDir = path.join(__dirname, '..', 'public');
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
const sessionCookieName = 'digital_signage_session';
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
@@ -20,7 +19,6 @@ function createWebConfig() {
thumbnailsDir: thumbnailsDir,
assetDir: assetDir,
playerInternalBaseUrl: playerInternalBaseUrl,
playerPublicBaseUrl: playerPublicBaseUrl,
sessionCookieName: sessionCookieName,
sessionMaxAgeMs: sessionMaxAgeMs,
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
+61
View File
@@ -876,6 +876,67 @@
font-size: 0.875rem;
}
.dashboard-launcher-actions {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
gap: 0.5rem;
}
.dashboard-launcher-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.dashboard-launcher-copy {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-kiosk-launcher-confirmation {
background: var(--bs-tertiary-bg);
}
.dashboard-kiosk-launcher-confirmation .form-check.form-switch {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0;
}
.dashboard-kiosk-launcher-confirmation .form-check-input {
float: none;
margin: 0;
transform: scale(0.92);
transform-origin: left center;
}
.dashboard-kiosk-launcher-confirmation .form-check-label {
letter-spacing: 0.01em;
line-height: 1.1;
}
.dashboard-launcher-open-button {
min-width: 5rem;
}
.dashboard-kiosk-launcher-download {
text-align: left;
}
.dashboard-kiosk-launcher-download.disabled,
.dashboard-kiosk-launcher-download[aria-disabled='true'] {
pointer-events: none;
opacity: 0.6;
}
.dashboard-launcher-download {
width: 9rem;
}
.screen-command-card .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
@@ -773,10 +773,62 @@
});
}
function initKioskLauncherModal() {
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
if (!modal) {
return;
}
var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]');
var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]'));
function setDownloadsEnabled(enabled) {
downloadLinks.forEach(function (link) {
if (!link) {
return;
}
link.classList.toggle('disabled', !enabled);
link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
if (enabled) {
link.removeAttribute('tabindex');
} else {
link.setAttribute('tabindex', '-1');
}
});
}
function resetModalState() {
if (checkbox) {
checkbox.checked = false;
}
setDownloadsEnabled(false);
}
if (checkbox) {
checkbox.addEventListener('change', function () {
setDownloadsEnabled(Boolean(checkbox.checked));
});
}
modal.addEventListener('show.bs.modal', resetModalState);
modal.addEventListener('hidden.bs.modal', resetModalState);
modal.addEventListener('click', function (event) {
var target = event.target && event.target.closest ? event.target.closest('[data-kiosk-launcher-download]') : null;
if (target && target.classList.contains('disabled')) {
event.preventDefault();
event.stopPropagation();
}
});
resetModalState();
}
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
initClientMoveHandler();
initKioskLauncherModal();
var screenCommandSelect = document.getElementById('screen-command-select');
if (screenCommandSelect) {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
+5 -2
View File
@@ -10,7 +10,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
@@ -212,7 +211,11 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
: null;
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const targetBaseUrl = String(
targetPlayerRecord && targetPlayerRecord.public_base_url ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
await forwardPlayerCommand(slug, {
+6 -2
View File
@@ -11,7 +11,6 @@ module.exports = function registerManageRoutes(app, deps) {
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
const requirePermission = deps.requirePermission;
const SCREEN_NAME_MAX_LENGTH = 255;
@@ -153,9 +152,14 @@ module.exports = function registerManageRoutes(app, deps) {
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, previousSlug)
: null;
const playerBaseUrl = playerRecord && playerRecord.public_base_url
? String(playerRecord.public_base_url).replace(/\/$/, '')
: typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchPlayerPublicBaseUrl(pool)
: '';
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: (playerRecord && playerRecord.public_base_url ? String(playerRecord.public_base_url).replace(/\/$/, '') : PLAYER_PUBLIC_BASE_URL) + `/screen/${encodeURIComponent(slug)}`
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
}
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
+7 -4
View File
@@ -1,7 +1,5 @@
// Shared route helpers for web view rendering and player URL formatting.
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -11,8 +9,13 @@ function escapeHtml(value) {
.replace(/'/g, ''');
}
function screenPlayerUrl(slug) {
return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`;
function screenPlayerUrl(slug, baseUrl) {
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '');
if (!normalizedBaseUrl) {
return '';
}
return `${normalizedBaseUrl}/screen/${encodeURIComponent(slug)}`;
}
module.exports = {
+1 -3
View File
@@ -48,7 +48,7 @@ function registerAuthAndAccountRoutes(app, deps) {
buildDashboardState: deps.buildDashboardState,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections
getScreenConnections: deps.playerActionService.getScreenConnections,
});
registerAccountRoutes(app, {
@@ -107,7 +107,6 @@ function registerSignageRoutes(app, deps) {
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
});
@@ -129,7 +128,6 @@ function registerSignageRoutes(app, deps) {
isClientNameAvailable: deps.isClientNameAvailable,
withClientNameReservation: deps.withClientNameReservation,
broadcastDashboardState: deps.broadcastDashboardState,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
});
}
+82 -1
View File
@@ -1,5 +1,7 @@
// Screen route registration and dashboard wiring.
const fs = require('fs');
module.exports = function registerScreensRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -10,6 +12,12 @@ module.exports = function registerScreensRoutes(app, deps) {
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat');
const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh');
const launcherDownloadPaths = {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
};
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
@@ -55,6 +63,36 @@ module.exports = function registerScreensRoutes(app, deps) {
});
}
async function buildPlayerUrl() {
if (typeof common.fetchPlayerPublicBaseUrl !== 'function') {
return null;
}
return common.fetchPlayerPublicBaseUrl(pool);
}
function buildLauncherContent(templatePath, playerUrl) {
const template = fs.readFileSync(templatePath, 'utf8');
const normalizedPlayerUrl = String(playerUrl || '').trim();
if (!normalizedPlayerUrl) {
return null;
}
if (template.indexOf('http://localhost:8081') === -1) {
throw new Error(`Launcher template is missing the default player URL placeholder: ${templatePath}`);
}
return template.replace('http://localhost:8081', normalizedPlayerUrl);
}
function sendLauncherDownload(res, fileName, content) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.attachment(fileName);
res.type('text/plain; charset=utf-8');
res.send(content);
}
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try {
if (req.query.edit) {
@@ -62,6 +100,10 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.player_url = await buildPlayerUrl();
if (screen.player_url) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -99,7 +141,10 @@ module.exports = function registerScreensRoutes(app, deps) {
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
screen.player_url = screen.slug && playerUrlsBySlug[screen.slug] ? String(playerUrlsBySlug[screen.slug]).trim() : null;
screen.player_url = String(playerUrlsBySlug[screen.slug] || '').trim() || null;
if (screen.player_url) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -107,5 +152,41 @@ module.exports = function registerScreensRoutes(app, deps) {
next(error);
}
});
app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
const content = buildLauncherContent(batTemplatePath, playerUrl);
if (!content) {
return res.status(404).send('Launcher template is not available.');
}
sendLauncherDownload(res, 'pulse-signage-kiosk.bat', content);
} catch (error) {
next(error);
}
});
app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
const content = buildLauncherContent(shTemplatePath, playerUrl);
if (!content) {
return res.status(404).send('Launcher template is not available.');
}
sendLauncherDownload(res, 'pulse-signage-kiosk.sh', content);
} catch (error) {
next(error);
}
});
};
+48 -48
View File
@@ -13,58 +13,58 @@
</div>
</div>
<div class="card-body">
{{#if screens.length}}
<div class="screen-command-panel">
<div class="screen-command-panel-left">
<div class="screen-command-selector-field">
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
<div class="input-group input-group-sm">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
<select
id="screen-command-select"
class="form-select"
aria-label="Target screen group"
data-screen-command-select
>
{{#each screens}}
<option
value="{{slug}}"
data-screen-name="{{name}}"
data-player-connection-count="{{player_connection_count}}"
data-playlist-name="{{playlist_name}}"
>{{name}}</option>
{{/each}}
</select>
{{#if screens.length}}
<div class="screen-command-panel">
<div class="screen-command-panel-left">
<div class="screen-command-selector-field">
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
<div class="input-group input-group-sm">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
<select
id="screen-command-select"
class="form-select"
aria-label="Target screen group"
data-screen-command-select
>
{{#each screens}}
<option
value="{{slug}}"
data-screen-name="{{name}}"
data-player-connection-count="{{player_connection_count}}"
data-playlist-name="{{playlist_name}}"
>{{name}}</option>
{{/each}}
</select>
</div>
</div>
<div class="screen-command-actions">
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-async-command data-screen-command-form data-screen-command-action="reload">
<input type="hidden" name="command" value="reload" />
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-async-command data-screen-command-form data-screen-command-action="pause">
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="paused" value="true" />
<button type="submit" class="btn btn-sm btn-info"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-async-command data-screen-command-form data-screen-command-action="blackout">
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="true" />
<button type="submit" class="btn btn-sm btn-secondary"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
</form>
</div>
</div>
<div class="screen-command-actions">
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-async-command data-screen-command-form data-screen-command-action="reload">
<input type="hidden" name="command" value="reload" />
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-async-command data-screen-command-form data-screen-command-action="pause">
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="paused" value="true" />
<button type="submit" class="btn btn-sm btn-info"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
</form>
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-async-command data-screen-command-form data-screen-command-action="blackout">
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="true" />
<button type="submit" class="btn btn-sm btn-secondary"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
</form>
<div class="screen-command-panel-right">
<div class="screen-command-summary">
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
<strong data-screen-command-name>Select a target screen group</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
</div>
</div>
</div>
<div class="screen-command-panel-right">
<div class="screen-command-summary">
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
<strong data-screen-command-name>Select a target screen group</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
</div>
</div>
</div>
{{else}}
<div class="empty">No screen groups are available to target yet.</div>
{{/if}}
{{else}}
<div class="empty">No screen groups are available to target yet.</div>
{{/if}}
</div>
</div>
{{/if}}
+63 -3
View File
@@ -43,9 +43,9 @@
</section>
{{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row">
<div class="col-12">
<div class="card mb-4 card-outline card-primary dashboard-actions-card">
<div class="row pb-4">
<div class="col-12 col-xl-8">
<div class="card card-outline card-primary dashboard-actions-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Quick actions</h3>
@@ -108,7 +108,67 @@
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card card-outline card-secondary dashboard-launcher-card h-100">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Kiosk launchers</h3>
<p class="dashboard-card-subtitle">Open the player base URL on a device for onboarding.</p>
</div>
</div>
<div class="card-body">
<div class="dashboard-launcher-row h-100">
<p class="mb-0 text-muted small dashboard-launcher-copy">Download the Windows or Linux launcher to start a browser in kiosk mode and open the correct player page for onboarding.</p>
<div class="dashboard-launcher-actions h-100">
<button type="button" class="btn btn-outline-secondary btn-lg d-inline-flex align-items-center justify-content-center dashboard-launcher-open-button h-100" data-bs-toggle="modal" data-bs-target="#dashboard-kiosk-launcher-modal">
<i class="bi bi-download" aria-hidden="true"></i>
<span></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{#> modal-shell modalId="dashboard-kiosk-launcher-modal" modalLabelId="dashboard-kiosk-launcher-modal-label" modalDialogClass="modal-dialog-centered modal-lg"}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="dashboard-kiosk-launcher-modal-label">Kiosk launcher downloads</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-4">
<div class="alert alert-info mb-0">
<p class="mb-2 fw-semibold">Kiosk mode is for dedicated signage screens.</p>
<ul class="mb-0 ps-3">
<li>The script looks for a supported browser on the device and launches the first one it finds.</li>
<li>The launcher opens the correct player page automatically, so no manual URL entry is needed.</li>
<li>It uses a kiosk-mode browser window, so the screen stays focused on signage instead of normal browsing.</li>
<li>Exit with Alt+F4 on Windows or Linux. On Linux, that is usually the standard close-window shortcut too.</li>
</ul>
<div class="mt-3 pt-3 border-top">
<p class="mb-0 text-muted small">These downloads are scripts, not applications: the Windows download is a .bat file and the Linux download is a .sh file.</p>
</div>
</div>
<div class="dashboard-kiosk-launcher-confirmation p-3 border rounded-3 bg-body-tertiary">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" value="1" id="dashboard-kiosk-launcher-confirm" data-kiosk-launcher-confirm />
<label class="form-check-label fw-semibold fs-5" for="dashboard-kiosk-launcher-confirm">I Understand</label>
</div>
<p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2">
<a class="btn btn-outline-primary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.bat" data-kiosk-launcher-download aria-disabled="true" tabindex="-1">
<i class="bi bi-windows me-2" aria-hidden="true"></i>
<span>Download for Windows</span>
</a>
<a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.sh" data-kiosk-launcher-download aria-disabled="true" tabindex="-1">
<i class="bi bi-terminal me-2" aria-hidden="true"></i>
<span>Download for Linux</span>
</a>
</div>
</div>
{{/modal-shell}}
{{/if}}
{{#if (hasPermission currentUser "screens.read")}}
+153
View File
@@ -0,0 +1,153 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreensRoutes = require('../src/web/routes/signage/screens/routes');
test('screen edit page includes shared launcher downloads and base player url', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let renderedArgs = null;
const pages = {
renderScreenEditPage(screen, data, message, currentUser) {
renderedArgs = { screen, data, message, currentUser };
return 'ok';
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchScreenPlayerUrls() {
return {
'demo-conference': 'http://player.example/screen/demo-conference'
};
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages,
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const handler = handlers['/screens/:id/edit'];
assert.equal(Array.isArray(handler), true);
const res = {
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_url, 'http://player.example/screen/demo-conference');
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
});
});
test('screen launcher downloads are shared and attached', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchScreenPlayerUrls() {
return {
'demo-conference': 'http://player.example/screen/demo-conference'
};
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages: { renderScreenEditPage() { return 'ok'; } },
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const windowsHandler = handlers['/downloads/kiosk/pulse-signage-kiosk.bat'];
assert.equal(Array.isArray(windowsHandler), true);
const res = {
headers: {},
statusCode: 200,
set(name, value) {
this.headers[name.toLowerCase()] = value;
},
attachment(filename) {
this.headers['content-disposition'] = `attachment; filename="${filename}"`;
},
type(value) {
this.headers['content-type'] = value;
},
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await windowsHandler[1]({ params: { id: '7' } }, res, () => {});
assert.equal(res.statusCode, 200);
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
assert.match(res.body, /--kiosk/);
});