@raydiant/playlist-window-manager 0.0.0-electron-upgrade-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.eslintrc +6 -0
  2. package/README.md +16 -0
  3. package/babel.config.js +13 -0
  4. package/build/BrowserViewRenderer.d.ts +19 -0
  5. package/build/BrowserViewRenderer.js +213 -0
  6. package/build/BrowserWindowRenderer.d.ts +47 -0
  7. package/build/BrowserWindowRenderer.js +524 -0
  8. package/build/IFrameRenderer.d.ts +20 -0
  9. package/build/IFrameRenderer.js +226 -0
  10. package/build/PlaylistWindowManager.d.ts +111 -0
  11. package/build/PlaylistWindowManager.js +1056 -0
  12. package/build/PlaylistWindowManagerState.d.ts +28 -0
  13. package/build/PlaylistWindowManagerState.js +60 -0
  14. package/build/PresentationWindow.d.ts +149 -0
  15. package/build/PresentationWindow.js +1235 -0
  16. package/build/PresentationWindow.test.d.ts +1 -0
  17. package/build/PresentationWindow.test.js +340 -0
  18. package/build/WindowLoadError.d.ts +3 -0
  19. package/build/WindowLoadError.js +29 -0
  20. package/build/WindowTimeoutError.d.ts +3 -0
  21. package/build/WindowTimeoutError.js +29 -0
  22. package/build/WindowUnresponsiveError.d.ts +3 -0
  23. package/build/WindowUnresponsiveError.js +29 -0
  24. package/build/embeddedApps.d.ts +2 -0
  25. package/build/embeddedApps.js +59 -0
  26. package/build/embeddedApps.test.d.ts +1 -0
  27. package/build/embeddedApps.test.js +123 -0
  28. package/build/index.d.ts +10 -0
  29. package/build/index.js +30 -0
  30. package/build/preload.d.ts +1 -0
  31. package/build/preload.js +20 -0
  32. package/build/testUtils.d.ts +43 -0
  33. package/build/testUtils.js +209 -0
  34. package/build/types.d.ts +162 -0
  35. package/build/types.js +2 -0
  36. package/jest.config.js +6 -0
  37. package/package.json +30 -0
  38. package/src/BrowserViewRenderer.ts +180 -0
  39. package/src/BrowserWindowRenderer.ts +595 -0
  40. package/src/IFrameRenderer.ts +251 -0
  41. package/src/PlaylistWindowManager.ts +1094 -0
  42. package/src/PlaylistWindowManagerState.ts +70 -0
  43. package/src/PresentationWindow.test.ts +333 -0
  44. package/src/PresentationWindow.ts +1434 -0
  45. package/src/WindowLoadError.ts +8 -0
  46. package/src/WindowTimeoutError.ts +8 -0
  47. package/src/WindowUnresponsiveError.ts +8 -0
  48. package/src/embeddedApps.test.ts +231 -0
  49. package/src/embeddedApps.ts +74 -0
  50. package/src/index.ts +13 -0
  51. package/src/preload.ts +27 -0
  52. package/src/testUtils.ts +160 -0
  53. package/src/types.ts +199 -0
  54. package/tasks/npm-publish.sh +13 -0
  55. package/tsconfig.json +7 -0
@@ -0,0 +1,1434 @@
1
+ import * as RE from '@raydiant/playlist-rule-engine';
2
+ import deepEqual from 'fast-deep-equal';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import createEmbeddedApp from './embeddedApps';
5
+ import PlaylistWindowManagerState from './PlaylistWindowManagerState';
6
+ import PlaylistWindowManager from './PlaylistWindowManager';
7
+ import WindowTimeoutError from './WindowTimeoutError';
8
+ import WindowLoadError from './WindowLoadError';
9
+ import {
10
+ Presentation,
11
+ ApplicationVersion,
12
+ Theme,
13
+ Device,
14
+ PresentationWindowRenderer,
15
+ WindowBounds,
16
+ Logger,
17
+ OpenChildWindowOptions,
18
+ BuilderState,
19
+ WorkerState,
20
+ EmbeddedAppMetadata,
21
+ AnalyticsEvent,
22
+ PreviewState,
23
+ Charge,
24
+ ChargeRequest,
25
+ ChargeResponse,
26
+ Terminal,
27
+ } from './types';
28
+
29
+ export interface PresentationWindowOptions {
30
+ windowManager?: PlaylistWindowManager;
31
+ windowId: string;
32
+ windowName?: string;
33
+ parentWindowId?: string;
34
+ builderState?: BuilderState;
35
+ presentation: Presentation;
36
+ applicationVersion: ApplicationVersion;
37
+ theme?: Theme;
38
+ device?: Device;
39
+ playlistId?: string;
40
+ playlistName?: string;
41
+ rule?: RE.Rule | null;
42
+ publishedAt: string;
43
+ playlistWindowManagerState: PlaylistWindowManagerState;
44
+ ruleEngineState: RE.State;
45
+ logger: Logger;
46
+ globalRule?: RE.Rule;
47
+ renderer: PresentationWindowRenderer;
48
+ readyTimeoutDurationSeconds?: number;
49
+ appRendererUrl?: string;
50
+ bounds?: WindowBounds;
51
+ backgroundColor?: string;
52
+ isPreview?: boolean;
53
+ forceMute?: boolean;
54
+ workerState: WorkerState;
55
+ onPresentationProperties?: (
56
+ properties: any[],
57
+ string: { [key: string]: string }
58
+ ) => void;
59
+ getTerminal?: () => Terminal | null;
60
+ }
61
+
62
+ // TODO: Automatically inject latest version when building in CI.
63
+ const defaultAppRendererUrl = 'https://apps.raydiant.com/3.0.4/index.html';
64
+
65
+ const isValidNumber = (value: any) =>
66
+ typeof value === 'number' && !isNaN(value);
67
+
68
+ const isValidAnalyticsEvent = (value: any): value is AnalyticsEvent =>
69
+ value &&
70
+ typeof value === 'object' &&
71
+ typeof value.type === 'string' &&
72
+ typeof value.source === 'string' &&
73
+ (typeof value.productCategory === 'string' ||
74
+ value.productCategory === undefined ||
75
+ value.productCategory === null);
76
+
77
+ const isValidChargeRequest = (value: any): value is ChargeRequest =>
78
+ value &&
79
+ typeof value === 'object' &&
80
+ typeof value.requestId === 'string' &&
81
+ typeof value.amount === 'number' &&
82
+ (value.tags === undefined ||
83
+ value.tags === null ||
84
+ typeof value.tags === 'object') &&
85
+ (value.idempotencyId === undefined ||
86
+ value.idempotencyId === null ||
87
+ typeof value.idempotencyId === 'string');
88
+
89
+ export default class PresentationWindow {
90
+ private didReceiveReady: boolean = false;
91
+ private readyCallback: (() => void) | null = null;
92
+ private didReceiveComplete: boolean = false;
93
+ private completeCallback: (() => void) | null = null;
94
+ private errorCallback: ((err: Error) => void) | null = null;
95
+ private readyTimeout: ReturnType<typeof setTimeout> | null = null;
96
+ private windowManagers: { [windowId: string]: PlaylistWindowManager } = {};
97
+ private focusedWindowManager: PlaylistWindowManager | null = null;
98
+ private didEmbeddedAppLoad: boolean = false;
99
+ private lastPlayedAt: Date | null = null;
100
+ private configurableDurationTimeout: ReturnType<typeof setTimeout> | null =
101
+ null;
102
+ private isWindowLocked: boolean = false;
103
+ private isChildWindowLocked: { [windowId: string]: boolean } = {};
104
+ private enableInterruptLockCallback: (() => void) | null = null;
105
+ private disableInterruptLockCallback: (() => void) | null = null;
106
+ private analyticsEventCallback: ((event: AnalyticsEvent) => void) | null =
107
+ null;
108
+ private chargeRequestCallback: ((request: ChargeRequest) => void) | null =
109
+ null;
110
+
111
+ private builderState: BuilderState | undefined;
112
+ private presentation: Presentation;
113
+ private playlistWindowManagerState: PlaylistWindowManagerState;
114
+ private ruleEngineState: RE.State;
115
+ private theme: Theme | undefined;
116
+ private forceMute: boolean = false;
117
+ private error: Error | null = null;
118
+
119
+ private workerState: WorkerState = {};
120
+ private workerSubscriptions = new Set<string>();
121
+ private runtimeSubscriptions = new Set<string>();
122
+ private previewSubscriptions = new Set<string>();
123
+ private previewState: PreviewState = { inputs: {} };
124
+ private paymentSubscriptions = new Set<string>();
125
+
126
+ private createObjectURLResolvers: Record<string, (url: string) => void> = {};
127
+ private createObjectURLTimeouts: Record<
128
+ string,
129
+ ReturnType<typeof setTimeout>
130
+ > = {};
131
+
132
+ constructor(private options: PresentationWindowOptions) {
133
+ this.builderState = options.builderState;
134
+ this.presentation = options.presentation;
135
+ this.playlistWindowManagerState = options.playlistWindowManagerState;
136
+ this.ruleEngineState = options.ruleEngineState;
137
+ this.theme = options.theme;
138
+ this.forceMute = options.forceMute || false;
139
+ this.workerState = options.workerState;
140
+ }
141
+
142
+ public load(): Promise<void> {
143
+ if (this.isEmbeddedApp()) {
144
+ return this.loadEmbeddedApp();
145
+ }
146
+ return this.loadLegacyApp();
147
+ }
148
+
149
+ public show(isFocused: boolean = true): void {
150
+ const { windowId, renderer } = this.options;
151
+
152
+ // Show the main window and focus it before any child windows. Showing a child
153
+ // window that requires focus will move focus from the main window to the child.
154
+ renderer.showWindow(windowId, isFocused);
155
+
156
+ // Windows are created muted, check if we should unmute the window when showing.
157
+ // This ensures presentations that use audio take precendence over SYB playback on device.
158
+ // This checks the presentation for mute properties when shown but does not handle the case
159
+ // when an app becomes unmuted or muted while running (ie. someone interacting with a video player).
160
+ // A possible better solution would be to have apps fire an event when they need audio.
161
+ const isMuted = this.shouldMute();
162
+ if (!isMuted) {
163
+ renderer.unMuteWindow(windowId);
164
+ }
165
+
166
+ // Show child windows after main window, setting focus if necessary.
167
+ let isChildWindowFocused = false;
168
+ Object.values(this.windowManagers).map((wm) => {
169
+ if (
170
+ isFocused &&
171
+ this.focusedWindowManager &&
172
+ this.focusedWindowManager === wm
173
+ ) {
174
+ isChildWindowFocused = true;
175
+ wm.show(true);
176
+ } else {
177
+ wm.show(false);
178
+ }
179
+ });
180
+
181
+ this.logInfo(
182
+ `Show window '${windowId}' isMuted=${isMuted} isFocused=${isFocused}${
183
+ isChildWindowFocused ? ` isChildWindowFocused=true` : ''
184
+ }`
185
+ );
186
+ }
187
+
188
+ public hide(): void {
189
+ const { windowId, renderer } = this.options;
190
+ // Hide child windows before main window.
191
+ Object.values(this.windowManagers).map((wm) => wm.hide());
192
+ renderer.hideWindow(windowId);
193
+ }
194
+
195
+ public async remove(): Promise<void> {
196
+ const { windowId, renderer } = this.options;
197
+
198
+ // Clean up callbacks
199
+ this.readyCallback = null;
200
+ this.completeCallback = null;
201
+ this.errorCallback = null;
202
+ this.enableInterruptLockCallback = null;
203
+ this.disableInterruptLockCallback = null;
204
+
205
+ // Clean up subscriptions
206
+ this.workerSubscriptions.clear();
207
+
208
+ // Clear timers
209
+ if (this.readyTimeout) {
210
+ clearTimeout(this.readyTimeout);
211
+ this.readyTimeout = null;
212
+ }
213
+ if (this.configurableDurationTimeout) {
214
+ clearTimeout(this.configurableDurationTimeout);
215
+ this.configurableDurationTimeout = null;
216
+ }
217
+
218
+ // Remove any child window managers.
219
+ Object.values(this.windowManagers).forEach((wm) => {
220
+ wm.destroy();
221
+ });
222
+
223
+ // Remove the main window after removing all the child window managers.
224
+ try {
225
+ await renderer.removeWindow(windowId);
226
+ this.logInfo(`Removed window '${windowId}'`);
227
+ } catch (err: any) {
228
+ this.logError(
229
+ `Failed to remove window '${windowId}' with '${err.message}'`
230
+ );
231
+ }
232
+ }
233
+
234
+ // Method to modify the rule engine state for the inner playlist windows (called from outer playlist window manager)
235
+ public reinitializePresentationWindowRuleEngineState(
236
+ newRulesDataStoreObj: RE.RulesDataStore
237
+ ) {
238
+ Object.values(this.windowManagers).forEach((wm) => {
239
+ wm.reinitializeRuleEngineState(newRulesDataStoreObj);
240
+ });
241
+ }
242
+
243
+ public play(): void {
244
+ const { applicationVersion, presentation } = this.options;
245
+
246
+ if (applicationVersion.configurableDuration) {
247
+ const durationMs = Math.max(presentation.duration * 1000, 5000);
248
+ if (this.configurableDurationTimeout) {
249
+ clearTimeout(this.configurableDurationTimeout);
250
+ }
251
+ this.configurableDurationTimeout = setTimeout(() => {
252
+ if (this.completeCallback) {
253
+ this.completeCallback();
254
+ }
255
+ }, durationMs);
256
+ }
257
+
258
+ if (!this.isEmbeddedApp()) {
259
+ this.sendMessageString('play');
260
+
261
+ // Play any child window managers.
262
+ Object.values(this.windowManagers).forEach((wm) => {
263
+ // We only want to trigger play on child windows the first play of the main window or if
264
+ // the child window is paused.
265
+ if (!wm.isPlaying() || wm.isPaused()) {
266
+ wm.play();
267
+ }
268
+ });
269
+ } else if (this.runtimeSubscriptions.has('play')) {
270
+ // If the embedded app has subscribed to the play event, send it a play event.
271
+ this.sendMessageString('play');
272
+ }
273
+
274
+ this.lastPlayedAt = new Date();
275
+ }
276
+
277
+ public pause() {
278
+ // Pause all child window managers.
279
+ Object.values(this.windowManagers).forEach((wm) => wm.pause());
280
+ }
281
+
282
+ public resume() {
283
+ // Resume all child window managers.
284
+ Object.values(this.windowManagers).forEach((wm) => wm.resume());
285
+ }
286
+
287
+ public reset(): void {
288
+ this.didReceiveComplete = false;
289
+ }
290
+
291
+ public onComplete(callback: (() => void) | null) {
292
+ this.completeCallback = callback;
293
+ }
294
+
295
+ public onError(callback: ((err: Error) => void) | null) {
296
+ this.errorCallback = callback;
297
+ }
298
+
299
+ public onEnableInterruptLock(callback: (() => void) | null) {
300
+ this.enableInterruptLockCallback = callback;
301
+ }
302
+
303
+ public onDisableInterruptLock(callback: (() => void) | null) {
304
+ this.disableInterruptLockCallback = callback;
305
+ }
306
+
307
+ public onAnalyticsEvent(callback: ((event: AnalyticsEvent) => void) | null) {
308
+ this.analyticsEventCallback = callback;
309
+ }
310
+
311
+ public onChargeRequest(callback: ((request: ChargeRequest) => void) | null) {
312
+ this.chargeRequestCallback = callback;
313
+ }
314
+
315
+ public isReady() {
316
+ // TODO: To support the 'ready' event in embedded apps, they will need to tell the
317
+ // runtime that it should wait for ready instead of DOMLoaded. This could be a "wait
318
+ // for ready" option in the Developer Portal.
319
+ if (this.isEmbeddedApp()) {
320
+ return this.didEmbeddedAppLoad;
321
+ }
322
+ return this.didReceiveReady && this.allChildWindowManagersReady();
323
+ }
324
+
325
+ public isComplete() {
326
+ return this.didReceiveComplete;
327
+ }
328
+
329
+ public isPlaying() {
330
+ return this.lastPlayedAt !== null;
331
+ }
332
+
333
+ public updateBounds(bounds: WindowBounds) {
334
+ const { windowId, renderer } = this.options;
335
+ renderer.updateWindowBounds(windowId, bounds);
336
+ }
337
+
338
+ public didError() {
339
+ return !!this.error;
340
+ }
341
+
342
+ public isLocked() {
343
+ return (
344
+ this.isWindowLocked ||
345
+ Object.values(this.isChildWindowLocked).some(Boolean)
346
+ );
347
+ }
348
+
349
+ public setPresentation(
350
+ presentation: Presentation,
351
+ playlistWindowManagerState: PlaylistWindowManagerState,
352
+ ruleEngineState: RE.State
353
+ ) {
354
+ this.presentation = presentation;
355
+ this.ruleEngineState = ruleEngineState;
356
+ this.playlistWindowManagerState = playlistWindowManagerState;
357
+ }
358
+
359
+ public setTheme(theme?: Theme) {
360
+ this.theme = theme;
361
+ }
362
+
363
+ public setBuilderState(builderState?: BuilderState) {
364
+ this.builderState = builderState;
365
+ }
366
+
367
+ public setForceMute(forceMute: boolean) {
368
+ this.forceMute = forceMute;
369
+ }
370
+
371
+ public async reload() {
372
+ const { applicationVersion, windowId } = this.options;
373
+
374
+ if (this.isEmbeddedApp()) {
375
+ if (applicationVersion.embeddedUrlFormat) {
376
+ if (this.previewSubscriptions.has('inputs')) {
377
+ this.logInfo(
378
+ `Reload window '${windowId}' for embedded app preview state`
379
+ );
380
+
381
+ const updatedInputs = transformInputValues(
382
+ this.presentation.applicationVariables
383
+ );
384
+
385
+ if (!deepEqual(updatedInputs, this.previewState.inputs)) {
386
+ this.sendMessageObject('preview-inputs', updatedInputs);
387
+ }
388
+
389
+ this.previewState.inputs = updatedInputs;
390
+ } else {
391
+ const url = this.getEmbeddedAppUrl();
392
+
393
+ this.logInfo(
394
+ `Reload window '${windowId}' for embedded app url '${url}'`
395
+ );
396
+ await this.loadWindow(url);
397
+ }
398
+ }
399
+ } else {
400
+ this.logInfo(`Reload window '${windowId}'`);
401
+ this.sendRenderMessage();
402
+ }
403
+ }
404
+
405
+ public runGetProperties(builderState?: BuilderState) {
406
+ // Do no support conditional properties for embedded apps. We will likely
407
+ // change the way conditional properties work.
408
+ if (!this.isEmbeddedApp()) {
409
+ this.sendMessageString('run_get_properties', builderState);
410
+ }
411
+ }
412
+
413
+ public async createObjectURL(blob: Blob): Promise<string> {
414
+ if (this.isEmbeddedApp()) {
415
+ return this.createObjectURLForEmbeddedApp(blob);
416
+ }
417
+
418
+ return URL.createObjectURL(blob);
419
+ }
420
+
421
+ public sendChargeResponse(response: ChargeResponse) {
422
+ // TODO: Instead of sending the charge to all windows, we should only send it to the app
423
+ // that requested the charge. We probably don't want to use the windowId to avoid missing
424
+ // the events in case the window was closed/restarted. Since we are only sending the charge
425
+ // id and status this seems ok for now but need to be mindful of sending any sensitive data.
426
+ this.sendMessageObject('payments-charge-response', {
427
+ requestId: response.requestId,
428
+ charge: response.charge,
429
+ error: response.error,
430
+ });
431
+ // Forward the event to the child window managers.
432
+ Object.values(this.windowManagers).forEach((wm) =>
433
+ wm.sendChargeResponse(response)
434
+ );
435
+ }
436
+
437
+ public sendCharge(charge: Charge) {
438
+ // TODO: Instead of sending the charge to all windows, we should only send it to the app
439
+ // that requested the charge.
440
+ if (this.paymentSubscriptions.has('charge')) {
441
+ this.sendMessageObject('payments-charge-event', { charge });
442
+ }
443
+ // Forward the event to the child window managers.
444
+ Object.values(this.windowManagers).forEach((wm) => wm.sendCharge(charge));
445
+ }
446
+
447
+ public sendTerminal(terminal: Terminal | null) {
448
+ if (this.paymentSubscriptions.has('terminal')) {
449
+ this.sendMessageObject('payments-terminal-event', { terminal });
450
+ }
451
+ // Forward the event to the child window managers.
452
+ Object.values(this.windowManagers).forEach((wm) =>
453
+ wm.sendTerminal(terminal)
454
+ );
455
+ }
456
+
457
+ private isEmbeddedApp() {
458
+ const { applicationVersion, isPreview } = this.options;
459
+
460
+ // If the application has a source url, always use it for rendering the preview.
461
+ // This fixes an issue with Hoopla which is an embedded app but uses a legacy app
462
+ // for it's preview.
463
+ if (isPreview && applicationVersion.sourceUrl) {
464
+ return false;
465
+ }
466
+
467
+ return !!applicationVersion.embeddedUrlFormat;
468
+ }
469
+
470
+ private allChildWindowManagersReady(): boolean {
471
+ // Return true if the presentation doesn't contain any playlists.
472
+ if (this.presentation.r.presentationsPlaylists.length === 0) {
473
+ return true;
474
+ }
475
+
476
+ return Object.values(this.windowManagers).every((wm) => wm.isReady());
477
+ }
478
+
479
+ private checkIsReadyCallback() {
480
+ if (this.isReady()) {
481
+ if (this.readyTimeout) {
482
+ clearTimeout(this.readyTimeout);
483
+ }
484
+
485
+ if (this.readyCallback) {
486
+ this.readyCallback();
487
+ }
488
+
489
+ // Trigger the enable interrupt lock callback if the presentation fired
490
+ // enable_interrupt_lock before presentation_ready.
491
+ if (this.isLocked() && this.enableInterruptLockCallback) {
492
+ this.enableInterruptLockCallback();
493
+ }
494
+ }
495
+ }
496
+
497
+ private appendWindowIdToHash(urlStr: string, windowId: string) {
498
+ const url = new URL(urlStr);
499
+ const hashParams = new URLSearchParams(url.hash.slice(1));
500
+ hashParams.set('__raydiantWindowId', windowId);
501
+ url.hash = hashParams.toString();
502
+
503
+ return url.toString();
504
+ }
505
+
506
+ private async loadEmbeddedApp(): Promise<void> {
507
+ const { windowId, applicationVersion } = this.options;
508
+
509
+ if (!applicationVersion.embeddedUrlFormat) {
510
+ throw new Error(
511
+ `Error loading embedded app, missing embeddedUrlFormat for '${applicationVersion.id}'`
512
+ );
513
+ }
514
+
515
+ const url = this.getEmbeddedAppUrl();
516
+ this.logInfo(`Create window '${windowId}' for embedded app url '${url}'`);
517
+
518
+ await this.createAndLoadWindow(
519
+ url,
520
+ // Embedded apps should not have a backgroundColor because some sites expect
521
+ // the browser default of white will be applied.
522
+ '#ffffff',
523
+ this.handleEmbeddedMessage
524
+ );
525
+ this.didEmbeddedAppLoad = true;
526
+
527
+ this.checkIsReadyCallback();
528
+ }
529
+
530
+ private async loadLegacyApp(): Promise<void> {
531
+ const {
532
+ windowId,
533
+ appRendererUrl = defaultAppRendererUrl,
534
+ backgroundColor,
535
+ } = this.options;
536
+
537
+ this.logInfo(`Create window '${windowId}'`);
538
+
539
+ await this.createAndLoadWindow(
540
+ this.appendWindowIdToHash(appRendererUrl, windowId),
541
+ backgroundColor || '#000000', // Default to black bg for Raydiant apps
542
+ this.handleLegacyMessage
543
+ );
544
+
545
+ await this.waitForPesentationReady();
546
+ }
547
+
548
+ private async createAndLoadWindow(
549
+ url: string,
550
+ backgroundColor?: string,
551
+ receiveMessageCallback?: (type: string, payload?: any) => void
552
+ ) {
553
+ const { windowId, parentWindowId, renderer, bounds } = this.options;
554
+
555
+ renderer.createWindow({
556
+ windowId,
557
+ parentWindowId,
558
+ bounds,
559
+ backgroundColor,
560
+ receiveMessageCallback,
561
+ errorCallback: this.handleError,
562
+ });
563
+
564
+ // Mute new window. This ensures preloaded windows are muted by default and
565
+ // prevents opening an audio channel on the device for SYB playback. When using
566
+ // the BrowserWindowRenderer this will call setAudioMuted(true) but the IFrameRenderer
567
+ // has no API for muting so we rely on apps that use mute presentation props to be
568
+ // initially muted before playing.
569
+ this.logInfo(`Mute window ${windowId}`);
570
+ renderer.muteWindow(windowId);
571
+
572
+ await this.loadWindow(url);
573
+ }
574
+
575
+ private async loadWindow(url: string) {
576
+ const { windowId, renderer } = this.options;
577
+ try {
578
+ await renderer.loadWindow(windowId, url);
579
+ this.logInfo(`Loaded window '${windowId}'`);
580
+ } catch (err: any) {
581
+ this.handleError(
582
+ new WindowLoadError(`Failed to load window with '${err.message}'`)
583
+ );
584
+ }
585
+ }
586
+
587
+ // Send a JSON stringified message to the renderer. This is the legacy way of sending
588
+ // messages and should be deprecated in favor of sendMessageObject.
589
+ private sendMessageString(type: string, payload?: any) {
590
+ const { windowId, renderer } = this.options;
591
+ const message = JSON.stringify({ type, payload, windowId });
592
+
593
+ this.logInfo(`Sending '${type}' to window '${windowId}'`);
594
+ renderer.sendMessageString(windowId, message);
595
+ }
596
+
597
+ // Send a message object to the renderer. This is the preferred way of sending messages
598
+ // going forward since it allows for more complex payloads like ArrayBuffers (for File Inputs).
599
+ private sendMessageObject(type: string, payload?: any) {
600
+ const { windowId, renderer } = this.options;
601
+
602
+ this.logInfo(`Sending '${type}' to window '${windowId}'`);
603
+ renderer.sendMessageObject(windowId, type, payload);
604
+ }
605
+
606
+ private waitForPesentationReady(): Promise<void> {
607
+ const { windowId, readyTimeoutDurationSeconds = 30 } = this.options;
608
+
609
+ return new Promise<void>((resolve) => {
610
+ if (this.didReceiveReady) {
611
+ resolve();
612
+ } else {
613
+ this.readyCallback = () => {
614
+ resolve();
615
+ };
616
+
617
+ if (this.readyTimeout) {
618
+ clearTimeout(this.readyTimeout);
619
+ }
620
+
621
+ this.readyTimeout = setTimeout(() => {
622
+ this.handleError(
623
+ new WindowTimeoutError(
624
+ `Window ${windowId} timed out after ${readyTimeoutDurationSeconds} seconds`
625
+ )
626
+ );
627
+ // Resolve the promise is the window times out. This ensures windowManager.load() resolves
628
+ // even if the current window times out. Fixes an issue where the boot screen isn't removed
629
+ // if the first presentation times out.
630
+ resolve();
631
+ }, readyTimeoutDurationSeconds * 1000);
632
+ }
633
+ });
634
+ }
635
+
636
+ private buildTheme(): Theme | undefined {
637
+ if (!this.theme) return;
638
+
639
+ const nextTheme = { ...this.theme };
640
+
641
+ if (nextTheme.headingFontNew && nextTheme.headingFontNew.fileUpload) {
642
+ nextTheme.headingFont =
643
+ nextTheme.headingFontNew.fileUpload.url || nextTheme.headingFont;
644
+ }
645
+
646
+ if (nextTheme.heading2FontNew && nextTheme.heading2FontNew.fileUpload) {
647
+ nextTheme.heading2Font =
648
+ nextTheme.heading2FontNew.fileUpload.url || nextTheme.heading2Font;
649
+ }
650
+
651
+ if (nextTheme.bodyFontNew && nextTheme.bodyFontNew.fileUpload) {
652
+ nextTheme.bodyFont =
653
+ nextTheme.bodyFontNew.fileUpload.url || nextTheme.bodyFont;
654
+ }
655
+
656
+ if (nextTheme.backgroundImageFileUpload) {
657
+ nextTheme.backgroundImage =
658
+ nextTheme.backgroundImageFileUpload.url || nextTheme.backgroundImage;
659
+ }
660
+
661
+ return nextTheme;
662
+ }
663
+
664
+ private sendRenderMessage() {
665
+ const { applicationVersion, isPreview, device } = this.options;
666
+ // IFrames do not have a mute/unmute API so we are overriding any mute properties
667
+ // to support muting MZ zones when using the IFrameRenderer (SecondScreen).
668
+ if (this.forceMute) {
669
+ this.sendMessageString('render', {
670
+ builderState: this.builderState,
671
+ presentation: {
672
+ ...this.presentation,
673
+ applicationVariables: {
674
+ ...this.presentation.applicationVariables,
675
+ // TODO: We need a better way to identify mute properties (ie. PropType.mute())
676
+ // Force mute the presentation, existing apps use either mute or muted.
677
+ mute: true,
678
+ muted: true,
679
+ },
680
+ },
681
+ theme: this.buildTheme(),
682
+ applicationVersion,
683
+ device,
684
+ isPreview,
685
+ });
686
+ } else {
687
+ this.sendMessageString('render', {
688
+ builderState: this.builderState,
689
+ presentation: this.presentation,
690
+ theme: this.buildTheme(),
691
+ applicationVersion,
692
+ device,
693
+ isPreview,
694
+ });
695
+ }
696
+ }
697
+
698
+ private async openChildWindow(
699
+ windowName: string,
700
+ playlistId: string,
701
+ bounds: WindowBounds,
702
+ forceMute: boolean = false,
703
+ isFocused: boolean = false,
704
+ themeOverride?: any
705
+ ) {
706
+ const {
707
+ windowId,
708
+ renderer,
709
+ device,
710
+ readyTimeoutDurationSeconds,
711
+ appRendererUrl,
712
+ logger,
713
+ isPreview,
714
+ } = this.options;
715
+ const parentWindowManager = this.options.windowManager;
716
+
717
+ let windowManager = this.windowManagers[windowName];
718
+
719
+ if (windowManager) {
720
+ const prevBounds = windowManager.getBounds();
721
+ const prevThemeOverride = windowManager.getThemeOverride();
722
+ const prevRuleEngine = windowManager.getRuleEngine();
723
+ const prevForceMute = windowManager.getForceMute();
724
+
725
+ const didBoundsChange = prevBounds
726
+ ? !deepEqual(bounds, prevBounds)
727
+ : true;
728
+
729
+ if (didBoundsChange) {
730
+ windowManager.updateBounds(bounds);
731
+ }
732
+
733
+ const didForceMuteChange = forceMute !== prevForceMute;
734
+ const didThemeOverrideChange = prevThemeOverride
735
+ ? !deepEqual(themeOverride, prevThemeOverride)
736
+ : true;
737
+
738
+ if (didForceMuteChange || didThemeOverrideChange) {
739
+ windowManager.setThemeOverride(themeOverride);
740
+ windowManager.setForceMute(forceMute);
741
+ windowManager.reload();
742
+ }
743
+
744
+ const didPlaylistChange = prevRuleEngine
745
+ ? playlistId !== prevRuleEngine.playlistId
746
+ : true;
747
+
748
+ if (didPlaylistChange) {
749
+ windowManager.reinitialize(
750
+ this.playlistWindowManagerState,
751
+ this.createRuleEngine(playlistId),
752
+ true
753
+ );
754
+ }
755
+ } else {
756
+ windowManager = new PlaylistWindowManager({
757
+ parentWindowId: windowId,
758
+ windowName,
759
+ ruleEngine: this.createRuleEngine(playlistId),
760
+ state: this.playlistWindowManagerState,
761
+ logger,
762
+ device,
763
+ readyTimeoutDurationSeconds: readyTimeoutDurationSeconds,
764
+ appRendererUrl: appRendererUrl,
765
+ bounds: {
766
+ x: bounds.x,
767
+ y: bounds.y,
768
+ width: bounds.width,
769
+ height: bounds.height,
770
+ },
771
+ backgroundColor: 'transparent',
772
+ themeOverride,
773
+ forceMute,
774
+ isPreview,
775
+ onEnableInterruptLock: () => this.enableInteruptLock(windowName),
776
+ onDisableInterruptLock: () => this.disableInteruptLock(windowName),
777
+ getTerminal: this.options.getTerminal,
778
+ });
779
+
780
+ // Hook up child window manager events to bubble up to the parent.
781
+ if (parentWindowManager) {
782
+ windowManager.on('window_preload_error', (metadata) => {
783
+ parentWindowManager.emit('window_preload_error', metadata);
784
+ });
785
+ windowManager.on('window_play', (metadata) => {
786
+ parentWindowManager.emit('window_play', metadata);
787
+ });
788
+ windowManager.on('window_end_success', (metadata) => {
789
+ parentWindowManager.emit('window_end_success', metadata);
790
+ });
791
+ windowManager.on('window_end_error', (metadata) => {
792
+ parentWindowManager.emit('window_end_error', metadata);
793
+ });
794
+ windowManager.on('window_analytics_event', (metadata) => {
795
+ parentWindowManager.emit('window_analytics_event', metadata);
796
+ });
797
+ windowManager.on('window_charge_request', (metadata) => {
798
+ parentWindowManager.emit('window_charge_request', metadata);
799
+ });
800
+ }
801
+
802
+ this.windowManagers[windowName] = windowManager;
803
+
804
+ windowManager.setRenderer(renderer);
805
+ await windowManager.load();
806
+
807
+ // The child window manager should be focused instead of the main window when focus() is called
808
+ // on the main window. Setting focus currently only happens on window create and can't be updated.
809
+ // This really only exists to support using VR with the remote in MZ. Only one child window can have
810
+ // focus, the last window to send an open window message wins. Handling focus needs more thought now
811
+ // that we have Secondscreen. We need to be extra careful about forcing focus in a touch environment.
812
+ if (isFocused) {
813
+ this.focusedWindowManager = windowManager;
814
+ }
815
+
816
+ // If a child window is opened and the presentation is already playing (Kiosk),
817
+ // play the window after opening it. Otherwise we'll will wait until the presentation
818
+ // is ready and has received play before playing it.
819
+ if (this.isPlaying()) {
820
+ windowManager.play();
821
+ } else {
822
+ this.checkIsReadyCallback();
823
+ }
824
+ }
825
+ }
826
+
827
+ private closeChildWindow(windowName: string) {
828
+ const windowManager = this.windowManagers[windowName];
829
+ if (windowManager) {
830
+ windowManager.destroy();
831
+ delete this.windowManagers[windowName];
832
+ } else {
833
+ this.logWarning(
834
+ `Failed to close child window manager for '${windowName}'.`
835
+ );
836
+ }
837
+ }
838
+
839
+ private enableInteruptLock(windowName?: string) {
840
+ if (windowName) {
841
+ this.isChildWindowLocked[windowName] = true;
842
+ } else {
843
+ this.isWindowLocked = true;
844
+ }
845
+
846
+ // If the presentation is ready and locked, trigger the enable interrupt callback.
847
+ if (this.isReady() && this.isLocked() && this.enableInterruptLockCallback) {
848
+ this.enableInterruptLockCallback();
849
+ }
850
+ }
851
+
852
+ private disableInteruptLock(windowName?: string) {
853
+ if (windowName) {
854
+ this.isChildWindowLocked[windowName] = false;
855
+ } else {
856
+ this.isWindowLocked = false;
857
+ }
858
+
859
+ // If the presentation is ready and unlocked, trigger the disable interrupt callback.
860
+ if (
861
+ this.isReady() &&
862
+ !this.isLocked() &&
863
+ this.disableInterruptLockCallback
864
+ ) {
865
+ this.disableInterruptLockCallback();
866
+ }
867
+ }
868
+
869
+ private getEmbeddedAppUrl() {
870
+ const { windowId } = this.options;
871
+
872
+ const metadata: EmbeddedAppMetadata = {
873
+ renderMode: this.options.isPreview ? 'preview' : 'live',
874
+ };
875
+
876
+ const url = createEmbeddedApp(
877
+ this.presentation,
878
+ this.options.applicationVersion,
879
+ metadata
880
+ );
881
+
882
+ return this.appendWindowIdToHash(url, windowId);
883
+ }
884
+
885
+ private handleEmbeddedMessage = async (type: string, payload?: any) => {
886
+ const { windowId } = this.options;
887
+
888
+ switch (type) {
889
+ case 'ready': {
890
+ this.logInfo(`Received 'ready' from window '${windowId}'`);
891
+ this.handleReady();
892
+ break;
893
+ }
894
+
895
+ case 'complete': {
896
+ if (payload === undefined) {
897
+ this.logInfo(`Received 'complete' from window '${windowId}'`);
898
+ this.handleComplete();
899
+ } else {
900
+ this.logInfo(
901
+ `Received 'complete' with error from window '${windowId}, error=${payload}'`
902
+ );
903
+ this.handleError(payload);
904
+ }
905
+ break;
906
+ }
907
+
908
+ case 'runtime-subscribe': {
909
+ if (typeof payload === 'string') {
910
+ this.logInfo(
911
+ `Received 'runtime-subscribe' from window '${windowId}'`
912
+ );
913
+ // The payload is the event name (ex. 'play').
914
+ this.runtimeSubscriptions.add(payload);
915
+
916
+ // Immediately send play if the app if already playing.
917
+ if (this.isPlaying()) {
918
+ this.sendMessageString('play');
919
+ }
920
+ } else {
921
+ this.logWarning(
922
+ `Invalid payload for runtime-subscribe message: ${payload}`
923
+ );
924
+ }
925
+ break;
926
+ }
927
+
928
+ case 'runtime-unsubscribe': {
929
+ if (typeof payload === 'string') {
930
+ this.logInfo(
931
+ `Received 'runtime-unsubscribe' from window '${windowId}'`
932
+ );
933
+ // The payload is the event name (ex. 'play').
934
+ this.runtimeSubscriptions.delete(payload);
935
+ } else {
936
+ this.logWarning(
937
+ `Invalid payload for runtime-subscribe message: ${payload}`
938
+ );
939
+ }
940
+ break;
941
+ }
942
+
943
+ case 'worker-subscribe': {
944
+ if (typeof payload === 'string') {
945
+ this.logInfo(`Received 'worker-subscribe' from window '${windowId}'`);
946
+
947
+ // The payload is the worker name.
948
+ this.workerSubscriptions.add(payload);
949
+
950
+ // Send the current state to the worker if it exists as soon
951
+ // as the app subscribes.
952
+ const workerState = this.workerState[payload];
953
+ if (workerState !== undefined) {
954
+ this.sendMessageString('worker-state', {
955
+ name: payload,
956
+ state: workerState,
957
+ });
958
+ }
959
+ } else {
960
+ this.logWarning(
961
+ `Invalid payload for worker-subscribe message: ${payload}`
962
+ );
963
+ }
964
+ break;
965
+ }
966
+
967
+ case 'worker-unsubscribe': {
968
+ if (typeof payload === 'string') {
969
+ this.logInfo(
970
+ `Received 'worker-unsubscribe' from window '${windowId}'`
971
+ );
972
+
973
+ // The payload is the worker name.
974
+ this.workerSubscriptions.delete(payload);
975
+ } else {
976
+ this.logWarning(
977
+ `Invalid payload for worker-unsubscribe message: ${payload}`
978
+ );
979
+ }
980
+ break;
981
+ }
982
+
983
+ case 'analytics-event': {
984
+ if (isValidAnalyticsEvent(payload)) {
985
+ if (this.analyticsEventCallback) {
986
+ this.analyticsEventCallback(payload);
987
+ }
988
+ } else {
989
+ this.logWarning(
990
+ `Invalid payload for analytics-event message: ${payload}`
991
+ );
992
+ }
993
+ break;
994
+ }
995
+
996
+ case 'create-object-url-result': {
997
+ if (isValidCreateObjectURLResultPayload(payload)) {
998
+ // Resolve promise with the local blob URL.
999
+ if (this.createObjectURLResolvers[payload.requestId]) {
1000
+ this.createObjectURLResolvers[payload.requestId](payload.url);
1001
+ delete this.createObjectURLResolvers[payload.requestId];
1002
+ }
1003
+ // Clear the timeout for the request.
1004
+ if (this.createObjectURLTimeouts[payload.requestId]) {
1005
+ clearTimeout(this.createObjectURLTimeouts[payload.requestId]);
1006
+ delete this.createObjectURLTimeouts[payload.requestId];
1007
+ }
1008
+ } else {
1009
+ this.logWarning(
1010
+ `Invalid payload for create-object-url-result message: ${payload}`
1011
+ );
1012
+ }
1013
+ break;
1014
+ }
1015
+
1016
+ case 'preview-subscribe': {
1017
+ if (typeof payload === 'string') {
1018
+ this.logInfo(
1019
+ `Received 'preview-subscribe' from window '${windowId}'`
1020
+ );
1021
+ // The payload is the event name (ex. 'inputs').
1022
+ this.previewSubscriptions.add(payload);
1023
+ } else {
1024
+ this.logWarning(
1025
+ `Invalid payload for preview-subscribe message: ${payload}`
1026
+ );
1027
+ }
1028
+ break;
1029
+ }
1030
+
1031
+ case 'preview-unsubscribe': {
1032
+ if (typeof payload === 'string') {
1033
+ this.logInfo(
1034
+ `Received 'preview-unsubscribe' from window '${windowId}'`
1035
+ );
1036
+ // The payload is the event name (ex. 'inputs').
1037
+ this.previewSubscriptions.delete(payload);
1038
+ } else {
1039
+ this.logWarning(
1040
+ `Invalid payload for preview-subscribe message: ${payload}`
1041
+ );
1042
+ }
1043
+ break;
1044
+ }
1045
+
1046
+ case 'payments-charge-request': {
1047
+ if (isValidChargeRequest(payload)) {
1048
+ if (this.chargeRequestCallback) {
1049
+ this.chargeRequestCallback(payload);
1050
+ }
1051
+ } else {
1052
+ this.logWarning(
1053
+ `Invalid payload for payments-charge-request message: ${payload}`
1054
+ );
1055
+ }
1056
+ break;
1057
+ }
1058
+
1059
+ case 'payments-subscribe': {
1060
+ if (typeof payload === 'string') {
1061
+ this.logInfo(`Received 'worker-subscribe' from window '${windowId}'`);
1062
+ // The payload is the payment subscription type (ex. 'charge', 'terminal')
1063
+ this.paymentSubscriptions.add(payload);
1064
+
1065
+ // Immediately send terminal when subscribing.
1066
+ if (payload === 'terminal' && this.options.getTerminal) {
1067
+ this.sendMessageObject('payments-terminal-event', {
1068
+ terminal: this.options.getTerminal(),
1069
+ });
1070
+ }
1071
+ } else {
1072
+ this.logWarning(
1073
+ `Invalid payload for payments-subscribe message: ${payload}`
1074
+ );
1075
+ }
1076
+ break;
1077
+ }
1078
+
1079
+ case 'payments-unsubscribe': {
1080
+ if (typeof payload === 'string') {
1081
+ this.logInfo(
1082
+ `Received 'payments-unsubscribe' from window '${windowId}'`
1083
+ );
1084
+ this.paymentSubscriptions.delete(payload);
1085
+ } else {
1086
+ this.logWarning(
1087
+ `Invalid payload for payments-unsubscribe message: ${payload}`
1088
+ );
1089
+ }
1090
+ break;
1091
+ }
1092
+
1093
+ default: {
1094
+ this.logWarning(`Received unknown embedded message type: ${type}`);
1095
+ }
1096
+ }
1097
+ };
1098
+
1099
+ private handleLegacyMessage = async (type: string, payload?: any) => {
1100
+ const { windowId, onPresentationProperties } = this.options;
1101
+
1102
+ if (type === 'loaded') {
1103
+ this.logInfo(`Received 'loaded' from window '${windowId}'`);
1104
+ this.sendRenderMessage();
1105
+ }
1106
+
1107
+ if (type === 'presentation_ready') {
1108
+ this.logInfo(`Received 'presentation_ready' from window '${windowId}'`);
1109
+ this.handleReady();
1110
+ }
1111
+
1112
+ if (type === 'presentation_complete') {
1113
+ this.logInfo(
1114
+ `Received 'presentation_complete' from window '${windowId}'`
1115
+ );
1116
+ this.handleComplete();
1117
+ }
1118
+
1119
+ if (type === 'enable_interrupt_lock') {
1120
+ this.logInfo(
1121
+ `Received 'enable_interrupt_lock' from window '${windowId}'`
1122
+ );
1123
+ this.enableInteruptLock();
1124
+ }
1125
+
1126
+ if (type === 'disable_interrupt_lock') {
1127
+ this.logInfo(
1128
+ `Received 'disable_interrupt_lock' from window '${windowId}'`
1129
+ );
1130
+ this.disableInteruptLock();
1131
+ }
1132
+
1133
+ if (type === 'open_child_window') {
1134
+ this.logInfo(
1135
+ `Received 'open_child_window' from window '${windowId}', windowId=${payload.windowId} playlistId=${payload.playlistId} isMuted=${payload.isMuted} x=${payload.x} y=${payload.y} width=${payload.width} height=${payload.height}`
1136
+ );
1137
+ if (this.isValidOpenChildWindowPayload(payload)) {
1138
+ this.openChildWindow(
1139
+ payload.windowId,
1140
+ payload.playlistId,
1141
+ {
1142
+ x: Math.round(payload.x),
1143
+ y: Math.round(payload.y),
1144
+ width: Math.round(payload.width),
1145
+ height: Math.round(payload.height),
1146
+ },
1147
+ payload.isMuted,
1148
+ payload.isFocused,
1149
+ payload.theme
1150
+ );
1151
+ } else {
1152
+ this.logWarning(
1153
+ `Invalid payload received for 'open_child_window', ignoring`
1154
+ );
1155
+ }
1156
+ }
1157
+
1158
+ if (type === 'close_child_window') {
1159
+ // TODO: Validate close_child_window message payload.
1160
+ this.logInfo(
1161
+ `Received 'close_child_window', windowId=${payload.windowId}`
1162
+ );
1163
+ this.closeChildWindow(payload.windowId);
1164
+ }
1165
+
1166
+ if (type === 'log_error') {
1167
+ this.handleError(
1168
+ `Received error from window '${windowId}', error=${JSON.stringify(
1169
+ payload
1170
+ )}`
1171
+ );
1172
+ }
1173
+
1174
+ if (type === 'log_info') {
1175
+ this.logInfo(
1176
+ `Received info log from window '${windowId}', message=${JSON.stringify(
1177
+ payload
1178
+ )}`
1179
+ );
1180
+ }
1181
+
1182
+ if (type === 'log_debug') {
1183
+ this.logDebug(
1184
+ `Received info debug from window '${windowId}', message=${JSON.stringify(
1185
+ payload
1186
+ )}`
1187
+ );
1188
+ }
1189
+
1190
+ if (type === 'presentation_properties') {
1191
+ this.logInfo(
1192
+ `Received 'presentation_properties' from window '${windowId}'`
1193
+ );
1194
+ if (onPresentationProperties) {
1195
+ onPresentationProperties(payload.properties, payload.strings);
1196
+ }
1197
+ }
1198
+ };
1199
+
1200
+ private handleReady() {
1201
+ this.didReceiveReady = true;
1202
+ this.checkIsReadyCallback();
1203
+ }
1204
+
1205
+ private handleComplete() {
1206
+ this.didReceiveComplete = true;
1207
+ if (this.completeCallback) {
1208
+ this.completeCallback();
1209
+ }
1210
+ }
1211
+
1212
+ private handleError = (error: any) => {
1213
+ this.error = error;
1214
+
1215
+ const message = error instanceof Error ? error.message : error;
1216
+ this.logError(message);
1217
+
1218
+ if (this.errorCallback) {
1219
+ this.errorCallback(error);
1220
+ }
1221
+ };
1222
+
1223
+ private logDebug(message: any) {
1224
+ this.options.logger.debug(message, this.getLogContext());
1225
+ }
1226
+
1227
+ private logInfo(message: any) {
1228
+ this.options.logger.info(message, this.getLogContext());
1229
+ }
1230
+
1231
+ private logWarning(message: any) {
1232
+ this.options.logger.warn(message, this.getLogContext());
1233
+ }
1234
+
1235
+ private logError(message: any) {
1236
+ this.options.logger.error(message, this.getLogContext());
1237
+ }
1238
+
1239
+ private getLogContext() {
1240
+ const {
1241
+ windowId,
1242
+ windowName,
1243
+ parentWindowId,
1244
+ presentation,
1245
+ applicationVersion,
1246
+ publishedAt,
1247
+ playlistId,
1248
+ playlistName,
1249
+ rule,
1250
+ } = this.options;
1251
+
1252
+ return {
1253
+ windowId,
1254
+ windowName,
1255
+ parentWindowId,
1256
+ presentationId: presentation.id,
1257
+ presentationName: presentation.name,
1258
+ applicationId: applicationVersion.applicationId,
1259
+ applicationVersionName: applicationVersion.name,
1260
+ applicationVersionId: applicationVersion.id,
1261
+ publishedAt,
1262
+ playlistId,
1263
+ playlistName,
1264
+ rule,
1265
+ };
1266
+ }
1267
+
1268
+ public metadata() {
1269
+ return this.getLogContext();
1270
+ }
1271
+
1272
+ public runWorkerSubscriptions(state: WorkerState) {
1273
+ // For each worker subscription, compare the previous state to the new state.
1274
+ // If the state has changed, send the new state to the subscription.
1275
+ for (const workerName of Array.from(this.workerSubscriptions)) {
1276
+ const workerState = state[workerName];
1277
+ const previousWorkerState = this.workerState[workerName];
1278
+
1279
+ if (
1280
+ workerState !== undefined &&
1281
+ !deepEqual(workerState, previousWorkerState)
1282
+ ) {
1283
+ this.sendMessageString('worker-state', {
1284
+ name: workerName,
1285
+ state: workerState,
1286
+ });
1287
+ }
1288
+ }
1289
+
1290
+ // Persist the new state so we can compare if the previous values have changed..
1291
+ this.workerState = state;
1292
+ }
1293
+
1294
+ private createRuleEngine(playlistId: string) {
1295
+ const { globalRule } = this.options;
1296
+ return new RE.RuleEngine(
1297
+ playlistId,
1298
+ this.ruleEngineState,
1299
+ globalRule
1300
+ ? {
1301
+ // Append to the global rule with an AND to filter out other presentations
1302
+ // that have playlists. Prevents MZ in MZ, Kiosk in Kiosk, MZ in Kiosk, etc...
1303
+ type: 'and',
1304
+ group: [
1305
+ globalRule,
1306
+ {
1307
+ type: 'equals',
1308
+ left: { var: ['target', 'hasPlaylists'] },
1309
+ right: { const: false },
1310
+ },
1311
+ ],
1312
+ }
1313
+ : {
1314
+ // A global rule was not provided so we don't need to combine the rule with an AND.
1315
+ type: 'equals',
1316
+ left: { var: ['target', 'hasPlaylists'] },
1317
+ right: { const: false },
1318
+ }
1319
+ );
1320
+ }
1321
+
1322
+ private isValidOpenChildWindowPayload(
1323
+ payload?: any
1324
+ ): payload is OpenChildWindowOptions {
1325
+ if (!payload) return false;
1326
+ if (typeof payload !== 'object') return false;
1327
+ if (!payload.windowId) return false;
1328
+ if (!payload.playlistId) return false;
1329
+ if (!isValidNumber(payload.x)) return false;
1330
+ if (!isValidNumber(payload.y)) return false;
1331
+ if (!isValidNumber(payload.width)) return false;
1332
+ if (!isValidNumber(payload.height)) return false;
1333
+
1334
+ return true;
1335
+ }
1336
+
1337
+ private shouldMute() {
1338
+ if (this.forceMute) {
1339
+ return true;
1340
+ }
1341
+ // Check app vars for muted properties. This is gross because it relies on
1342
+ // presentation properties having specific names. A better solution is needed
1343
+ // long term.
1344
+ const appVars = this.presentation.applicationVariables;
1345
+ const hasMuteProp = appVars.mute === true || appVars.muted === true;
1346
+ if (hasMuteProp) {
1347
+ return true;
1348
+ }
1349
+ const isMutedMultizone =
1350
+ appVars.muteZone1 === true && appVars.muteZone2 === true;
1351
+ return isMutedMultizone;
1352
+ }
1353
+
1354
+ private createObjectURLForEmbeddedApp(blob: Blob): Promise<string> {
1355
+ return new Promise((resolve, reject) => {
1356
+ // Handle multiple concurrent createObjectURL calls by creating a request id for each
1357
+ // call. We send the request id along with the message to the running app and the app
1358
+ // sends the request id back with the result so we can resolve the correct promise.
1359
+ const requestId = uuidv4();
1360
+ this.createObjectURLResolvers[requestId] = resolve;
1361
+
1362
+ // Start a timer to reject the promise if the app doesn't respond with the create object
1363
+ // url result within 1 second. This should be more or less instant.
1364
+ this.createObjectURLTimeouts[requestId] = setTimeout(() => {
1365
+ const error = new Error(
1366
+ `Timed out waiting for the create-object-url-result message for request id ${requestId}`
1367
+ );
1368
+ reject(error);
1369
+ delete this.createObjectURLResolvers[requestId];
1370
+ delete this.createObjectURLTimeouts[requestId];
1371
+ }, 1000);
1372
+
1373
+ // Create a FileReader object to read the file data as an ArrayBuffer.
1374
+ const reader = new FileReader();
1375
+
1376
+ reader.addEventListener(
1377
+ 'load',
1378
+ () => {
1379
+ const buffer = reader.result;
1380
+
1381
+ // Check if buffer is an ArrayBuffer
1382
+ if (buffer instanceof ArrayBuffer) {
1383
+ // Send the ArrayBuffer to the running app to create the local file blob URL.
1384
+ this.sendMessageObject('create-object-url', { requestId, buffer });
1385
+ }
1386
+ },
1387
+ false
1388
+ );
1389
+
1390
+ reader.readAsArrayBuffer(blob);
1391
+
1392
+ return '';
1393
+ });
1394
+ }
1395
+ }
1396
+
1397
+ const isValidCreateObjectURLResultPayload = (
1398
+ payload: any
1399
+ ): payload is { requestId: string; url: string } => {
1400
+ return (
1401
+ // Ensure payload is an object.
1402
+ typeof payload === 'object' &&
1403
+ payload !== null &&
1404
+ // Ensure requestId is a string.
1405
+ 'requestId' in payload &&
1406
+ typeof payload['requestId'] === 'string' &&
1407
+ // Ensure payload.file is an object.
1408
+ 'url' in payload &&
1409
+ typeof payload['url'] === 'string'
1410
+ );
1411
+ };
1412
+
1413
+ const transformInputValues = (inputs: Record<string, any>) => {
1414
+ const transformed: Record<string, any> = {};
1415
+
1416
+ // Recursively traverse inputs, transforming any file input values to remove internal
1417
+ // properties like file_upload_id.
1418
+ for (const [key, value] of Object.entries(inputs)) {
1419
+ if (typeof value === 'object' && value !== null && 'url' in value) {
1420
+ // Value is a file input, strip out the file_upload_id property.
1421
+ transformed[key] = { url: value.url };
1422
+ } else if (Array.isArray(value)) {
1423
+ // Value is an array input, traverse each item in the array for file input values.
1424
+ transformed[key] = [];
1425
+ for (const item of value) {
1426
+ transformed[key].push(transformInputValues(item));
1427
+ }
1428
+ } else {
1429
+ transformed[key] = value;
1430
+ }
1431
+ }
1432
+
1433
+ return transformed;
1434
+ };