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