import type { FrontendApplicationConfig } from '@theia/application-package/lib/application-props'; import { environment } from '@theia/application-package/lib/environment'; import { app, BrowserWindow, contentTracing, Event as ElectronEvent, ipcMain, } from '@theia/core/electron-shared/electron'; import { Disposable, DisposableCollection, } from '@theia/core/lib/common/disposable'; import { FileUri } from '@theia/core/lib/common/file-uri'; import { isOSX } from '@theia/core/lib/common/os'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { isObject, MaybePromise, Mutable } from '@theia/core/lib/common/types'; import { ElectronSecurityToken } from '@theia/core/lib/electron-common/electron-token'; import { ElectronMainCommandOptions, ElectronMainApplication as TheiaElectronMainApplication, } from '@theia/core/lib/electron-main/electron-main-application'; import type { TheiaBrowserWindowOptions } from '@theia/core/lib/electron-main/theia-electron-window'; import { inject, injectable } from '@theia/core/shared/inversify'; import { URI } from '@theia/core/shared/vscode-uri'; import { log as logToFile, setup as setupFileLog } from 'node-log-rotate'; import { fork } from 'node:child_process'; import { promises as fs, readFileSync, rm, rmSync } from 'node:fs'; import type { AddressInfo } from 'node:net'; import { isAbsolute, join, resolve } from 'node:path'; import type { Argv } from 'yargs'; import { Sketch } from '../../common/protocol'; import { poolWhile } from '../../common/utils'; import { AppInfo, appInfoPropertyLiterals, CHANNEL_PLOTTER_WINDOW_DID_CLOSE, CHANNEL_SCHEDULE_DELETION, CHANNEL_SHOW_PLOTTER_WINDOW, isShowPlotterWindowParams, } from '../../electron-common/electron-arduino'; import { IsTempSketch } from '../../node/is-temp-sketch'; import { isAccessibleSketchPath } from '../../node/sketches-service-impl'; import { ErrnoException } from '../../node/utils/errors'; app.commandLine.appendSwitch('disable-http-cache'); const consoleLogFunctionNames = [ 'log', 'trace', 'debug', 'info', 'warn', 'error', ] as const; type ConsoleLogSeverity = (typeof consoleLogFunctionNames)[number]; interface ConsoleLogParams { readonly severity: ConsoleLogSeverity; readonly message: string; } function isConsoleLogParams(arg: unknown): arg is ConsoleLogParams { return ( isObject(arg) && typeof arg.message === 'string' && typeof arg.severity === 'string' && consoleLogFunctionNames.includes(arg.severity as ConsoleLogSeverity) ); } // Patch for on Linux when `XDG_CONFIG_HOME` is not available, `node-log-rotate` creates the folder with `undefined` name. // See https://github.com/lemon-sour/node-log-rotate/issues/23 and https://github.com/arduino/arduino-ide/issues/394. // If the IDE2 is running on Linux, and the `XDG_CONFIG_HOME` variable is not available, set it to avoid the `undefined` folder. // From the specs: https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html // "If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config should be used." function enableFileLogger() { const os = require('os'); const util = require('util'); if (os.platform() === 'linux' && !process.env['XDG_CONFIG_HOME']) { const { join } = require('path'); const home = process.env['HOME']; const xdgConfigHome = home ? join(home, '.config') : join(os.homedir(), '.config'); process.env['XDG_CONFIG_HOME'] = xdgConfigHome; } setupFileLog({ appName: 'Arduino IDE', maxSize: 10 * 1024 * 1024, }); for (const name of consoleLogFunctionNames) { const original = console[name]; console[name] = function () { // eslint-disable-next-line prefer-rest-params const messages = Object.values(arguments); const message = util.format(...messages); original(message); logToFile(message); }; } } const isProductionMode = !environment.electron.isDevMode(); if (isProductionMode) { enableFileLogger(); } interface WorkspaceOptions { file: string; x: number; y: number; width: number; height: number; isMaximized: boolean; isFullScreen: boolean; time: number; } const WORKSPACES = 'workspaces'; /** * If the app is started with `--open-devtools` argument, the `Dev Tools` will be opened. */ const APP_STARTED_WITH_DEV_TOOLS = typeof process !== 'undefined' && process.argv.indexOf('--open-devtools') !== -1; /** * If the app is started with `--content-trace` argument, the `Dev Tools` will be opened and content tracing will start. */ const APP_STARTED_WITH_CONTENT_TRACE = typeof process !== 'undefined' && process.argv.indexOf('--content-trace') !== -1; const createYargs: ( argv?: string[], cwd?: string ) => Argv = require('yargs/yargs'); @injectable() export class ElectronMainApplication extends TheiaElectronMainApplication { @inject(IsTempSketch) private readonly isTempSketch: IsTempSketch; private startup = false; private _firstWindowId: number | undefined; private _appInfo: AppInfo = { appVersion: '', cliVersion: '', buildDate: '', }; private openFilePromise = new Deferred(); /** * It contains all things the IDE2 must clean up before a normal stop. * * When deleting the sketch, the IDE2 must close the browser window and * recursively delete the sketch folder from the filesystem. The sketch * cannot be deleted when the window is open because that is the currently * opened workspace. IDE2 cannot delete the sketch folder from the * filesystem after closing the browser window because the window can be * the last, and when the last window closes, the application quits. * There is no way to clean up the undesired resources. * * This array contains disposable instances wrapping synchronous sketch * delete operations. When IDE2 closes the browser window, it schedules * the sketch deletion, and the window closes. * * When IDE2 schedules a sketch for deletion, it creates a synchronous * folder deletion as a disposable instance and pushes it into this * array. After the push, IDE2 starts the sketch deletion in an * asynchronous way. When the deletion completes, the disposable is * removed. If the app quits when the asynchronous deletion is still in * progress, it disposes the elements of this array. Since it is * synchronous, it is [ensured by Theia](https://github.com/eclipse-theia/theia/blob/678e335644f1b38cb27522cc27a3b8209293cf31/packages/core/src/node/backend-application.ts#L91-L97) * that IDE2 won't quit before the cleanup is done. It works only in normal * quit. */ // TODO: Why is it here and not in the Theia backend? // https://github.com/eclipse-theia/theia/discussions/12135 private readonly scheduledDeletions: Disposable[] = []; override async start(config: FrontendApplicationConfig): Promise { createYargs(this.argv, process.cwd()) .command( '$0 [file]', false, (cmd) => cmd .option('electronUserData', { type: 'string', describe: 'The area where the electron main process puts its data', }) .positional('file', { type: 'string' }), async (args) => { if (args.electronUserData) { console.info( `using electron user data area : '${args.electronUserData}'` ); await fs.mkdir(args.electronUserData, { recursive: true }); app.setPath('userData', args.electronUserData); } // Explicitly set the app name to have better menu items on macOS. ("About", "Hide", and "Quit") // See: https://github.com/electron-userland/electron-builder/issues/2468 // Regression in Theia: https://github.com/eclipse-theia/theia/issues/8701 console.log(`${config.applicationName} ${app.getVersion()}`); app.on('ready', () => app.setName(config.applicationName)); const cwd = process.cwd(); this.attachFileAssociations(cwd); this.useNativeWindowFrame = this.getTitleBarStyle(config) === 'native'; this._config = await updateFrontendApplicationConfigFromPackageJson( config ); this._appInfo = updateAppInfo(this._appInfo, this._config); this.hookApplicationEvents(); this.showInitialWindow(undefined); const [port] = await Promise.all([ this.startBackend(), app.whenReady(), ]); this.startContentTracing(); this._backendPort.resolve(port); await Promise.all([ this.attachElectronSecurityToken(port), this.startContributions(), ]); this.handleMainCommand({ file: args.file, cwd, secondInstance: false, }); } ) .parse(); } private startContentTracing(): void { if (!APP_STARTED_WITH_CONTENT_TRACE) { return; } if (!app.isReady()) { throw new Error( 'Cannot start content tracing when the electron app is not ready.' ); } const defaultTraceCategories: Readonly> = [ '-*', 'devtools.timeline', 'disabled-by-default-devtools.timeline', 'disabled-by-default-devtools.timeline.frame', 'toplevel', 'blink.console', 'disabled-by-default-devtools.timeline.stack', 'disabled-by-default-v8.cpu_profile', 'disabled-by-default-v8.cpu_profiler', 'disabled-by-default-v8.cpu_profiler.hires', ]; const traceOptions = { categoryFilter: defaultTraceCategories.join(','), traceOptions: 'record-until-full', options: 'sampling-frequency=10000', }; (async () => { const appPath = app.getAppPath(); let traceFile: string | undefined; if (appPath) { const tracesPath = join(appPath, 'traces'); await fs.mkdir(tracesPath, { recursive: true }); traceFile = join(tracesPath, `trace-${new Date().toISOString()}.trace`); } console.log('>>> Content tracing has started...'); await contentTracing.startRecording(traceOptions); await new Promise((resolve) => setTimeout(resolve, 10_000)); contentTracing .stopRecording(traceFile) .then((out) => console.log( `<<< Content tracing has finished. The trace data was written to: ${out}.` ) ); })(); } private attachFileAssociations(cwd: string): void { // OSX: register open-file event if (isOSX) { app.on('open-file', async (event, path) => { event.preventDefault(); const resolvedPath = await this.resolvePath(path, cwd); if (resolvedPath) { const sketchFolderPath = await isAccessibleSketchPath( resolvedPath, true ); if (sketchFolderPath) { this.openFilePromise.reject(new InterruptWorkspaceRestoreError()); // open-file event is triggered before the app is ready and initialWindow is created. // Wait for initialWindow to be set before opening the sketch on the first instance. // See https://github.com/arduino/arduino-ide/pull/2693 try { await app.whenReady(); if (!this.firstWindowId) { await poolWhile(() => !this.initialWindow, 100, 3000); } } catch {} await this.openSketch(sketchFolderPath); } } }); setTimeout(() => this.openFilePromise.resolve(), 500); } else { this.openFilePromise.resolve(); } } private async resolvePath( maybePath: string, cwd: string ): Promise { if (isAbsolute(maybePath)) { return maybePath; } try { const resolved = await fs.realpath(resolve(cwd, maybePath)); return resolved; } catch (err) { if (ErrnoException.isENOENT(err)) { return undefined; } throw err; } } protected override async handleMainCommand( options: ElectronMainCommandOptions ): Promise { try { // When running on MacOS, we either have to wait until // 1. The `open-file` command has been received by the app, rejecting the promise // 2. A short timeout resolves the promise automatically, falling back to the usual app launch await this.openFilePromise.promise; } catch (err) { if (err instanceof InterruptWorkspaceRestoreError) { // Application has received the `open-file` event and will skip the default application launch return; } throw err; } if (await this.launchFromArgs(options)) { // Application has received a file in its arguments and will skip the default application launch return; } this.startup = true; const workspaces: WorkspaceOptions[] | undefined = this.electronStore.get(WORKSPACES); let useDefault = true; if (workspaces && workspaces.length > 0) { console.log( `Restoring workspace roots: ${workspaces.map(({ file }) => file)}` ); for (const workspace of workspaces) { const resolvedPath = await this.resolvePath( workspace.file, options.cwd ); if (!resolvedPath) { continue; } const sketchFolderPath = await isAccessibleSketchPath( resolvedPath, true ); if (sketchFolderPath) { workspace.file = sketchFolderPath; if (this.isTempSketch.is(workspace.file)) { console.info( `Skipped opening sketch. The sketch was detected as temporary. Workspace path: ${workspace.file}.` ); continue; } useDefault = false; await this.openSketch(workspace); } } } this.startup = false; if (useDefault) { super.handleMainCommand(options); } } private get argv(): string[] { return this.processArgv.getProcessArgvWithoutBin(process.argv).slice(); } private async launchFromArgs( params: ElectronMainCommandOptions, argv?: string[] ): Promise { // Copy to prevent manipulation of original array const argCopy = [...(argv || this.argv)]; let path: string | undefined; for (const maybePath of argCopy) { const resolvedPath = await this.resolvePath(maybePath, params.cwd); if (!resolvedPath) { continue; } const sketchFolderPath = await isAccessibleSketchPath(resolvedPath, true); if (sketchFolderPath) { path = sketchFolderPath; break; } } if (path) { await this.openSketch(path); return true; } return false; } private async openSketch( workspaceOrPath: WorkspaceOptions | string ): Promise { const options = await this.getLastWindowOptions(); let file: string; if (typeof workspaceOrPath === 'object') { options.x = workspaceOrPath.x; options.y = workspaceOrPath.y; options.width = workspaceOrPath.width; options.height = workspaceOrPath.height; options.isMaximized = workspaceOrPath.isMaximized; options.isFullScreen = workspaceOrPath.isFullScreen; file = workspaceOrPath.file; } else { file = workspaceOrPath; } const [uri, electronWindow] = await Promise.all([ this.createWindowUri(), this.reuseOrCreateWindow(options), ]); electronWindow.loadURL(uri.withFragment(encodeURI(file)).toString(true)); return electronWindow; } protected override avoidOverlap( options: TheiaBrowserWindowOptions ): TheiaBrowserWindowOptions { if (this.startup) { return options; } return super.avoidOverlap(options); } protected override getTitleBarStyle( // eslint-disable-next-line @typescript-eslint/no-unused-vars _config: FrontendApplicationConfig ): 'native' | 'custom' { return 'native'; } protected override hookApplicationEvents(): void { app.on('will-quit', this.onWillQuit.bind(this)); app.on('second-instance', this.onSecondInstance.bind(this)); app.on('window-all-closed', this.onWindowAllClosed.bind(this)); ipcMain.on(CHANNEL_SCHEDULE_DELETION, (event, sketch: unknown) => { if (Sketch.is(sketch)) { console.log(`Sketch ${sketch.uri} was scheduled for deletion`); // TODO: remove deleted sketch from closedWorkspaces? this.delete(sketch); } }); ipcMain.on(CHANNEL_SHOW_PLOTTER_WINDOW, (event, args) => this.handleShowPlotterWindow(event, args) ); } // keys are the host window IDs private readonly plotterWindows = new Map(); private handleShowPlotterWindow( event: Electron.IpcMainEvent, args: unknown ): void { if (!isShowPlotterWindowParams(args)) { console.warn( `Received unexpected params on the '${CHANNEL_SHOW_PLOTTER_WINDOW}' channel. Sender ID: ${ event.sender.id }, params: ${JSON.stringify(args)}` ); return; } const electronWindow = BrowserWindow.fromWebContents(event.sender); if (!electronWindow) { console.warn( `Could not find the host window of event received on the '${CHANNEL_SHOW_PLOTTER_WINDOW}' channel. Sender ID: ${ event.sender.id }, params: ${JSON.stringify(args)}` ); return; } const windowId = electronWindow.id; let plotterWindow = this.plotterWindows.get(windowId); if (plotterWindow) { if (!args.forceReload) { plotterWindow.focus(); } else { plotterWindow.loadURL(args.url); } return; } plotterWindow = new BrowserWindow({ width: 800, minWidth: 620, height: 500, minHeight: 320, x: 100, y: 100, webPreferences: { devTools: true, nativeWindowOpen: true, openerId: electronWindow.webContents.id, }, }); this.plotterWindows.set(windowId, plotterWindow); plotterWindow.setMenu(null); plotterWindow.on('closed', () => { this.plotterWindows.delete(windowId); electronWindow.webContents.send(CHANNEL_PLOTTER_WINDOW_DID_CLOSE); }); plotterWindow.loadURL(args.url); } protected override async onSecondInstance( event: ElectronEvent, argv: string[], cwd: string ): Promise { if (await this.launchFromArgs({ cwd, secondInstance: true }, argv)) { // Application has received a file in its arguments return; } super.onSecondInstance(event, argv, cwd); } override async createWindow( asyncOptions: MaybePromise = this.getDefaultTheiaWindowOptions() ): Promise { const electronWindow = await super.createWindow(asyncOptions); if (APP_STARTED_WITH_DEV_TOOLS) { electronWindow.webContents.openDevTools(); } this.attachListenersToWindow(electronWindow); if (this._firstWindowId === undefined) { this._firstWindowId = electronWindow.id; } return electronWindow; } protected override getDefaultOptions(): TheiaBrowserWindowOptions { const options = super.getDefaultOptions(); if (!options.webPreferences) { options.webPreferences = {}; } options.webPreferences.v8CacheOptions = 'bypassHeatCheck'; // TODO: verify this. VS Code use this V8 option. options.minWidth = 680; options.minHeight = 593; return options; } private attachListenersToWindow(electronWindow: BrowserWindow) { this.attachClosedWorkspace(electronWindow); this.attachClosePlotterWindow(electronWindow); } protected override async startBackend(): Promise { // Check if we should run everything as one process. const noBackendFork = process.argv.indexOf('--no-cluster') !== -1; // We cannot use the `process.cwd()` as the application project path (the location of the `package.json` in other words) // in a bundled electron application because it depends on the way we start it. For instance, on OS X, these are a differences: // https://github.com/eclipse-theia/theia/issues/3297#issuecomment-439172274 process.env.THEIA_APP_PROJECT_PATH = this.globals.THEIA_APP_PROJECT_PATH; // Set the electron version for both the dev and the production mode. (https://github.com/eclipse-theia/theia/issues/3254) // Otherwise, the forked backend processes will not know that they're serving the electron frontend. process.env.THEIA_ELECTRON_VERSION = process.versions.electron; if (noBackendFork) { process.env[ElectronSecurityToken] = JSON.stringify( this.electronSecurityToken ); // The backend server main file is supposed to export a promise resolving with the port used by the http(s) server. const address: AddressInfo = await require(this.globals .THEIA_BACKEND_MAIN_PATH); return address.port; } else { let args = this.processArgv.getProcessArgvWithoutBin(); // https://github.com/eclipse-theia/theia/issues/8227 if (process.platform === 'darwin') { // https://github.com/electron/electron/issues/3657 // https://stackoverflow.com/questions/10242115/os-x-strange-psn-command-line-parameter-when-launched-from-finder#comment102377986_10242200 // macOS appends an extra `-psn_0_someNumber` arg if a file is opened from Finder after downloading from the Internet. // "AppName" is an app downloaded from the Internet. Are you sure you want to open it? args = args.filter((arg) => !arg.startsWith('-psn')); } const backendProcess = fork( this.globals.THEIA_BACKEND_MAIN_PATH, args, await this.getForkOptions() ); console.log(`Starting backend process. PID: ${backendProcess.pid}`); return new Promise((resolve, reject) => { // The forked backend process sends the resolved http(s) server port via IPC, and forwards the log messages. backendProcess.on('message', (arg: unknown) => { if (isConsoleLogParams(arg)) { const { message, severity } = arg; console[severity](message); } else if (isAddressInfo(arg)) { resolve(arg.port); } }); backendProcess.on('error', (error) => { reject(error); }); app.on('quit', () => { try { // If we forked the process for the clusters, we need to manually terminate it. // See: https://github.com/eclipse-theia/theia/issues/835 if (backendProcess.pid) { process.kill(backendProcess.pid); } } catch (e) { if (e.code === 'ESRCH') { console.log( 'Could not terminate the backend process. It was not running.' ); return; } throw e; } }); }); } } private closedWorkspaces: WorkspaceOptions[] = []; private attachClosedWorkspace(window: BrowserWindow): void { // Since the `before-quit` event is only fired when closing the *last* window // We need to keep track of recently closed windows/workspaces manually window.on('close', () => { const url = window.webContents.getURL(); const workspace = URI.parse(url).fragment; if (workspace) { const workspaceUri = URI.file(workspace); const bounds = window.getNormalBounds(); const now = Date.now(); // Do not try to reopen the sketch if it was temp. // Unfortunately, IDE2 has two different logic of restoring recent sketches: the Theia default `recentworkspace.json` and there is the `recent-sketches.json`. const file = workspaceUri.fsPath; if (this.isTempSketch.is(file)) { console.info( `Ignored marking workspace as a closed sketch. The sketch was detected as temporary. Workspace URI: ${workspaceUri.toString()}.` ); return; } console.log( `Marking workspace as a closed sketch. Workspace URI: ${workspaceUri.toString()}. Date: ${now}.` ); this.closedWorkspaces.push({ ...bounds, isMaximized: window.isMaximized(), isFullScreen: window.isFullScreen(), file: workspaceUri.fsPath, time: now, }); } }); } private attachClosePlotterWindow(window: BrowserWindow): void { window.on('close', () => { this.plotterWindows.get(window.id)?.close(); this.plotterWindows.delete(window.id); }); } protected override onWillQuit(event: Electron.Event): void { // Only add workspaces which were closed within the last second (1000 milliseconds) const threshold = Date.now() - 1000; const visited = new Set(); const workspaces = this.closedWorkspaces .filter((e) => { if (e.time < threshold) { console.log( `Skipped storing sketch as workspace root. Expected minimum threshold: <${threshold}>. Was: <${e.time}>.` ); return false; } if (visited.has(e.file)) { console.log( `Skipped storing sketch as workspace root. Already visited: <${e.file}>.` ); return false; } visited.add(e.file); console.log(`Storing the sketch as a workspace root: <${e.file}>.`); return true; }) .sort((a, b) => a.file.localeCompare(b.file)); this.electronStore.set(WORKSPACES, workspaces); console.log( `Stored workspaces roots: ${workspaces.map(({ file }) => file)}` ); if (this.scheduledDeletions.length) { console.log( '>>> Finishing scheduled sketch deletions before app quit...' ); new DisposableCollection(...this.scheduledDeletions).dispose(); console.log('<<< Successfully finishing scheduled sketch deletions.'); } else { console.log('No sketches were scheduled for deletion.'); } if (this.plotterWindows.size) { for (const [ hostWindowId, plotterWindow, ] of this.plotterWindows.entries()) { plotterWindow.close(); this.plotterWindows.delete(hostWindowId); } } super.onWillQuit(event); } get browserWindows(): BrowserWindow[] { return Array.from(this.windows.values()).map(({ window }) => window); } get firstWindowId(): number | undefined { return this._firstWindowId; } get appInfo(): AppInfo { return this._appInfo; } private async delete(sketch: Sketch): Promise { const sketchPath = FileUri.fsPath(sketch.uri); const disposable = Disposable.create(() => { try { this.deleteSync(sketchPath); } catch (err) { console.error( `Could not delete sketch ${sketchPath} on app quit.`, err ); } }); this.scheduledDeletions.push(disposable); return new Promise((resolve, reject) => { rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { if (error) { console.error(`Failed to delete sketch ${sketchPath}`, error); reject(error); } else { console.info(`Successfully deleted sketch ${sketchPath}`); resolve(); const index = this.scheduledDeletions.indexOf(disposable); if (index >= 0) { this.scheduledDeletions.splice(index, 1); console.info( `Successfully completed the scheduled sketch deletion: ${sketchPath}` ); } else { console.warn( `Could not find the scheduled sketch deletion: ${sketchPath}` ); } } }); }); } private deleteSync(sketchPath: string): void { console.info( `>>> Running sketch deletion ${sketchPath} before app quit...` ); try { rmSync(sketchPath, { recursive: true, maxRetries: 5 }); console.info(`<<< Deleted sketch ${sketchPath}`); } catch (err) { if (!ErrnoException.isENOENT(err)) { throw err; } else { console.info(`<<< Sketch ${sketchPath} did not exist.`); } } } // Fallback app config when starting IDE2 from an ino file (from Explorer, Finder, etc.) and the app config is not yet set. // https://github.com/arduino/arduino-ide/issues/2209 private _fallbackConfig: FrontendApplicationConfig | undefined; override get config(): FrontendApplicationConfig { if (!this._config) { if (!this._fallbackConfig) { this._fallbackConfig = readFrontendAppConfigSync(); } return this._fallbackConfig; } return super.config; } } class InterruptWorkspaceRestoreError extends Error { constructor() { super( "Received 'open-file' event. Interrupting the default launch workflow." ); Object.setPrototypeOf(this, InterruptWorkspaceRestoreError.prototype); } } // This is a workaround for a limitation with the Theia CLI and `electron-builder`. // It is possible to run the `electron-builder` with `-c.extraMetadata.foo.bar=36` option. // On the fly, a `package.json` file will be generated for the final bundled application with the additional `{ "foo": { "bar": 36 } }` metadata. // The Theia build (via the CLI) requires the extra `foo.bar=36` metadata to be in the `package.json` at build time (before `electron-builder` time). // See the generated `./electron-app/src-gen/backend/electron-main.js` and how this works. // This method merges in any additional required properties defined in the current! `package.json` of the application. For example, the `buildDate`. // The current package.json is the package.json of the `electron-app` if running from the source code, // but it's the `package.json` inside the `resources/app/` folder if it's the final bundled app. // See https://github.com/arduino/arduino-ide/pull/2144#pullrequestreview-1556343430. async function updateFrontendApplicationConfigFromPackageJson( config: Mutable ): Promise { if (!isProductionMode) { console.debug( 'Skipping frontend application configuration customizations. Running in dev mode.' ); return config; } try { console.debug( `Checking for frontend application configuration customizations. Module path: ${__filename}, destination 'package.json': ${packageJsonPath}` ); const rawPackageJson = await fs.readFile(packageJsonPath, { encoding: 'utf8', }); const packageJson = JSON.parse(rawPackageJson); if (packageJson?.theia?.frontend?.config) { const packageJsonConfig: Record = packageJson?.theia?.frontend?.config; for (const property of appInfoPropertyLiterals) { const value = packageJsonConfig[property]; if (value && !config[property]) { if (!config[property]) { console.debug( `Setting 'theia.frontend.config.${property}' application configuration value to: ${JSON.stringify( value )} (type of ${typeof value})` ); } else { console.warn( `Overriding 'theia.frontend.config.${property}' application configuration value with: ${JSON.stringify( value )} (type of ${typeof value}). Original value: ${JSON.stringify( config[property] )}` ); } config[property] = value; } } console.debug( `Frontend application configuration after modifications: ${JSON.stringify( config )}` ); return config; } } catch (err) { console.error( `Could not read the frontend application configuration from the 'package.json' file. Falling back to (the Theia CLI) generated default config: ${JSON.stringify( config )}`, err ); } return config; } const fallbackFrontendAppConfig: FrontendApplicationConfig = { applicationName: 'Arduino IDE', defaultTheme: { light: 'arduino-theme', dark: 'arduino-theme-dark', }, defaultIconTheme: 'none', validatePreferencesSchema: false, defaultLocale: '', electron: { showWindowEarly: true, uriScheme: 'arduino-ide', }, reloadOnReconnect: true, }; // When the package.json must go from `./lib/backend/electron-main.js` to `./package.json` when the app is webpacked. // Only for production mode! const packageJsonPath = join(__filename, '..', '..', '..', 'package.json'); function readFrontendAppConfigSync(): FrontendApplicationConfig { if (environment.electron.isDevMode()) { console.debug( 'Running in dev mode. Using the fallback fronted application config.' ); return fallbackFrontendAppConfig; } try { const raw = readFileSync(packageJsonPath, { encoding: 'utf8' }); const packageJson = JSON.parse(raw); const config = packageJson?.theia?.frontend?.config; if (config) { return config; } throw new Error(`Frontend application config not found. ${packageJson}`); } catch (err) { console.error( `Could not read package.json content from ${packageJsonPath}.`, err ); return fallbackFrontendAppConfig; } } /** * Mutates the `toUpdate` argument and returns with it. */ function updateAppInfo( toUpdate: Mutable, updateWith: Record ): AppInfo { appInfoPropertyLiterals.forEach((property) => { const newValue = updateWith[property]; if (typeof newValue === 'string') { toUpdate[property] = newValue; } }); return toUpdate; } function isAddressInfo(arg: unknown): arg is Pick { // Cannot do the type-guard on all properties, but the port is sufficient as the address is always `localhost`. // For example, the `family` might be absent if the address is IPv6. return isObject(arg) && typeof arg.port === 'number'; }