From bfe1bcc31a5faf610e2c6e64b12bbce2d35d11b1 Mon Sep 17 00:00:00 2001 From: Gleb Volkov Date: Wed, 22 Jul 2026 16:21:46 +0200 Subject: [PATCH] Run CLI commands in the live editor when the project is already open (#8847) CLI commands with `--run-command` used to always start a separate headless process, even when the target project was already open in the editor. They now goes to the live window so results (for example imported extensions) appear in memory right away, without reopening the project. --- newIDE/README.md | 4 + .../src/MainFrame/LocalCliCommandRunner.js | 232 ++++++++++++++---- newIDE/app/src/MainFrame/index.js | 2 + newIDE/electron-app/app/CliCommandHandoff.js | 73 ++++++ .../electron-app/app/OpenProjectsRegistry.js | 66 +++++ newIDE/electron-app/app/main.js | 70 ++++-- 6 files changed, 368 insertions(+), 79 deletions(-) create mode 100644 newIDE/electron-app/app/CliCommandHandoff.js create mode 100644 newIDE/electron-app/app/OpenProjectsRegistry.js diff --git a/newIDE/README.md b/newIDE/README.md index 4df5c0e6b6..2c4f17aa1d 100644 --- a/newIDE/README.md +++ b/newIDE/README.md @@ -142,6 +142,10 @@ GDevelop.exe --disable-update-check --run-command IMPORT_EXTENSION_AND_SAVE path Extra flags: `--keep-open` (don't quit after command), `--dev-tools` (open DevTools). +If the same project is already open in a running editor, the command runs in that +window (fire-and-forget; the CLI exits without waiting or reporting the real result). +Otherwise the command runs headless with a real exit code (including CI). + ### Making the CLI available on PATH When GDevelop is installed with the Windows NSIS installer, the install diff --git a/newIDE/app/src/MainFrame/LocalCliCommandRunner.js b/newIDE/app/src/MainFrame/LocalCliCommandRunner.js index f23da47169..624ac1d5b3 100644 --- a/newIDE/app/src/MainFrame/LocalCliCommandRunner.js +++ b/newIDE/app/src/MainFrame/LocalCliCommandRunner.js @@ -17,6 +17,8 @@ import { type FileMetadata } from '../ProjectsStorage'; const electron = optionalRequire('electron'); const ipcRenderer = electron ? electron.ipcRenderer : null; const fs = optionalRequire('fs'); +const path = optionalRequire('path'); +const process = optionalRequire('process'); export type ImportExtension = (options: {| i18n: I18nType, @@ -47,14 +49,17 @@ export type CliCommandRunner = ( |} ) => Promise; -const getCommandArgs = (): Array => { - const appArguments = Window.getArguments(); - const arg = appArguments['cmd-args']; +const normalizeCommandArgs = ( + arg: string | Array | void +): Array => { if (!arg) return []; const values = Array.isArray(arg) ? arg : [arg]; return values.map(value => value.trim()).filter(Boolean); }; +const getCommandArgs = (): Array => + normalizeCommandArgs(Window.getArguments()['cmd-args']); + const runners: { [commandName: string]: CliCommandRunner } = { EXPORT_HTML5_EXTERNAL: async (project, i18n, { preferences }) => { if (preferences.getBlockPreviewAndExportOnDiagnosticErrors()) { @@ -117,6 +122,21 @@ const exitApp = (exitCode: number) => { if (ipcRenderer) ipcRenderer.send('app-exit', exitCode); }; +// Must match electron-app/app/OpenProjectsRegistry.js normalizeFileIdentifier (IPC routing). +const normalizeFileIdentifier = (fileIdentifier: ?string): ?string => { + if (!fileIdentifier || !path) return null; + const resolved = path.resolve(fileIdentifier); + return process && process.platform === 'win32' + ? resolved.toLowerCase() + : resolved; +}; + +type RunCliCommandIpcPayload = {| + commandName: string, + commandArgs: string | Array | void, + fileIdentifier: ?string, +|}; + const ensureProjectExtensionsReadyForCli = async ( eventsFunctionsExtensionsState: EventsFunctionsExtensionsState ): Promise => { @@ -146,9 +166,82 @@ const ensureProjectExtensionsReadyForCli = async ( return true; }; +type RunCliCommandOptions = {| + project: gdProject, + i18n: I18nType, + commandName: string, + commandArgs: Array, + eventsFunctionsExtensionsState: EventsFunctionsExtensionsState, + commandPaletteRef: {| current: ?CommandPaletteInterface |}, + preferences: Preferences, + importExtension: ImportExtension, + onWillInstallExtension: (extensionNames: Array) => void, + onExtensionInstalled: (extensionNames: Array) => void, + saveProject: SaveProject, + onFinished: (exitCode: number) => void, +|}; + +const runCliCommand = async ({ + project, + i18n, + commandName, + commandArgs, + eventsFunctionsExtensionsState, + commandPaletteRef, + preferences, + importExtension, + onWillInstallExtension, + onExtensionInstalled, + saveProject, + onFinished, +}: RunCliCommandOptions): Promise => { + try { + const extensionsReady = await ensureProjectExtensionsReadyForCli( + eventsFunctionsExtensionsState + ); + if (!extensionsReady) { + onFinished(1); + return; + } + + const awaitableRunner = getAwaitableCliRunner(commandName); + if (awaitableRunner) { + await awaitableRunner(project, i18n, { + preferences, + commandArgs, + importExtension, + onWillInstallExtension, + onExtensionInstalled, + saveProject, + }); + console.info(`[CLI] Command "${commandName}" finished successfully.`); + onFinished(0); + return; + } + + if (commandPaletteRef.current && commandPaletteRef.current.launchCommand) { + commandPaletteRef.current.launchCommand((commandName: any)); + console.info( + `[CLI] Command "${commandName}" dispatched (fire-and-forget).` + ); + setTimeout(() => onFinished(0), FIRE_AND_FORGET_GRACE_MS); + return; + } + + console.error( + `[CLI] Command "${commandName}" could not be dispatched: command palette not ready.` + ); + onFinished(1); + } catch (error) { + console.error(`[CLI] Command "${commandName}" failed:`, error); + onFinished(1); + } +}; + type Props = {| project: ?gdProject, i18n: I18nType, + fileIdentifier: ?string, commandPaletteRef: {| current: ?CommandPaletteInterface |}, importExtension: ImportExtension, onWillInstallExtension: (extensionNames: Array) => void, @@ -159,6 +252,7 @@ type Props = {| export const useCliCommandRunner = ({ project, i18n, + fileIdentifier, commandPaletteRef, importExtension, onWillInstallExtension, @@ -170,6 +264,14 @@ export const useCliCommandRunner = ({ ); const preferences = React.useContext(PreferencesContext); + React.useEffect( + () => { + if (ipcRenderer) + ipcRenderer.send('set-window-project-path', fileIdentifier); + }, + [fileIdentifier] + ); + // Dispatch `--run-command` once the project is loaded. "Awaitable" commands // are awaited for a proper exit code; others fall back to fire-and-forget // via commandPaletteRef.launchCommand. @@ -186,57 +288,22 @@ export const useCliCommandRunner = ({ cliCommandRanRef.current = true; const keepOpen = !!appArguments['keep-open']; - const run = async () => { - try { - const extensionsReady = await ensureProjectExtensionsReadyForCli( - eventsFunctionsExtensionsState - ); - if (!extensionsReady) { - if (!keepOpen) exitApp(1); - return; - } - - const awaitableRunner = getAwaitableCliRunner(commandName); - if (awaitableRunner) { - await awaitableRunner(project, i18n, { - preferences, - commandArgs: getCommandArgs(), - importExtension, - onWillInstallExtension, - onExtensionInstalled, - saveProject, - }); - console.info( - `[CLI] Command "${commandName}" finished successfully.` - ); - if (!keepOpen) exitApp(0); - return; - } - - if ( - commandPaletteRef.current && - commandPaletteRef.current.launchCommand - ) { - commandPaletteRef.current.launchCommand((commandName: any)); - console.info( - `[CLI] Command "${commandName}" dispatched (fire-and-forget).` - ); - if (!keepOpen) - setTimeout(() => exitApp(0), FIRE_AND_FORGET_GRACE_MS); - return; - } - - console.error( - `[CLI] Command "${commandName}" could not be dispatched: command palette not ready.` - ); - if (!keepOpen) exitApp(1); - } catch (error) { - console.error(`[CLI] Command "${commandName}" failed:`, error); - if (!keepOpen) exitApp(1); - } - }; - - run(); + runCliCommand({ + project, + i18n, + commandName, + commandArgs: getCommandArgs(), + eventsFunctionsExtensionsState, + commandPaletteRef, + preferences, + importExtension, + onWillInstallExtension, + onExtensionInstalled, + saveProject, + onFinished: exitCode => { + if (!keepOpen) exitApp(exitCode); + }, + }); }, [ project, @@ -251,6 +318,63 @@ export const useCliCommandRunner = ({ ] ); + React.useEffect( + () => { + if (!ipcRenderer || !project) return; + + const onRunCliCommand = ( + event: any, + { + commandName, + commandArgs, + fileIdentifier: routedFileIdentifier, + }: RunCliCommandIpcPayload + ) => { + if ( + normalizeFileIdentifier(routedFileIdentifier) !== + normalizeFileIdentifier(fileIdentifier) + ) { + console.error( + `[CLI] Command "${commandName}" was routed to this window for "${routedFileIdentifier || + ''}", but it now has "${fileIdentifier || ''}" open. Ignoring.` + ); + return; + } + + runCliCommand({ + project, + i18n, + commandName, + commandArgs: normalizeCommandArgs(commandArgs), + eventsFunctionsExtensionsState, + commandPaletteRef, + preferences, + importExtension, + onWillInstallExtension, + onExtensionInstalled, + saveProject, + onFinished: () => {}, + }); + }; + + ipcRenderer.on('run-cli-command', onRunCliCommand); + return () => + ipcRenderer.removeListener('run-cli-command', onRunCliCommand); + }, + [ + project, + i18n, + fileIdentifier, + commandPaletteRef, + eventsFunctionsExtensionsState, + preferences, + importExtension, + onWillInstallExtension, + onExtensionInstalled, + saveProject, + ] + ); + React.useEffect( () => { if (!Window.isRunningCommandFromCli()) return; diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 788788f3f9..13c0c4238c 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -413,6 +413,7 @@ export type Props = {| useCliCommandRunner: ({| project: ?gdProject, i18n: I18n, + fileIdentifier: ?string, commandPaletteRef: {| current: ?CommandPaletteInterface |}, importExtension: ImportExtension, onWillInstallExtension: (extensionNames: Array) => void, @@ -5278,6 +5279,7 @@ const MainFrame = (props: Props): React.MixedElement => { useCliCommandRunner({ project: state.currentProject, i18n, + fileIdentifier, commandPaletteRef, importExtension, onWillInstallExtension, diff --git a/newIDE/electron-app/app/CliCommandHandoff.js b/newIDE/electron-app/app/CliCommandHandoff.js new file mode 100644 index 0000000000..424ba51fb1 --- /dev/null +++ b/newIDE/electron-app/app/CliCommandHandoff.js @@ -0,0 +1,73 @@ +const parseArgs = require('minimist'); +const { + normalizeFileIdentifier, + readOpenProjectFileIdentifiers, + getWindowFileIdentifier, +} = require('./OpenProjectsRegistry'); + +const argsParserOptions = { + boolean: ['dev-tools', 'disable-update-check', 'keep-open'], + string: ['_', 'run-command', 'cmd-args'], +}; + +// Drop switches Chromium may inject into argv (e.g. --allow-file-access-from-files) +// so they can't steal the value of our own -- flags during parsing. +const knownCliFlagNames = new Set( + [...argsParserOptions.boolean, ...argsParserOptions.string].filter( + name => name !== '_' + ) +); +const parseGDevelopArgs = argv => + parseArgs( + argv.filter( + arg => + !arg.startsWith('--') || + knownCliFlagNames.has(arg.slice(2).split('=')[0]) + ), + argsParserOptions + ); + +// Electron reorders commandLine (switches first, values last) before forwarding +// to 'second-instance', breaking flag-to-value pairing on reparse. Prefer +// additionalData.args (parsed by the second instance itself) instead. +const parseSecondInstanceArgs = ({ commandLine, additionalData, isDev }) => + additionalData && additionalData.args + ? additionalData.args + // In dev, electron is launched with an extra argument, so skip one more. + : parseGDevelopArgs(commandLine.slice(isDev ? 2 : 1)); + +const isCliProjectAlreadyOpenElsewhere = parsedArgs => { + const fileIdentifier = normalizeFileIdentifier(parsedArgs._[0]); + return ( + !!fileIdentifier && + readOpenProjectFileIdentifiers().includes(fileIdentifier) + ); +}; + +// Redirects the command to an already open editor instead of spawning a new window. +const routeCliCommandToLiveEditor = ({ parsedArgs, mainWindows }) => { + const commandName = parsedArgs['run-command']; + if (!commandName) return false; + + const fileIdentifier = normalizeFileIdentifier(parsedArgs._[0]); + const targetWindow = Array.from(mainWindows).find( + window => getWindowFileIdentifier(window.id) === fileIdentifier + ); + if (!targetWindow) return false; + + targetWindow.webContents.send('run-cli-command', { + commandName, + commandArgs: parsedArgs['cmd-args'], + fileIdentifier, + }); + targetWindow.focus(); + return true; +}; + +module.exports = { + argsParserOptions, + parseGDevelopArgs, + parseSecondInstanceArgs, + isCliProjectAlreadyOpenElsewhere, + routeCliCommandToLiveEditor, +}; diff --git a/newIDE/electron-app/app/OpenProjectsRegistry.js b/newIDE/electron-app/app/OpenProjectsRegistry.js new file mode 100644 index 0000000000..2ad001506a --- /dev/null +++ b/newIDE/electron-app/app/OpenProjectsRegistry.js @@ -0,0 +1,66 @@ +const electron = require('electron'); +const path = require('path'); +const fs = require('fs'); +const app = electron.app; + +// Tracks which file identifiers are open in this process's windows, persisted to +// disk so a separate CLI process can find a live editor to route a command to. +// File identifier == local file path for now; will cover cloud project UUIDs later. +const registryFilePath = path.join( + app.getPath('userData'), + 'open-projects.json' +); + +const windowFileIdentifiers = new Map(); + +const normalizeFileIdentifier = fileIdentifier => { + if (!fileIdentifier) return null; + try { + const resolved = path.resolve(fileIdentifier); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } catch (e) { + return null; + } +}; + +const writeOpenProjectsRegistry = () => { + try { + const fileIdentifiers = Array.from( + new Set(windowFileIdentifiers.values()) + ); + fs.writeFileSync(registryFilePath, JSON.stringify(fileIdentifiers)); + } catch (e) {} +}; + +const readOpenProjectFileIdentifiers = () => { + try { + const fileIdentifiers = JSON.parse( + fs.readFileSync(registryFilePath, 'utf8') + ); + return Array.isArray(fileIdentifiers) ? fileIdentifiers : []; + } catch (e) { + return []; + } +}; + +const getWindowFileIdentifier = windowId => + windowFileIdentifiers.get(windowId) || null; + +const setWindowFileIdentifier = (windowId, fileIdentifier) => { + const normalized = normalizeFileIdentifier(fileIdentifier); + if (normalized) windowFileIdentifiers.set(windowId, normalized); + else windowFileIdentifiers.delete(windowId); + writeOpenProjectsRegistry(); +}; + +const clearWindowFileIdentifier = windowId => { + if (windowFileIdentifiers.delete(windowId)) writeOpenProjectsRegistry(); +}; + +module.exports = { + normalizeFileIdentifier, + readOpenProjectFileIdentifiers, + getWindowFileIdentifier, + setWindowFileIdentifier, + clearWindowFileIdentifier, +}; diff --git a/newIDE/electron-app/app/main.js b/newIDE/electron-app/app/main.js index 469d8f384c..2607bfdf66 100644 --- a/newIDE/electron-app/app/main.js +++ b/newIDE/electron-app/app/main.js @@ -1,12 +1,12 @@ const electron = require('electron'); const path = require('path'); +const fs = require('fs'); const child_process = require('child_process'); const app = electron.app; // Module to control application life. const BrowserWindow = electron.BrowserWindow; // Module to create native browser window. const Menu = electron.Menu; const Notification = electron.Notification; const protocol = electron.protocol; -const parseArgs = require('minimist'); const isDev = require('electron-is-dev'); const ipcMain = electron.ipcMain; const autoUpdater = require('electron-updater').autoUpdater; @@ -46,6 +46,16 @@ const { } = require('./LocalGDJSDevelopmentWatcher'); const { setupWatcher, disableWatcher } = require('./LocalFilesystemWatcher'); const { installCliInPath } = require('./InstallCliInPath'); +const { + setWindowFileIdentifier, + clearWindowFileIdentifier, +} = require('./OpenProjectsRegistry'); +const { + parseGDevelopArgs, + parseSecondInstanceArgs, + isCliProjectAlreadyOpenElsewhere, + routeCliCommandToLiveEditor, +} = require('./CliCommandHandoff'); // Initialize `@electron/remote` module require('@electron/remote/main').initialize(); @@ -66,13 +76,7 @@ let mainWindows = new Set(); let mainWindow = null; // Primary window reference for backwards compatibility let windowCounter = 0; // Counter for creating unique session partitions -// Parse arguments (knowing that in dev, we run electron with an argument, -// so have to ignore one more). -const argsParserOptions = { - boolean: ['dev-tools', 'disable-update-check', 'keep-open'], - string: ['_', 'run-command', 'cmd-args'], -}; -const args = parseArgs(process.argv.slice(isDev ? 2 : 1), argsParserOptions); +const args = parseGDevelopArgs(process.argv.slice(isDev ? 2 : 1)); const devTools = !!args['dev-tools']; @@ -87,25 +91,28 @@ if (process.platform === 'win32') { // Single instance lock - prevents multiple Electron processes // This solves Firebase IndexedDB locking issues while still allowing multiple windows. -// -// When invoked via `--run-command` (CLI / CI scenarios), we deliberately -// SKIP the single-instance lock so each CLI invocation runs in its own -// process. This lets the launching shell wait for completion and observe -// the exit code, instead of being immediately backgrounded by the existing -// instance taking over via the second-instance handler. +// CLI: skip the lock unless the target project is a known open window (registry hit); +// a stale registry entry falls back to running headless in this process. const isCliRunCommand = !!args['run-command']; -const gotTheLock = isCliRunCommand ? true : app.requestSingleInstanceLock(); +const gotTheLock = + isCliRunCommand && !isCliProjectAlreadyOpenElsewhere(args) + ? true + : app.requestSingleInstanceLock({ args }); if (!gotTheLock) { // Second instance attempted - quit immediately app.quit(); -} else if (!isCliRunCommand) { - // First instance - handle second-instance events by creating new windows - app.on('second-instance', (event, commandLine, workingDirectory) => { - const secondInstanceArgs = parseArgs( - commandLine.slice(isDev ? 2 : 1), - argsParserOptions - ); +} else { + app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => { + const secondInstanceArgs = parseSecondInstanceArgs({ + commandLine, + additionalData, + isDev, + }); + + if (routeCliCommandToLiveEditor({ parsedArgs: secondInstanceArgs, mainWindows })) { + return; + } // Update the global args so the new window's renderer (which reads them // via remote.getGlobal('args')) picks up the second-instance CLI flags @@ -145,6 +152,8 @@ const windowTargetIdToBrowserWindowIds = new Map(); // Function to create a new GDevelop window function createNewWindow(windowArgs = args) { const isIntegrated = windowArgs.mode === 'integrated'; + // windowArgs: a GUI primary process can still open a CLI window (second-instance fallback). + const isCliWindow = !!windowArgs['run-command']; if (isIntegrated && app.dock) { app.dock.hide(); @@ -202,13 +211,13 @@ function createNewWindow(windowArgs = args) { options.show = false; } - if (isCliRunCommand && !windowArgs['keep-open']) { + if (isCliWindow && !windowArgs['keep-open']) { options.show = false; options.skipTaskbar = true; } const newWindow = new BrowserWindow(options); - if (!isIntegrated && !isCliRunCommand) newWindow.maximize(); + if (!isIntegrated && !isCliWindow) newWindow.maximize(); // Capture window ID and whether this is the primary window before it can be destroyed const windowId = newWindow.id; @@ -230,7 +239,7 @@ function createNewWindow(windowArgs = args) { // Uses process.stdout/stderr directly (not electron-log) to avoid // re-entering the renderer console and causing an infinite loop. - if (isCliRunCommand) { + if (isCliWindow) { newWindow.webContents.on('console-message', (_event, level, message) => { if (level < 1) return; if (message.startsWith('%c')) return; @@ -271,6 +280,7 @@ function createNewWindow(windowArgs = args) { newWindow.on('closed', function() { // Remove from tracked windows mainWindows.delete(newWindow); + clearWindowFileIdentifier(windowId); // If this was the primary window, set a new primary if (isPrimaryWindow) { @@ -381,6 +391,11 @@ function createNewWindow(windowArgs = args) { // This method will be called when Electron has finished // initialization and is ready to create browser windows. app.on('ready', function() { + // Handoff calls app.quit() but 'ready' still runs; without the lock, do not create a window. + if (!gotTheLock) { + return; + } + registerGdideProtocol({ isDev }); // Create the first window @@ -422,6 +437,11 @@ app.on('ready', function() { app.exit(typeof exitCode === 'number' ? exitCode : 0); }); + ipcMain.on('set-window-project-path', (event, fileIdentifier) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) setWindowFileIdentifier(window.id, fileIdentifier); + }); + ipcMain.handle('install-cli-in-path', async () => { // Inside an AppImage, process.execPath points into a transient mount that // vanishes on quit; APPIMAGE is the stable file path (still breaks if moved).