Release v2.10.6
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-29 14:59:06 +01:00
parent ca9f38f3b9
commit 8c0c22156e
30 changed files with 922 additions and 531 deletions
+1
View File
@@ -121,6 +121,7 @@ function createWebBootstrap(options) {
pool: pool,
common: common,
playerInternalBaseUrl: configuredPlayerInternalUrl,
bridgeInternalBaseUrl: configuredBridgeInternalUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
+13 -18
View File
@@ -36,22 +36,6 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
const deviceId = String(playerIdentifier || '').trim();
if (!targetBaseUrl || !deviceId) {
return targetBaseUrl;
}
try {
const url = new URL(targetBaseUrl);
url.searchParams.set('deviceId', deviceId);
return url.toString().replace(/\/$/, '');
} catch (_error) {
return targetBaseUrl;
}
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
@@ -68,6 +52,7 @@ function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -545,6 +530,9 @@ function createUploadSyncService(options) {
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) {
@@ -566,11 +554,13 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer
});
const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const mediaUploadUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`;
const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim();
const response = await fetch(mediaUploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders
},
body: fileBuffer
@@ -601,6 +591,9 @@ function createUploadSyncService(options) {
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return false;
@@ -610,11 +603,13 @@ function createUploadSyncService(options) {
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}`
});
const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const mediaDeleteUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`;
const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim();
const response = await fetch(mediaDeleteUrl, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}),
...authHeaders
}
});
+20 -2
View File
@@ -1,6 +1,6 @@
// Route screen refresh commands to the physical player hosting each screen.
function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl) {
function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, forwardPlayerCommandToBaseUrl, resolvePlayerBaseUrl, forwardPlayerCommandToDevice) {
if (typeof forwardPlayerCommand !== 'function') {
throw new Error('createNotifyPlayerScreens requires a player command sender.');
}
@@ -19,17 +19,35 @@ function createNotifyPlayerScreens(forwardPlayerCommand, getScreenConnections, f
if (typeof getScreenConnections === 'function' && typeof forwardPlayerCommandToBaseUrl === 'function') {
const screenState = await getScreenConnections(slug);
const connections = Array.isArray(screenState && screenState.connections) ? screenState.connections : [];
const deviceIds = Array.from(new Set(connections.map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
const playerBaseUrls = Array.from(new Set(connections.map(function (connection) {
if (String(connection && connection.playerDeviceId || '').trim()) {
return '';
}
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
const deviceResults = deviceIds.length && typeof forwardPlayerCommandToDevice === 'function'
? await Promise.all(deviceIds.map(function (deviceId) {
return forwardPlayerCommandToDevice(deviceId, {
command: commandOrPayload || 'refresh',
screenSlug: slug
});
}))
: [];
let baseUrlResults = [];
if (playerBaseUrls.length) {
const resolvedPlayerBaseUrls = typeof resolvePlayerBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { return resolvePlayerBaseUrl(playerBaseUrl); }))
: playerBaseUrls;
return Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
baseUrlResults = await Promise.all(resolvedPlayerBaseUrls.filter(Boolean).map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandOrPayload || 'refresh');
}));
}
if (deviceResults.length || baseUrlResults.length) {
return deviceResults.concat(baseUrlResults);
}
}
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) {
+17
View File
@@ -243,6 +243,23 @@ function createPlayerActionService(options) {
}
async function forwardAnnouncementRefresh(slug) {
try {
const screenState = await getScreenConnections(slug);
const deviceIds = Array.from(new Set((screenState && Array.isArray(screenState.connections) ? screenState.connections : []).map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
if (deviceIds.length) {
const deviceResults = await Promise.all(deviceIds.map(function (deviceId) {
return forwardPlayerCommandToDevice(deviceId, {
command: 'announcement-refresh',
screenSlug: slug
});
}));
return deviceResults;
}
} catch (_error) {
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([
resolvedPlayerInternalBaseUrl,
-4
View File
@@ -901,10 +901,6 @@
padding: 0.75rem 1rem;
}
.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) {
opacity: 0.55;
}
.onboarding-pairing-progress {
display: flex;
align-items: center;
+26 -13
View File
@@ -44,6 +44,27 @@
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
scannerDebugOutput.textContent = lines.slice(-8).join('\n');
}
function wait(milliseconds) {
return new Promise(function (resolve) { window.setTimeout(resolve, milliseconds); });
}
function submitPairing(body, attempt) {
return fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
var transientCodeError = response.status === 401 && payload && String(payload.error || '').indexOf('valid kiosk pairing code') !== -1;
var transientTransportError = [502, 503, 504].indexOf(response.status) !== -1;
if ((!response.ok && (transientCodeError || transientTransportError)) && attempt < 2) {
return wait(300 * (attempt + 1)).then(function () { return submitPairing(body, attempt + 1); });
}
return { response: response, payload: payload };
});
});
}
var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
function normalizeCode(value) {
@@ -321,28 +342,21 @@
if (firstEmptyInput) { firstEmptyInput.focus(); }
return;
}
message.className = 'alert alert-info';
message.textContent = 'Pairing player...';
message.className = 'alert d-none';
message.textContent = '';
var pairingBody = new URLSearchParams(new FormData(form)).toString();
if (pairingCard) { pairingCard.classList.add('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: pairingBody
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
submitPairing(pairingBody, 0).then(function (result) {
var response = result.response;
var payload = result.payload;
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
}
if (payload && payload.queued) {
message.className = 'alert alert-info';
message.textContent = 'Pairing is still being completed. Keep this page open and wait for confirmation.';
return;
}
message.className = 'alert alert-success';
@@ -363,7 +377,6 @@
}
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
if (manualButton) { manualButton.classList.remove('d-none'); }
});
}).catch(function (error) {
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
+46 -16
View File
@@ -4,6 +4,12 @@ const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
function wait(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}
module.exports = function registerOnboardingRoutes(app, deps) {
const pool = deps.pool;
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, '');
@@ -42,11 +48,19 @@ module.exports = function registerOnboardingRoutes(app, deps) {
{
const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
});
const resolveBody = await resolveResponse.text();
let resolveResponse = null;
let resolveBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
});
resolveBody = await resolveResponse.text();
if (resolveResponse.status !== 401 || resolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
let resolved = null;
try { resolved = JSON.parse(resolveBody); } catch (_error) {}
if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
@@ -61,11 +75,19 @@ module.exports = function registerOnboardingRoutes(app, deps) {
for (const remoteRegistration of remoteRegistrations) {
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
});
const bridgeResolveBody = await bridgeResolveResponse.text();
let bridgeResolveResponse = null;
let bridgeResolveBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
});
bridgeResolveBody = await bridgeResolveResponse.text();
if (bridgeResolveResponse.status !== 401 || bridgeResolveBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
break;
@@ -86,12 +108,20 @@ module.exports = function registerOnboardingRoutes(app, deps) {
}
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload });
const response = await fetch(`${targetBaseUrl}/api/onboarding`, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload)
});
const responseBody = await response.text();
let response = null;
let responseBody = '';
for (let attempt = 0; attempt < 5; attempt += 1) {
response = await fetch(`${targetBaseUrl}/api/onboarding`, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload)
});
responseBody = await response.text();
if (response.status !== 401 || responseBody.indexOf('valid kiosk pairing code') === -1 || attempt === 4) {
break;
}
await wait(200 * (attempt + 1));
}
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
} catch (error) {
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' });