@raydiant/playlist-window-manager 0.0.0-test36 → 0.0.1-test1

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.
@@ -86,6 +86,11 @@ var BrowserWindowRenderer = /** @class */ (function () {
86
86
  // Create a hidden window to preload BrowserViews.
87
87
  this.offscreenWindow = new electron_1.BrowserWindow({
88
88
  show: false,
89
+ /**
90
+ * This option fixes window not being able to take fullscreen leaving
91
+ * 1px gap at the right and bottom of the screen.
92
+ */
93
+ resizable: false,
89
94
  frame: this.opts.frame,
90
95
  hasShadow: this.opts.hasShadow,
91
96
  webPreferences: {
@@ -124,6 +129,11 @@ var BrowserWindowRenderer = /** @class */ (function () {
124
129
  })
125
130
  : this.createOrGetPooledBrowserWindow(windowId, {
126
131
  show: false,
132
+ /**
133
+ * This option fixes window not being able to take fullscreen leaving
134
+ * 1px gap at the right and bottom of the screen.
135
+ */
136
+ resizable: false,
127
137
  frame: this.opts.frame,
128
138
  // NOTE: useWindowPool: true does not work with renderChildWindowsAsBrowserViews: false
129
139
  // because the parent can't be changed after creating the BrowserWindow.
@@ -449,8 +459,6 @@ var BrowserWindowRenderer = /** @class */ (function () {
449
459
  this.browserViewPool.add(browserView);
450
460
  }
451
461
  }
452
- this.logger.info("Opening devTools", { windowId: windowId });
453
- browserView.webContents.openDevTools();
454
462
  return browserView;
455
463
  };
456
464
  BrowserWindowRenderer.prototype.destroyOrUnloadPooledBrowserView = function (windowId, browserView) {
@@ -68,6 +68,8 @@ export default class PresentationWindow {
68
68
  private paymentSubscriptions;
69
69
  private createObjectURLResolvers;
70
70
  private createObjectURLTimeouts;
71
+ private interruptLocks;
72
+ private previouslyLocked;
71
73
  constructor(options: PresentationWindowOptions);
72
74
  load(): Promise<void>;
73
75
  show(isFocused?: boolean): void;
@@ -147,4 +149,9 @@ export default class PresentationWindow {
147
149
  private isValidOpenChildWindowPayload;
148
150
  private shouldMute;
149
151
  private createObjectURLForEmbeddedApp;
152
+ private createInterruptLock;
153
+ private renewInterruptLock;
154
+ private releaseInterruptLock;
155
+ private createLockTimeout;
156
+ private runInterruptCallback;
150
157
  }
@@ -81,7 +81,7 @@ var PlaylistWindowManager_1 = __importDefault(require("./PlaylistWindowManager")
81
81
  var WindowTimeoutError_1 = __importDefault(require("./WindowTimeoutError"));
82
82
  var WindowLoadError_1 = __importDefault(require("./WindowLoadError"));
83
83
  // TODO: Automatically inject latest version when building in CI.
84
- var defaultAppRendererUrl = 'https://apps.raydiant.com/3.0.7/index.html';
84
+ var defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.0/index.html';
85
85
  var isValidNumber = function (value) {
86
86
  return typeof value === 'number' && !isNaN(value);
87
87
  };
@@ -98,8 +98,27 @@ var isValidChargeRequest = function (value) {
98
98
  return value &&
99
99
  typeof value === 'object' &&
100
100
  typeof value.requestId === 'string' &&
101
- typeof value.amount === 'number';
101
+ typeof value.subTotal === 'number' &&
102
+ typeof value.tip === 'number' &&
103
+ typeof value.tax === 'number' &&
104
+ typeof value.total === 'number';
102
105
  };
106
+ var isValidAcquireLockRequest = function (value) {
107
+ return value && typeof value === 'object' && typeof value.requestId === 'string';
108
+ };
109
+ var isValidRenewLockRequest = function (value) {
110
+ return value &&
111
+ typeof value === 'object' &&
112
+ typeof value.requestId === 'string' &&
113
+ typeof value.lockId === 'string';
114
+ };
115
+ var isValidReleaseLockRequest = function (value) {
116
+ return value &&
117
+ typeof value === 'object' &&
118
+ typeof value.requestId === 'string' &&
119
+ typeof value.lockId === 'string';
120
+ };
121
+ var lockDuration = 1000 * 60; // 1 minute
103
122
  var PresentationWindow = /** @class */ (function () {
104
123
  function PresentationWindow(options) {
105
124
  var _this = this;
@@ -131,8 +150,10 @@ var PresentationWindow = /** @class */ (function () {
131
150
  this.paymentSubscriptions = new Set();
132
151
  this.createObjectURLResolvers = {};
133
152
  this.createObjectURLTimeouts = {};
153
+ this.interruptLocks = {};
154
+ this.previouslyLocked = false;
134
155
  this.handleEmbeddedMessage = function (type, payload) { return __awaiter(_this, void 0, void 0, function () {
135
- var windowId, workerState;
156
+ var windowId, workerState, lock, lock, lock;
136
157
  return __generator(this, function (_a) {
137
158
  windowId = this.options.windowId;
138
159
  switch (type) {
@@ -296,6 +317,48 @@ var PresentationWindow = /** @class */ (function () {
296
317
  }
297
318
  break;
298
319
  }
320
+ case 'runtime-acquire-interrupt-lock-request': {
321
+ if (isValidAcquireLockRequest(payload)) {
322
+ this.logInfo("Received 'runtime-acquire-interrupt-lock-request'");
323
+ lock = this.createInterruptLock();
324
+ this.sendMessageObject('runtime-acquire-interrupt-lock-response', {
325
+ requestId: payload.requestId,
326
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
327
+ });
328
+ }
329
+ else {
330
+ this.logWarning("Invalid payload for runtime-acquire-interrupt-lock-request message: ".concat(payload));
331
+ }
332
+ break;
333
+ }
334
+ case 'runtime-renew-interrupt-lock-request': {
335
+ if (isValidRenewLockRequest(payload)) {
336
+ this.logInfo("Received 'runtime-renew-interrupt-lock-request'");
337
+ lock = this.renewInterruptLock(payload.lockId);
338
+ this.sendMessageObject('runtime-renew-interrupt-lock-response', {
339
+ requestId: payload.requestId,
340
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
341
+ });
342
+ }
343
+ else {
344
+ this.logWarning("Invalid payload for runtime-renew-interrupt-lock-request message: ".concat(payload));
345
+ }
346
+ break;
347
+ }
348
+ case 'runtime-release-interrupt-lock-request': {
349
+ if (isValidReleaseLockRequest(payload)) {
350
+ this.logInfo("Received 'runtime-release-interrupt-lock-request'");
351
+ lock = this.releaseInterruptLock(payload.lockId);
352
+ this.sendMessageObject('runtime-release-interrupt-lock-response', {
353
+ requestId: payload.requestId,
354
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
355
+ });
356
+ }
357
+ else {
358
+ this.logWarning("Invalid payload for runtime-release-interrupt-lock-request message: ".concat(payload));
359
+ }
360
+ break;
361
+ }
299
362
  default: {
300
363
  this.logWarning("Received unknown embedded message type: ".concat(type));
301
364
  }
@@ -557,8 +620,29 @@ var PresentationWindow = /** @class */ (function () {
557
620
  return !!this.error;
558
621
  };
559
622
  PresentationWindow.prototype.isLocked = function () {
560
- return (this.isWindowLocked ||
561
- Object.values(this.isChildWindowLocked).some(Boolean));
623
+ // Legacy check for a lock on the current window or child windows. These are set by enableInterruptLock
624
+ // and disableInterruptLock, which are only used by RaydiantKit apps (ie. Virtual Rooms).
625
+ if (this.isWindowLocked) {
626
+ this.logDebug("Window '".concat(this.options.windowId, "' is locked due to enableInterruptLock"));
627
+ return true;
628
+ }
629
+ if (Object.values(this.isChildWindowLocked).some(Boolean)) {
630
+ this.logDebug("Window '".concat(this.options.windowId, "' is locked due to enableInterruptLock on child windows"));
631
+ return true;
632
+ }
633
+ // Check interrupt locks on current window or child windows, used by embedded apps (apps created through
634
+ // the Developer Portal).
635
+ var now = +new Date();
636
+ var isExpired = function (lock) { return lock.expiresAt < now; };
637
+ if (!Object.values(this.interruptLocks).every(isExpired)) {
638
+ this.logDebug("Window '".concat(this.options.windowId, "' is locked due to interrupt lock"));
639
+ return true;
640
+ }
641
+ if (Object.values(this.windowManagers).some(function (wm) { return wm.isLocked(); })) {
642
+ this.logDebug("Window '".concat(this.options.windowId, "' is locked due to interrupt lock on child windows"));
643
+ return true;
644
+ }
645
+ return false;
562
646
  };
563
647
  PresentationWindow.prototype.setPresentation = function (presentation, playlistWindowManagerState, ruleEngineState) {
564
648
  this.presentation = presentation;
@@ -952,8 +1036,24 @@ var PresentationWindow = /** @class */ (function () {
952
1036
  themeOverride: themeOverride,
953
1037
  forceMute: forceMute,
954
1038
  isPreview: isPreview,
955
- onEnableInterruptLock: function () { return _this.enableInteruptLock(windowName); },
956
- onDisableInterruptLock: function () { return _this.disableInteruptLock(windowName); },
1039
+ onEnableInterruptLock: function () {
1040
+ // enableInterruptLock is only used by legacy RaydiantKit apps (ie. Virtual Rooms), while
1041
+ // runInterruptCallback is used by Developer Portal apps.
1042
+ if (_this.isEmbeddedApp()) {
1043
+ _this.runInterruptCallback();
1044
+ }
1045
+ else {
1046
+ _this.enableInteruptLock(windowName);
1047
+ }
1048
+ },
1049
+ onDisableInterruptLock: function () {
1050
+ if (_this.isEmbeddedApp()) {
1051
+ _this.runInterruptCallback();
1052
+ }
1053
+ else {
1054
+ _this.disableInteruptLock(windowName);
1055
+ }
1056
+ },
957
1057
  getTerminal: this.options.getTerminal,
958
1058
  });
959
1059
  // Hook up child window manager events to bubble up to the parent.
@@ -1015,6 +1115,8 @@ var PresentationWindow = /** @class */ (function () {
1015
1115
  this.logWarning("Failed to close child window manager for '".concat(windowName, "'."));
1016
1116
  }
1017
1117
  };
1118
+ // Legacy enable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
1119
+ // replaced by createInterruptLock for embedded apps.
1018
1120
  PresentationWindow.prototype.enableInteruptLock = function (windowName) {
1019
1121
  if (windowName) {
1020
1122
  this.isChildWindowLocked[windowName] = true;
@@ -1022,11 +1124,12 @@ var PresentationWindow = /** @class */ (function () {
1022
1124
  else {
1023
1125
  this.isWindowLocked = true;
1024
1126
  }
1025
- // If the presentation is ready and locked, trigger the enable interrupt callback.
1026
1127
  if (this.isReady() && this.isLocked() && this.enableInterruptLockCallback) {
1027
1128
  this.enableInterruptLockCallback();
1028
1129
  }
1029
1130
  };
1131
+ // Legacy disable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
1132
+ // replaced by releaseInterruptLock for embedded apps.
1030
1133
  PresentationWindow.prototype.disableInteruptLock = function (windowName) {
1031
1134
  if (windowName) {
1032
1135
  this.isChildWindowLocked[windowName] = false;
@@ -1034,7 +1137,6 @@ var PresentationWindow = /** @class */ (function () {
1034
1137
  else {
1035
1138
  this.isWindowLocked = false;
1036
1139
  }
1037
- // If the presentation is ready and unlocked, trigger the disable interrupt callback.
1038
1140
  if (this.isReady() &&
1039
1141
  !this.isLocked() &&
1040
1142
  this.disableInterruptLockCallback) {
@@ -1200,6 +1302,79 @@ var PresentationWindow = /** @class */ (function () {
1200
1302
  return '';
1201
1303
  });
1202
1304
  };
1305
+ PresentationWindow.prototype.createInterruptLock = function () {
1306
+ var lockId = (0, uuid_1.v4)();
1307
+ var lock = {
1308
+ id: lockId,
1309
+ expiresAt: +new Date() + lockDuration,
1310
+ timeout: this.createLockTimeout(lockId),
1311
+ };
1312
+ this.interruptLocks[lock.id] = lock;
1313
+ this.runInterruptCallback();
1314
+ return lock;
1315
+ };
1316
+ PresentationWindow.prototype.renewInterruptLock = function (id) {
1317
+ var lock = this.interruptLocks[id];
1318
+ if (lock) {
1319
+ // Reset expiration time.
1320
+ lock.expiresAt = +new Date() + lockDuration;
1321
+ // Reset timeout.
1322
+ if (lock.timeout) {
1323
+ clearTimeout(lock.timeout);
1324
+ }
1325
+ lock.timeout = this.createLockTimeout(id);
1326
+ }
1327
+ else {
1328
+ // If the lock doesn't exist it was already released, create a new lock with the same id.
1329
+ lock = {
1330
+ id: id,
1331
+ expiresAt: +new Date() + lockDuration,
1332
+ timeout: this.createLockTimeout(id),
1333
+ };
1334
+ this.interruptLocks[lock.id] = lock;
1335
+ }
1336
+ this.runInterruptCallback();
1337
+ return lock;
1338
+ };
1339
+ PresentationWindow.prototype.releaseInterruptLock = function (id) {
1340
+ var lock = this.interruptLocks[id];
1341
+ if (lock) {
1342
+ if (lock.timeout) {
1343
+ clearTimeout(lock.timeout);
1344
+ }
1345
+ delete this.interruptLocks[id];
1346
+ }
1347
+ else {
1348
+ // If the lock doesn't exist it was already released, return a fake lock object to
1349
+ // send a response back to the running app.
1350
+ lock = { id: id, expiresAt: 0, timeout: null };
1351
+ }
1352
+ this.runInterruptCallback();
1353
+ return lock;
1354
+ };
1355
+ PresentationWindow.prototype.createLockTimeout = function (id) {
1356
+ var _this = this;
1357
+ return setTimeout(function () {
1358
+ _this.logInfo("Interrupt lock expired, releasing: ".concat(id));
1359
+ _this.releaseInterruptLock(id);
1360
+ }, lockDuration);
1361
+ };
1362
+ PresentationWindow.prototype.runInterruptCallback = function () {
1363
+ var currentlyLocked = this.isLocked();
1364
+ // If locked and the lock state has changed, run the interrupt lock callback.
1365
+ // Or if not locked and the lock state has changed, run the disable interrupt lock callback.
1366
+ if (currentlyLocked &&
1367
+ this.previouslyLocked !== currentlyLocked &&
1368
+ this.enableInterruptLockCallback) {
1369
+ this.enableInterruptLockCallback();
1370
+ }
1371
+ else if (!currentlyLocked &&
1372
+ this.previouslyLocked !== currentlyLocked &&
1373
+ this.disableInterruptLockCallback) {
1374
+ this.disableInterruptLockCallback();
1375
+ }
1376
+ this.previouslyLocked = currentlyLocked;
1377
+ };
1203
1378
  return PresentationWindow;
1204
1379
  }());
1205
1380
  exports.default = PresentationWindow;
@@ -260,11 +260,17 @@ describe('payments', function () {
260
260
  _a.sent();
261
261
  testRenderer.runReceiveMessageCallback('payments-charge-request', {
262
262
  requestId: 'test',
263
- amount: 100,
263
+ subTotal: 100,
264
+ tip: 1,
265
+ tax: 10,
266
+ total: 111,
264
267
  });
265
268
  expect(callback).toHaveBeenCalledWith({
266
269
  requestId: 'test',
267
- amount: 100,
270
+ subTotal: 100,
271
+ tip: 1,
272
+ tax: 10,
273
+ total: 111,
268
274
  });
269
275
  // Send charge response.
270
276
  window.sendChargeResponse({
@@ -337,4 +343,70 @@ describe('payments', function () {
337
343
  });
338
344
  }); });
339
345
  });
346
+ describe('interrupt locks', function () {
347
+ test('acquire, renew, release lock', function () { return __awaiter(void 0, void 0, void 0, function () {
348
+ var testRenderer, onEnableInterruptLock, onDisableInterruptLock, window, sendMessageObjectCall, lock, renewLockSendMessageObjectCall, renewedLock, releaseLockSendMessageObjectCall, releasedLock;
349
+ return __generator(this, function (_a) {
350
+ switch (_a.label) {
351
+ case 0:
352
+ testRenderer = (0, testUtils_1.createRenderer)();
353
+ onEnableInterruptLock = jest.fn();
354
+ onDisableInterruptLock = jest.fn();
355
+ window = (0, testUtils_1.createPresentationWindow)({
356
+ windowId: 'test-interrupt-lock-window-id',
357
+ presentation: (0, testUtils_1.createPresentation)(),
358
+ applicationVersion: (0, testUtils_1.createApplicationVersion)({
359
+ embeddedUrlFormat: 'http://test.local/:input',
360
+ }),
361
+ renderer: testRenderer,
362
+ });
363
+ window.onEnableInterruptLock(onEnableInterruptLock);
364
+ window.onDisableInterruptLock(onDisableInterruptLock);
365
+ return [4 /*yield*/, window.load()];
366
+ case 1:
367
+ _a.sent();
368
+ // Acquire lock.
369
+ testRenderer.runReceiveMessageCallback('runtime-acquire-interrupt-lock-request', { requestId: 'acquire-request' });
370
+ sendMessageObjectCall = testRenderer.sendMessageObject.mock.calls[0];
371
+ expect(sendMessageObjectCall[0]).toBe('test-interrupt-lock-window-id');
372
+ expect(sendMessageObjectCall[1]).toBe('runtime-acquire-interrupt-lock-response');
373
+ expect(sendMessageObjectCall[2].requestId).toBe('acquire-request');
374
+ lock = sendMessageObjectCall[2].lock;
375
+ expect(typeof lock.id).toBe('string');
376
+ expect(lock.id).toBeTruthy();
377
+ expect(typeof lock.expiresAt).toBe('number');
378
+ expect(lock.expiresAt).toBeTruthy();
379
+ expect(onEnableInterruptLock).toHaveBeenCalled();
380
+ // Wait 1ms to make sure we have a different expiresAt value.
381
+ return [4 /*yield*/, delay(1)];
382
+ case 2:
383
+ // Wait 1ms to make sure we have a different expiresAt value.
384
+ _a.sent();
385
+ // Renew lock.
386
+ testRenderer.runReceiveMessageCallback('runtime-renew-interrupt-lock-request', { requestId: 'renew-request', lockId: lock.id });
387
+ renewLockSendMessageObjectCall = testRenderer.sendMessageObject.mock.calls[1];
388
+ expect(renewLockSendMessageObjectCall[0]).toBe('test-interrupt-lock-window-id');
389
+ expect(renewLockSendMessageObjectCall[1]).toBe('runtime-renew-interrupt-lock-response');
390
+ expect(renewLockSendMessageObjectCall[2].requestId).toBe('renew-request');
391
+ renewedLock = renewLockSendMessageObjectCall[2].lock;
392
+ expect(renewedLock.id).toBe(lock.id);
393
+ expect(renewedLock.expiresAt).toBeGreaterThan(lock.expiresAt);
394
+ // Ensure onEnableInterruptLock is not called again.
395
+ expect(onEnableInterruptLock).toHaveBeenCalledTimes(1);
396
+ // Release lock.
397
+ testRenderer.runReceiveMessageCallback('runtime-release-interrupt-lock-request', { requestId: 'release-request', lockId: lock.id });
398
+ releaseLockSendMessageObjectCall = testRenderer.sendMessageObject.mock.calls[2];
399
+ expect(releaseLockSendMessageObjectCall[0]).toBe('test-interrupt-lock-window-id');
400
+ expect(releaseLockSendMessageObjectCall[1]).toBe('runtime-release-interrupt-lock-response');
401
+ expect(releaseLockSendMessageObjectCall[2].requestId).toBe('release-request');
402
+ releasedLock = releaseLockSendMessageObjectCall[2].lock;
403
+ expect(releasedLock.id).toBe(lock.id);
404
+ expect(releasedLock.expiresAt).toBe(renewedLock.expiresAt);
405
+ // Ensure onDisableInterruptLock is called.
406
+ expect(onDisableInterruptLock).toHaveBeenCalled();
407
+ return [2 /*return*/];
408
+ }
409
+ });
410
+ }); });
411
+ });
340
412
  // TODO: Backfill tests for all legacy and embedded apps.
package/build/types.d.ts CHANGED
@@ -142,14 +142,20 @@ export interface AnalyticsEvent {
142
142
  productCategory?: string | null;
143
143
  }
144
144
  export interface ChargeRequest {
145
+ orderId?: string;
145
146
  requestId: string;
146
- amount: number;
147
+ subTotal: number;
148
+ tip: number;
149
+ tax: number;
150
+ total: number;
147
151
  items: Array<{
148
152
  name: string;
149
- price: number;
150
153
  description?: string;
151
154
  sku?: string;
152
- quantity?: number;
155
+ price: number;
156
+ quantity: number;
157
+ tax: number;
158
+ total: number;
153
159
  }>;
154
160
  }
155
161
  export interface Charge {
@@ -166,3 +172,8 @@ export interface ChargeResponse {
166
172
  export interface Terminal {
167
173
  status: string;
168
174
  }
175
+ export interface InterruptLock {
176
+ id: string;
177
+ expiresAt: number;
178
+ timeout: ReturnType<typeof setTimeout> | null;
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raydiant/playlist-window-manager",
3
- "version": "0.0.0-test36",
3
+ "version": "0.0.1-test1",
4
4
  "description": "Window manager for playlist playback.",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "fast-deep-equal": "^3.1.3",
15
- "@raydiant/playlist-rule-engine": "3.2.2",
15
+ "@raydiant/playlist-rule-engine": "3.3.0",
16
16
  "raydiant-kit": "4.7.3",
17
17
  "uuid": "^8.3.0",
18
18
  "eventemitter3": "^4.0.7"
@@ -22,7 +22,7 @@
22
22
  "@types/jschannel": "^1.0.1",
23
23
  "@types/jest": "^24.0.15",
24
24
  "@types/uuid": "8.3.0",
25
- "electron": "28.1.2",
25
+ "electron": "32.1.0",
26
26
  "jest": "^24.8.0",
27
27
  "ts-jest": "^24.0.2",
28
28
  "typescript": "^4.1.2"
@@ -63,6 +63,11 @@ export default class BrowserWindowRenderer
63
63
  // Create a hidden window to preload BrowserViews.
64
64
  private offscreenWindow = new BrowserWindow({
65
65
  show: false,
66
+ /**
67
+ * This option fixes window not being able to take fullscreen leaving
68
+ * 1px gap at the right and bottom of the screen.
69
+ */
70
+ resizable: false,
66
71
  frame: this.opts.frame,
67
72
  hasShadow: this.opts.hasShadow,
68
73
  webPreferences: {
@@ -110,6 +115,11 @@ export default class BrowserWindowRenderer
110
115
  })
111
116
  : this.createOrGetPooledBrowserWindow(windowId, {
112
117
  show: false,
118
+ /**
119
+ * This option fixes window not being able to take fullscreen leaving
120
+ * 1px gap at the right and bottom of the screen.
121
+ */
122
+ resizable: false,
113
123
  frame: this.opts.frame,
114
124
  // NOTE: useWindowPool: true does not work with renderChildWindowsAsBrowserViews: false
115
125
  // because the parent can't be changed after creating the BrowserWindow.
@@ -519,9 +529,6 @@ export default class BrowserWindowRenderer
519
529
  }
520
530
  }
521
531
 
522
- this.logger.info(`Opening devTools`, { windowId });
523
- browserView.webContents.openDevTools();
524
-
525
532
  return browserView;
526
533
  }
527
534
 
@@ -238,12 +238,18 @@ describe('payments', () => {
238
238
 
239
239
  testRenderer.runReceiveMessageCallback('payments-charge-request', {
240
240
  requestId: 'test',
241
- amount: 100,
241
+ subTotal: 100,
242
+ tip: 1,
243
+ tax: 10,
244
+ total: 111,
242
245
  });
243
246
 
244
247
  expect(callback).toHaveBeenCalledWith({
245
248
  requestId: 'test',
246
- amount: 100,
249
+ subTotal: 100,
250
+ tip: 1,
251
+ tax: 10,
252
+ total: 111,
247
253
  });
248
254
 
249
255
  // Send charge response.
@@ -330,4 +336,99 @@ describe('payments', () => {
330
336
  });
331
337
  });
332
338
 
339
+ describe('interrupt locks', () => {
340
+ test('acquire, renew, release lock', async () => {
341
+ const testRenderer = createRenderer();
342
+
343
+ const onEnableInterruptLock = jest.fn();
344
+ const onDisableInterruptLock = jest.fn();
345
+
346
+ const window = createPresentationWindow({
347
+ windowId: 'test-interrupt-lock-window-id',
348
+ presentation: createPresentation(),
349
+ applicationVersion: createApplicationVersion({
350
+ embeddedUrlFormat: 'http://test.local/:input',
351
+ }),
352
+ renderer: testRenderer,
353
+ });
354
+
355
+ window.onEnableInterruptLock(onEnableInterruptLock);
356
+ window.onDisableInterruptLock(onDisableInterruptLock);
357
+
358
+ await window.load();
359
+
360
+ // Acquire lock.
361
+ testRenderer.runReceiveMessageCallback(
362
+ 'runtime-acquire-interrupt-lock-request',
363
+ { requestId: 'acquire-request' }
364
+ );
365
+
366
+ // Ensure lock response is sent.
367
+ const sendMessageObjectCall = testRenderer.sendMessageObject.mock.calls[0];
368
+ expect(sendMessageObjectCall[0]).toBe('test-interrupt-lock-window-id');
369
+ expect(sendMessageObjectCall[1]).toBe(
370
+ 'runtime-acquire-interrupt-lock-response'
371
+ );
372
+ expect(sendMessageObjectCall[2].requestId).toBe('acquire-request');
373
+ const lock = sendMessageObjectCall[2].lock;
374
+ expect(typeof lock.id).toBe('string');
375
+ expect(lock.id).toBeTruthy();
376
+ expect(typeof lock.expiresAt).toBe('number');
377
+ expect(lock.expiresAt).toBeTruthy();
378
+
379
+ expect(onEnableInterruptLock).toHaveBeenCalled();
380
+
381
+ // Wait 1ms to make sure we have a different expiresAt value.
382
+ await delay(1);
383
+
384
+ // Renew lock.
385
+ testRenderer.runReceiveMessageCallback(
386
+ 'runtime-renew-interrupt-lock-request',
387
+ { requestId: 'renew-request', lockId: lock.id }
388
+ );
389
+
390
+ // Ensure renew lock response is sent.
391
+ const renewLockSendMessageObjectCall =
392
+ testRenderer.sendMessageObject.mock.calls[1];
393
+ expect(renewLockSendMessageObjectCall[0]).toBe(
394
+ 'test-interrupt-lock-window-id'
395
+ );
396
+ expect(renewLockSendMessageObjectCall[1]).toBe(
397
+ 'runtime-renew-interrupt-lock-response'
398
+ );
399
+ expect(renewLockSendMessageObjectCall[2].requestId).toBe('renew-request');
400
+ const renewedLock = renewLockSendMessageObjectCall[2].lock;
401
+ expect(renewedLock.id).toBe(lock.id);
402
+ expect(renewedLock.expiresAt).toBeGreaterThan(lock.expiresAt);
403
+
404
+ // Ensure onEnableInterruptLock is not called again.
405
+ expect(onEnableInterruptLock).toHaveBeenCalledTimes(1);
406
+
407
+ // Release lock.
408
+ testRenderer.runReceiveMessageCallback(
409
+ 'runtime-release-interrupt-lock-request',
410
+ { requestId: 'release-request', lockId: lock.id }
411
+ );
412
+
413
+ // Ensure release lock response is sent.
414
+ const releaseLockSendMessageObjectCall =
415
+ testRenderer.sendMessageObject.mock.calls[2];
416
+ expect(releaseLockSendMessageObjectCall[0]).toBe(
417
+ 'test-interrupt-lock-window-id'
418
+ );
419
+ expect(releaseLockSendMessageObjectCall[1]).toBe(
420
+ 'runtime-release-interrupt-lock-response'
421
+ );
422
+ expect(releaseLockSendMessageObjectCall[2].requestId).toBe(
423
+ 'release-request'
424
+ );
425
+ const releasedLock = releaseLockSendMessageObjectCall[2].lock;
426
+ expect(releasedLock.id).toBe(lock.id);
427
+ expect(releasedLock.expiresAt).toBe(renewedLock.expiresAt);
428
+
429
+ // Ensure onDisableInterruptLock is called.
430
+ expect(onDisableInterruptLock).toHaveBeenCalled();
431
+ });
432
+ });
433
+
333
434
  // 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 {
@@ -61,7 +62,7 @@ export interface PresentationWindowOptions {
61
62
  }
62
63
 
63
64
  // TODO: Automatically inject latest version when building in CI.
64
- const defaultAppRendererUrl = 'https://apps.raydiant.com/3.0.7/index.html';
65
+ const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.0/index.html';
65
66
 
66
67
  const isValidNumber = (value: any) =>
67
68
  typeof value === 'number' && !isNaN(value);
@@ -79,7 +80,33 @@ const isValidChargeRequest = (value: any): value is ChargeRequest =>
79
80
  value &&
80
81
  typeof value === 'object' &&
81
82
  typeof value.requestId === 'string' &&
82
- typeof value.amount === 'number';
83
+ typeof value.subTotal === 'number' &&
84
+ typeof value.tip === 'number' &&
85
+ typeof value.tax === 'number' &&
86
+ typeof value.total === 'number';
87
+
88
+ const isValidAcquireLockRequest = (
89
+ value: any
90
+ ): value is { requestId: string } =>
91
+ value && typeof value === 'object' && typeof value.requestId === 'string';
92
+
93
+ const isValidRenewLockRequest = (
94
+ value: any
95
+ ): value is { requestId: string; lockId: string } =>
96
+ value &&
97
+ typeof value === 'object' &&
98
+ typeof value.requestId === 'string' &&
99
+ typeof value.lockId === 'string';
100
+
101
+ const isValidReleaseLockRequest = (
102
+ value: any
103
+ ): value is { requestId: string; lockId: string } =>
104
+ value &&
105
+ typeof value === 'object' &&
106
+ typeof value.requestId === 'string' &&
107
+ typeof value.lockId === 'string';
108
+
109
+ const lockDuration = 1000 * 60; // 1 minute
83
110
 
84
111
  export default class PresentationWindow {
85
112
  private didReceiveReady: boolean = false;
@@ -124,6 +151,9 @@ export default class PresentationWindow {
124
151
  ReturnType<typeof setTimeout>
125
152
  > = {};
126
153
 
154
+ private interruptLocks: Record<string, InterruptLock> = {};
155
+ private previouslyLocked = false;
156
+
127
157
  constructor(private options: PresentationWindowOptions) {
128
158
  this.builderState = options.builderState;
129
159
  this.presentation = options.presentation;
@@ -335,10 +365,39 @@ export default class PresentationWindow {
335
365
  }
336
366
 
337
367
  public isLocked() {
338
- return (
339
- this.isWindowLocked ||
340
- Object.values(this.isChildWindowLocked).some(Boolean)
341
- );
368
+ // Legacy check for a lock on the current window or child windows. These are set by enableInterruptLock
369
+ // and disableInterruptLock, which are only used by RaydiantKit apps (ie. Virtual Rooms).
370
+ if (this.isWindowLocked) {
371
+ this.logDebug(
372
+ `Window '${this.options.windowId}' is locked due to enableInterruptLock`
373
+ );
374
+ return true;
375
+ }
376
+ if (Object.values(this.isChildWindowLocked).some(Boolean)) {
377
+ this.logDebug(
378
+ `Window '${this.options.windowId}' is locked due to enableInterruptLock on child windows`
379
+ );
380
+ return true;
381
+ }
382
+
383
+ // Check interrupt locks on current window or child windows, used by embedded apps (apps created through
384
+ // the Developer Portal).
385
+ const now = +new Date();
386
+ const isExpired = (lock: InterruptLock) => lock.expiresAt < now;
387
+ if (!Object.values(this.interruptLocks).every(isExpired)) {
388
+ this.logDebug(
389
+ `Window '${this.options.windowId}' is locked due to interrupt lock`
390
+ );
391
+ return true;
392
+ }
393
+ if (Object.values(this.windowManagers).some((wm) => wm.isLocked())) {
394
+ this.logDebug(
395
+ `Window '${this.options.windowId}' is locked due to interrupt lock on child windows`
396
+ );
397
+ return true;
398
+ }
399
+
400
+ return false;
342
401
  }
343
402
 
344
403
  public setPresentation(
@@ -783,8 +842,22 @@ export default class PresentationWindow {
783
842
  themeOverride,
784
843
  forceMute,
785
844
  isPreview,
786
- onEnableInterruptLock: () => this.enableInteruptLock(windowName),
787
- onDisableInterruptLock: () => this.disableInteruptLock(windowName),
845
+ onEnableInterruptLock: () => {
846
+ // enableInterruptLock is only used by legacy RaydiantKit apps (ie. Virtual Rooms), while
847
+ // runInterruptCallback is used by Developer Portal apps.
848
+ if (this.isEmbeddedApp()) {
849
+ this.runInterruptCallback();
850
+ } else {
851
+ this.enableInteruptLock(windowName);
852
+ }
853
+ },
854
+ onDisableInterruptLock: () => {
855
+ if (this.isEmbeddedApp()) {
856
+ this.runInterruptCallback();
857
+ } else {
858
+ this.disableInteruptLock(windowName);
859
+ }
860
+ },
788
861
  getTerminal: this.options.getTerminal,
789
862
  });
790
863
 
@@ -847,6 +920,8 @@ export default class PresentationWindow {
847
920
  }
848
921
  }
849
922
 
923
+ // Legacy enable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
924
+ // replaced by createInterruptLock for embedded apps.
850
925
  private enableInteruptLock(windowName?: string) {
851
926
  if (windowName) {
852
927
  this.isChildWindowLocked[windowName] = true;
@@ -854,12 +929,13 @@ export default class PresentationWindow {
854
929
  this.isWindowLocked = true;
855
930
  }
856
931
 
857
- // If the presentation is ready and locked, trigger the enable interrupt callback.
858
932
  if (this.isReady() && this.isLocked() && this.enableInterruptLockCallback) {
859
933
  this.enableInterruptLockCallback();
860
934
  }
861
935
  }
862
936
 
937
+ // Legacy disable interrupt lock for RaydiantKit apps (ie. Virtual Rooms). This has been
938
+ // replaced by releaseInterruptLock for embedded apps.
863
939
  private disableInteruptLock(windowName?: string) {
864
940
  if (windowName) {
865
941
  this.isChildWindowLocked[windowName] = false;
@@ -867,7 +943,6 @@ export default class PresentationWindow {
867
943
  this.isWindowLocked = false;
868
944
  }
869
945
 
870
- // If the presentation is ready and unlocked, trigger the disable interrupt callback.
871
946
  if (
872
947
  this.isReady() &&
873
948
  !this.isLocked() &&
@@ -1105,6 +1180,61 @@ export default class PresentationWindow {
1105
1180
  break;
1106
1181
  }
1107
1182
 
1183
+ case 'runtime-acquire-interrupt-lock-request': {
1184
+ if (isValidAcquireLockRequest(payload)) {
1185
+ this.logInfo(`Received 'runtime-acquire-interrupt-lock-request'`);
1186
+
1187
+ const lock = this.createInterruptLock();
1188
+
1189
+ this.sendMessageObject('runtime-acquire-interrupt-lock-response', {
1190
+ requestId: payload.requestId,
1191
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1192
+ });
1193
+ } else {
1194
+ this.logWarning(
1195
+ `Invalid payload for runtime-acquire-interrupt-lock-request message: ${payload}`
1196
+ );
1197
+ }
1198
+
1199
+ break;
1200
+ }
1201
+
1202
+ case 'runtime-renew-interrupt-lock-request': {
1203
+ if (isValidRenewLockRequest(payload)) {
1204
+ this.logInfo(`Received 'runtime-renew-interrupt-lock-request'`);
1205
+
1206
+ const lock = this.renewInterruptLock(payload.lockId);
1207
+
1208
+ this.sendMessageObject('runtime-renew-interrupt-lock-response', {
1209
+ requestId: payload.requestId,
1210
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1211
+ });
1212
+ } else {
1213
+ this.logWarning(
1214
+ `Invalid payload for runtime-renew-interrupt-lock-request message: ${payload}`
1215
+ );
1216
+ }
1217
+ break;
1218
+ }
1219
+
1220
+ case 'runtime-release-interrupt-lock-request': {
1221
+ if (isValidReleaseLockRequest(payload)) {
1222
+ this.logInfo(`Received 'runtime-release-interrupt-lock-request'`);
1223
+
1224
+ const lock = this.releaseInterruptLock(payload.lockId);
1225
+
1226
+ this.sendMessageObject('runtime-release-interrupt-lock-response', {
1227
+ requestId: payload.requestId,
1228
+ lock: { id: lock.id, expiresAt: lock.expiresAt },
1229
+ });
1230
+ } else {
1231
+ this.logWarning(
1232
+ `Invalid payload for runtime-release-interrupt-lock-request message: ${payload}`
1233
+ );
1234
+ }
1235
+ break;
1236
+ }
1237
+
1108
1238
  default: {
1109
1239
  this.logWarning(`Received unknown embedded message type: ${type}`);
1110
1240
  }
@@ -1407,6 +1537,95 @@ export default class PresentationWindow {
1407
1537
  return '';
1408
1538
  });
1409
1539
  }
1540
+
1541
+ private createInterruptLock() {
1542
+ const lockId = uuidv4();
1543
+ const lock = {
1544
+ id: lockId,
1545
+ expiresAt: +new Date() + lockDuration,
1546
+ timeout: this.createLockTimeout(lockId),
1547
+ };
1548
+
1549
+ this.interruptLocks[lock.id] = lock;
1550
+
1551
+ this.runInterruptCallback();
1552
+
1553
+ return lock;
1554
+ }
1555
+
1556
+ private renewInterruptLock(id: string) {
1557
+ let lock = this.interruptLocks[id];
1558
+ if (lock) {
1559
+ // Reset expiration time.
1560
+ lock.expiresAt = +new Date() + lockDuration;
1561
+
1562
+ // Reset timeout.
1563
+ if (lock.timeout) {
1564
+ clearTimeout(lock.timeout);
1565
+ }
1566
+ lock.timeout = this.createLockTimeout(id);
1567
+ } else {
1568
+ // If the lock doesn't exist it was already released, create a new lock with the same id.
1569
+ lock = {
1570
+ id,
1571
+ expiresAt: +new Date() + lockDuration,
1572
+ timeout: this.createLockTimeout(id),
1573
+ };
1574
+
1575
+ this.interruptLocks[lock.id] = lock;
1576
+ }
1577
+
1578
+ this.runInterruptCallback();
1579
+
1580
+ return lock;
1581
+ }
1582
+
1583
+ private releaseInterruptLock(id: string) {
1584
+ let lock = this.interruptLocks[id];
1585
+ if (lock) {
1586
+ if (lock.timeout) {
1587
+ clearTimeout(lock.timeout);
1588
+ }
1589
+ delete this.interruptLocks[id];
1590
+ } else {
1591
+ // If the lock doesn't exist it was already released, return a fake lock object to
1592
+ // send a response back to the running app.
1593
+ lock = { id, expiresAt: 0, timeout: null };
1594
+ }
1595
+
1596
+ this.runInterruptCallback();
1597
+
1598
+ return lock;
1599
+ }
1600
+
1601
+ private createLockTimeout(id: string) {
1602
+ return setTimeout(() => {
1603
+ this.logInfo(`Interrupt lock expired, releasing: ${id}`);
1604
+ this.releaseInterruptLock(id);
1605
+ }, lockDuration);
1606
+ }
1607
+
1608
+ private runInterruptCallback() {
1609
+ const currentlyLocked = this.isLocked();
1610
+
1611
+ // If locked and the lock state has changed, run the interrupt lock callback.
1612
+ // Or if not locked and the lock state has changed, run the disable interrupt lock callback.
1613
+ if (
1614
+ currentlyLocked &&
1615
+ this.previouslyLocked !== currentlyLocked &&
1616
+ this.enableInterruptLockCallback
1617
+ ) {
1618
+ this.enableInterruptLockCallback();
1619
+ } else if (
1620
+ !currentlyLocked &&
1621
+ this.previouslyLocked !== currentlyLocked &&
1622
+ this.disableInterruptLockCallback
1623
+ ) {
1624
+ this.disableInterruptLockCallback();
1625
+ }
1626
+
1627
+ this.previouslyLocked = currentlyLocked;
1628
+ }
1410
1629
  }
1411
1630
 
1412
1631
  const isValidCreateObjectURLResultPayload = (
package/src/types.ts CHANGED
@@ -176,14 +176,20 @@ export interface AnalyticsEvent {
176
176
  }
177
177
 
178
178
  export interface ChargeRequest {
179
+ orderId?: string;
179
180
  requestId: string;
180
- amount: number;
181
+ subTotal: number;
182
+ tip: number;
183
+ tax: number;
184
+ total: number;
181
185
  items: Array<{
182
186
  name: string;
183
- price: number;
184
187
  description?: string;
185
188
  sku?: string;
186
- quantity?: number;
189
+ price: number;
190
+ quantity: number;
191
+ tax: number;
192
+ total: number;
187
193
  }>;
188
194
  }
189
195
 
@@ -203,3 +209,9 @@ export interface ChargeResponse {
203
209
  export interface Terminal {
204
210
  status: string;
205
211
  }
212
+
213
+ export interface InterruptLock {
214
+ id: string;
215
+ expiresAt: number;
216
+ timeout: ReturnType<typeof setTimeout> | null;
217
+ }
File without changes