Add onboarding weather and template gradients
This commit is contained in:
+186
-13
@@ -1,20 +1,44 @@
|
||||
// Player onboarding routes and signup flow helpers.
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
|
||||
const { isTransientDbError } = require('./store');
|
||||
|
||||
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
|
||||
const PAIRING_CODE_LENGTH = 6;
|
||||
const PAIRING_CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const PAIRING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const onboardingSignupAttempts = new Map();
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
}
|
||||
|
||||
function createPairingCode() {
|
||||
const bytes = crypto.randomBytes(PAIRING_CODE_LENGTH);
|
||||
let code = '';
|
||||
for (let index = 0; index < PAIRING_CODE_LENGTH; index += 1) {
|
||||
code += PAIRING_CODE_ALPHABET[bytes[index] % PAIRING_CODE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function createPairingSession(deviceId, clientId) {
|
||||
return { deviceId: normalizeDeviceId(deviceId), clientId: normalizeDeviceId(clientId), code: createPairingCode(), expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
|
||||
}
|
||||
|
||||
function isValidOnboardingPairingCode(pairingSession, pairingCode, now) {
|
||||
const suppliedCode = Buffer.from(String(pairingCode || '').trim().toUpperCase());
|
||||
const expectedCode = Buffer.from(String(pairingSession && pairingSession.code || '').trim());
|
||||
const currentTime = Number(now || Date.now());
|
||||
return Boolean(pairingSession && pairingSession.deviceId && pairingSession.expiresAt > currentTime && suppliedCode.length === expectedCode.length && suppliedCode.length > 0 && crypto.timingSafeEqual(suppliedCode, expectedCode));
|
||||
}
|
||||
|
||||
function getPublicBaseUrl(req, configuredUrl) {
|
||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||
@@ -120,7 +144,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
}
|
||||
const screen = screenRows[0];
|
||||
|
||||
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
|
||||
if (!available) {
|
||||
const error = new Error('Client name already exists.');
|
||||
error.statusCode = 400;
|
||||
@@ -217,6 +241,38 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
|
||||
const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null;
|
||||
const pairingSessions = new Map();
|
||||
|
||||
function getPairingSession(deviceId, clientId) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId) || playerDeviceId;
|
||||
if (!normalizedDeviceId) {
|
||||
return null;
|
||||
}
|
||||
const sessionKey = `${normalizedDeviceId}:${normalizeDeviceId(clientId) || 'default'}`;
|
||||
let pairingSession = pairingSessions.get(sessionKey);
|
||||
if (!isValidOnboardingPairingCode(pairingSession, pairingSession && pairingSession.code)) {
|
||||
pairingSession = createPairingSession(normalizedDeviceId, clientId);
|
||||
pairingSessions.set(sessionKey, pairingSession);
|
||||
}
|
||||
if (onPairingCode) {
|
||||
onPairingCode(pairingSession.code, Array.from(pairingSessions.entries()).filter(function (entry) {
|
||||
return isValidOnboardingPairingCode(entry[1], entry[1] && entry[1].code);
|
||||
}).map(function (entry) {
|
||||
return { deviceId: entry[1].deviceId, clientId: entry[1].clientId || null, code: entry[1].code };
|
||||
}));
|
||||
}
|
||||
return pairingSession;
|
||||
}
|
||||
|
||||
function findPairingSession(pairingCode) {
|
||||
for (const [deviceId, pairingSession] of pairingSessions.entries()) {
|
||||
if (isValidOnboardingPairingCode(pairingSession, pairingCode)) {
|
||||
return { deviceId: pairingSession.deviceId, session: pairingSession };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!app || !common) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
|
||||
@@ -289,15 +345,71 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/', function (_req, res) {
|
||||
function requireOnboardingAuth(req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const pageToken = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const pagePayload = verifyPageAuthToken(pageToken);
|
||||
if (pagePayload && String(pagePayload.scope || '').trim() === 'onboarding') {
|
||||
req.playerPageAuth = pagePayload;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (verifyRequestAuth(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Onboarding authentication required.' });
|
||||
}
|
||||
|
||||
app.get('/', async function (req, res, next) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerOnboardingLandingPage());
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query && req.query.clientId);
|
||||
let status = null;
|
||||
if (deviceId) {
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
|
||||
method: 'GET'
|
||||
});
|
||||
status = await readJsonResponse(response);
|
||||
} else {
|
||||
status = await getOnboardingStatus(pool, deviceId);
|
||||
}
|
||||
}
|
||||
const screenId = status && (status.screen_id || status.screenId);
|
||||
const screenSlug = status && (status.screen_slug || status.screenSlug);
|
||||
if (screenId && screenSlug) {
|
||||
return res.redirect('/screen/' + encodeURIComponent(screenSlug));
|
||||
}
|
||||
res.send(common.renderPlayerOnboardingLandingPage({ pairingCode: '' }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/onboard', async function (req, res, next) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
try {
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
|
||||
const deviceId = playerDeviceId;
|
||||
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
|
||||
const clientId = normalizeDeviceId(req.query.clientId);
|
||||
const pairingSession = getPairingSession(deviceId, clientId);
|
||||
const pairingParams = [];
|
||||
if (pairingSession && pairingSession.code) {
|
||||
pairingParams.push(`code=${encodeURIComponent(pairingSession.code)}`);
|
||||
}
|
||||
const pairingQuery = pairingParams.length ? `?${pairingParams.join('&')}` : '';
|
||||
if (bridgeBaseUrl && pairingSession && pairingSession.code) {
|
||||
const response = await fetchThinClient(req, `/api/onboarding/url?pairingCode=${encodeURIComponent(pairingSession.code)}`);
|
||||
const payload = await readJsonResponse(response);
|
||||
if (payload && payload.url) {
|
||||
return res.redirect(String(payload.url));
|
||||
}
|
||||
}
|
||||
return res.redirect(`${onboardingBaseUrl}/pairing${pairingQuery}`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -315,7 +427,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
next();
|
||||
}, async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.clientId);
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
|
||||
method: 'GET'
|
||||
@@ -347,6 +459,26 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/resolve', requireOnboardingAuth, function (req, res) {
|
||||
const pairingCode = String(req.query.pairingCode || '').trim();
|
||||
const pairing = findPairingSession(pairingCode);
|
||||
if (!pairing) {
|
||||
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
||||
}
|
||||
res.json({ deviceId: pairing.deviceId, clientId: pairing.session.clientId || null });
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/session', requireOnboardingPageAuth, function (req, res) {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||
const clientId = normalizeDeviceId(req.query.clientId);
|
||||
const pairingSession = getPairingSession(deviceId, clientId);
|
||||
if (!pairingSession) {
|
||||
return res.status(503).json({ error: 'Player identity is unavailable.' });
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ deviceId: deviceId, pairingCode: pairingSession.code });
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||
try {
|
||||
if (bridgeBaseUrl) {
|
||||
@@ -366,14 +498,40 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||
const clientId = normalizeDeviceId(req.query.clientId);
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||
const pairingSession = getPairingSession(deviceId, clientId);
|
||||
if (!pairingSession) {
|
||||
return res.status(503).json({ error: 'Pairing session is unavailable.' });
|
||||
}
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchThinClient(req, `/api/onboarding/qr?pairingCode=${encodeURIComponent(pairingSession.code)}`);
|
||||
if (response && response.ok) {
|
||||
const svg = await response.text();
|
||||
res.set('Content-Type', response.headers.get('content-type') || 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send(svg);
|
||||
}
|
||||
}
|
||||
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
|
||||
const onboardingUrl = `${onboardingBaseUrl}/pairing?code=${encodeURIComponent(pairingSession.code)}`;
|
||||
const svg = await createStyledQrCodeSvg({
|
||||
value: onboardingUrl,
|
||||
qr_margin: 20,
|
||||
qr_dots_type: 'dots',
|
||||
qr_dots_color: '#f4f8f5',
|
||||
qr_corners_square_type: 'dot',
|
||||
qr_corners_square_color: '#f4f8f5',
|
||||
qr_corners_dot_type: 'dot',
|
||||
qr_corners_dot_color: '#f0bd70',
|
||||
qr_background_transparent: true
|
||||
});
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.send(svg);
|
||||
@@ -382,20 +540,28 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
|
||||
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
|
||||
const deviceId = playerDeviceId;
|
||||
const clientName = String((req.body && req.body.clientName) || '').trim();
|
||||
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||
const pairingCode = String((req.body && req.body.pairingCode) || '').trim();
|
||||
const clientId = normalizeDeviceId(req.body && req.body.clientId);
|
||||
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
||||
if (retryAfterSeconds) {
|
||||
res.set('Retry-After', String(retryAfterSeconds));
|
||||
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
||||
}
|
||||
|
||||
const pairing = findPairingSession(pairingCode);
|
||||
const pairingSession = pairing && pairing.session;
|
||||
if (!isValidOnboardingPairingCode(pairingSession, pairingCode)) {
|
||||
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
||||
}
|
||||
|
||||
if (bridgeBaseUrl) {
|
||||
const forwardedBody = Object.assign({}, req.body || {}, {
|
||||
deviceId: deviceId
|
||||
clientId: clientId || null
|
||||
});
|
||||
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
|
||||
method: 'POST',
|
||||
@@ -413,6 +579,9 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
if (!payload) {
|
||||
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
|
||||
}
|
||||
if (response.ok) {
|
||||
pairingSessions.delete(deviceId);
|
||||
}
|
||||
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
|
||||
return res.json(payload);
|
||||
}
|
||||
@@ -426,8 +595,11 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
return res.status(400).json({ error: 'Screen is required' });
|
||||
}
|
||||
|
||||
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||
await bindPlayerToScreen(pool, deviceId, screenSlug);
|
||||
if (!clientId) {
|
||||
return res.status(400).json({ error: 'Client ID is required' });
|
||||
}
|
||||
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||
pairingSessions.delete(deviceId);
|
||||
res.json({
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : clientName,
|
||||
@@ -456,5 +628,6 @@ module.exports = {
|
||||
upsertPlayerRegistration: upsertPlayerRegistration,
|
||||
bindPlayerToScreen: bindPlayerToScreen,
|
||||
bindDeviceToScreen: bindDeviceToScreen,
|
||||
isValidOnboardingPairingCode: isValidOnboardingPairingCode,
|
||||
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||
};
|
||||
@@ -65,10 +65,11 @@
|
||||
var formData = new FormData(form);
|
||||
var clientName = String(formData.get("clientName") || "").trim();
|
||||
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||
var pairingCode = String(formData.get("pairingCode") || "").trim();
|
||||
if (!clientName) { setMessage("Client name is required."); return; }
|
||||
if (!screenSlug) { setMessage("Screen is required."); return; }
|
||||
setMessage("Saving client...");
|
||||
var payload = { clientName: clientName, screenSlug: screenSlug };
|
||||
var payload = { clientName: clientName, screenSlug: screenSlug, pairingCode: pairingCode };
|
||||
if (deviceId) {
|
||||
payload.deviceId = deviceId;
|
||||
}
|
||||
|
||||
@@ -13,23 +13,12 @@
|
||||
}
|
||||
var screenKey = "pulse-signage-player-screen-slug";
|
||||
var qr = document.getElementById("onboarding-qr");
|
||||
var status = document.getElementById("onboarding-status");
|
||||
var localForm = document.getElementById("onboarding-local-form");
|
||||
var localMessage = document.getElementById("onboarding-message");
|
||||
var localScreenSelect = document.getElementById("onboarding-screen-select");
|
||||
var pairingCodeElement = document.getElementById("onboarding-pairing-code");
|
||||
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
|
||||
function parseResponseError(response) {
|
||||
return response.text().then(function (text) {
|
||||
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
return payload && payload.error ? payload.error : fallbackMessage;
|
||||
} catch (_error) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
});
|
||||
}
|
||||
function getDeviceId() {
|
||||
var configuredDeviceId = document.getElementById("onboarding-shell");
|
||||
configuredDeviceId = configuredDeviceId ? String(configuredDeviceId.getAttribute("data-player-device-id") || "").trim() : "";
|
||||
if (configuredDeviceId) { return configuredDeviceId; }
|
||||
var stored = "";
|
||||
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
|
||||
if (stored) { return stored; }
|
||||
@@ -37,115 +26,57 @@
|
||||
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
|
||||
return next;
|
||||
}
|
||||
function setStatus(message) { if (status) { status.textContent = message; } }
|
||||
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
|
||||
function setSelectOptions(select, screens, selectedSlug) {
|
||||
if (!select) { return; }
|
||||
while (select.firstChild) { select.removeChild(select.firstChild); }
|
||||
var placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a screen";
|
||||
select.appendChild(placeholder);
|
||||
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
|
||||
var option = document.createElement("option");
|
||||
option.value = String(screen && screen.slug ? screen.slug : "");
|
||||
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||
if (selectedSlug && String(option.value) === String(selectedSlug)) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
function loadScreens(selectedSlug) {
|
||||
return fetch("/api/onboarding/screens", { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||
setSelectOptions(localScreenSelect, screens, selectedSlug);
|
||||
return screens;
|
||||
})
|
||||
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||
}
|
||||
function loadQr(deviceId) {
|
||||
function loadQr(deviceId, clientId) {
|
||||
if (!qr) { return; }
|
||||
qr.onerror = function () {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
};
|
||||
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
|
||||
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId);
|
||||
}
|
||||
function submitOnboarding(deviceId, clientName, screenSlug) {
|
||||
return fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||
})
|
||||
.then(function (response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return parseResponseError(response).then(function (messageText) {
|
||||
throw new Error(messageText);
|
||||
});
|
||||
})
|
||||
function loadPairingSession(deviceId, clientId) {
|
||||
return fetch("/api/onboarding/session?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId), { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
setLocalMessage("Onboarding complete.");
|
||||
if (localForm) {
|
||||
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
if (payload && payload.pairingCode && pairingCodeElement) {
|
||||
pairingCodeElement.textContent = payload.pairingCode;
|
||||
}
|
||||
});
|
||||
return payload;
|
||||
})
|
||||
.catch(function () { return null; });
|
||||
}
|
||||
function getClientId() {
|
||||
var stored = getSessionStorageItem("pulse-signage-player-client-id");
|
||||
if (stored) { return stored; }
|
||||
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "client-" + Date.now() + "-" + Math.random().toString(16).slice(2));
|
||||
setSessionStorageItem("pulse-signage-player-client-id", next);
|
||||
return next;
|
||||
}
|
||||
function redirectIfOnboarded(deviceId) {
|
||||
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
|
||||
var bindingId = getClientId() || deviceId;
|
||||
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(bindingId), { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
if (payload && payload.onboarded && payload.screenSlug) {
|
||||
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.catch(function () { return false; });
|
||||
}
|
||||
var clientId = getClientId();
|
||||
var deviceId = getDeviceId();
|
||||
if (localForm) {
|
||||
localForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(localForm);
|
||||
var clientName = String(formData.get("clientName") || "").trim();
|
||||
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||
if (!clientName) { setLocalMessage("Client name is required."); return; }
|
||||
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
|
||||
setLocalMessage("Saving client...");
|
||||
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
|
||||
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
|
||||
});
|
||||
});
|
||||
}
|
||||
loadScreens().then(function () {
|
||||
try {
|
||||
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
||||
if (storedClientName && localForm) {
|
||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
||||
}
|
||||
} catch (_error) {}
|
||||
});
|
||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||
if (redirected) { return; }
|
||||
if (qr && !qr.getAttribute("src")) {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
}
|
||||
loadQr(deviceId);
|
||||
setStatus("Waiting for onboarding to finish.");
|
||||
loadPairingSession(deviceId, clientId).then(function () {
|
||||
loadQr(deviceId, clientId);
|
||||
});
|
||||
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
});
|
||||
}).then(function (payload) {
|
||||
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
|
||||
if (serverName) {
|
||||
if (serverName && !getOnboardingClientName()) {
|
||||
applyOnboardingClientName(serverName, null);
|
||||
}
|
||||
return onboardingClientName || getOnboardingClientName();
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
if (window.__pulsePageAuthToken) {
|
||||
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
if (typeof getCommandClientId === 'function') {
|
||||
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
|
||||
}
|
||||
if (announcementEtag) {
|
||||
request.setRequestHeader('If-None-Match', announcementEtag);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
const slug = {{SLUG_JSON}};
|
||||
const initialData = {{INITIAL_DATA_JSON}};
|
||||
let initialData = {{INITIAL_DATA_JSON}};
|
||||
window.slug = slug;
|
||||
window.initialData = initialData;
|
||||
const app = document.getElementById('app');
|
||||
@@ -73,7 +73,7 @@
|
||||
let slideExpiresAt = null;
|
||||
const slideFadeDurationMs = 560;
|
||||
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
|
||||
const commandClientStorageKey = 'pulse-signage-player-client-id:' + slug;
|
||||
const commandClientStorageKey = 'pulse-signage-player-client-id';
|
||||
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
|
||||
const initialPlaylistSnapshot = loadPlaylistSnapshot();
|
||||
let offlineBanner = null;
|
||||
@@ -181,10 +181,6 @@
|
||||
releaseScreenWakeLock();
|
||||
});
|
||||
|
||||
if (initialPlaylistSnapshot && initialPlaylistSnapshot.slides.length) {
|
||||
applyPlaylistSnapshot(initialPlaylistSnapshot);
|
||||
}
|
||||
|
||||
if (slides.length) {
|
||||
if (!currentPlaylistSignature) {
|
||||
currentPlaylistSignature = getPlaylistRevision(initialData && initialData.slides ? initialData : { slides: slides });
|
||||
|
||||
@@ -223,7 +223,7 @@ function createPlayerPlaylistService(options) {
|
||||
let regionRows = [];
|
||||
if (templateIds.length) {
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
@@ -422,6 +422,8 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||
updatePlaylistRevisionHash(hash, template.background_color);
|
||||
updatePlaylistRevisionHash(hash, template.background_gradient);
|
||||
updatePlaylistRevisionHash(hash, template.background_gradient);
|
||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ body {
|
||||
|
||||
body.onboarding-page {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
|
||||
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
|
||||
radial-gradient(circle at 78% 20%, rgba(55, 195, 178, 0.16), transparent 28%),
|
||||
radial-gradient(circle at 12% 90%, rgba(237, 177, 89, 0.12), transparent 30%),
|
||||
linear-gradient(145deg, #07131b 0%, #0b2028 58%, #10252a 100%);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -48,14 +48,43 @@ body.thumbnail-preview .player-offline-banner {
|
||||
}
|
||||
|
||||
.onboarding-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(16px, 3vw, 40px);
|
||||
padding: clamp(24px, 5vw, 72px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-stage {
|
||||
width: min(100%, 1160px);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.onboarding-brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: #f0bd70;
|
||||
color: #10252a;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.onboarding-label {
|
||||
margin: 3px 0 0;
|
||||
color: #8faeb0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: min(100%, 1040px);
|
||||
padding: clamp(20px, 3vw, 40px);
|
||||
@@ -73,14 +102,35 @@ body.thumbnail-preview .player-offline-banner {
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.onboarding-stage h1 {
|
||||
max-width: 560px;
|
||||
font-size: clamp(2.5rem, 5vw, 5rem);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.onboarding-stage--landing h1 {
|
||||
max-width: 500px;
|
||||
font-size: clamp(2.1rem, 3.6vw, 3.4rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.onboarding-kicker {
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #8ab4ff;
|
||||
color: #f0bd70;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.onboarding-step {
|
||||
margin: 0 0 18px;
|
||||
color: #70d0c2;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.onboarding-copy {
|
||||
margin: 0 0 28px;
|
||||
color: #cbd5e1;
|
||||
@@ -90,36 +140,123 @@ body.thumbnail-preview .player-offline-banner {
|
||||
|
||||
.onboarding-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
|
||||
gap: clamp(20px, 3vw, 32px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.onboarding-copy-panel {
|
||||
display: flex;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.onboarding-instructions {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 440px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: setup-step;
|
||||
}
|
||||
|
||||
.onboarding-instructions li {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 10px;
|
||||
color: #b7cccd;
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.35;
|
||||
counter-increment: setup-step;
|
||||
}
|
||||
|
||||
.onboarding-instructions li::before {
|
||||
content: counter(setup-step);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(112, 208, 194, 0.55);
|
||||
border-radius: 50%;
|
||||
color: #70d0c2;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-pin-block {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.onboarding-pin-label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #8faeb0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.onboarding-pin-label strong { color: #f0bd70; }
|
||||
|
||||
.onboarding-pin {
|
||||
display: block;
|
||||
color: #f4f8f5;
|
||||
font-size: clamp(1.7rem, 3vw, 2.8rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.onboarding-qr-pane {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: min(100%, 420px);
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
gap: 0;
|
||||
align-content: start;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.onboarding-qr-frame {
|
||||
display: flex;
|
||||
width: min(100%, 420px);
|
||||
aspect-ratio: 1;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
padding: 22px;
|
||||
border-radius: 26px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
background: rgba(7, 19, 27, 0.7);
|
||||
border: 1px solid rgba(244, 248, 245, 0.42);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.onboarding-qr-frame img {
|
||||
width: min(100%, 320px);
|
||||
width: min(100%, 390px);
|
||||
aspect-ratio: 1;
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-qr-caption {
|
||||
margin: 6px 0 0;
|
||||
color: #8faeb0;
|
||||
font-size: 0.92rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.onboarding-brand-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.onboarding-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -181,14 +318,14 @@ body.thumbnail-preview .player-offline-banner {
|
||||
}
|
||||
|
||||
.onboarding-status {
|
||||
margin-top: 8px;
|
||||
margin-top: clamp(32px, 6vh, 56px);
|
||||
min-height: 1.4em;
|
||||
color: #cbd5e1;
|
||||
color: #8faeb0;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.onboarding-card--landing .onboarding-status {
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 860px), (orientation: portrait) {
|
||||
@@ -200,12 +337,45 @@ body.thumbnail-preview .player-offline-banner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-copy-panel,
|
||||
.onboarding-qr-pane {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.onboarding-copy-panel {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.onboarding-qr-pane {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-qr-frame img {
|
||||
width: min(100%, 280px);
|
||||
width: min(100%, 390px);
|
||||
}
|
||||
|
||||
.onboarding-qr-frame {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.onboarding-qr-pane {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.onboarding-stage--landing h1 {
|
||||
font-size: clamp(2rem, 7vw, 3rem);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
margin-bottom: 38px;
|
||||
}
|
||||
|
||||
.onboarding-pin {
|
||||
font-size: clamp(1.7rem, 9vw, 2.8rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +409,29 @@ body.screen-blackout #app {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.player-keyboard-feedback {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
left: 50%;
|
||||
z-index: 10000;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -0.5rem);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.player-keyboard-feedback.is-visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.player-announcement-layer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
|
||||
@@ -440,6 +440,34 @@ function isEditableTarget(target) {
|
||||
return ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'].indexOf(tagName) !== -1;
|
||||
}
|
||||
|
||||
var keyboardFeedbackTimer = null;
|
||||
|
||||
function showKeyboardFeedback(message) {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
var feedback = document.querySelector('.player-keyboard-feedback');
|
||||
if (!feedback) {
|
||||
feedback = document.createElement('div');
|
||||
feedback.className = 'player-keyboard-feedback';
|
||||
feedback.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(feedback);
|
||||
}
|
||||
|
||||
feedback.textContent = String(message || '');
|
||||
feedback.classList.remove('is-visible');
|
||||
void feedback.offsetWidth;
|
||||
feedback.classList.add('is-visible');
|
||||
if (keyboardFeedbackTimer) {
|
||||
window.clearTimeout(keyboardFeedbackTimer);
|
||||
}
|
||||
keyboardFeedbackTimer = window.setTimeout(function () {
|
||||
feedback.classList.remove('is-visible');
|
||||
keyboardFeedbackTimer = null;
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function handlePlayerKeydown(event) {
|
||||
if (!event || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
@@ -452,12 +480,28 @@ function handlePlayerKeydown(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
navigateSlides(-1);
|
||||
showKeyboardFeedback('Previous slide');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
navigateSlides(1);
|
||||
showKeyboardFeedback('Next slide');
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(event.key || '').toLowerCase() === 'p') {
|
||||
event.preventDefault();
|
||||
setPaused(!isPaused);
|
||||
showKeyboardFeedback(isPaused ? 'Paused' : 'Playing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(event.key || '').toLowerCase() === 'b') {
|
||||
event.preventDefault();
|
||||
setBlackout(!isBlackout);
|
||||
showKeyboardFeedback(isBlackout ? 'Blackout on' : 'Blackout off');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +533,10 @@ function handleCommandMessage(rawMessage) {
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'client-id-conflict') {
|
||||
handleClientIdConflict();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'command') {
|
||||
return;
|
||||
@@ -505,7 +553,17 @@ function handleCommandMessage(rawMessage) {
|
||||
return;
|
||||
case 'redirect':
|
||||
if (payload.url) {
|
||||
window.location.replace(String(payload.url));
|
||||
var redirectUrl = String(payload.url);
|
||||
var authorizeMove = payload.moveToken
|
||||
? fetch('/api/screen-move-authorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ moveToken: String(payload.moveToken) })
|
||||
})
|
||||
: Promise.resolve();
|
||||
authorizeMove.finally(function () {
|
||||
window.location.replace(redirectUrl);
|
||||
});
|
||||
}
|
||||
return;
|
||||
case 'pause':
|
||||
@@ -541,6 +599,13 @@ function handleCommandMessage(rawMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleClientIdConflict() {
|
||||
var replacementClientId = regenerateCommandClientId();
|
||||
var onboardingUrl = new URL('/', window.location.origin);
|
||||
onboardingUrl.searchParams.set('clientId', replacementClientId);
|
||||
window.location.replace(onboardingUrl.toString());
|
||||
}
|
||||
|
||||
// Retry the command websocket after a disconnect.
|
||||
function scheduleCommandReconnect() {
|
||||
if (commandReconnectTimer) {
|
||||
@@ -577,8 +642,12 @@ function connectCommandSocket() {
|
||||
handleCommandMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
socket.onclose = function (event) {
|
||||
commandSocket = null;
|
||||
if (event && event.code === 4009) {
|
||||
handleClientIdConflict();
|
||||
return;
|
||||
}
|
||||
scheduleCommandReconnect();
|
||||
};
|
||||
|
||||
|
||||
@@ -156,6 +156,9 @@ function refresh() {
|
||||
if (window.__pulsePageAuthToken) {
|
||||
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
if (typeof getCommandClientId === 'function') {
|
||||
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
|
||||
}
|
||||
if (currentPlaylistEtag) {
|
||||
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
||||
}
|
||||
@@ -172,6 +175,10 @@ function refresh() {
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
if (request.status === 401 || request.status === 403) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
logDebug(
|
||||
@@ -189,16 +196,21 @@ function refresh() {
|
||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||
if (window.initialData && typeof window.initialData === 'object') {
|
||||
window.initialData.screen = data.screen || window.initialData.screen || null;
|
||||
window.initialData.playlist = data.playlist || null;
|
||||
window.initialData.slides = nextSlides;
|
||||
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
|
||||
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
|
||||
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
|
||||
window.initialData.weatherLocations = Array.isArray(data.weatherLocations) ? data.weatherLocations : [];
|
||||
window.initialData.revision = nextSignature;
|
||||
var refreshedInitialData = Object.assign({},
|
||||
typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), {
|
||||
screen: data.screen || null,
|
||||
playlist: data.playlist || null,
|
||||
slides: nextSlides,
|
||||
rssFeeds: Array.isArray(data.rssFeeds) ? data.rssFeeds : [],
|
||||
apiSources: Array.isArray(data.apiSources) ? data.apiSources : [],
|
||||
timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [],
|
||||
weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [],
|
||||
revision: nextSignature
|
||||
});
|
||||
if (typeof initialData !== 'undefined') {
|
||||
initialData = refreshedInitialData;
|
||||
}
|
||||
window.initialData = refreshedInitialData;
|
||||
savePlaylistSnapshot({
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
|
||||
@@ -211,6 +211,16 @@ function getCommandClientId() {
|
||||
return commandClientId;
|
||||
}
|
||||
|
||||
function regenerateCommandClientId() {
|
||||
commandClientId = null;
|
||||
try {
|
||||
window.sessionStorage.removeItem(commandClientStorageKey);
|
||||
} catch (_error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
return getCommandClientId();
|
||||
}
|
||||
|
||||
// Load the most recent playlist snapshot from browser storage.
|
||||
function loadPlaylistSnapshot() {
|
||||
try {
|
||||
|
||||
@@ -782,6 +782,7 @@ function getTemplateLayout(template) {
|
||||
canvasHeight: canvasSize.height,
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
||||
backgroundColor: template.background_color || '#111111',
|
||||
backgroundGradient: template.background_gradient || '',
|
||||
regions: regions
|
||||
};
|
||||
|
||||
@@ -790,17 +791,31 @@ function getTemplateLayout(template) {
|
||||
}
|
||||
|
||||
// Build a dark backdrop style for template and media canvases.
|
||||
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||
function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGradient) {
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||
var gradient = '';
|
||||
try {
|
||||
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
|
||||
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
|
||||
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
|
||||
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
||||
if (stops.length >= 2) {
|
||||
var angle = Number(gradientData.angle);
|
||||
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
gradient = '';
|
||||
}
|
||||
|
||||
if (backgroundImagePath) {
|
||||
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
|
||||
style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
|
||||
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '")' + (gradient ? ',' + gradient : '') + ';';
|
||||
style += 'background-position:center,center;background-size:contain,cover;background-repeat:no-repeat,no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
|
||||
style += 'background-image:' + (gradient || 'none') + ';background-position:center;background-size:cover;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
@@ -876,7 +891,7 @@ function renderTemplateSlideMarkup(slide) {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path, layout.backgroundGradient) : '';
|
||||
if (layout) {
|
||||
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ function getPlayerAnnouncementTemplatesScript() {
|
||||
function getAnnouncementIconsDataScript() {
|
||||
return [
|
||||
'(function () {',
|
||||
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';',
|
||||
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_CATALOG_KEYS) + ';',
|
||||
' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';',
|
||||
'}());'
|
||||
].join('\n');
|
||||
|
||||
+24
-23
@@ -36,34 +36,31 @@ function getPlayerServiceWorkerRegistrationScript() {
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderOnboardingLandingBody() {
|
||||
function renderOnboardingLandingBody(options) {
|
||||
const onboardingCode = String(options && options.pairingCode || '').trim();
|
||||
const deviceId = String(options && options.deviceId || '').trim();
|
||||
return [
|
||||
'<main class="onboarding-shell">',
|
||||
' <section class="onboarding-card onboarding-card--landing">',
|
||||
' <p class="onboarding-kicker">Pulse Signage</p>',
|
||||
' <h1>Onboard this player</h1>',
|
||||
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
|
||||
' <main id="onboarding-shell" class="onboarding-shell">',
|
||||
' <section class="onboarding-stage onboarding-stage--landing">',
|
||||
' <div class="onboarding-layout">',
|
||||
' <div class="onboarding-qr-pane">',
|
||||
' <div class="onboarding-qr-frame">',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
|
||||
' </div>',
|
||||
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
|
||||
' <p class="onboarding-qr-caption">Scan this QR code with your phone</p>',
|
||||
' </div>',
|
||||
' <div class="onboarding-copy-panel">',
|
||||
' <p class="onboarding-kicker onboarding-brand-title">Pulse Signage</p>',
|
||||
' <h1>Quickly set up with your phone</h1>',
|
||||
' <ol class="onboarding-instructions">',
|
||||
' <li>Open the camera and scan the QR code.</li>',
|
||||
' <li>Log in to Pulse Signage and choose a screen.</li>',
|
||||
' </ol>',
|
||||
' <div class="onboarding-pin-block">',
|
||||
' <span class="onboarding-pin-label">Pairing Code</span>',
|
||||
' <strong id="onboarding-pairing-code" class="onboarding-pin">' + Handlebars.escapeExpression(onboardingCode || 'Loading...') + '</strong>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
|
||||
' <label>',
|
||||
' <span>Client name</span>',
|
||||
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
|
||||
' </label>',
|
||||
' <label>',
|
||||
' <span>Screen</span>',
|
||||
' <select name="screenSlug" id="onboarding-screen-select" required>',
|
||||
' <option value="">Loading screens...</option>',
|
||||
' </select>',
|
||||
' </label>',
|
||||
' <button type="submit">Save client</button>',
|
||||
' <div id="onboarding-message" class="onboarding-status"></div>',
|
||||
' </form>',
|
||||
' </div>',
|
||||
' </section>',
|
||||
'</main>'
|
||||
@@ -79,6 +76,10 @@ function renderOnboardingFormBody(deviceId) {
|
||||
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
|
||||
' <form id="onboarding-form" class="onboarding-form">',
|
||||
' <label>',
|
||||
' <span>Pairing code</span>',
|
||||
' <input name="pairingCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autocomplete="one-time-code" placeholder="Enter the code shown on the kiosk" />',
|
||||
' </label>',
|
||||
' <label>',
|
||||
' <span>Client name</span>',
|
||||
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
|
||||
' </label>',
|
||||
@@ -136,13 +137,13 @@ function renderPlayerPage(slug, initialData) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerOnboardingLandingPage() {
|
||||
function renderPlayerOnboardingLandingPage(options) {
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
return renderPage(getPlayerPageTemplate(), {
|
||||
title: 'Onboard player',
|
||||
bodyClass: 'onboarding-page',
|
||||
body: renderOnboardingLandingBody(),
|
||||
body: renderOnboardingLandingBody(options),
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
|
||||
});
|
||||
|
||||
+87
-14
@@ -62,11 +62,15 @@ function registerPlayerRoutes(app, options) {
|
||||
body: body
|
||||
}));
|
||||
|
||||
if (req.headers['x-pulse-page-auth']) {
|
||||
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
|
||||
const requestHeaders = req && req.headers ? req.headers : {};
|
||||
if (requestHeaders['x-pulse-page-auth']) {
|
||||
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim();
|
||||
}
|
||||
if (req.headers['if-none-match']) {
|
||||
headers['if-none-match'] = String(req.headers['if-none-match']).trim();
|
||||
if (requestHeaders['if-none-match']) {
|
||||
headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
|
||||
}
|
||||
if (requestHeaders['x-pulse-client-id']) {
|
||||
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
|
||||
}
|
||||
if (requestOptions.contentType) {
|
||||
headers['content-type'] = requestOptions.contentType;
|
||||
@@ -96,6 +100,76 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getBoundScreenSlug(req) {
|
||||
if (!playerDeviceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientId = String(req.query && req.query.clientId || '').trim();
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
const bindingId = clientId;
|
||||
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchBridge(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(bindingId), {
|
||||
method: 'GET'
|
||||
});
|
||||
const status = await readJsonResponse(response);
|
||||
return status && status.screenSlug ? String(status.screenSlug).trim() : null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.slug
|
||||
FROM d_onboarding_devices d
|
||||
JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?`,
|
||||
[bindingId]
|
||||
);
|
||||
return rows[0] && rows[0].slug ? String(rows[0].slug).trim() : null;
|
||||
}
|
||||
|
||||
async function isClientAuthorizedForScreen(req, requestedSlug) {
|
||||
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
|
||||
if (!clientId) {
|
||||
return false;
|
||||
}
|
||||
const boundSlug = await getBoundScreenSlug({ query: { clientId: clientId } });
|
||||
return boundSlug === String(requestedSlug || '').trim();
|
||||
}
|
||||
|
||||
function isAuthorizedScreenMove(req, requestedSlug) {
|
||||
const queryToken = String(req.query && req.query.moveToken || '').trim();
|
||||
const cookieHeader = String(req.headers && req.headers.cookie || '');
|
||||
const cookieToken = cookieHeader.split(';').map(function (part) {
|
||||
const separator = part.indexOf('=');
|
||||
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
|
||||
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-screen-move'; });
|
||||
const token = queryToken || (cookieToken ? decodeURIComponent(cookieToken[1]) : '');
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = verifyPageAuthToken(token);
|
||||
return Boolean(payload
|
||||
&& String(payload.scope || '').trim() === 'screen-move'
|
||||
&& String(payload.playerId || '').trim() === String(playerDeviceId || '').trim()
|
||||
&& String(payload.screenSlug || '').trim() === String(requestedSlug || '').trim());
|
||||
}
|
||||
|
||||
app.post('/api/screen-move-authorize', express.json(), function (req, res) {
|
||||
const token = String(req.body && req.body.moveToken || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload
|
||||
|| String(payload.scope || '').trim() !== 'screen-move'
|
||||
|| String(payload.playerId || '').trim() !== String(playerDeviceId || '').trim()) {
|
||||
return res.status(401).json({ error: 'Invalid screen move authorization.' });
|
||||
}
|
||||
|
||||
res.setHeader('Set-Cookie', `pulse-screen-move=${encodeURIComponent(token)}; Max-Age=60; Path=/; HttpOnly; SameSite=Lax`);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
function requirePageAuth(allowedScopes) {
|
||||
return function (req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
@@ -296,7 +370,7 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
app.get('/screen/:slug', async function (req, res, next) {
|
||||
if (onPlayerPublicBaseUrl) {
|
||||
try {
|
||||
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
|
||||
@@ -321,7 +395,7 @@ function registerPlayerRoutes(app, options) {
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}).catch(function (error) {
|
||||
if (!isBridgeFetchError(error)) {
|
||||
console.error(error);
|
||||
@@ -332,15 +406,8 @@ function registerPlayerRoutes(app, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { bindPlayerToScreen } = require('./onboarding');
|
||||
if (playerDeviceId) {
|
||||
void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
res.set('X-Player-Offline', '1');
|
||||
@@ -405,6 +472,9 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
|
||||
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
|
||||
}
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
|
||||
method: 'GET'
|
||||
@@ -448,6 +518,9 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
|
||||
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
|
||||
}
|
||||
if (bridgeBaseUrl) {
|
||||
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
|
||||
method: 'GET'
|
||||
|
||||
+22
-1
@@ -32,6 +32,21 @@ function createPlayerRuntime(options) {
|
||||
const announcementListenersBySlug = new Map();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
function hasActiveClientId(clientId, currentConnection) {
|
||||
const normalizedClientId = String(clientId || '').trim();
|
||||
if (!normalizedClientId) {
|
||||
return false;
|
||||
}
|
||||
for (const connections of connectionsBySlug.values()) {
|
||||
for (const connection of connections.values()) {
|
||||
if (connection !== currentConnection && String(connection.clientId || '').trim() === normalizedClientId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeClientIp(value) {
|
||||
const ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
@@ -534,7 +549,13 @@ function createPlayerRuntime(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||
const nextClientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||
if (nextClientId && hasActiveClientId(nextClientId, connection)) {
|
||||
socket.send(JSON.stringify({ type: 'client-id-conflict' }));
|
||||
socket.close(4009, 'Client ID is already in use.');
|
||||
return;
|
||||
}
|
||||
connection.clientId = nextClientId;
|
||||
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
|
||||
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
|
||||
if (!connection.clientName && connection.clientId) {
|
||||
|
||||
Reference in New Issue
Block a user