@raydiant/playlist-window-manager 0.24.1 → 0.25.1-canary.0

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.
@@ -330,4 +330,99 @@ describe('payments', () => {
330
330
  });
331
331
  });
332
332
 
333
+ describe('interrupt locks', () => {
334
+ test('acquire, renew, release lock', async () => {
335
+ const testRenderer = createRenderer();
336
+
337
+ const onEnableInterruptLock = jest.fn();
338
+ const onDisableInterruptLock = jest.fn();
339
+
340
+ const window = createPresentationWindow({
341
+ windowId: 'test-interrupt-lock-window-id',
342
+ presentation: createPresentation(),
343
+ applicationVersion: createApplicationVersion({
344
+ embeddedUrlFormat: 'http://test.local/:input',
345
+ }),
346
+ renderer: testRenderer,
347
+ });
348
+
349
+ window.onEnableInterruptLock(onEnableInterruptLock);
350
+ window.onDisableInterruptLock(onDisableInterruptLock);
351
+
352
+ await window.load();
353
+
354
+ // Acquire lock.
355
+ testRenderer.runReceiveMessageCallback(
356
+ 'runtime-acquire-interrupt-lock-request',
357
+ { requestId: 'acquire-request' }
358
+ );
359
+
360
+ // Ensure lock response is sent.
361
+ const sendMessageObjectCall = testRenderer.sendMessageObject.mock.calls[0];
362
+ expect(sendMessageObjectCall[0]).toBe('test-interrupt-lock-window-id');
363
+ expect(sendMessageObjectCall[1]).toBe(
364
+ 'runtime-acquire-interrupt-lock-response'
365
+ );
366
+ expect(sendMessageObjectCall[2].requestId).toBe('acquire-request');
367
+ const lock = sendMessageObjectCall[2].lock;
368
+ expect(typeof lock.id).toBe('string');
369
+ expect(lock.id).toBeTruthy();
370
+ expect(typeof lock.expiresAt).toBe('number');
371
+ expect(lock.expiresAt).toBeTruthy();
372
+
373
+ expect(onEnableInterruptLock).toHaveBeenCalled();
374
+
375
+ // Wait 1ms to make sure we have a different expiresAt value.
376
+ await delay(1);
377
+
378
+ // Renew lock.
379
+ testRenderer.runReceiveMessageCallback(
380
+ 'runtime-renew-interrupt-lock-request',
381
+ { requestId: 'renew-request', lockId: lock.id }
382
+ );
383
+
384
+ // Ensure renew lock response is sent.
385
+ const renewLockSendMessageObjectCall =
386
+ testRenderer.sendMessageObject.mock.calls[1];
387
+ expect(renewLockSendMessageObjectCall[0]).toBe(
388
+ 'test-interrupt-lock-window-id'
389
+ );
390
+ expect(renewLockSendMessageObjectCall[1]).toBe(
391
+ 'runtime-renew-interrupt-lock-response'
392
+ );
393
+ expect(renewLockSendMessageObjectCall[2].requestId).toBe('renew-request');
394
+ const renewedLock = renewLockSendMessageObjectCall[2].lock;
395
+ expect(renewedLock.id).toBe(lock.id);
396
+ expect(renewedLock.expiresAt).toBeGreaterThan(lock.expiresAt);
397
+
398
+ // Ensure onEnableInterruptLock is not called again.
399
+ expect(onEnableInterruptLock).toHaveBeenCalledTimes(1);
400
+
401
+ // Release lock.
402
+ testRenderer.runReceiveMessageCallback(
403
+ 'runtime-release-interrupt-lock-request',
404
+ { requestId: 'release-request', lockId: lock.id }
405
+ );
406
+
407
+ // Ensure release lock response is sent.
408
+ const releaseLockSendMessageObjectCall =
409
+ testRenderer.sendMessageObject.mock.calls[2];
410
+ expect(releaseLockSendMessageObjectCall[0]).toBe(
411
+ 'test-interrupt-lock-window-id'
412
+ );
413
+ expect(releaseLockSendMessageObjectCall[1]).toBe(
414
+ 'runtime-release-interrupt-lock-response'
415
+ );
416
+ expect(releaseLockSendMessageObjectCall[2].requestId).toBe(
417
+ 'release-request'
418
+ );
419
+ const releasedLock = releaseLockSendMessageObjectCall[2].lock;
420
+ expect(releasedLock.id).toBe(lock.id);
421
+ expect(releasedLock.expiresAt).toBe(renewedLock.expiresAt);
422
+
423
+ // Ensure onDisableInterruptLock is called.
424
+ expect(onDisableInterruptLock).toHaveBeenCalled();
425
+ });
426
+ });
427
+
333
428
  // TODO: Backfill tests for all legacy and embedded apps.
@@ -24,6 +24,7 @@ import {
24
24
  ChargeRequest,
25
25
  ChargeResponse,
26
26
  Terminal,
27
+ InterruptLock,
27
28
  } from './types';
28
29
 
29
30
  export interface PresentationWindowOptions {
@@ -81,6 +82,29 @@ const isValidChargeRequest = (value: any): value is ChargeRequest =>
81
82
  typeof value.requestId === 'string' &&
82
83
  typeof value.amount === 'number';
83
84
 
85
+ const isValidAcquireLockRequest = (
86
+ value: any
87
+ ): value is { requestId: string } =>
88
+ value && typeof value === 'object' && typeof value.requestId === 'string';
89
+
90
+ const isValidRenewLockRequest = (
91
+ value: any
92
+ ): value is { requestId: string; lockId: string } =>
93
+ value &&
94
+ typeof value === 'object' &&
95
+ typeof value.requestId === 'string' &&
96
+ typeof value.lockId === 'string';
97
+
98
+ const isValidReleaseLockRequest = (
99
+ value: any
100
+ ): value is { requestId: string; lockId: string } =>
101
+ value &&
102
+ typeof value === 'object' &&
103
+ typeof value.requestId === 'string' &&
104
+ typeof value.lockId === 'string';
105
+
106
+ const lockDuration = 1000 * 60; // 1 minute
107
+
84
108
  export default class PresentationWindow {
85
109
  private didReceiveReady: boolean = false;
86
110
  private readyCallback: (() => void) | null = null;
@@ -124,6 +148,9 @@ export default class PresentationWindow {
124
148
  ReturnType<typeof setTimeout>
125
149
  > = {};
126
150
 
151
+ private interruptLocks: Record<string, InterruptLock> = {};
152
+ private previouslyLocked = false;
153
+
127
154
  constructor(private options: PresentationWindowOptions) {
128
155
  this.builderState = options.builderState;
129
156
  this.presentation = options.presentation;
@@ -335,10 +362,39 @@ export default class PresentationWindow {
335
362
  }
336
363
 
337
364
  public isLocked() {
338
- return (
339
- this.isWindowLocked ||
340
- Object.values(this.isChildWindowLocked).some(Boolean)
341
- );
365
+ // Legacy check for a lock on the current window or child windows. These are set by enableInterruptLock
366
+ // and disableInterruptLock, which are only used by RaydiantKit apps (ie. Virtual Rooms).
367
+ if (this.isWindowLocked) {
368
+ this.logDebug(
369
+ `Window '${this.options.windowId}' is locked due to enableInterruptLock`
370
+ );
371
+ return true;
372
+ }
373
+ if (Object.values(this.isChildWindowLocked).some(Boolean)) {
374
+ this.logDebug(
375
+ `Window '${this.options.windowId}' is locked due to enableInterruptLock on child windows`
376
+ );
377
+ return true;
378
+ }
379
+
380
+ // Check interrupt locks on current window or child windows, used by embedded apps (apps created through
381
+ // the Developer Portal).
382
+ const now = +new Date();
383
+ const isExpired = (lock: InterruptLock) => lock.expiresAt < now;
384
+ if (!Object.values(this.interruptLocks).every(isExpired)) {
385
+ this.logDebug(
386
+ `Window '${this.options.windowId}' is locked due to interrupt lock`
387
+ );
388
+ return true;
389
+ }
390
+ if (Object.values(this.windowManagers).some((wm) => wm.isLocked())) {
391
+ this.logDebug(
392
+ `Window '${this.options.windowId}' is locked due to interrupt lock on child windows`
393
+ );
394
+ return true;
395
+ }
396
+
397
+ return false;
342
398
  }
343
399
 
344
400
  public setPresentation(
@@ -783,8 +839,22 @@ export default class PresentationWindow {
783
839
  themeOverride,
784
840
  forceMute,
785
841
  isPreview,
786
- onEnableInterruptLock: () => this.enableInteruptLock(windowName),
787
- onDisableInterruptLock: () => this.disableInteruptLock(windowName),
842
+ onEnableInterruptLock: () => {
843
+ // enableInterruptLock is only used by legacy RaydiantKit apps (ie. Virtual Rooms), while
844
+ // runInterruptCallback is used by Developer Portal apps.
845
+ if (this.isEmbeddedApp()) {
846
+ this.runInterruptCallback();
847
+ } else {
848
+ this.enableInteruptLock(windowName);
849
+ }
850
+ },
851
+ onDisableInterruptLock: () => {
852
+ if (this.isEmbeddedApp()) {
853
+ this.runInterruptCallback();
854
+ } else {
855
+ this.disableInteruptLock(windowName);
856
+ }
857
+ },
788
858
  getTerminal: this.options.getTerminal,
789
859
  });
790
860
 
@@ -847,6 +917,8 @@ export default class PresentationWindow {
847
917
  }
848
918
  }
849
919
 
920
+ // Legacy enable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
921
+ // replaced by createInterruptLock for embedded apps.
850
922
  private enableInteruptLock(windowName?: string) {
851
923
  if (windowName) {
852
924
  this.isChildWindowLocked[windowName] = true;
@@ -854,12 +926,13 @@ export default class PresentationWindow {
854
926
  this.isWindowLocked = true;
855
927
  }
856
928
 
857
- // If the presentation is ready and locked, trigger the enable interrupt callback.
858
929
  if (this.isReady() && this.isLocked() && this.enableInterruptLockCallback) {
859
930
  this.enableInterruptLockCallback();
860
931
  }
861
932
  }
862
933
 
934
+ // Legacy disable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
935
+ // replaced by releaseInterruptLock for embedded apps.
863
936
  private disableInteruptLock(windowName?: string) {
864
937
  if (windowName) {
865
938
  this.isChildWindowLocked[windowName] = false;
@@ -867,7 +940,6 @@ export default class PresentationWindow {
867
940
  this.isWindowLocked = false;
868
941
  }
869
942
 
870
- // If the presentation is ready and unlocked, trigger the disable interrupt callback.
871
943
  if (
872
944
  this.isReady() &&
873
945
  !this.isLocked() &&
@@ -1105,6 +1177,61 @@ export default class PresentationWindow {
1105
1177
  break;
1106
1178
  }
1107
1179
 
1180
+ case 'runtime-acquire-interrupt-lock-request': {
1181
+ if (isValidAcquireLockRequest(payload)) {
1182
+ this.logInfo(`Received 'runtime-acquire-interrupt-lock-request'`);
1183
+
1184
+ const lock = this.createInterruptLock();
1185
+
1186
+ this.sendMessageObject('runtime-acquire-interrupt-lock-response', {
1187
+ requestId: payload.requestId,
1188
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1189
+ });
1190
+ } else {
1191
+ this.logWarning(
1192
+ `Invalid payload for runtime-acquire-interrupt-lock-request message: ${payload}`
1193
+ );
1194
+ }
1195
+
1196
+ break;
1197
+ }
1198
+
1199
+ case 'runtime-renew-interrupt-lock-request': {
1200
+ if (isValidRenewLockRequest(payload)) {
1201
+ this.logInfo(`Received 'runtime-renew-interrupt-lock-request'`);
1202
+
1203
+ const lock = this.renewInterruptLock(payload.lockId);
1204
+
1205
+ this.sendMessageObject('runtime-renew-interrupt-lock-response', {
1206
+ requestId: payload.requestId,
1207
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1208
+ });
1209
+ } else {
1210
+ this.logWarning(
1211
+ `Invalid payload for runtime-renew-interrupt-lock-request message: ${payload}`
1212
+ );
1213
+ }
1214
+ break;
1215
+ }
1216
+
1217
+ case 'runtime-release-interrupt-lock-request': {
1218
+ if (isValidReleaseLockRequest(payload)) {
1219
+ this.logInfo(`Received 'runtime-release-interrupt-lock-request'`);
1220
+
1221
+ const lock = this.releaseInterruptLock(payload.lockId);
1222
+
1223
+ this.sendMessageObject('runtime-release-interrupt-lock-response', {
1224
+ requestId: payload.requestId,
1225
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1226
+ });
1227
+ } else {
1228
+ this.logWarning(
1229
+ `Invalid payload for runtime-release-interrupt-lock-request message: ${payload}`
1230
+ );
1231
+ }
1232
+ break;
1233
+ }
1234
+
1108
1235
  default: {
1109
1236
  this.logWarning(`Received unknown embedded message type: ${type}`);
1110
1237
  }
@@ -1407,6 +1534,95 @@ export default class PresentationWindow {
1407
1534
  return '';
1408
1535
  });
1409
1536
  }
1537
+
1538
+ private createInterruptLock() {
1539
+ const lockId = uuidv4();
1540
+ const lock = {
1541
+ id: lockId,
1542
+ expiresAt: +new Date() + lockDuration,
1543
+ timeout: this.createLockTimeout(lockId),
1544
+ };
1545
+
1546
+ this.interruptLocks[lock.id] = lock;
1547
+
1548
+ this.runInterruptCallback();
1549
+
1550
+ return lock;
1551
+ }
1552
+
1553
+ private renewInterruptLock(id: string) {
1554
+ let lock = this.interruptLocks[id];
1555
+ if (lock) {
1556
+ // Reset expiration time.
1557
+ lock.expiresAt = +new Date() + lockDuration;
1558
+
1559
+ // Reset timeout.
1560
+ if (lock.timeout) {
1561
+ clearTimeout(lock.timeout);
1562
+ }
1563
+ lock.timeout = this.createLockTimeout(id);
1564
+ } else {
1565
+ // If the lock doesn't exist it was already released, create a new lock with the same id.
1566
+ lock = {
1567
+ id,
1568
+ expiresAt: +new Date() + lockDuration,
1569
+ timeout: this.createLockTimeout(id),
1570
+ };
1571
+
1572
+ this.interruptLocks[lock.id] = lock;
1573
+ }
1574
+
1575
+ this.runInterruptCallback();
1576
+
1577
+ return lock;
1578
+ }
1579
+
1580
+ private releaseInterruptLock(id: string) {
1581
+ let lock = this.interruptLocks[id];
1582
+ if (lock) {
1583
+ if (lock.timeout) {
1584
+ clearTimeout(lock.timeout);
1585
+ }
1586
+ delete this.interruptLocks[id];
1587
+ } else {
1588
+ // If the lock doesn't exist it was already released, return a fake lock object to
1589
+ // send a response back to the running app.
1590
+ lock = { id, expiresAt: 0, timeout: null };
1591
+ }
1592
+
1593
+ this.runInterruptCallback();
1594
+
1595
+ return lock;
1596
+ }
1597
+
1598
+ private createLockTimeout(id: string) {
1599
+ return setTimeout(() => {
1600
+ this.logInfo(`Interrupt lock expired, releasing: ${id}`);
1601
+ this.releaseInterruptLock(id);
1602
+ }, lockDuration);
1603
+ }
1604
+
1605
+ private runInterruptCallback() {
1606
+ const currentlyLocked = this.isLocked();
1607
+
1608
+ // If locked and the lock state has changed, run the interrupt lock callback.
1609
+ // Or if not locked and the lock state has changed, run the disable interrupt lock callback.
1610
+ if (
1611
+ currentlyLocked &&
1612
+ this.previouslyLocked !== currentlyLocked &&
1613
+ this.enableInterruptLockCallback
1614
+ ) {
1615
+ this.enableInterruptLockCallback();
1616
+ } else if (
1617
+ !currentlyLocked &&
1618
+ this.previouslyLocked !== currentlyLocked &&
1619
+ this.disableInterruptLockCallback
1620
+ ) {
1621
+ this.disableInterruptLockCallback();
1622
+ }
1623
+
1624
+ this.previouslyLocked = currentlyLocked;
1625
+ }
1410
1626
  }
1411
1627
 
1412
1628
  const isValidCreateObjectURLResultPayload = (
package/src/types.ts CHANGED
@@ -203,3 +203,9 @@ export interface ChargeResponse {
203
203
  export interface Terminal {
204
204
  status: string;
205
205
  }
206
+
207
+ export interface InterruptLock {
208
+ id: string;
209
+ expiresAt: number;
210
+ timeout: ReturnType<typeof setTimeout> | null;
211
+ }
@@ -10,4 +10,4 @@ mkdir build
10
10
  yarn build
11
11
 
12
12
  # Publish
13
- yarn publish --access public
13
+ yarn publish --non-interactive --access public
@@ -1 +0,0 @@
1
- export {};