Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/replay/backend/Application.ts
1029 views
1
import * as Path from 'path';
2
import * as Os from 'os';
3
import { app, dialog, ipcMain, Menu, protocol, screen } from 'electron';
4
import * as Fs from 'fs';
5
import OverlayManager from './managers/OverlayManager';
6
import generateAppMenu from './menus/generateAppMenu';
7
import ReplayApi from './api';
8
import storage from './storage';
9
import Window from './models/Window';
10
import IReplayMeta from '../shared/interfaces/IReplayMeta';
11
import ScriptRegistrationServer from '~backend/api/ScriptRegistrationServer';
12
13
// NOTE: this has to come before app load
14
protocol.registerSchemesAsPrivileged([
15
{ scheme: 'app', privileges: { secure: true, standard: true } },
16
]);
17
18
export default class Application {
19
public static instance = new Application();
20
public static devServerUrl = process.env.WEBPACK_DEV_SERVER_URL;
21
public overlayManager = new OverlayManager();
22
public registrationServer: ScriptRegistrationServer;
23
24
public async start() {
25
const gotTheLock = app.requestSingleInstanceLock();
26
27
if (!gotTheLock) {
28
app.quit();
29
return;
30
}
31
32
app.on('second-instance', () => {
33
console.log('CLOSING SECOND APP');
34
});
35
36
app.on('quit', () => {
37
ReplayApi.quit();
38
this.registrationServer?.close();
39
40
storage.persistAll();
41
});
42
43
this.bindEventHandlers();
44
45
await app.whenReady();
46
47
if (screen.getAllDisplays().length === 0) {
48
console.log('No displays are available to launch replay. Quitting');
49
process.exit(1);
50
return;
51
}
52
this.registerFileProtocol();
53
await this.overlayManager.start();
54
console.log('Launched with args', process.argv);
55
this.registrationServer = new ScriptRegistrationServer(this.registerScript.bind(this));
56
Menu.setApplicationMenu(generateAppMenu());
57
58
const defaultNodePath = process.argv.find(x => x.startsWith('--sa-default-node-path='));
59
60
if (defaultNodePath) {
61
const nodePath = defaultNodePath.split('--sa-default-node-path=').pop();
62
console.log('Default nodePath provided', nodePath);
63
ReplayApi.nodePath = nodePath;
64
}
65
66
if (!process.argv.includes('--sa-replay')) {
67
this.createWindowIfNeeded();
68
}
69
}
70
71
public getPageUrl(page: string) {
72
if (Application.devServerUrl) {
73
return new URL(page, Application.devServerUrl).href;
74
}
75
return `app://./${page}.html`;
76
}
77
78
public async registerScript(replayMeta: IReplayMeta) {
79
if (this.shouldAppendToOpenReplayScript(replayMeta)) return;
80
81
const window = await this.loadSessionReplay(replayMeta, true);
82
window?.replayOnFocus();
83
}
84
85
private shouldAppendToOpenReplayScript(replay: IReplayMeta) {
86
const { scriptInstanceId, scriptStartDate } = replay;
87
const windowWithScriptRun = Window.list.find(x => {
88
const session = x.replayApi?.saSession;
89
if (!session) return false;
90
return (
91
session.scriptInstanceId === scriptInstanceId &&
92
session.scriptStartDate === scriptStartDate &&
93
// make sure this isn't the current id
94
session.id !== replay.sessionId
95
);
96
});
97
if (windowWithScriptRun) {
98
windowWithScriptRun.addRelatedSession({ id: replay.sessionId, name: replay.sessionName });
99
console.log('Adding session to script instance', { replay });
100
return true;
101
}
102
return false;
103
}
104
105
private createWindowIfNeeded() {
106
if (Window.noneOpen()) {
107
Window.create();
108
}
109
}
110
111
private async loadSessionReplay(replay: IReplayMeta, findOpenReplayScriptWindow = false) {
112
let replayApi: ReplayApi;
113
try {
114
replayApi = await ReplayApi.connect(replay);
115
} catch (err) {
116
console.log('ERROR launching replay', err);
117
dialog.showErrorBox(`Unable to Load Replay`, err.message ?? String(err));
118
return;
119
}
120
121
storage.addToHistory({
122
dataLocation: replayApi.saSession.dataLocation,
123
sessionName: replayApi.saSession.name,
124
scriptInstanceId: replayApi.saSession.scriptInstanceId,
125
scriptEntrypoint: replayApi.saSession.scriptEntrypoint,
126
});
127
128
let existingWindow = Window.current;
129
if (findOpenReplayScriptWindow) {
130
existingWindow = Window.list.find(
131
x => x.replayApi?.saSession?.scriptEntrypoint === replayApi.saSession.scriptEntrypoint,
132
);
133
}
134
135
if (!existingWindow && Window.current?.isReplayActive === false) {
136
existingWindow = Window.current;
137
}
138
139
if (!existingWindow) {
140
return Window.create({ replayApi });
141
}
142
143
await existingWindow.openReplayApi(replayApi);
144
return existingWindow;
145
}
146
147
private bindEventHandlers() {
148
ipcMain.setMaxListeners(0);
149
150
// WINDOWS
151
152
app.on('activate', () => {
153
// triggered when clicking icon on OS taskbar
154
this.createWindowIfNeeded();
155
});
156
157
ipcMain.on('window:create', () => {
158
Window.create();
159
});
160
161
ipcMain.on('window:focus', () => {
162
Window.current.browserWindow.focus();
163
Window.current.webContents.focus();
164
});
165
166
ipcMain.on('window:toggle-maximize', () => {
167
const window = Window.current;
168
if (window.browserWindow.isMaximized()) {
169
window.browserWindow.unmaximize();
170
} else {
171
window.browserWindow.maximize();
172
}
173
});
174
175
ipcMain.on('window:toggle-minimize', () => {
176
const window = Window.current;
177
window.browserWindow.minimize();
178
});
179
180
ipcMain.on('window:close', () => {
181
const window = Window.current;
182
window.browserWindow.close();
183
});
184
185
ipcMain.on('window:print', () => {
186
Window.current.activeView.webContents.print();
187
});
188
189
// OVERLAYS
190
191
ipcMain.on('overlay:toggle', (e, name, rect) => {
192
const browserWindow = Window.current.browserWindow;
193
this.overlayManager.toggle(name, browserWindow, rect);
194
});
195
196
ipcMain.on('overlay:show', (e, name, rect, ...args) => {
197
const browserWindow = Window.current.browserWindow;
198
this.overlayManager.show(name, browserWindow, rect, ...args);
199
});
200
201
ipcMain.on('message-overlay:hide', (e, webContentsId, messageId) => {
202
this.overlayManager.getByWebContentsId(webContentsId).hide();
203
Window.current.hideMessageOverlay(messageId);
204
});
205
206
ipcMain.on('overlay:hide', (e, webContentsId) => {
207
this.overlayManager.getByWebContentsId(webContentsId).hide();
208
});
209
210
// GOTO
211
ipcMain.on('go-back', () => {
212
Window.current.goBack();
213
});
214
215
ipcMain.on('go-forward', () => {
216
Window.current.goForward();
217
});
218
219
ipcMain.on('navigate-to-location', (e, location) => {
220
Window.current.openAppLocation(location);
221
});
222
223
ipcMain.on('navigate-to-history', async (e, replayMeta) => {
224
await this.loadSessionReplay(replayMeta);
225
});
226
227
ipcMain.on('navigate-to-session', (e, session: { id: string; name: string }) => {
228
const current = Window.current.replayApi.saSession;
229
const replayMeta: IReplayMeta = {
230
sessionId: session.id,
231
sessionName: session.name,
232
scriptInstanceId: current.scriptInstanceId,
233
dataLocation: current.dataLocation,
234
};
235
console.log('navigate-to-session', replayMeta);
236
return this.loadSessionReplay(replayMeta, true);
237
});
238
239
ipcMain.on('navigate-to-session-tab', (e, tab: { id: number }) => {
240
Window.current?.loadReplayTab(tab.id);
241
});
242
243
// TICKS
244
let tickDebounce: NodeJS.Timeout;
245
ipcMain.on('on-tick-drag', (e, tickValue) => {
246
clearTimeout(tickDebounce);
247
const replayView = Window.current?.replayView;
248
if (!replayView) return;
249
tickDebounce = setTimeout(() => replayView.onTickDrag(tickValue), 10);
250
});
251
252
ipcMain.handle('next-tick', (e, startMillisDeficit) => {
253
return Window.current?.replayView?.nextTick(startMillisDeficit);
254
});
255
256
ipcMain.on('on-tick-hover', (e, containerRect, tickValue) => {
257
Window.current?.replayView?.onTickHover(containerRect, tickValue);
258
});
259
260
ipcMain.on('toggle-output-panel', (e, isShowing) => {
261
Window.current?.replayView?.toggleOutputView(isShowing);
262
});
263
264
ipcMain.on('output-drag', (e, diffX) => {
265
Window.current?.replayView?.growOutputView(diffX);
266
});
267
// SETTINGS
268
269
ipcMain.on('settings:save', (e, { settings }: { settings: string }) => {
270
storage.settings = JSON.parse(settings);
271
});
272
273
ipcMain.on('settings:fetch', e => {
274
e.returnValue = storage.settings;
275
});
276
277
// MISC
278
279
ipcMain.on('open-file', async () => {
280
const result = await dialog.showOpenDialog({
281
properties: ['openFile', 'showHiddenFiles'],
282
defaultPath: Path.join(Os.tmpdir(), '.secret-agent'),
283
filters: [
284
{ name: 'All Files', extensions: ['js', 'ts', 'db'] },
285
{ name: 'Session Database', extensions: ['db'] },
286
{ name: 'Javascript', extensions: ['js'] },
287
{ name: 'Typescript', extensions: ['ts'] },
288
],
289
});
290
if (result.filePaths.length) {
291
const [filename] = result.filePaths;
292
if (filename.endsWith('.db')) {
293
return this.loadSessionReplay({ dataLocation: filename });
294
}
295
let sessionContainerDir = Path.dirname(filename);
296
while (Fs.existsSync(sessionContainerDir)) {
297
const sessionsDir = Fs.existsSync(`${sessionContainerDir}/.sessions`);
298
if (sessionsDir) {
299
return this.loadSessionReplay({
300
dataLocation: `${sessionContainerDir}/.sessions`,
301
scriptEntrypoint: filename,
302
});
303
}
304
sessionContainerDir = Path.resolve(sessionContainerDir, '..');
305
}
306
}
307
});
308
309
ipcMain.on('find-in-page', () => {
310
const window = Window.current;
311
window.sendToRenderer('find');
312
});
313
314
ipcMain.handle('fetch-history', () => {
315
return storage.fetchHistory();
316
});
317
}
318
319
private registerFileProtocol() {
320
protocol.registerBufferProtocol('app', async (request, respond) => {
321
let pathName = new URL(request.url).pathname;
322
pathName = decodeURI(pathName); // Needed in case URL contains spaces
323
const filePath = Path.join(app.getAppPath(), 'frontend', pathName);
324
325
try {
326
const data = await Fs.promises.readFile(filePath);
327
const extension = Path.extname(pathName).toLowerCase();
328
let mimeType = '';
329
330
if (extension === '.js') {
331
mimeType = 'text/javascript';
332
} else if (extension === '.html') {
333
mimeType = 'text/html';
334
} else if (extension === '.css') {
335
mimeType = 'text/css';
336
} else if (extension === '.svg' || extension === '.svgz') {
337
mimeType = 'image/svg+xml';
338
} else if (extension === '.json') {
339
mimeType = 'application/json';
340
}
341
342
respond({ mimeType, data });
343
} catch (error) {
344
if (error) {
345
console.error(`Failed to read ${pathName} on app protocol`, error);
346
}
347
}
348
});
349
}
350
}
351
352