Add kiosk launcher downloads and player URL resolution
This commit is contained in:
@@ -2,3 +2,5 @@
|
|||||||
!package.json
|
!package.json
|
||||||
!src/
|
!src/
|
||||||
!src/**
|
!src/**
|
||||||
|
!scripts/
|
||||||
|
!scripts/**
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.5.13 - 2026-08-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ COPY package*.json ./
|
|||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
COPY src ./src
|
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/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
|
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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.13",
|
"version": "2.5.14",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -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!"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -86,6 +86,7 @@ module.exports = {
|
|||||||
fetchScreenById: data.fetchScreenById,
|
fetchScreenById: data.fetchScreenById,
|
||||||
fetchScreenEditData: data.fetchScreenEditData,
|
fetchScreenEditData: data.fetchScreenEditData,
|
||||||
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
|
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
|
||||||
|
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
|
||||||
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
||||||
fetchTemplateById: data.fetchTemplateById,
|
fetchTemplateById: data.fetchTemplateById,
|
||||||
fetchSlideById: data.fetchSlideById,
|
fetchSlideById: data.fetchSlideById,
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ const { fetchPlaylistById } = require('./playlists');
|
|||||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
||||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
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 { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||||
@@ -61,6 +61,7 @@ module.exports = {
|
|||||||
fetchScreenById,
|
fetchScreenById,
|
||||||
fetchScreenEditData,
|
fetchScreenEditData,
|
||||||
fetchScreenPlayerUrls,
|
fetchScreenPlayerUrls,
|
||||||
|
fetchPlayerPublicBaseUrl,
|
||||||
fetchScreenPlayerRecord,
|
fetchScreenPlayerRecord,
|
||||||
fetchTemplateById,
|
fetchTemplateById,
|
||||||
fetchSlideById,
|
fetchSlideById,
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ async function fetchScreenPlayerUrls(pool) {
|
|||||||
return playerUrls;
|
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) {
|
async function fetchScreenPlayerRecord(pool, slug) {
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
|
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
|
||||||
@@ -103,6 +114,7 @@ module.exports = {
|
|||||||
normalizePlayerBaseUrl,
|
normalizePlayerBaseUrl,
|
||||||
buildScreenPlayerUrl,
|
buildScreenPlayerUrl,
|
||||||
fetchScreenPlayerUrls,
|
fetchScreenPlayerUrls,
|
||||||
|
fetchPlayerPublicBaseUrl,
|
||||||
fetchScreenPlayerRecord,
|
fetchScreenPlayerRecord,
|
||||||
uniqueScreenSlug,
|
uniqueScreenSlug,
|
||||||
fetchScreenById,
|
fetchScreenById,
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ async function start() {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
||||||
playerPublicBaseUrl: webConfig.playerPublicBaseUrl,
|
|
||||||
uploadDir: webConfig.uploadsDir,
|
uploadDir: webConfig.uploadsDir,
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
|
|||||||
Vendored
-2
@@ -9,7 +9,6 @@ function createWebBootstrap(options) {
|
|||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
|
||||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||||
const dashboardRefreshIntervalMs = 5000;
|
const dashboardRefreshIntervalMs = 5000;
|
||||||
const formatDashboardDate = options && options.formatDashboardDate;
|
const formatDashboardDate = options && options.formatDashboardDate;
|
||||||
@@ -155,7 +154,6 @@ function createWebBootstrap(options) {
|
|||||||
playerSnapshotCache: playerSnapshotCache,
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
playerSnapshotSockets: playerSnapshotSockets,
|
playerSnapshotSockets: playerSnapshotSockets,
|
||||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
|
||||||
formatDashboardDate: formatDashboardDate
|
formatDashboardDate: formatDashboardDate
|
||||||
});
|
});
|
||||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ function registerUrlHelpers(Handlebars) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Handlebars.registerHelper('playerUrl', function (slug) {
|
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)}`;
|
return `${base}/screen/${encodeURIComponent(slug)}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ function createWebConfig() {
|
|||||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||||
const assetDir = path.join(__dirname, '..', 'public');
|
const assetDir = path.join(__dirname, '..', 'public');
|
||||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
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 sessionCookieName = 'digital_signage_session';
|
||||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||||
@@ -20,7 +19,6 @@ function createWebConfig() {
|
|||||||
thumbnailsDir: thumbnailsDir,
|
thumbnailsDir: thumbnailsDir,
|
||||||
assetDir: assetDir,
|
assetDir: assetDir,
|
||||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
|
||||||
sessionCookieName: sessionCookieName,
|
sessionCookieName: sessionCookieName,
|
||||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||||
|
|||||||
@@ -876,6 +876,67 @@
|
|||||||
font-size: 0.875rem;
|
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 {
|
.screen-command-card .card-header {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
padding-bottom: 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;
|
window.webHandleDashboardState = handleDashboardState;
|
||||||
|
|
||||||
initClientRenameHandler();
|
initClientRenameHandler();
|
||||||
initClientMoveHandler();
|
initClientMoveHandler();
|
||||||
|
initKioskLauncherModal();
|
||||||
var screenCommandSelect = document.getElementById('screen-command-select');
|
var screenCommandSelect = document.getElementById('screen-command-select');
|
||||||
if (screenCommandSelect) {
|
if (screenCommandSelect) {
|
||||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||||
const withClientNameReservation = deps.withClientNameReservation;
|
const withClientNameReservation = deps.withClientNameReservation;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
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'
|
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||||
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
|
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
|
||||||
: null;
|
: 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)}`;
|
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||||
|
|
||||||
await forwardPlayerCommand(slug, {
|
await forwardPlayerCommand(slug, {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||||
const getScreenConnections = deps.getScreenConnections;
|
const getScreenConnections = deps.getScreenConnections;
|
||||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||||
@@ -153,9 +152,14 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||||
? await common.fetchScreenPlayerRecord(pool, previousSlug)
|
? await common.fetchScreenPlayerRecord(pool, previousSlug)
|
||||||
: null;
|
: 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, {
|
await forwardPlayerCommand(previousSlug, {
|
||||||
command: 'redirect',
|
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, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// Shared route helpers for web view rendering and player URL formatting.
|
// 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) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -11,8 +9,13 @@ function escapeHtml(value) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function screenPlayerUrl(slug) {
|
function screenPlayerUrl(slug, baseUrl) {
|
||||||
return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`;
|
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
if (!normalizedBaseUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedBaseUrl}/screen/${encodeURIComponent(slug)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
|||||||
buildDashboardState: deps.buildDashboardState,
|
buildDashboardState: deps.buildDashboardState,
|
||||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||||
getScreenConnections: deps.playerActionService.getScreenConnections
|
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAccountRoutes(app, {
|
registerAccountRoutes(app, {
|
||||||
@@ -107,7 +107,6 @@ function registerSignageRoutes(app, deps) {
|
|||||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +128,6 @@ function registerSignageRoutes(app, deps) {
|
|||||||
isClientNameAvailable: deps.isClientNameAvailable,
|
isClientNameAvailable: deps.isClientNameAvailable,
|
||||||
withClientNameReservation: deps.withClientNameReservation,
|
withClientNameReservation: deps.withClientNameReservation,
|
||||||
broadcastDashboardState: deps.broadcastDashboardState,
|
broadcastDashboardState: deps.broadcastDashboardState,
|
||||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Screen route registration and dashboard wiring.
|
// Screen route registration and dashboard wiring.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
module.exports = function registerScreensRoutes(app, deps) {
|
module.exports = function registerScreensRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -10,6 +12,12 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
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) {
|
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||||
return function (req, res, next) {
|
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) {
|
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (req.query.edit) {
|
if (req.query.edit) {
|
||||||
@@ -62,6 +100,10 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
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));
|
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||||
const editData = await common.fetchScreenEditData(pool);
|
const editData = await common.fetchScreenEditData(pool);
|
||||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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'
|
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||||
? await common.fetchScreenPlayerUrls(pool)
|
? 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));
|
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||||
const editData = await common.fetchScreenEditData(pool);
|
const editData = await common.fetchScreenEditData(pool);
|
||||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,58 +13,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{#if screens.length}}
|
{{#if screens.length}}
|
||||||
<div class="screen-command-panel">
|
<div class="screen-command-panel">
|
||||||
<div class="screen-command-panel-left">
|
<div class="screen-command-panel-left">
|
||||||
<div class="screen-command-selector-field">
|
<div class="screen-command-selector-field">
|
||||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
|
||||||
<select
|
<select
|
||||||
id="screen-command-select"
|
id="screen-command-select"
|
||||||
class="form-select"
|
class="form-select"
|
||||||
aria-label="Target screen group"
|
aria-label="Target screen group"
|
||||||
data-screen-command-select
|
data-screen-command-select
|
||||||
>
|
>
|
||||||
{{#each screens}}
|
{{#each screens}}
|
||||||
<option
|
<option
|
||||||
value="{{slug}}"
|
value="{{slug}}"
|
||||||
data-screen-name="{{name}}"
|
data-screen-name="{{name}}"
|
||||||
data-player-connection-count="{{player_connection_count}}"
|
data-player-connection-count="{{player_connection_count}}"
|
||||||
data-playlist-name="{{playlist_name}}"
|
data-playlist-name="{{playlist_name}}"
|
||||||
>{{name}}</option>
|
>{{name}}</option>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
<div class="screen-command-actions">
|
<div class="screen-command-panel-right">
|
||||||
<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">
|
<div class="screen-command-summary">
|
||||||
<input type="hidden" name="command" value="reload" />
|
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||||
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
|
<strong data-screen-command-name>Select a target screen group</strong>
|
||||||
</form>
|
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||||
<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">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<div class="screen-command-panel-right">
|
{{else}}
|
||||||
<div class="screen-command-summary">
|
<div class="empty">No screen groups are available to target yet.</div>
|
||||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
{{/if}}
|
||||||
<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}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||||
<div class="row">
|
<div class="row pb-4">
|
||||||
<div class="col-12">
|
<div class="col-12 col-xl-8">
|
||||||
<div class="card mb-4 card-outline card-primary dashboard-actions-card">
|
<div class="card card-outline card-primary dashboard-actions-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Quick actions</h3>
|
<h3 class="card-title">Quick actions</h3>
|
||||||
@@ -108,7 +108,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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}}
|
||||||
|
|
||||||
{{#if (hasPermission currentUser "screens.read")}}
|
{{#if (hasPermission currentUser "screens.read")}}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user