From eda0b346052d7e5a280e57b986348c29d4c7b2b1 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 22 Sep 2026 12:17:12 -0500 Subject: [PATCH 1/3] fix: add a cache-busting header for all HTTP calls to processor --- src/lib/store/middleware/websocketMiddleware.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/store/middleware/websocketMiddleware.ts b/src/lib/store/middleware/websocketMiddleware.ts index 84c45ad..2dde32d 100644 --- a/src/lib/store/middleware/websocketMiddleware.ts +++ b/src/lib/store/middleware/websocketMiddleware.ts @@ -18,6 +18,15 @@ import { uiActions, UiConfigState } from '../ui/ui.slice'; const httpClient = axios.create(); +// Add a random query parameter to every request to bust processor/browser caches +httpClient.interceptors.request.use((config) => { + config.params = { + ...config.params, + _: Date.now().toString(36) + Math.random().toString(36).slice(2), + }; + return config; +}); + // Define RootState locally to avoid circular dependency type LocalRootState = { appConfig: AppConfigState; From b645e487e23424ae6afda66a6722e2d3de8cc1f0 Mon Sep 17 00:00:00 2001 From: equinoy Date: Thu, 24 Sep 2026 15:57:05 -0500 Subject: [PATCH 2/3] fix(cache-busting): gate request cache-buster to ForceAppRefresh clients Only append the unique query param when the app URL carries the 'cb' param that Essentials adds for ForceAppRefresh touchpanels (DGE/CH5). Navigators and normal browsers see no 'cb' param, so the interceptor is a no-op and their request/caching behavior is unchanged. --- .../store/middleware/websocketMiddleware.ts | 142 ++++++++++-------- 1 file changed, 76 insertions(+), 66 deletions(-) diff --git a/src/lib/store/middleware/websocketMiddleware.ts b/src/lib/store/middleware/websocketMiddleware.ts index 2dde32d..22cf5bb 100644 --- a/src/lib/store/middleware/websocketMiddleware.ts +++ b/src/lib/store/middleware/websocketMiddleware.ts @@ -18,12 +18,22 @@ import { uiActions, UiConfigState } from '../ui/ui.slice'; const httpClient = axios.create(); -// Add a random query parameter to every request to bust processor/browser caches +// Essentials only appends a `cb=` cache buster to the app URL for touchpanels with +// ForceAppRefresh enabled (e.g. DGE/CH5 panels), so its presence is a reliable signal that this +// client needs the aggressive-cache workaround too. Everyone else (Navigators, plain browsers) +// sees no `cb` param and this interceptor becomes a no-op, leaving their requests untouched. httpClient.interceptors.request.use((config) => { - config.params = { - ...config.params, - _: Date.now().toString(36) + Math.random().toString(36).slice(2), - }; + const isForceRefreshClient = new URLSearchParams(window.location.search).has( + 'cb', + ); + + if (isForceRefreshClient) { + config.params = { + ...config.params, + _: Date.now().toString(36) + Math.random().toString(36).slice(2), + }; + } + return config; }); @@ -59,7 +69,7 @@ export const wsSendMessage = (messageType: string, content: unknown) => ({ export const wsAddEventHandler = ( eventType: string, key: string, - callback: (data: Message) => void + callback: (data: Message) => void, ) => ({ type: WS_ADD_EVENT_HANDLER, payload: { eventType, key, callback }, @@ -231,7 +241,7 @@ export const createWebSocketMiddleware = (): Middleware< try { const configRes = await httpClient.get( '/_local-config/_config.local.json', - { baseURL } + { baseURL }, ); if (configRes.status === 200 && configRes.data) { @@ -242,7 +252,7 @@ export const createWebSocketMiddleware = (): Middleware< console.error('Error getting config', error); dispatch(uiActions.setConnectionStage('error')); dispatch( - uiActions.setErrorMessage(describeRequestError(error, configUrl)) + uiActions.setErrorMessage(describeRequestError(error, configUrl)), ); return true; } @@ -253,7 +263,7 @@ export const createWebSocketMiddleware = (): Middleware< try { // Get the runtime version info and set it in the store const versionRes = await httpClient.get( - `${apiPath}/version` + `${apiPath}/version`, ); if (versionRes.status === 200 && versionRes.data) { dispatch(runtimeConfigActions.setRuntimeConfig(versionRes.data)); @@ -263,8 +273,8 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - describeRequestError(error, `${apiPath}/version`) - ) + describeRequestError(error, `${apiPath}/version`), + ), ); } } @@ -283,11 +293,11 @@ export const createWebSocketMiddleware = (): Middleware< const getRoomData = async ( apiPath: string, token: string, - dispatch: Dispatch + dispatch: Dispatch, ): Promise => { try { const res = await httpClient.get( - `${apiPath}/ui/joinroom?token=${token}` + `${apiPath}/ui/joinroom?token=${token}`, ); if (res.status === 200 && res.data) { @@ -308,8 +318,8 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - `Token ${token} is invalid. Unable to join room` - ) + `Token ${token} is invalid. Unable to join room`, + ), ); return null; } @@ -318,8 +328,8 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - describeRequestError(err, `${apiPath}/ui/joinroom`) - ) + describeRequestError(err, `${apiPath}/ui/joinroom`), + ), ); return null; } @@ -373,7 +383,7 @@ export const createWebSocketMiddleware = (): Middleware< */ const requestRoomStatus = ( getState: () => LocalRootState, - roomKey?: string + roomKey?: string, ) => { const rootState = getState(); const currentRoomKey = roomKey ?? rootState.runtimeConfig.roomData.roomKey; @@ -397,7 +407,7 @@ export const createWebSocketMiddleware = (): Middleware< type: `/room/${currentRoomKey}/status`, clientId, content: null, - }) + }), ); } }; @@ -407,7 +417,7 @@ export const createWebSocketMiddleware = (): Middleware< */ const connect = async ( dispatch: Dispatch, - getState: () => LocalRootState + getState: () => LocalRootState, ) => { console.log('WebSocket middleware: Attempting to connect...'); @@ -421,7 +431,7 @@ export const createWebSocketMiddleware = (): Middleware< { hasApiPath: !!apiPath, hasToken: !!state.token, - } + }, ); // Without an apiPath or token there is nothing further this middleware @@ -432,15 +442,15 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - 'Unable to connect: app configuration failed to load (no apiPath). Check that _local-config/_config.local.json is reachable and valid.' - ) + 'Unable to connect: app configuration failed to load (no apiPath). Check that _local-config/_config.local.json is reachable and valid.', + ), ); } else { dispatch(uiActions.setConnectionStage('waiting-for-token')); dispatch( uiActions.setErrorMessage( - 'No connection token found. Reopen this app using a valid Mobile Control link (with a ?token= parameter).' - ) + 'No connection token found. Reopen this app using a valid Mobile Control link (with a ?token= parameter).', + ), ); } return; @@ -453,7 +463,7 @@ export const createWebSocketMiddleware = (): Middleware< { hasClient: !!state.client, waitingToReconnect: state.waitingToReconnect, - } + }, ); return; } @@ -467,7 +477,7 @@ export const createWebSocketMiddleware = (): Middleware< if (!roomData) { console.log( - 'WebSocket middleware: Failed to get room data, will retry...' + 'WebSocket middleware: Failed to get room data, will retry...', ); dispatch(uiActions.setConnectionStage('retrying')); startReconnectionLoop(dispatch); @@ -509,7 +519,7 @@ export const createWebSocketMiddleware = (): Middleware< console.log( 'WebSocket middleware: Disconnected', closeEvent.reason, - closeEvent.code + closeEvent.code, ); // Handle explicit client-side close @@ -532,13 +542,13 @@ export const createWebSocketMiddleware = (): Middleware< console.log('WebSocket middleware: User code changed'); stopReconnectionLoop(); dispatch( - runtimeConfigActions.setUserCode({ userCode: '', qrUrl: '' }) + runtimeConfigActions.setUserCode({ userCode: '', qrUrl: '' }), ); dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - 'User code changed. Click reconnect to enter the new code' - ) + 'User code changed. Click reconnect to enter the new code', + ), ); clearStateDataOnDisconnect(dispatch); return; @@ -550,8 +560,8 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - 'Room combination changed. Click Reconnect to re-join the room' - ) + 'Room combination changed. Click Reconnect to re-join the room', + ), ); clearStateDataOnDisconnect(dispatch); return; @@ -564,25 +574,25 @@ export const createWebSocketMiddleware = (): Middleware< if (hasTouchpanelKey) { console.log( - 'WebSocket middleware: Code 4001 received with touchpanel key present, will auto-reconnect' + 'WebSocket middleware: Code 4001 received with touchpanel key present, will auto-reconnect', ); // Will fall through to auto-reconnect logic below } else if (!serverIsRunningOnProcessorHardware) { console.log( - 'WebSocket middleware: Processor disconnected (no touchpanel key, not on processor hardware)' + 'WebSocket middleware: Processor disconnected (no touchpanel key, not on processor hardware)', ); stopReconnectionLoop(); dispatch(uiActions.setConnectionStage('error')); dispatch( uiActions.setErrorMessage( - 'Processor has disconnected. Click Reconnect to continue.' - ) + 'Processor has disconnected. Click Reconnect to continue.', + ), ); clearStateDataOnDisconnect(dispatch); return; } else { console.log( - 'WebSocket middleware: Code 4001 on processor hardware (no touchpanel key), will auto-reconnect' + 'WebSocket middleware: Code 4001 on processor hardware (no touchpanel key), will auto-reconnect', ); // Will fall through to auto-reconnect logic below } @@ -591,7 +601,7 @@ export const createWebSocketMiddleware = (): Middleware< // All other close codes (including 1000 and 4001 on processor hardware) will auto-reconnect if (state.client) { console.log( - 'WebSocket middleware: Closed by server, will auto-reconnect' + 'WebSocket middleware: Closed by server, will auto-reconnect', ); } else { console.log('WebSocket middleware: Closed by client'); @@ -605,8 +615,8 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.setConnectionStage('retrying')); dispatch( uiActions.setErrorMessage( - 'Connection lost. Attempting to reconnect...' - ) + 'Connection lost. Attempting to reconnect...', + ), ); dispatch(runtimeConfigActions.setWebsocketIsConnected(false)); dispatch(devicesActions.clearDevices()); @@ -636,8 +646,8 @@ export const createWebSocketMiddleware = (): Middleware< case '/system/touchpanelKey': dispatch( runtimeConfigActions.setTouchpanelKey( - message.content as string - ) + message.content as string, + ), ); break; case '/system/roomKey': @@ -646,13 +656,13 @@ export const createWebSocketMiddleware = (): Middleware< dispatch(uiActions.clearSyncState()); dispatch( runtimeConfigActions.setCurrentRoomKey( - message.content as string - ) + message.content as string, + ), ); break; case '/system/userCodeChanged': dispatch( - runtimeConfigActions.setUserCode(message.content as UserCode) + runtimeConfigActions.setUserCode(message.content as UserCode), ); break; case '/system/roomCombinationChanged': @@ -667,15 +677,15 @@ export const createWebSocketMiddleware = (): Middleware< dispatch( runtimeConfigActions.setDeviceInterfaces( - interfaces.deviceInterfaces - ) + interfaces.deviceInterfaces, + ), ); break; } default: console.log( 'WebSocket middleware: Unhandled system message', - message + message, ); break; } @@ -684,7 +694,7 @@ export const createWebSocketMiddleware = (): Middleware< if (import.meta.env.DEV) { console.log( 'WebSocket middleware: Event message received', - message + message, ); } const handlers = state.eventHandlers[message.type]; @@ -692,7 +702,7 @@ export const createWebSocketMiddleware = (): Middleware< if (!handlers) { console.log( 'WebSocket middleware: No handlers found for event type', - message.type + message.type, ); } @@ -703,7 +713,7 @@ export const createWebSocketMiddleware = (): Middleware< } catch (err) { console.error( 'WebSocket middleware: Event handler error', - err + err, ); } }); @@ -747,7 +757,7 @@ export const createWebSocketMiddleware = (): Middleware< const sendMessage = ( messageType: string, content: unknown, - getState: () => LocalRootState + getState: () => LocalRootState, ) => { const rootState = getState(); const isConnected = rootState.runtimeConfig.websocket.isConnected; @@ -755,7 +765,7 @@ export const createWebSocketMiddleware = (): Middleware< if (state.client && isConnected) { state.client.send( - JSON.stringify({ type: messageType, clientId, content }) + JSON.stringify({ type: messageType, clientId, content }), ); } else { console.warn('WebSocket middleware: Cannot send message - not connected'); @@ -768,7 +778,7 @@ export const createWebSocketMiddleware = (): Middleware< const addEventHandler = ( eventType: string, key: string, - callback: (data: Message) => void + callback: (data: Message) => void, ) => { if (!state.eventHandlers[eventType]) { state.eventHandlers[eventType] = {}; @@ -787,7 +797,7 @@ export const createWebSocketMiddleware = (): Middleware< console.log( 'WebSocket middleware: Event handler removed', eventType, - key + key, ); } }; @@ -835,7 +845,7 @@ export const createWebSocketMiddleware = (): Middleware< if (!joinToken) { console.log( - 'WebSocket middleware: Device panel mode - no token available yet from mcAppUrl, waiting for join 1 update' + 'WebSocket middleware: Device panel mode - no token available yet from mcAppUrl, waiting for join 1 update', ); state.token = null; store.dispatch(uiActions.setConnectionStage('waiting-for-token')); @@ -843,7 +853,7 @@ export const createWebSocketMiddleware = (): Middleware< } console.log( - 'WebSocket middleware: Using token parsed from mcAppUrl (device panel mode)' + 'WebSocket middleware: Using token parsed from mcAppUrl (device panel mode)', ); state.token = joinToken; // sessionStorage isn't available on Crestron panels, not sure on other devices that run Zoom Room Controller and allow loading Zoom Room Control Applications @@ -876,7 +886,7 @@ export const createWebSocketMiddleware = (): Middleware< sendMessage( typedAction.payload.messageType, typedAction.payload.content, - store.getState + store.getState, ); break; @@ -884,14 +894,14 @@ export const createWebSocketMiddleware = (): Middleware< addEventHandler( typedAction.payload.eventType, typedAction.payload.key, - typedAction.payload.callback + typedAction.payload.callback, ); break; case WS_REMOVE_EVENT_HANDLER: removeEventHandler( typedAction.payload.eventType, - typedAction.payload.key + typedAction.payload.key, ); break; @@ -908,7 +918,7 @@ export const createWebSocketMiddleware = (): Middleware< const isConnected = (action as AnyAction).payload; if (isConnected === true) { console.log( - '[WebSocket Middleware] Connection established, requesting room status...' + '[WebSocket Middleware] Connection established, requesting room status...', ); setTimeout(() => requestRoomStatus(store.getState), 100); } @@ -923,7 +933,7 @@ export const createWebSocketMiddleware = (): Middleware< roomData?.clientId ) { console.log( - '[WebSocket Middleware] Room data received, requesting room status...' + '[WebSocket Middleware] Room data received, requesting room status...', ); setTimeout(() => requestRoomStatus(store.getState), 100); } @@ -936,7 +946,7 @@ export const createWebSocketMiddleware = (): Middleware< console.log( '[WebSocket Middleware] Room changed to:', roomKey, - ', requesting room status...' + ', requesting room status...', ); setTimeout(() => requestRoomStatus(store.getState, roomKey), 100); } @@ -955,12 +965,12 @@ export const createWebSocketMiddleware = (): Middleware< if (joinToken && joinToken !== state.token) { if (state.client) { console.log( - '[WebSocket Middleware] Token rotated in mcAppUrl, reconnecting with new token...' + '[WebSocket Middleware] Token rotated in mcAppUrl, reconnecting with new token...', ); disconnect(); } else { console.log( - '[WebSocket Middleware] Token now available from mcAppUrl, connecting...' + '[WebSocket Middleware] Token now available from mcAppUrl, connecting...', ); } From 6b21e6d7ade8f1bc781a7a17fd5de67ef05ecfb4 Mon Sep 17 00:00:00 2001 From: equinoy Date: Thu, 24 Sep 2026 17:18:39 -0500 Subject: [PATCH 3/3] fix(websocket): reload to app base URL on room combination change A /system/roomCombinationChanged broadcast triggered window.location.reload(), which reloaded the panel's current deep route (e.g. /mc/app/tech). On DGE panels with forceAppRefresh this strands the app showing a blank screen until the program is restarted. Reload to the app base URL instead so routing re-runs cleanly like a fresh app push. The current query string (token / cache buster) is preserved. --- .../store/middleware/websocketMiddleware.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/lib/store/middleware/websocketMiddleware.ts b/src/lib/store/middleware/websocketMiddleware.ts index 22cf5bb..6488c73 100644 --- a/src/lib/store/middleware/websocketMiddleware.ts +++ b/src/lib/store/middleware/websocketMiddleware.ts @@ -133,6 +133,26 @@ const isXPanel = (): boolean => { */ const isDevicePanel = (): boolean => isZoomRoom() || isXPanel(); +/** + * Computes the app's base URL the same way the router basename and config loader do, so a + * reload lands on the app root instead of whatever deep route the panel is currently on. + * A blind reload of a deep path (e.g. /mc/app/tech) can strand DGE panels after a + * combine/divide; the current query string (token / cache buster) is preserved. + */ +const getAppBaseUrl = (): string => { + const segments = window.location.pathname + .split('/') + .filter((path) => path.length > 0 && !path.includes('.')); + + if (segments.length >= 5) { + segments.length = 5; + } else { + segments.length = 2; + } + + return `/${segments.join('/')}${window.location.search}`; +}; + /** * Builds a user-facing error message for a failed HTTP request. Axios (and * the browser XHR/fetch APIs underneath it) do not expose SSL/TLS @@ -666,7 +686,9 @@ export const createWebSocketMiddleware = (): Middleware< ); break; case '/system/roomCombinationChanged': - window.location.reload(); + // Reload to the app base URL, not the current deep route, so a combine/divide + // re-runs routing cleanly (like a fresh app push) instead of stranding the panel. + window.location.replace(getAppBaseUrl()); break; case '/system/deviceInterfaces': { const interfaces: {