Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/replay/backend/models/ReplayView.ts
1030 views
1
import { v1 as uuidv1 } from 'uuid';
2
import Window from './Window';
3
import ReplayApi from '~backend/api';
4
import ViewBackend from './ViewBackend';
5
import ReplayTabState from '~backend/api/ReplayTabState';
6
import PlaybarView from '~backend/models/PlaybarView';
7
import Application from '~backend/Application';
8
import { TOOLBAR_HEIGHT } from '~shared/constants/design';
9
import IRectangle from '~shared/interfaces/IRectangle';
10
import { DomActionType, IFrontendDomChangeEvent } from '~shared/interfaces/IDomChangeEvent';
11
import { IFrontendMouseEvent, IScrollRecord } from '~shared/interfaces/ISaSession';
12
import OutputView from '~backend/models/OutputView';
13
14
const domReplayerScript = require.resolve('../../injected-scripts/domReplayerSubscribe');
15
16
export default class ReplayView extends ViewBackend {
17
public static MESSAGE_HANG_ID = 'script-hang';
18
public replayApi: ReplayApi;
19
public tabState: ReplayTabState;
20
public readonly playbarView: PlaybarView;
21
public readonly outputView: OutputView;
22
23
public readonly toolbarHeight = TOOLBAR_HEIGHT;
24
public outputWidth = 300;
25
26
private previousWidth = 300;
27
28
private isTabLoaded = false;
29
private lastInactivityMillis = 0;
30
31
private checkResponsiveInterval: NodeJS.Timeout;
32
33
public constructor(window: Window) {
34
super(window, {
35
preload: domReplayerScript,
36
nodeIntegrationInSubFrames: true,
37
enableRemoteModule: false,
38
partition: uuidv1(),
39
contextIsolation: true,
40
webSecurity: false,
41
javascript: false,
42
});
43
44
this.playbarView = new PlaybarView(window);
45
this.outputView = new OutputView(window, this);
46
this.checkResponsive = this.checkResponsive.bind(this);
47
this.checkResponsiveInterval = setInterval(this.timerCheckResponsive.bind(this), 500).unref();
48
49
let resizeTimeout;
50
this.window.browserWindow.on('resize', () => {
51
if (resizeTimeout) clearTimeout(resizeTimeout);
52
resizeTimeout = setTimeout(() => this.sizeWebContentsToFit(), 20);
53
});
54
}
55
56
public async load(replayApi: ReplayApi) {
57
const isFirstLoad = !this.replayApi;
58
this.clearReplayApi();
59
60
this.replayApi = replayApi;
61
62
const session = this.webContents.session;
63
const scriptUrl = await replayApi.getReplayScript();
64
session.setPreloads([scriptUrl]);
65
if (isFirstLoad) this.detach(false);
66
this.replayApi.onTabChange = this.onTabChange.bind(this);
67
await replayApi.isReady;
68
await this.loadTab();
69
}
70
71
public start() {
72
this.playbarView.play();
73
}
74
75
public async loadTab(id?: number) {
76
this.isTabLoaded = false;
77
await this.clearTabState();
78
this.attach();
79
80
this.window.setAddressBarUrl('Loading session...');
81
82
this.tabState = id ? this.replayApi.getTab(id) : this.replayApi.startTab;
83
this.tabState.on('tick:changes', this.checkResponsive);
84
await this.playbarView.load(this.tabState);
85
86
console.log('Loaded tab state', this.tabState.startOrigin);
87
this.window.setActiveTabId(this.tabState.tabId);
88
this.window.setAddressBarUrl(this.tabState.startOrigin);
89
90
this.browserView.setBackgroundColor('#ffffff');
91
92
await Promise.race([
93
this.webContents.loadURL(this.tabState.startOrigin),
94
new Promise(resolve => setTimeout(resolve, 500)),
95
]);
96
97
this.isTabLoaded = true;
98
this.sizeWebContentsToFit();
99
100
if (this.tabState.currentPlaybarOffsetPct > 0) {
101
console.log('Resetting playbar offset to %s%', this.tabState.currentPlaybarOffsetPct);
102
const events = this.tabState.setTickValue(this.tabState.currentPlaybarOffsetPct, true);
103
await this.publishTickChanges(events);
104
}
105
}
106
107
public onTickHover(rect: IRectangle, tickValue: number) {
108
this.playbarView.onTickHover(rect, tickValue);
109
}
110
111
public toggleOutputView(show: boolean): void {
112
const bounds = this.bounds;
113
if (show === false) {
114
this.previousWidth = this.outputWidth;
115
this.outputWidth = 0;
116
} else {
117
this.outputWidth = this.previousWidth;
118
}
119
this.fixBounds(bounds);
120
}
121
122
public growOutputView(diffX: number): void {
123
const bounds = this.bounds;
124
this.outputWidth += diffX;
125
this.fixBounds(bounds);
126
}
127
128
public fixBounds(newBounds: { x: number; width: number; y: any; height: number }) {
129
this.playbarView.fixBounds({
130
x: 0,
131
y: newBounds.height + newBounds.y,
132
width: newBounds.width,
133
height: this.toolbarHeight,
134
});
135
this.outputView.fixBounds({
136
x: newBounds.width - this.outputWidth,
137
y: newBounds.y,
138
width: this.outputWidth,
139
height: newBounds.height,
140
});
141
newBounds.width -= this.outputWidth;
142
super.fixBounds(newBounds);
143
this.bounds.width += this.outputWidth;
144
this.sizeWebContentsToFit();
145
this.window.browserWindow.addBrowserView(this.outputView.browserView);
146
this.window.browserWindow.addBrowserView(this.playbarView.browserView);
147
}
148
149
public attach() {
150
if (this.isAttached) return;
151
super.attach();
152
this.playbarView.attach();
153
this.outputView.attach();
154
this.interceptHttpRequests();
155
this.webContents.openDevTools({ mode: 'detach', activate: false });
156
}
157
158
public detach(detachPlaybar = true) {
159
this.webContents.closeDevTools();
160
super.detach();
161
// clear out everytime we detach
162
this._browserView = null;
163
if (detachPlaybar) {
164
this.outputView.detach();
165
this.playbarView.detach();
166
}
167
}
168
169
public async onTickDrag(tickValue: number) {
170
if (!this.isAttached || !this.tabState) return;
171
const events = this.tabState.setTickValue(tickValue);
172
await this.publishTickChanges(events);
173
}
174
175
public async gotoNextTick() {
176
const nextTick = await this.nextTick();
177
this.playbarView.changeTickOffset(nextTick?.playbarOffset);
178
}
179
180
public async gotoPreviousTick() {
181
const state = this.tabState.transitionToPreviousTick();
182
await this.publishTickChanges(state);
183
this.playbarView.changeTickOffset(this.tabState.currentPlaybarOffsetPct);
184
}
185
186
public async nextTick(startMillisDeficit = 0) {
187
try {
188
if (!this.tabState) return { playbarOffset: 0, millisToNextTick: 100 };
189
const startTime = new Date();
190
// calculate when client should request the next tick
191
let millisToNextTick = 50;
192
193
const events = this.tabState.transitionToNextTick();
194
await this.publishTickChanges(events);
195
setImmediate(() => this.checkResponsive());
196
197
if (this.tabState.currentTick && this.tabState.nextTick) {
198
const nextTickTime = new Date(this.tabState.nextTick.timestamp);
199
const currentTickTime = new Date(this.tabState.currentTick.timestamp);
200
const diff = nextTickTime.getTime() - currentTickTime.getTime();
201
const fnDuration = new Date().getTime() - startTime.getTime();
202
millisToNextTick = diff - fnDuration + startMillisDeficit;
203
}
204
205
return { playbarOffset: this.tabState.currentPlaybarOffsetPct, millisToNextTick };
206
} catch (err) {
207
console.log('ERROR getting next tick', err);
208
return { playbarOffset: this.tabState.currentPlaybarOffsetPct, millisToNextTick: 100 };
209
}
210
}
211
212
public destroy() {
213
super.destroy();
214
this.clearReplayApi();
215
this.clearTabState();
216
}
217
218
public sizeWebContentsToFit() {
219
if (!this.tabState || !this.isTabLoaded) return;
220
const screenSize = this.browserView.getBounds();
221
222
const viewSize = {
223
height: Math.min(this.tabState.viewportHeight, screenSize.height),
224
width: this.tabState.viewportWidth,
225
};
226
227
// NOTE: This isn't working in electron, so setting scale instead
228
const viewPosition = {
229
x: screenSize.width > viewSize.width ? (screenSize.width - viewSize.width) / 2 : 0,
230
y: screenSize.height > viewSize.height ? (screenSize.height - viewSize.height) / 2 : 0,
231
};
232
233
const scale = screenSize.width / viewSize.width;
234
235
if (viewSize.height * scale > viewSize.height) {
236
viewSize.height *= scale;
237
}
238
239
this.browserView.webContents.enableDeviceEmulation({
240
deviceScaleFactor: 1,
241
screenPosition: 'mobile',
242
viewSize,
243
scale,
244
viewPosition,
245
screenSize,
246
});
247
}
248
249
private async publishTickChanges(
250
events: [
251
IFrontendDomChangeEvent[],
252
{ frameIdPath: string; nodeIds: number[] },
253
IFrontendMouseEvent,
254
IScrollRecord,
255
],
256
) {
257
if (!events || !events.length) return;
258
const [domChanges] = events;
259
260
if (domChanges?.length) {
261
const [{ action, frameIdPath }] = domChanges;
262
const hasNewUrlToLoad = action === DomActionType.newDocument && frameIdPath === 'main';
263
if (hasNewUrlToLoad) {
264
const nav = domChanges.shift();
265
await Promise.race([
266
this.webContents.loadURL(nav.textContent),
267
new Promise(resolve => setTimeout(resolve, 500)),
268
]);
269
}
270
}
271
272
const columns = [
273
'action',
274
'nodeId',
275
'nodeType',
276
'textContent',
277
'tagName',
278
'namespaceUri',
279
'parentNodeId',
280
'previousSiblingId',
281
'attributeNamespaces',
282
'attributes',
283
'properties',
284
'frameIdPath',
285
];
286
const compressedChanges = domChanges
287
? domChanges.map(x => columns.map(col => x[col]))
288
: undefined;
289
this.webContents.send('dom:apply', columns, compressedChanges, ...events.slice(1));
290
this.outputView.setCommandId(this.tabState.currentTick.commandId);
291
this.window.setAddressBarUrl(this.tabState.urlOrigin);
292
}
293
294
private async onTabChange(tab: ReplayTabState) {
295
await tab.isReady.promise;
296
this.window.onReplayTabChange({
297
tabId: tab.tabId,
298
detachedFromTabId: tab.detachedFromTabId,
299
startOrigin: tab.startOrigin,
300
createdTime: tab.tabCreatedTime,
301
width: tab.viewportWidth,
302
height: tab.viewportHeight,
303
});
304
}
305
306
private checkResponsive() {
307
const lastActivityMillis =
308
new Date().getTime() - (this.replayApi.lastActivityDate ?? new Date()).getTime();
309
310
if (lastActivityMillis < this.lastInactivityMillis) {
311
this.lastInactivityMillis = lastActivityMillis;
312
return;
313
}
314
if (lastActivityMillis - this.lastInactivityMillis < 500) return;
315
this.lastInactivityMillis = lastActivityMillis;
316
317
if (
318
!this.tabState.replayTime.close &&
319
lastActivityMillis >= 5e3 &&
320
this.replayApi.lastCommandName !== 'waitForMillis' &&
321
this.replayApi.showUnresponsiveMessage
322
) {
323
const lastActivitySecs = Math.floor(lastActivityMillis / 1e3);
324
Application.instance.overlayManager.show(
325
'message-overlay',
326
this.window.browserWindow,
327
this.window.browserWindow.getContentBounds(),
328
{
329
title: 'Did your script hang?',
330
message: `The last update was ${lastActivitySecs} seconds ago.`,
331
id: ReplayView.MESSAGE_HANG_ID,
332
},
333
);
334
} else {
335
Application.instance.overlayManager.getByName('message-overlay').hide();
336
}
337
}
338
339
private timerCheckResponsive(): void {
340
if (this.replayApi && this.tabState && this.isTabLoaded && !this.tabState.replayTime.close) {
341
this.checkResponsive();
342
}
343
}
344
345
private clearReplayApi() {
346
if (this.replayApi) {
347
this.outputView.clear();
348
this.webContents.session.setPreloads([]);
349
this.replayApi.onTabChange = null;
350
this.replayApi.close();
351
this.replayApi = null;
352
}
353
}
354
355
private clearTabState() {
356
if (this.tabState) {
357
this.tabState.off('tick:changes', this.checkResponsive);
358
this.tabState = null;
359
this.outputView.clear();
360
this.detach(false);
361
}
362
}
363
364
private interceptHttpRequests() {
365
const session = this.webContents.session;
366
session.protocol.interceptStreamProtocol('http', async (request, callback) => {
367
console.log('intercepting http stream', request.url);
368
const result = await this.replayApi.getResource(request.url);
369
callback(result);
370
});
371
session.protocol.interceptStreamProtocol('https', async (request, callback) => {
372
console.log('intercepting https stream', request.url);
373
const result = await this.replayApi.getResource(request.url);
374
callback(result);
375
});
376
}
377
}
378
379