mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-11-04 16:08:32 +00:00
* chore: use actual app name as `uriScheme` * fix: wait for `initialWindow` to be set before opening sketch from file
960 lines
33 KiB
TypeScript
960 lines
33 KiB
TypeScript
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<ConsoleLogParams>(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<void> {
|
|
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<Array<string>> = [
|
|
'-*',
|
|
'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<string | undefined> {
|
|
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<void> {
|
|
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<boolean> {
|
|
// 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<BrowserWindow> {
|
|
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<number, BrowserWindow>();
|
|
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: <Electron.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<void> {
|
|
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<TheiaBrowserWindowOptions> = this.getDefaultTheiaWindowOptions()
|
|
): Promise<BrowserWindow> {
|
|
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<number> {
|
|
// 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<string>();
|
|
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<void> {
|
|
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<void>((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<FrontendApplicationConfig>
|
|
): Promise<FrontendApplicationConfig> {
|
|
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<string, string> =
|
|
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<AppInfo>,
|
|
updateWith: Record<string, unknown>
|
|
): AppInfo {
|
|
appInfoPropertyLiterals.forEach((property) => {
|
|
const newValue = updateWith[property];
|
|
if (typeof newValue === 'string') {
|
|
toUpdate[property] = newValue;
|
|
}
|
|
});
|
|
return toUpdate;
|
|
}
|
|
|
|
function isAddressInfo(arg: unknown): arg is Pick<AddressInfo, 'port'> {
|
|
// 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<AddressInfo>(arg) && typeof arg.port === 'number';
|
|
}
|