Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
219036c
chore: add new IPC to get fiddle start params from renderer
dsanders11 May 7, 2026
b44c80a
chore: remove unneeded // eslint-disable-next-line
dsanders11 May 7, 2026
3101b28
chore: refactor to start fiddle in main process
dsanders11 May 7, 2026
1a7446c
fix: change run button state to installing modules correctly
dsanders11 May 7, 2026
c627803
refactor: move plural-maybe.ts to common utils
dsanders11 May 7, 2026
5e4fdb9
chore: drop dead code
dsanders11 May 7, 2026
e4f6dec
Merge branch 'main' into build/refactor-start-fiddle-ipc
ckerr May 8, 2026
797af9b
Merge branch 'main' into build/refactor-start-fiddle-ipc
dsanders11 May 8, 2026
7ae3c84
Merge branch 'main' into build/refactor-start-fiddle-ipc
dsanders11 May 8, 2026
203f2e1
refactor: remove runner.stop wrapper method
dsanders11 May 8, 2026
201c809
chore: move push output helpers to utils
dsanders11 May 8, 2026
426b792
refactor: move bisect.ts to common utils
dsanders11 May 8, 2026
9787428
chore: move autobisect to main process
dsanders11 May 8, 2026
e4bec97
refactor(renderer): consolidate run and runFiddle
dsanders11 May 8, 2026
7c5e08f
chore: fixup starting fiddle during autobisect
dsanders11 May 8, 2026
9ffed57
refactor(renderer): do not call startFiddle from runFiddle
dsanders11 May 8, 2026
9e346c4
test: update test coverage
dsanders11 May 8, 2026
cd3fae1
chore: handle error case with getStartFiddleOptions
dsanders11 May 8, 2026
6c4a09f
fix: prevent simultaneous runs
dsanders11 May 8, 2026
06f434e
chore: dead code cleanup
dsanders11 May 8, 2026
433cba9
fix: restore security check in deleteUserData
dsanders11 May 8, 2026
057336f
fix: send fiddle stopped event on error
dsanders11 May 8, 2026
197c21e
fix: restore getFiles options
dsanders11 May 9, 2026
e34e3d1
Merge branch 'main' into build/refactor-start-fiddle-ipc
dsanders11 May 9, 2026
3304486
fix: start fiddle directly from context menu
dsanders11 May 12, 2026
ee4e03c
test: fix path checking on Windows
dsanders11 May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
RunnableVersion,
SelectedLocalVersion,
SemVer,
StartFiddleParams,
StartFiddleOptions,
TestRequest,
Version,
} from './interfaces';
Expand Down Expand Up @@ -105,13 +105,11 @@ declare global {
): Promise<string>;
arch: string;
blockAccelerators(acceleratorsToBlock: BlockableAccelerator[]): void;
cleanupDirectory(dir: string): Promise<boolean>;
confirmQuit(): void;
createThemeFile(
newTheme: FiddleTheme,
name?: string,
): Promise<LoadedFiddleTheme>;
deleteUserData(name: string): Promise<void>;
downloadVersion(
version: string,
opts?: Partial<DownloadVersionParams>,
Expand Down Expand Up @@ -151,6 +149,9 @@ declare global {
transforms: Array<FileTransformOperation>,
) => Promise<{ localPath?: string; files: Files }>,
);
onGetStartFiddleOptions(
callback: () => Promise<StartFiddleOptions>,
): void;
openThemeFolder(): Promise<void>;
packageRun(
{ dir, packageManager }: PMOperationOptions,
Expand All @@ -169,7 +170,7 @@ declare global {
setShowMeTemplate(template?: string): void;
showWarningDialog(messageOptions: MessageOptions): void;
showWindow(): void;
startFiddle(params: StartFiddleParams): Promise<void>;
startFiddle(): Promise<void>;
stopFiddle(): void;
taskDone(result: RunResult): void;
themePath: string;
Expand Down
14 changes: 8 additions & 6 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export type FiddleEvent =
| 'electron-types-changed'
| 'execute-monaco-command'
| 'fiddle-runner-output'
| 'fiddle-modules-installed'
| 'fiddle-stopped'
| 'load-example'
| 'load-gist'
Expand Down Expand Up @@ -289,14 +290,15 @@ export interface PackageJsonOptions {
includeDependencies?: boolean;
}

export interface StartFiddleParams {
localPath: string | undefined;
export interface StartFiddleOptions {
version: string;
enableElectronLogging: boolean;
isValidBuild: boolean; // If the localPath is a valid Electron build
version: string; // The user selected version
dir: string;
options: string[];
executionFlags: string[];
env: { [x: string]: string | undefined };
modules: Array<[string, string]>;
packageManager: IPackageManager;
useSocketFirewall: boolean;
isKeepingUserDataDirs: boolean;
}

export interface DownloadVersionParams {
Expand Down
4 changes: 2 additions & 2 deletions src/ipc-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,17 @@ export enum IpcEvents {
GET_ELECTRON_TYPES = 'GET_ELECTRON_TYPES',
UNWATCH_ELECTRON_TYPES = 'UNWATCH_ELECTRON_TYPES',
GET_NODE_TYPES = 'GET_NODE_TYPES',
CLEANUP_DIRECTORY = 'CLEANUP_DIRECTORY',
DELETE_USER_DATA = 'DELETE_USER_DATA',
SAVE_FILES_TO_TEMP = 'SAVE_FILES_TO_TEMP',
SAVED_LOCAL_FIDDLE = 'SAVED_LOCAL_FIDDLE',
GET_FILES = 'GET_FILES',
GET_START_FIDDLE_OPTIONS = 'GET_START_FIDDLE_OPTIONS',
START_FIDDLE = 'START_FIDDLE',
STOP_FIDDLE = 'STOP_FIDDLE',
GET_VERSION_STATE = 'GET_VERSION_STATE',
DOWNLOAD_VERSION = 'DOWNLOAD_VERSION',
REMOVE_VERSION = 'REMOVE_VERSION',
FIDDLE_RUNNER_OUTPUT = 'FIDDLE_RUNNER_OUTPUT',
FIDDLE_MODULES_INSTALLED = 'FIDDLE_MODULES_INSTALLED',
FIDDLE_STOPPED = 'FIDDLE_STOPPED',
VERSION_DOWNLOAD_PROGRESS = 'VERSION_DOWNLOAD_PROGRESS',
VERSION_STATE_CHANGED = 'VERSION_STATE_CHANGED',
Expand Down
220 changes: 180 additions & 40 deletions src/main/fiddle-core.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ChildProcess } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import { ElectronVersions, Installer, Runner } from '@electron/fiddle-core';
import {
Expand All @@ -12,13 +10,26 @@ import {
} from 'electron';

import { ELECTRON_DOWNLOAD_PATH, ELECTRON_INSTALL_PATH } from './constants';
import { cleanupDirectory, deleteUserData, saveFilesToTemp } from './files';
import { ipcMainManager } from './ipc';
import { addModules, getIsPackageManagerInstalled } from './npm';
import { getFiles } from './utils/get-files';
import { getStartFiddleOptions } from './utils/get-start-fiddle-options';
import { getLocalVersions } from './versions';
import {
DownloadVersionParams,
IPackageManager,
PACKAGE_NAME,
ProgressObject,
StartFiddleParams,
} from '../interfaces';
import { IpcEvents } from '../ipc-events';
import { maybePlural } from '../utils/plural-maybe';

export interface PMOperationOptions {
dir: string;
packageManager: IPackageManager;
useSocketFirewall?: boolean;
}

let installer: Installer;
let runner: Runner;
Expand All @@ -33,16 +44,6 @@ const BLOCKED_ENV_KEYS = new Set([
'DYLD_LIBRARY_PATH',
]);

/**
* Returns true if `dir` resolves to a path inside the OS temp directory.
*/
function isInsideTempDir(dir: unknown): dir is string {
if (typeof dir !== 'string') return false;
const tmpDir = fs.realpathSync(os.tmpdir());
const resolved = fs.realpathSync(path.resolve(dir));
return resolved.startsWith(tmpDir + path.sep) || resolved === tmpDir;
}

// Keep track of which fiddle process belongs to which WebContents
const fiddleProcesses = new WeakMap<WebContents, ChildProcess>();

Expand All @@ -52,38 +53,164 @@ const removingVersions = new Map<string, Promise<void>>();
/**
* Push to the renderer's run output.
*/
function pushOutput(webContents: WebContents, message: string): void {
ipcMainManager.send(IpcEvents.FIDDLE_RUNNER_OUTPUT, [message], webContents);
function pushOutput(
webContents: WebContents,
message: string,
options?: { isNotPre?: boolean },
): void {
ipcMainManager.send(
IpcEvents.FIDDLE_RUNNER_OUTPUT,
[message, options],
webContents,
);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function pushOutputLine(webContents: WebContents, message: string): void {
pushOutput(webContents, `${message}\n`);
function pushOutputLine(
webContents: WebContents,
message: string,
options?: { isNotPre?: boolean },
): void {
pushOutput(webContents, `${message}\n`, options);
}

/**
* Start running an Electron fiddle.
* Little convenience method that pushes message and error.
*/
export async function startFiddle(
function pushError(webContents: WebContents, message: string, error: Error) {
pushOutput(webContents, `⚠️ ${message}. Error encountered:`);
pushOutput(webContents, error.toString());
console.warn(error);
}

/**
* Installs the specified modules
*/
export async function installModules(
webContents: WebContents,
params: StartFiddleParams,
modulesPairs: [string, string][],
options: PMOperationOptions,
): Promise<void> {
const { dir, enableElectronLogging, localPath, options, version } = params;
const modules = modulesPairs.map(([pkg, version]) => `${pkg}@${version}`);

// Install any modules the user added to the fiddle.
if (modules.length > 0) {
const pmInstalled = await getIsPackageManagerInstalled(
options.packageManager,
);
if (!pmInstalled) {
let message = `The ${maybePlural(`module`, modules)} ${modules.join(
', ',
)} need to be installed, `;
message += `but we could not find ${options.packageManager}. Fiddle requires Node.js and npm `;
message += `to support the installation of modules not included in `;
message += `Electron. Please visit https://nodejs.org to install Node.js `;
message += `and npm, or https://classic.yarnpkg.com/lang/en/ to install Yarn`;

pushOutput(webContents, message, { isNotPre: true });
throw new Error('Package manager not installed');
}

// Guard 1: working directory must be inside the OS temp directory.
if (!isInsideTempDir(dir)) {
throw new Error(`startFiddle: dir must be inside the temp directory`);
pushOutput(
webContents,
`Installing node modules using ${
options.packageManager
}: ${modules.join(', ')}...`,
{ isNotPre: true },
);

const result = await addModules(
{
dir: options.dir,
packageManager: options.packageManager,
useSocketFirewall: options.useSocketFirewall,
},
...modules,
);
if (result) pushOutputLine(webContents, result);
}
}

// Guard 2: determine whether to use the local path by verifying the
// executable exists on disk — do not trust the renderer-supplied
// isValidBuild flag.
/**
* Drive the entire run lifecycle from main: ask the renderer for the
* settings and files, write them to a temp directory, install modules,
* spawn Electron, and clean everything up when the process exits.
*/
export async function startFiddle(webContents: WebContents): Promise<void> {
const options = await getStartFiddleOptions(webContents);
const {
enableElectronLogging,
env: rendererEnv,
executionFlags,
isKeepingUserDataDirs,
modules,
packageManager,
useSocketFirewall,
version,
} = options;

// Look up local Electron builds by version string. Local builds use a
// version of the form `0.0.0-local.<timestamp>`, so only consult the
// stored local versions when the version string contains `-local`.
const localPath = version.includes('-local')
? getLocalVersions().find((v) => v.version === version)?.localPath
: undefined;

// Verify the executable exists on disk before using it.
const resolvedExec = localPath ? Installer.getExecPath(localPath) : undefined;
const useLocalPath = !!resolvedExec && fs.existsSync(resolvedExec);

// Guard 3: strip any CLI option containing a null byte, which can
// truncate strings at the OS level.
const safeOptions = options.filter(
// Get the fiddle's files from the renderer.
const files = new Map(
(await getFiles(BrowserWindow.fromWebContents(webContents)!, [])).files,
);

// Pull the project name out of the fiddle's package.json — that's the
// name Electron will use for its user-data dir, so that's what we
// need to delete on cleanup.
let appName: string | undefined;
try {
const pkg = JSON.parse(files.get(PACKAGE_NAME) ?? '{}');
if (typeof pkg.name === 'string') appName = pkg.name;
} catch {
// package.json is malformed; skip user-data cleanup.
}

// Create the temp directory and write files into it. This is the only
// place that creates the run directory — the renderer never sees it.
pushOutputLine(webContents, 'Saving files to temp directory...');
const dir = await saveFilesToTemp(files);
pushOutputLine(webContents, `Saved files to ${dir}`);

const cleanup = async () => {
await cleanupDirectory(dir);
if (!appName) return;
if (isKeepingUserDataDirs) {
console.log(
`Cleanup: Not deleting data dir due to isKeepingUserDataDirs setting`,
);
return;
}
console.log(`Cleanup: Deleting data dir for ${appName}`);
await deleteUserData(appName);
};

try {
await installModules(webContents, modules, {
dir,
packageManager,
useSocketFirewall,
});
} catch (error: any) {
console.error('Runner: Could not install modules', error);

pushError(webContents, 'Could not install modules', error);
await cleanup();
throw error;
}

// Strip any CLI option containing a null byte, which can truncate
// strings at the OS level.
const safeOptions = [dir, '--inspect', ...executionFlags].filter(
(opt) => typeof opt === 'string' && !opt.includes('\0'),
);

Expand All @@ -100,23 +227,36 @@ export async function startFiddle(
}

const safeEnv = Object.fromEntries(
Object.entries(params.env).filter(([key]) => !BLOCKED_ENV_KEYS.has(key)),
Object.entries(rendererEnv).filter(([key]) => !BLOCKED_ENV_KEYS.has(key)),
);
Object.assign(env, safeEnv);

const child = await runner.spawn(
useLocalPath ? resolvedExec! : version,
dir,
{ args: safeOptions, cwd: dir, env },
);
let child: ChildProcess;
try {
child = await runner.spawn(useLocalPath ? resolvedExec! : version, dir, {
args: safeOptions,
cwd: dir,
env,
});
} catch (error) {
await cleanup();
throw error;
}
fiddleProcesses.set(webContents, child);

// Signal the renderer that module installation is done and the process is
// running. Sent after fiddleProcesses.set() so that stopFiddle() will work
// as soon as the button transitions to "Stop".
ipcMainManager.send(IpcEvents.FIDDLE_MODULES_INSTALLED, [], webContents);

pushOutputLine(webContents, `Electron v${version} started as "${appName}"`);

child.stdout?.on('data', (data) => pushOutput(webContents, data.toString()));
child.stderr?.on('data', (data) => pushOutput(webContents, data.toString()));

child.on('close', async (code, signal) => {
fiddleProcesses.delete(webContents);

await cleanup();
ipcMainManager.send(IpcEvents.FIDDLE_STOPPED, [code, signal], webContents);
});
}
Expand Down Expand Up @@ -221,8 +361,8 @@ export async function setupFiddleCore(versions: ElectronVersions) {
);
ipcMainManager.handle(
IpcEvents.START_FIDDLE,
async (event: IpcMainInvokeEvent, params: StartFiddleParams) => {
await startFiddle(event.sender, params);
async (event: IpcMainInvokeEvent) => {
await startFiddle(event.sender);
},
);
ipcMainManager.on(IpcEvents.STOP_FIDDLE, (event: IpcMainEvent) => {
Expand Down
Loading
Loading