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

This commit is contained in:
2026-08-29 12:12:39 +01:00
parent 3f4f57020a
commit ed58da4416
18 changed files with 260 additions and 31 deletions
+7
View File
@@ -2,6 +2,13 @@
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.10.4 - 2026-08-29
### Fixed
- Fixed remote client move commands so they route through the player bridge to the correct browser tab using the physical player identity and connection ID.
- Removed the obsolete `PLAYER_BASE_URL` configuration fallback; local routing now uses `PLAYER_INTERNAL_URL`, while `PLAYER_PUBLIC_URL` remains the optional reachable player URL.
## 2.10.3 - 2026-08-28 ## 2.10.3 - 2026-08-28
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.10.3", "version": "2.10.4",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.10.3", "version": "2.10.4",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+3 -1
View File
@@ -6,7 +6,9 @@ PULSE_SIGNAGE_SHARED_SECRET=""
# Remote player # Remote player
PLAYER_IDENTIFIER="player-remote" PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_URL="http://remote-player.example.com:8081"
# Optional URL the player advertises when it is directly reachable
# PLAYER_PUBLIC_URL="http://remote-player.example.com:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000 PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote bridge connectivity # Remote bridge connectivity
+2 -3
View File
@@ -62,7 +62,6 @@ Responsibilities:
Key configuration: Key configuration:
- `PLAYER_PUBLIC_URL`
- `PLAYER_INTERNAL_URL` - `PLAYER_INTERNAL_URL`
- `PLAYER_IDENTIFIER` - `PLAYER_IDENTIFIER`
- `BRIDGE_PUBLIC_URL` in remote mode - `BRIDGE_PUBLIC_URL` in remote mode
@@ -132,7 +131,7 @@ Important values:
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest` - `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment - `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier - `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_URL` - public URL for the remote player - `PLAYER_PUBLIC_URL` - optional URL the player advertises when it is directly reachable; leave it unset when the player is reachable only through the bridge
- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to - `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent - `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
@@ -169,7 +168,7 @@ Leave it blank only if you intentionally want to run without request signing in
| `WEB_PUBLIC_URL` | web, player-bridge | Public URL of the web application. | | `WEB_PUBLIC_URL` | web, player-bridge | Public URL of the web application. |
| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. | | `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. | | `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. | | `PLAYER_PUBLIC_URL` | player, remote player | Optional public URL advertised by a directly reachable player. It is not required for bridge-based commands. |
| `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. | | `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. | | `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. |
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. | | `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- "8081:8081" - "8081:8081"
environment: environment:
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:?PLAYER_PUBLIC_URL must be set to a routable remote-player URL} PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-}
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:?BRIDGE_PUBLIC_URL must be set to a routable bridge URL} BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:?BRIDGE_PUBLIC_URL must be set to a routable bridge URL}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000} PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.3", "version": "2.10.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.3", "version": "2.10.4",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.10.3", "version": "2.10.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+12 -4
View File
@@ -916,8 +916,11 @@ async function start() {
return res.status(404).json({ ok: false, error: 'Player is not connected.' }); return res.status(404).json({ ok: false, error: 'Player is not connected.' });
} }
socket.send(JSON.stringify(payload)); const response = await sendPlayerCommandToSocket(socket, payload);
res.json({ ok: true, deviceId: deviceId, sent: true }); res.status(response && response.ok ? 200 : (response && response.status || 502)).json(Object.assign({
deviceId: deviceId,
connectionId: payload.connectionId || null
}, response && typeof response === 'object' ? response : { ok: false }));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -975,9 +978,14 @@ async function start() {
const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, ''); const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, '');
const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) { const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) {
if (!connection || typeof connection !== 'object' || !snapshotPlayerPublicBaseUrl) { if (!connection || typeof connection !== 'object' || !snapshotPlayerPublicBaseUrl) {
return connection; return connection && typeof connection === 'object'
? Object.assign({}, connection, { playerDeviceId: deviceId })
: connection;
} }
return Object.assign({}, connection, { playerPublicBaseUrl: snapshotPlayerPublicBaseUrl }); return Object.assign({}, connection, {
playerDeviceId: deviceId,
playerPublicBaseUrl: snapshotPlayerPublicBaseUrl
});
}) : []; }) : [];
setScreenSnapshotSource(slug, deviceId, connections); setScreenSnapshotSource(slug, deviceId, connections);
return; return;
+2 -2
View File
@@ -24,11 +24,11 @@ async function start() {
const app = express(); const app = express();
const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 8081); const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || '').trim().replace(/\/$/, '');
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL); const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, ''); const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || '')).trim().replace(/\/$/, '');
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier(); const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000); const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
const ASSET_DIR = path.join(__dirname, 'player', 'public'); const ASSET_DIR = path.join(__dirname, 'player', 'public');
+3 -3
View File
@@ -48,7 +48,7 @@ function getPublicBaseUrl(req, configuredUrl) {
return `${protocol}://${host}`.replace(/\/$/, ''); return `${protocol}://${host}`.replace(/\/$/, '');
} }
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || '').trim().replace(/\/$/, '');
return configured || null; return configured || null;
} }
@@ -75,7 +75,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) {
} }
function getPlayerInternalBaseUrl(configuredUrl) { function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || '').trim().replace(/\/$/, '');
return configured || null; return configured || null;
} }
@@ -238,7 +238,7 @@ function registerPlayerOnboardingRoutes(app, options) {
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null; const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null;
+1 -1
View File
@@ -48,7 +48,7 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
const snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : null; const snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : null;
+1 -1
View File
@@ -5,7 +5,7 @@ function createWebConfig() {
const uploadsDir = path.join(mediaDir, 'uploads'); const uploadsDir = path.join(mediaDir, 'uploads');
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 playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || 'http://player:8081').replace(/\/$/, '');
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, ''); const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
const sessionCookieName = 'digital_signage_session'; const sessionCookieName = 'digital_signage_session';
+58 -6
View File
@@ -7,6 +7,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const common = deps.common; const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice;
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable; const isClientNameAvailable = deps.isClientNameAvailable;
const findAvailableClientName = deps.findAvailableClientName; const findAvailableClientName = deps.findAvailableClientName;
@@ -104,12 +105,51 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) { async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections); const connectionList = Array.isArray(connections) ? connections : [];
const targetedConnections = connectionId
? connectionList.filter(function (connection) {
const candidateConnectionId = String(connection && connection.id || '').trim();
const candidateClientId = String(connection && connection.clientId || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId
|| candidateClientId === connectionId
|| candidateDeviceId === connectionId;
})
: (deviceId
? connectionList.filter(function (connection) {
return String(connection && connection.deviceId || '').trim() === String(deviceId).trim();
})
: connectionList);
const remoteConnections = targetedConnections.filter(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
});
const localConnections = targetedConnections.filter(function (connection) {
return !String(connection && connection.playerDeviceId || '').trim();
});
const deviceTargets = Array.from(new Set(remoteConnections.map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
const results = [];
if (deviceTargets.length && typeof forwardPlayerCommandToDevice === 'function') {
const remoteResults = await Promise.allSettled(deviceTargets.map(function (targetDeviceId) {
const payload = Object.assign({ screenSlug: screenSlug }, commandPayload || {});
if (connectionId) {
payload.connectionId = connectionId;
}
return forwardPlayerCommandToDevice(targetDeviceId, payload);
}));
results.push.apply(results, remoteResults);
}
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(localConnections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { const localResults = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined); return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
})); }));
results.push.apply(results, localResults);
}
if (results.length) {
return { return {
ok: true, ok: true,
sent: results.filter(function (result) { sent: results.filter(function (result) {
@@ -373,6 +413,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return candidateConnectionId === connectionId || candidateClientId === connectionId; return candidateConnectionId === connectionId || candidateClientId === connectionId;
}) || null; }) || null;
const liveDeviceId = String(liveConnection && liveConnection.deviceId || '').trim(); const liveDeviceId = String(liveConnection && liveConnection.deviceId || '').trim();
const livePlayerDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim();
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId && !liveDeviceId) { if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId && !liveDeviceId) {
@@ -382,7 +423,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Target screen is required' }); return res.status(400).json({ error: 'Target screen is required' });
} }
if (livePlayerBaseUrl || submittedPlayerBaseUrl) { if (livePlayerDeviceId) {
physicalPlayerId = livePlayerDeviceId;
} else if (livePlayerBaseUrl || submittedPlayerBaseUrl) {
const [playerRows] = await pool.query( const [playerRows] = await pool.query(
'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1', 'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1',
[livePlayerBaseUrl || submittedPlayerBaseUrl] [livePlayerBaseUrl || submittedPlayerBaseUrl]
@@ -523,7 +566,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|| candidateDeviceId === physicalPlayerId; || candidateDeviceId === physicalPlayerId;
}) || liveConnections[0] || null }) || liveConnections[0] || null
: null; : null;
const sourcePlayerBaseUrl = registeredPlayerBaseUrl const sourcePlayerBaseUrl = livePlayerDeviceId ? '' : registeredPlayerBaseUrl
|| normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl); || normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl);
if (sourcePlayerBaseUrl) { if (sourcePlayerBaseUrl) {
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`; targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
@@ -537,7 +580,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
connectionId: connectionId || null, connectionId: connectionId || null,
screenSlug: targetScreenSlug screenSlug: targetScreenSlug
}); });
if (sourcePlayerBaseUrl) { if (livePlayerDeviceId) {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: 'redirect',
url: targetPlayerUrl,
moveToken: moveToken || null
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
} else if (sourcePlayerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, sourcePlayerBaseUrl, { await forwardPlayerCommandForPlayerBaseUrl(slug, sourcePlayerBaseUrl, {
command: 'redirect', command: 'redirect',
url: targetPlayerUrl, url: targetPlayerUrl,
@@ -585,7 +634,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
: null; : null;
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl; const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl;
const result = targetPlayerBaseUrl const liveDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim();
const result = liveDeviceId && typeof forwardPlayerCommandToDevice === 'function'
? await forwardPlayerCommandToDevice(liveDeviceId, Object.assign({ screenSlug: slug }, commandPayload, connectionId ? { connectionId: connectionId } : {}))
: targetPlayerBaseUrl
? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null) ? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null)
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null); : await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
+21 -2
View File
@@ -15,6 +15,7 @@ module.exports = function registerManageRoutes(app, deps) {
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice;
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const SCREEN_NAME_MAX_LENGTH = 255; const SCREEN_NAME_MAX_LENGTH = 255;
@@ -88,12 +89,30 @@ module.exports = function registerManageRoutes(app, deps) {
} }
async function forwardPlayerCommandForConnections(slug, connections, commandPayload) { async function forwardPlayerCommandForConnections(slug, connections, commandPayload) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections); const connectionList = Array.isArray(connections) ? connections : [];
const remoteDeviceIds = Array.from(new Set(connectionList.map(function (connection) {
return String(connection && connection.playerDeviceId || '').trim();
}).filter(Boolean)));
const localConnections = connectionList.filter(function (connection) {
return !String(connection && connection.playerDeviceId || '').trim();
});
const results = [];
if (remoteDeviceIds.length && typeof forwardPlayerCommandToDevice === 'function') {
const remoteResults = await Promise.allSettled(remoteDeviceIds.map(function (deviceId) {
return forwardPlayerCommandToDevice(deviceId, Object.assign({ screenSlug: slug }, commandPayload || {}));
}));
results.push.apply(results, remoteResults);
}
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(localConnections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { const localResults = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload); return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload);
})); }));
results.push.apply(results, localResults);
}
if (results.length) {
return { return {
ok: true, ok: true,
sent: results.filter(function (result) { sent: results.filter(function (result) {
+1
View File
@@ -180,6 +180,7 @@ function registerSignageRoutes(app, deps) {
common: deps.common, common: deps.common,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
getScreenConnections: deps.playerActionService.getScreenConnections, getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable, isClientNameAvailable: deps.isClientNameAvailable,
findAvailableClientName: deps.findAvailableClientName, findAvailableClientName: deps.findAvailableClientName,
+88
View File
@@ -210,6 +210,94 @@ test('move client requires a registered player identity', async () => {
assert.equal(response.body.error, 'Registered player identity is required'); assert.equal(response.body.error, 'Registered player identity is required');
}); });
test('move client routes a remote connection through its bridge player identity', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
return [[{ client_name: 'Remote Client', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
}
return [[]];
}
},
common: { fetchPlayerRegistrations: async () => [] },
forwardPlayerCommand() {
throw new Error('direct player fallback should not be used');
},
forwardPlayerCommandToBaseUrl() {
throw new Error('public URL routing should not be used');
},
forwardPlayerCommandToDevice(deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
},
getScreenConnections: async () => ({
connections: [{
id: 'connection-1',
clientId: 'connection-1',
deviceId: 'browser-tab-1',
playerDeviceId: 'remote-player-1',
playerPublicBaseUrl: null
}]
}),
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await handlers['/clients/:slug/commands'][1]({
params: { slug: 'source-screen' },
body: {
command: 'moveclient',
clientId: 'browser-tab-1',
clientName: 'Remote Client',
targetScreenSlug: 'target-screen',
connectionId: 'connection-1'
},
query: {}
}, response, (error) => { throw error; });
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: {
screenSlug: 'source-screen',
command: 'redirect',
url: '/screen/target-screen',
moveToken: calls[0] && calls[0].payload.moveToken,
connectionId: 'connection-1'
}
}]);
});
test('screen control commands can target all screens', async () => { test('screen control commands can target all screens', async () => {
const calls = []; const calls = [];
const { app, handlers } = createHandlers(); const { app, handlers } = createHandlers();
+53
View File
@@ -195,3 +195,56 @@ test('client command route forwards a single screen command', async () => {
assert.equal(calls[0].connectionId, undefined); assert.equal(calls[0].connectionId, undefined);
assert.equal(response.body.ok, true); assert.equal(response.body.ok, true);
}); });
test('client command route uses the bridge device route without a public player URL', async () => {
const { app, handlers } = createAppHarness();
const calls = [];
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
return [[{ id: 1, name: 'Lobby', slug: 'lobby' }]];
}
return [[]];
}
},
common: { async fetchPlayerRegistrations() { return []; } },
forwardPlayerCommand() {
throw new Error('direct player fallback should not be used');
},
forwardPlayerCommandToBaseUrl() {
throw new Error('public URL fallback should not be used');
},
forwardPlayerCommandToDevice(deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
},
getScreenConnections: async () => ({
connections: [{ id: 'connection-1', deviceId: 'browser-tab-1', playerDeviceId: 'remote-player-1', playerPublicBaseUrl: null }]
}),
isClientNameAvailable() { return true; },
withClientNameReservation() {},
broadcastDashboardState() {},
requirePermission() {
return function (_req, _res, next) { next(); };
}
});
const response = {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; }
};
await handlers['/clients/:slug/commands'][1]({
params: { slug: 'lobby' },
body: { command: 'pause', connectionId: 'connection-1' },
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: { screenSlug: 'lobby', command: 'pause', connectionId: 'connection-1' }
}]);
});