From ed58da4416efee537cd9c7ab2cdf10eba00ddadf Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 29 Aug 2026 12:12:39 +0100 Subject: [PATCH] Release v2.10.4 --- CHANGELOG.md | 7 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- docker-compose/.env.remote.example | 4 +- docker-compose/README.md | 5 +- docker-compose/docker-compose.remote.yml | 2 +- package-lock.json | 4 +- package.json | 2 +- src/player-bridge/index.js | 16 +++-- src/player.js | 4 +- src/player/onboarding/index.js | 6 +- src/player/routes.js | 2 +- src/web/lib/config.js | 2 +- src/web/routes/admin/client-commands.js | 68 +++++++++++++++--- src/web/routes/admin/manage.js | 23 ++++++- src/web/routes/register.js | 1 + test/admin-client-commands.test.js | 88 ++++++++++++++++++++++++ test/client-commands-route.test.js | 53 ++++++++++++++ 18 files changed, 260 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b82016..37fdc14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index c7450ef..a453225 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.10.3", + "version": "2.10.4", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 90998d3..e32aaf3 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.10.3", + "version": "2.10.4", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index 298436e..d543b4c 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -6,7 +6,9 @@ PULSE_SIGNAGE_SHARED_SECRET="" # Remote player 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 # Remote bridge connectivity diff --git a/docker-compose/README.md b/docker-compose/README.md index e500af4..22f7281 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -62,7 +62,6 @@ Responsibilities: Key configuration: -- `PLAYER_PUBLIC_URL` - `PLAYER_INTERNAL_URL` - `PLAYER_IDENTIFIER` - `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_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_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 - `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_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. | | `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. | | `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. | | `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. | diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml index 05b0cc5..90abeb0 100644 --- a/docker-compose/docker-compose.remote.yml +++ b/docker-compose/docker-compose.remote.yml @@ -11,7 +11,7 @@ services: - "8081:8081" environment: 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} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000} diff --git a/package-lock.json b/package-lock.json index e2345d8..9664026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.10.3", + "version": "2.10.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.10.3", + "version": "2.10.4", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 13f0403..79c55ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.10.3", + "version": "2.10.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index aea954c..7619b07 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -916,8 +916,11 @@ async function start() { return res.status(404).json({ ok: false, error: 'Player is not connected.' }); } - socket.send(JSON.stringify(payload)); - res.json({ ok: true, deviceId: deviceId, sent: true }); + const response = await sendPlayerCommandToSocket(socket, payload); + 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) { next(error); } @@ -975,9 +978,14 @@ async function start() { const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, ''); const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) { 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); return; diff --git a/src/player.js b/src/player.js index cda699a..a0e7a36 100644 --- a/src/player.js +++ b/src/player.js @@ -24,11 +24,11 @@ async function start() { const app = express(); const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); 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 WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); 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_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000); const ASSET_DIR = path.join(__dirname, 'player', 'public'); diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 22a57e3..b12bba0 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -48,7 +48,7 @@ function getPublicBaseUrl(req, configuredUrl) { 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; } @@ -75,7 +75,7 @@ function getPlayerPublicBaseUrl(req, 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; } @@ -238,7 +238,7 @@ function registerPlayerOnboardingRoutes(app, options) { const common = options && options.common ? options.common : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : 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 playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null; diff --git a/src/player/routes.js b/src/player/routes.js index f710e33..df255af 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -48,7 +48,7 @@ function registerPlayerRoutes(app, options) { const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : 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 playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; const snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : null; diff --git a/src/web/lib/config.js b/src/web/lib/config.js index 161a4af..edff9a9 100644 --- a/src/web/lib/config.js +++ b/src/web/lib/config.js @@ -5,7 +5,7 @@ function createWebConfig() { const uploadsDir = path.join(mediaDir, 'uploads'); const thumbnailsDir = path.join(mediaDir, 'thumbnails'); 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 webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const sessionCookieName = 'digital_signage_session'; diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index 2588cf0..f0c2426 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -7,6 +7,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const common = deps.common; const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; + const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; const findAvailableClientName = deps.findAvailableClientName; @@ -104,12 +105,51 @@ module.exports = function registerScreenCommandRoutes(app, deps) { } 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') { - 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); })); + results.push.apply(results, localResults); + } + if (results.length) { return { ok: true, sent: results.filter(function (result) { @@ -373,6 +413,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return candidateConnectionId === connectionId || candidateClientId === connectionId; }) || null; const liveDeviceId = String(liveConnection && liveConnection.deviceId || '').trim(); + const livePlayerDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim(); const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); 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' }); } - if (livePlayerBaseUrl || submittedPlayerBaseUrl) { + if (livePlayerDeviceId) { + physicalPlayerId = livePlayerDeviceId; + } else if (livePlayerBaseUrl || submittedPlayerBaseUrl) { const [playerRows] = await pool.query( 'SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ? LIMIT 1', [livePlayerBaseUrl || submittedPlayerBaseUrl] @@ -523,7 +566,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) { || candidateDeviceId === physicalPlayerId; }) || liveConnections[0] || null : null; - const sourcePlayerBaseUrl = registeredPlayerBaseUrl + const sourcePlayerBaseUrl = livePlayerDeviceId ? '' : registeredPlayerBaseUrl || normalizeExplicitPlayerBaseUrl(matchedLiveConnection && matchedLiveConnection.playerPublicBaseUrl); if (sourcePlayerBaseUrl) { targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`; @@ -537,7 +580,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) { connectionId: connectionId || null, 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, { command: 'redirect', url: targetPlayerUrl, @@ -585,9 +634,12 @@ module.exports = function registerScreenCommandRoutes(app, deps) { : null; const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl); const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl; - const result = targetPlayerBaseUrl - ? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null) - : await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null); + 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 forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 7ac9f95..0d3bc52 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -15,6 +15,7 @@ module.exports = function registerManageRoutes(app, deps) { const getScreenConnections = deps.getScreenConnections; const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; + const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice; const requirePermission = deps.requirePermission; const SCREEN_NAME_MAX_LENGTH = 255; @@ -88,12 +89,30 @@ module.exports = function registerManageRoutes(app, deps) { } 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') { - const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { + const localResults = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload); })); + results.push.apply(results, localResults); + } + if (results.length) { return { ok: true, sent: results.filter(function (result) { diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 4787548..b0a3c9c 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -180,6 +180,7 @@ function registerSignageRoutes(app, deps) { common: deps.common, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, + forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice, getScreenConnections: deps.playerActionService.getScreenConnections, isClientNameAvailable: deps.isClientNameAvailable, findAvailableClientName: deps.findAvailableClientName, diff --git a/test/admin-client-commands.test.js b/test/admin-client-commands.test.js index 7ebceaf..1d3f626 100644 --- a/test/admin-client-commands.test.js +++ b/test/admin-client-commands.test.js @@ -210,6 +210,94 @@ test('move client requires a registered player identity', async () => { 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 () => { const calls = []; const { app, handlers } = createHandlers(); diff --git a/test/client-commands-route.test.js b/test/client-commands-route.test.js index ad06d4b..c63f9ca 100644 --- a/test/client-commands-route.test.js +++ b/test/client-commands-route.test.js @@ -195,3 +195,56 @@ test('client command route forwards a single screen command', async () => { assert.equal(calls[0].connectionId, undefined); 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' } + }]); +});