-
Notifications
You must be signed in to change notification settings - Fork 323
/
index.ts
389 lines (357 loc) · 17.5 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
/** @file Definition of an Electron application, which entails the creation of a rudimentary HTTP
* server and the presentation of a Chrome web view, designed for optimal performance and
* compatibility across a wide range of hardware configurations. The application's web component
* is then served and showcased within the web view, complemented by the establishment of an
* Inter-Process Communication channel, which enables seamless communication between the served web
* application and the Electron process. */
import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs'
import * as pathModule from 'node:path'
import process from 'node:process'
import * as electron from 'electron'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as authentication from 'authentication'
import * as config from 'config'
import * as configParser from 'config/parser'
import * as debug from 'debug'
// eslint-disable-next-line no-restricted-syntax
import * as fileAssociations from 'file-associations'
import * as ipc from 'ipc'
import * as naming from 'naming'
import * as paths from 'paths'
import * as projectManager from 'bin/project-manager'
import * as security from 'security'
import * as server from 'bin/server'
import * as utils from '../../../utils'
const logger = contentConfig.logger
// ===========
// === App ===
// ===========
/** The Electron application. It is responsible for starting all the required services, and
* displaying and managing the app window. */
class App {
window: electron.BrowserWindow | null = null
server: server.Server | null = null
args: config.Args = config.CONFIG
isQuitting = false
async run() {
// Register file associations for macOS.
electron.app.on('open-file', fileAssociations.onFileOpened)
const { windowSize, chromeOptions, fileToOpen } = this.processArguments()
if (fileToOpen != null) {
try {
// This makes the IDE open the relevant project. Also, this prevents us from using this
// method after IDE has been fully set up, as the initializing code would have already
// read the value of this argument.
this.args.groups.startup.options.project.value =
fileAssociations.handleOpenFile(fileToOpen)
} catch (e) {
// If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message.
}
}
if (this.args.options.version.value) {
await this.printVersion()
electron.app.quit()
} else if (this.args.groups.debug.options.info.value) {
await electron.app.whenReady().then(async () => {
await debug.printInfo()
electron.app.quit()
})
} else {
this.setChromeOptions(chromeOptions)
security.enableAll()
electron.app.on('before-quit', () => (this.isQuitting = true))
/** TODO [NP]: https://github.com/enso-org/enso/issues/5851
* The `electron.app.whenReady()` listener is preferable to the
* `electron.app.on('ready', ...)` listener. When the former is used in combination with
* the `authentication.initModule` call that is called in the listener, the application
* freezes. This freeze should be diagnosed and fixed. Then, the `whenReady()` listener
* should be used here instead. */
electron.app.on('ready', () => {
void this.main(windowSize)
})
this.registerShortcuts()
}
}
processArguments() {
// We parse only "client arguments", so we don't have to worry about the Electron-Dev vs
// Electron-Proper distinction.
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
// If we are opening a file (i.e. we were spawned with just a path of the file to open as
// the argument), it means that effectively we don't have any non-standard arguments.
// We just need to let caller know that we are opening a file.
const argsToParse = fileToOpen ? [] : fileAssociations.CLIENT_ARGUMENTS
return { ...configParser.parseArgs(argsToParse), fileToOpen }
}
/** Set Chrome options based on the app configuration. For comprehensive list of available
* Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */
setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
const addIf = (
opt: contentConfig.Option<boolean>,
chromeOptName: string,
value?: string
) => {
if (opt.value) {
const chromeOption = new configParser.ChromeOption(chromeOptName, value)
const chromeOptionStr = chromeOption.display()
const optionName = opt.qualifiedName()
logger.log(`Setting '${chromeOptionStr}' because '${optionName}' was enabled.`)
chromeOptions.push(chromeOption)
}
}
const add = (option: string, value?: string) =>
chromeOptions.push(new configParser.ChromeOption(option, value))
logger.groupMeasured('Setting Chrome options', () => {
const perfOpts = this.args.groups.performance.options
addIf(perfOpts.disableGpuSandbox, 'disable-gpu-sandbox')
addIf(perfOpts.disableGpuVsync, 'disable-gpu-vsync')
addIf(perfOpts.disableSandbox, 'no-sandbox')
addIf(perfOpts.disableSmoothScrolling, 'disable-smooth-scrolling')
addIf(perfOpts.enableNativeGpuMemoryBuffers, 'enable-native-gpu-memory-buffers')
addIf(perfOpts.forceHighPerformanceGpu, 'force_high_performance_gpu')
addIf(perfOpts.ignoreGpuBlocklist, 'ignore-gpu-blocklist')
add('use-angle', perfOpts.angleBackend.value)
chromeOptions.sort((a, b) => a.name.localeCompare(b.name))
if (chromeOptions.length > 0) {
for (const chromeOption of chromeOptions) {
electron.app.commandLine.appendSwitch(chromeOption.name, chromeOption.value)
}
const cfgName = config.HELP_EXTENDED_OPTION_NAME
logger.log(`See '-${cfgName}' to learn why these options were enabled.`)
}
})
}
/** Main app entry point. */
async main(windowSize: config.WindowSize) {
// We catch all errors here. Otherwise, it might be possible that the app will run partially
// and enter a "zombie mode", where user is not aware of the app still running.
try {
await logger.asyncGroupMeasured('Starting the application', async () => {
// Note that we want to do all the actions synchronously, so when the window
// appears, it serves the website immediately.
await this.startBackendIfEnabled()
await this.startContentServerIfEnabled()
await this.createWindowIfEnabled(windowSize)
this.initIpc()
/** The non-null assertion on the following line is safe because the window
* initialization is guarded by the `createWindowIfEnabled` method. The window is
* not yet created at this point, but it will be created by the time the
* authentication module uses the lambda providing the window. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
authentication.initModule(() => this.window!)
this.loadWindowContent()
})
} catch (err) {
console.error('Failed to initialize the application, shutting down. Error:', err)
electron.app.quit()
}
}
/** Run the provided function if the provided option was enabled. Log a message otherwise. */
async runIfEnabled(option: contentConfig.Option<boolean>, fn: () => Promise<void> | void) {
if (option.value) {
await fn()
} else {
logger.log(`The app is configured not to use ${option.name}.`)
}
}
/** Start the backend processes. */
async startBackendIfEnabled() {
await this.runIfEnabled(this.args.options.engine, () => {
const backendOpts = this.args.groups.debug.options.verbose.value ? ['-vv'] : []
projectManager.spawn(this.args, backendOpts)
})
}
/** Start the content server, which will serve the application content (HTML) to the window. */
async startContentServerIfEnabled() {
await this.runIfEnabled(this.args.options.server, async () => {
await logger.asyncGroupMeasured('Starting the content server.', async () => {
const serverCfg = new server.Config({
dir: paths.ASSETS_PATH,
port: this.args.groups.server.options.port.value,
})
this.server = await server.Server.create(serverCfg)
})
})
}
/** Create the Electron window and display it on the screen. */
async createWindowIfEnabled(windowSize: config.WindowSize) {
await this.runIfEnabled(this.args.options.window, () => {
logger.groupMeasured('Creating the window.', () => {
const argGroups = this.args.groups
const useFrame = this.args.groups.window.options.frame.value
const macOS = process.platform === 'darwin'
const useHiddenInsetTitleBar = !useFrame && macOS
const useVibrancy = this.args.groups.window.options.vibrancy.value
const webPreferences: electron.WebPreferences = {
preload: pathModule.join(paths.APP_PATH, 'preload.cjs'),
sandbox: true,
backgroundThrottling: argGroups.performance.options.backgroundThrottling.value,
devTools: argGroups.debug.options.devTools.value,
enableBlinkFeatures: argGroups.chrome.options.enableBlinkFeatures.value,
disableBlinkFeatures: argGroups.chrome.options.disableBlinkFeatures.value,
spellcheck: false,
}
const windowPreferences: electron.BrowserWindowConstructorOptions = {
webPreferences,
width: windowSize.width,
height: windowSize.height,
frame: useFrame,
transparent: false,
titleBarStyle: useHiddenInsetTitleBar ? 'hiddenInset' : 'default',
...(useVibrancy ? { vibrancy: 'fullscreen-ui' } : {}),
}
const window = new electron.BrowserWindow(windowPreferences)
window.setMenuBarVisibility(false)
if (this.args.groups.debug.options.devTools.value) {
window.webContents.openDevTools()
}
const allowedPermissions = ['clipboard-read', 'clipboard-sanitized-write']
window.webContents.session.setPermissionRequestHandler(
(_webContents, permission, callback) => {
if (allowedPermissions.includes(permission)) {
callback(true)
} else {
console.error(`Denied permission check '${permission}'.`)
callback(false)
}
}
)
window.on('close', evt => {
if (!this.isQuitting && !this.args.groups.window.options.closeToQuit.value) {
evt.preventDefault()
window.hide()
}
})
electron.app.on('activate', () => {
if (!this.args.groups.window.options.closeToQuit.value) {
window.show()
}
})
window.webContents.on('render-process-gone', (_event, details) => {
logger.error('Error, the render process crashed.', details)
})
this.window = window
})
})
}
/** Initialize Inter-Process Communication between the Electron application and the served
* website. */
initIpc() {
electron.ipcMain.on(ipc.Channel.error, (_event, data) => {
logger.error(`IPC error: ${JSON.stringify(data)}`)
})
const argProfiles = this.args.groups.profile.options.load.value
const profilePromises: Promise<string>[] = argProfiles.map((path: string) =>
fs.readFile(path, 'utf8')
)
const profilesPromise = Promise.all(profilePromises)
electron.ipcMain.on(ipc.Channel.loadProfiles, event => {
void profilesPromise.then(profiles => {
event.reply('profiles-loaded', profiles)
})
})
const profileOutPath = this.args.groups.profile.options.save.value
if (profileOutPath) {
electron.ipcMain.on(ipc.Channel.saveProfile, (_event, data: string) => {
fsSync.writeFileSync(profileOutPath, data)
})
}
electron.ipcMain.on(ipc.Channel.openGpuDebugInfo, _event => {
if (this.window != null) {
this.window.loadURL('chrome://gpu')
}
})
electron.ipcMain.on(ipc.Channel.quit, () => {
electron.app.quit()
})
}
/** The server port. In case the server was not started, the port specified in the configuration
* is returned. This might be used to connect this application window to another, existing
* application server. */
serverPort(): number {
return this.server?.config.port ?? this.args.groups.server.options.port.value
}
/** Redirect the web view to `localhost:<port>` to see the served website. */
loadWindowContent() {
if (this.window != null) {
const urlCfg: Record<string, string> = {}
for (const option of this.args.optionsRecursive()) {
if (option.value !== option.default && option.passToWebApplication) {
urlCfg[option.qualifiedName()] = String(option.value)
}
}
const params = server.urlParamsFromObject(urlCfg)
const address = `http://localhost:${this.serverPort()}${params}`
logger.log(`Loading the window address '${address}'.`)
void this.window.loadURL(address)
}
}
printVersion(): Promise<void> {
const indent = ' '.repeat(utils.INDENT_SIZE)
let maxNameLen = 0
for (const name in debug.VERSION_INFO) {
maxNameLen = Math.max(maxNameLen, name.length)
}
console.log('Frontend:')
for (const [name, value] of Object.entries(debug.VERSION_INFO)) {
const label = naming.capitalizeFirstLetter(name)
const spacing = ' '.repeat(maxNameLen - name.length)
console.log(`${indent}${label}:${spacing} ${value}`)
}
console.log('')
console.log('Backend:')
return projectManager.version(this.args).then(backend => {
if (!backend) {
console.log(`${indent}No backend available.`)
} else {
const lines = backend.split(/\r?\n/).filter(line => line.length > 0)
for (const line of lines) {
console.log(`${indent}${line}`)
}
}
})
}
registerShortcuts() {
electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => {
webContents.on('before-input-event', (_beforeInputEvent, input) => {
const { code, alt, control, shift, meta, type } = input
if (type === 'keyDown') {
const focusedWindow = electron.BrowserWindow.getFocusedWindow()
if (focusedWindow) {
if (control && alt && shift && !meta && code === 'KeyI') {
focusedWindow.webContents.toggleDevTools()
}
if (control && alt && shift && !meta && code === 'KeyR') {
focusedWindow.reload()
}
}
const cmdQ = meta && !control && !alt && !shift && code === 'KeyQ'
const ctrlQ = !meta && control && !alt && !shift && code === 'KeyQ'
const altF4 = !meta && !control && alt && !shift && code === 'F4'
const ctrlW = !meta && control && !alt && !shift && code === 'KeyW'
const quitOnMac = process.platform === 'darwin' && (cmdQ || altF4)
const quitOnWin = process.platform === 'win32' && (altF4 || ctrlW)
const quitOnLinux = process.platform === 'linux' && (altF4 || ctrlQ || ctrlW)
const quit = quitOnMac || quitOnWin || quitOnLinux
if (quit) {
electron.app.quit()
}
}
})
})
}
}
// ===================
// === App startup ===
// ===================
process.on('uncaughtException', (err, origin) => {
console.error(`Uncaught exception: ${String(err)}\nException origin: ${origin}`)
electron.dialog.showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString())
electron.app.exit(1)
})
const APP = new App()
void APP.run()