mirror of
https://github.com/4ian/GDevelop.git
synced 2026-10-12 02:01:17 +00:00
Add argument for CLI command execution for CI pipelines (#8587)
- Add `--run-command` CLI flag that executes any Command Palette command after loading a project. In CLI mode all blocking dialogs (autosave, changelog, confirmations) are auto-resolved, the window is hidden, and the process exits with a meaningful exit code. - This enables CI pipelines to run exports (e.g. `EXPORT_HTML5_EXTERNAL`) using only the editor distribution - no installation or display required.
This commit is contained in:
@@ -544,6 +544,24 @@ jobs:
|
||||
|
||||
node scripts/build.js --skip-app-build --win appx --publish=never
|
||||
|
||||
cd ..\..
|
||||
- run:
|
||||
name: Build portable zip (without code signing)
|
||||
command: |
|
||||
cd newIDE\electron-app
|
||||
|
||||
$Env:GD_SIGNTOOL_THUMBPRINT = ''
|
||||
|
||||
$Env:GD_SIGNTOOL_SUBJECT_NAME = ''
|
||||
|
||||
$Env:CSC_LINK = ''
|
||||
|
||||
$Env:CSC_KEY_PASSWORD = ''
|
||||
|
||||
$Env:GD_PORTABLE_BUILD = 'true'
|
||||
|
||||
node scripts/build.js --skip-app-build --win zip --publish=never
|
||||
|
||||
cd ..\..
|
||||
|
||||
- run:
|
||||
|
||||
@@ -99,6 +99,35 @@ Cloud storage providers are set up with development keys when you're running GDe
|
||||
|
||||
> This is only necessary if you want to have cloud storage providers working in development. If not done, GDevelop will simply display an error while trying to use them.
|
||||
|
||||
## (Optional) Building a portable bundle for CI / headless usage 🤖
|
||||
|
||||
Build a self-contained zip (no installer, no code-signing) that can be
|
||||
extracted on a CI runner and used to execute CLI commands:
|
||||
|
||||
```bash
|
||||
cd newIDE/electron-app
|
||||
npm install
|
||||
|
||||
# Linux / macOS
|
||||
GD_PORTABLE_BUILD=true npm run build -- --publish never
|
||||
|
||||
# Windows (PowerShell)
|
||||
$Env:GD_PORTABLE_BUILD='true'; npm run build -- --publish never
|
||||
```
|
||||
|
||||
The resulting zip contains the full editor. After extracting, run commands like:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
GDevelop.exe --disable-update-check --run-command EXPORT_HTML5_EXTERNAL path\to\game.json
|
||||
|
||||
# Linux (install required libs: sudo apt install -y libnss3 libasound2t64)
|
||||
./gdevelop --no-sandbox --disable-update-check \
|
||||
--run-command EXPORT_HTML5_EXTERNAL /path/to/game.json
|
||||
```
|
||||
|
||||
Extra flags: `--keep-open` (don't quit after command), `--dev-tools` (open DevTools).
|
||||
|
||||
## (Optional) Building and deploying the standalone app 📦
|
||||
|
||||
> 🖐 This section is only for maintainers that want to deploy the "official app" on the GDevelop website. If you're working on contributions for GDevelop, you won't need it. You can download ["Nightly Builds" of GDevelop here too](./docs/Nightly-Builds-and-continuous-deployment.md).
|
||||
|
||||
@@ -201,7 +201,10 @@
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/!(locales)/**/*.js": "prettier --write"
|
||||
"src/!(locales)/**/*.js": [
|
||||
"prettier --write",
|
||||
"eslint --max-warnings=0"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
||||
@@ -55,6 +55,10 @@ const artifactsToDownload = {
|
||||
url: `${pathToArtifacts}/GDevelop 5 ${version}.appx`,
|
||||
outputFilename: `GDevelop 5 ${version}.appx`,
|
||||
},
|
||||
'Windows portable zip': {
|
||||
url: `${pathToArtifacts}/GDevelop 5-${version}-win.zip`,
|
||||
outputFilename: `GDevelop-5-${version}-win.zip`,
|
||||
},
|
||||
'Windows auto-update file': {
|
||||
url: `${pathToArtifacts}/latest.yml`,
|
||||
outputFilename: 'latest.yml',
|
||||
@@ -85,6 +89,10 @@ const artifactsToDownload = {
|
||||
url: `${pathToArtifacts}/GDevelop 5-${version}-arm64.AppImage`,
|
||||
outputFilename: `GDevelop-5-${version}-arm64.AppImage`,
|
||||
},
|
||||
'Linux portable zip': {
|
||||
url: `${pathToArtifacts}/gdevelop-${version}.zip`,
|
||||
outputFilename: `GDevelop-5-${version}-linux.zip`,
|
||||
},
|
||||
'Linux auto-update file (amd64)': {
|
||||
url: `${pathToArtifacts}/latest-linux.yml`,
|
||||
outputFilename: 'latest-linux.yml',
|
||||
|
||||
@@ -19,6 +19,7 @@ export type CommandName =
|
||||
| 'CLOSE_PROJECT'
|
||||
| 'RELOAD_PROJECT'
|
||||
| 'EXPORT_GAME'
|
||||
| 'EXPORT_HTML5_EXTERNAL'
|
||||
| 'INVITE_COLLABORATORS'
|
||||
| 'OPEN_RECENT_PROJECT'
|
||||
| 'OPEN_COMMAND_PALETTE'
|
||||
@@ -161,6 +162,11 @@ const commandsList: { [CommandName]: CommandMetadata } = {
|
||||
displayText: t`Export game`,
|
||||
handledByElectron: true,
|
||||
},
|
||||
EXPORT_HTML5_EXTERNAL: {
|
||||
area: 'PROJECT',
|
||||
displayText: t`Export HTML5 (external websites)`,
|
||||
noShortcut: true,
|
||||
},
|
||||
INVITE_COLLABORATORS: {
|
||||
area: 'PROJECT',
|
||||
displayText: t`Invite collaborators`,
|
||||
|
||||
+8
-9
@@ -35,15 +35,14 @@ export const makeLocalEventsFunctionCodeWriter = ({
|
||||
os.tmpdir(),
|
||||
`GDGeneratedEventsFunctions-` + getUID()
|
||||
);
|
||||
fs.mkdir(outputDir, err => {
|
||||
if (err && err.code !== 'EEXIST') {
|
||||
console.error(
|
||||
'Unable to create the directory where to output events functions generated code: ',
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Unable to create the directory where to output events functions generated code: ',
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
const getPathFor = (codeNamespace: string) => {
|
||||
return `${outputDir}/${slugs(codeNamespace)}.js`;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import optionalRequire from '../../Utils/OptionalRequire';
|
||||
import { localHTML5ExportPipeline } from '../LocalExporters/LocalHTML5Export';
|
||||
|
||||
const path = optionalRequire('path');
|
||||
|
||||
const DEFAULT_BUILD_FOLDER_NAME = 'build';
|
||||
|
||||
export const resolveHtml5OutputDir = (project: gdProject): string => {
|
||||
const stored = project.getLastCompilationDirectory();
|
||||
if (stored) return stored;
|
||||
|
||||
if (!path) {
|
||||
throw new Error(
|
||||
'Cannot derive a default build folder: Node "path" module is not available (this requires Electron / Node).'
|
||||
);
|
||||
}
|
||||
const projectFile = project.getProjectFile();
|
||||
if (!projectFile) {
|
||||
throw new Error(
|
||||
'Cannot derive a default build folder: project has no file path on disk. Save the project first or pass an explicit outputDir.'
|
||||
);
|
||||
}
|
||||
return path.join(path.dirname(projectFile), DEFAULT_BUILD_FOLDER_NAME);
|
||||
};
|
||||
|
||||
type Options = {|
|
||||
project: gdProject,
|
||||
i18n: I18nType,
|
||||
outputDir?: string,
|
||||
|};
|
||||
|
||||
type Result = {| outputDir: string |};
|
||||
|
||||
/** Headless HTML5 export — no UI, no dialogs. */
|
||||
export const exportLocalHtml5Headless = async ({
|
||||
project,
|
||||
i18n,
|
||||
outputDir,
|
||||
}: Options): Promise<Result> => {
|
||||
const resolvedOutputDir = outputDir || resolveHtml5OutputDir(project);
|
||||
project.setLastCompilationDirectory(resolvedOutputDir);
|
||||
|
||||
const context = {
|
||||
project,
|
||||
exportState: { outputDir: resolvedOutputDir },
|
||||
updateStepProgress: (count: number, total: number) => {},
|
||||
i18n,
|
||||
};
|
||||
|
||||
const preparedExporter = await localHTML5ExportPipeline.prepareExporter(
|
||||
context
|
||||
);
|
||||
const exportOutput = await localHTML5ExportPipeline.launchExport(
|
||||
context,
|
||||
preparedExporter,
|
||||
null
|
||||
);
|
||||
const resourcesDownloadOutput = await localHTML5ExportPipeline.launchResourcesDownload(
|
||||
context,
|
||||
exportOutput
|
||||
);
|
||||
await localHTML5ExportPipeline.launchCompression(
|
||||
context,
|
||||
resourcesDownloadOutput
|
||||
);
|
||||
|
||||
return { outputDir: resolvedOutputDir };
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { type CommandPaletteInterface } from '../CommandPalette/CommandPalette';
|
||||
import { exportLocalHtml5Headless } from '../ExportAndShare/Headless/ExportLocalHtml5Headless';
|
||||
import EventsFunctionsExtensionsContext, {
|
||||
type EventsFunctionsExtensionsState,
|
||||
} from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import PreferencesContext, {
|
||||
type Preferences,
|
||||
} from './Preferences/PreferencesContext';
|
||||
import { scanProjectForValidationErrors } from '../Utils/EventsValidationScanner';
|
||||
import Window from '../Utils/Window';
|
||||
import optionalRequire from '../Utils/OptionalRequire';
|
||||
|
||||
const electron = optionalRequire('electron');
|
||||
const ipcRenderer = electron ? electron.ipcRenderer : null;
|
||||
const fs = optionalRequire('fs');
|
||||
|
||||
// Commands registered here are awaited by the CLI dispatcher so the process
|
||||
// exits with a meaningful code. Unregistered commands fall back to
|
||||
// fire-and-forget via launchCommand.
|
||||
export type CliCommandRunner = (
|
||||
project: gdProject,
|
||||
i18n: I18nType,
|
||||
context: {| preferences: Preferences |}
|
||||
) => Promise<void>;
|
||||
|
||||
const runners: { [commandName: string]: CliCommandRunner } = {
|
||||
EXPORT_HTML5_EXTERNAL: async (project, i18n, { preferences }) => {
|
||||
if (preferences.getBlockPreviewAndExportOnDiagnosticErrors()) {
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`[CLI] Diagnostic report has ${
|
||||
errors.length
|
||||
} error(s). Export blocked.`
|
||||
);
|
||||
throw new Error('Export blocked by diagnostic errors.');
|
||||
}
|
||||
}
|
||||
await exportLocalHtml5Headless({ project, i18n });
|
||||
},
|
||||
};
|
||||
|
||||
export const getAwaitableCliRunner = (commandName: string): ?CliCommandRunner =>
|
||||
runners[commandName] || null;
|
||||
|
||||
const CLI_PROJECT_LOAD_TIMEOUT_MS = 120_000;
|
||||
const FIRE_AND_FORGET_GRACE_MS = 1500;
|
||||
|
||||
const exitApp = (exitCode: number) => {
|
||||
if (ipcRenderer) ipcRenderer.send('app-exit', exitCode);
|
||||
};
|
||||
|
||||
const ensureProjectExtensionsReadyForCli = async (
|
||||
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState
|
||||
): Promise<boolean> => {
|
||||
await eventsFunctionsExtensionsState.ensureLoadFinished();
|
||||
|
||||
if (eventsFunctionsExtensionsState.eventsFunctionsExtensionsError) {
|
||||
console.error(
|
||||
'[CLI] Project extensions failed to load:',
|
||||
eventsFunctionsExtensionsState.eventsFunctionsExtensionsError
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fs) {
|
||||
const includeFileHashs = eventsFunctionsExtensionsState.getIncludeFileHashs();
|
||||
const missingIncludeFiles = Object.keys(includeFileHashs).filter(
|
||||
includeFile => !fs.existsSync(includeFile)
|
||||
);
|
||||
if (missingIncludeFiles.length > 0) {
|
||||
console.warn(
|
||||
'[CLI] Some generated extension code files are missing on disk (extension compatibility issue):',
|
||||
missingIncludeFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: ?gdProject,
|
||||
i18n: I18nType,
|
||||
commandPaletteRef: {| current: ?CommandPaletteInterface |},
|
||||
|};
|
||||
|
||||
export const useCliCommandRunner = ({
|
||||
project,
|
||||
i18n,
|
||||
commandPaletteRef,
|
||||
}: Props) => {
|
||||
const eventsFunctionsExtensionsState = React.useContext(
|
||||
EventsFunctionsExtensionsContext
|
||||
);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
|
||||
// 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.
|
||||
const cliCommandRanRef = React.useRef(false);
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (cliCommandRanRef.current) return;
|
||||
if (!project) return;
|
||||
|
||||
const appArguments = Window.getArguments();
|
||||
const commandName = appArguments['run-command'];
|
||||
if (!commandName || typeof commandName !== 'string') return;
|
||||
|
||||
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 });
|
||||
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();
|
||||
},
|
||||
[
|
||||
project,
|
||||
i18n,
|
||||
commandPaletteRef,
|
||||
eventsFunctionsExtensionsState,
|
||||
preferences,
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!Window.isRunningCommandFromCli()) return;
|
||||
if (project) return;
|
||||
const timer = setTimeout(() => {
|
||||
console.error('[CLI] Project failed to load within timeout. Exiting.');
|
||||
exitApp(1);
|
||||
}, CLI_PROJECT_LOAD_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[project]
|
||||
);
|
||||
};
|
||||
@@ -60,6 +60,7 @@ type CommandHandlers = {|
|
||||
onCloseProject: () => Promise<void>,
|
||||
onReloadProject: () => Promise<void>,
|
||||
onExportGame: () => void,
|
||||
onExportHtml5External: () => void | Promise<void>,
|
||||
onInviteCollaborators: () => void,
|
||||
onOpenLayout: string => void,
|
||||
onOpenExternalEvents: string => void,
|
||||
@@ -159,6 +160,10 @@ const useMainFrameCommands = (handlers: CommandHandlers) => {
|
||||
handler: handlers.onExportGame,
|
||||
});
|
||||
|
||||
useCommand('EXPORT_HTML5_EXTERNAL', !!handlers.project, {
|
||||
handler: handlers.onExportHtml5External,
|
||||
});
|
||||
|
||||
useCommand('INVITE_COLLABORATORS', !!handlers.project, {
|
||||
handler: handlers.onInviteCollaborators,
|
||||
});
|
||||
|
||||
@@ -75,6 +75,8 @@ import {
|
||||
type ObjectGroupsOutsideEditorChanges,
|
||||
} from './EditorContainers/BaseEditor';
|
||||
import { type Exporter } from '../ExportAndShare/ShareDialog';
|
||||
import { exportLocalHtml5Headless } from '../ExportAndShare/Headless/ExportLocalHtml5Headless';
|
||||
import { useCliCommandRunner } from './CliCommandRunner';
|
||||
import ResourcesLoader from '../ResourcesLoader/index';
|
||||
import {
|
||||
type PreviewLauncherInterface,
|
||||
@@ -931,7 +933,9 @@ const MainFrame = (props: Props): React.MixedElement => {
|
||||
// We use the current storage provider, as it's supposed to be able to open
|
||||
// the initial file metadata. Indeed, it's the responsibility of the `ProjectStorageProviders`
|
||||
// to set the initial storage provider if an initial file metadata is set.
|
||||
const state = await openFromFileMetadata(initialFileMetadataToOpen);
|
||||
const state = await openFromFileMetadata(initialFileMetadataToOpen, {
|
||||
ignoreAutoSave: Window.isRunningCommandFromCli(),
|
||||
});
|
||||
if (state)
|
||||
openSceneOrProjectManager({
|
||||
currentProject: state.currentProject,
|
||||
@@ -1181,21 +1185,28 @@ const MainFrame = (props: Props): React.MixedElement => {
|
||||
ResourcesLoader.burstAllUrlsCache();
|
||||
PixiResourcesLoader.burstCache();
|
||||
|
||||
// Set the on-disk path before exposing the project via state so that
|
||||
// consumers (like the CLI command dispatcher) can call getProjectFile()
|
||||
// immediately after the re-render triggered by setState.
|
||||
if (updatedFileMetadata) {
|
||||
project.setProjectFile(updatedFileMetadata.fileIdentifier);
|
||||
}
|
||||
|
||||
// Start extension code generation before exposing the project via state.
|
||||
// This ensures that when the CLI useEffect fires (triggered by the
|
||||
// setState below), ensureLoadFinished() will see the pending promise
|
||||
// and wait for generation to complete.
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
project
|
||||
);
|
||||
|
||||
const state = await setState(state => ({
|
||||
...state,
|
||||
currentProject: project,
|
||||
currentFileMetadata: updatedFileMetadata,
|
||||
}));
|
||||
|
||||
// Load all the EventsFunctionsExtension when the game is loaded. If they are modified,
|
||||
// their editor will take care of reloading them.
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
project
|
||||
);
|
||||
|
||||
if (updatedFileMetadata) {
|
||||
project.setProjectFile(updatedFileMetadata.fileIdentifier);
|
||||
|
||||
const storageProvider = getStorageProvider();
|
||||
const storageProviderOperations = getStorageProviderOperations(
|
||||
storageProvider
|
||||
@@ -4935,6 +4946,15 @@ const MainFrame = (props: Props): React.MixedElement => {
|
||||
onExportGame: () => {
|
||||
openShareDialog('publish');
|
||||
},
|
||||
onExportHtml5External: async () => {
|
||||
const project = state.currentProject;
|
||||
if (!project) return;
|
||||
try {
|
||||
await exportLocalHtml5Headless({ project, i18n });
|
||||
} catch (error) {
|
||||
console.error('Headless HTML5 export failed:', error);
|
||||
}
|
||||
},
|
||||
onInviteCollaborators: () => {
|
||||
openShareDialog('invite');
|
||||
},
|
||||
@@ -4951,6 +4971,12 @@ const MainFrame = (props: Props): React.MixedElement => {
|
||||
onOpenMemoryTrackerRegistry: () => setMemoryTrackedRegistryDialogOpen(true),
|
||||
});
|
||||
|
||||
useCliCommandRunner({
|
||||
project: state.currentProject,
|
||||
i18n,
|
||||
commandPaletteRef,
|
||||
});
|
||||
|
||||
const resourceManagementProps: ResourceManagementProps = React.useMemo(
|
||||
() => ({
|
||||
resourceSources,
|
||||
@@ -5469,7 +5495,7 @@ const MainFrame = (props: Props): React.MixedElement => {
|
||||
language={props.i18n.language}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
<ChangelogDialogContainer />
|
||||
{!Window.isRunningCommandFromCli() && <ChangelogDialogContainer />}
|
||||
{selectedInAppTutorialInfo && (
|
||||
<StartInAppTutorialDialog
|
||||
open
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
type ShowYesNoCancelDialogOptionsWithCallback,
|
||||
} from './AlertContext';
|
||||
import YesNoCancelDialog from './YesNoCancelDialog';
|
||||
import Window from '../../Utils/Window';
|
||||
|
||||
type Props = {| children: React.Node |};
|
||||
|
||||
function ConfirmProvider({ children }: Props): React.Node {
|
||||
const isCli = Window.isRunningCommandFromCli();
|
||||
|
||||
// Alert
|
||||
const [alertDialogOpen, setAlertDialogOpen] = React.useState<boolean>(false);
|
||||
const [
|
||||
@@ -23,10 +26,14 @@ function ConfirmProvider({ children }: Props): React.Node {
|
||||
] = React.useState<?ShowAlertDialogOptionsWithCallback>(null);
|
||||
const openAlertDialog = React.useCallback(
|
||||
(options: ShowAlertDialogOptionsWithCallback) => {
|
||||
if (isCli) {
|
||||
options.callback();
|
||||
return;
|
||||
}
|
||||
setAlertDialogOpen(true);
|
||||
setAlertDialogConfig(options);
|
||||
},
|
||||
[]
|
||||
[isCli]
|
||||
);
|
||||
|
||||
// Confirm
|
||||
@@ -39,10 +46,14 @@ function ConfirmProvider({ children }: Props): React.Node {
|
||||
] = React.useState<?ShowConfirmDialogOptionsWithCallback>(null);
|
||||
const openConfirmDialog = React.useCallback(
|
||||
(options: ShowConfirmDialogOptionsWithCallback) => {
|
||||
if (isCli) {
|
||||
options.callback(false);
|
||||
return;
|
||||
}
|
||||
setConfirmDialogOpen(true);
|
||||
setConfirmDialogConfig(options);
|
||||
},
|
||||
[]
|
||||
[isCli]
|
||||
);
|
||||
|
||||
// Confirm Delete
|
||||
@@ -56,13 +67,17 @@ function ConfirmProvider({ children }: Props): React.Node {
|
||||
] = React.useState<?ShowConfirmDeleteDialogOptionsWithCallback>(null);
|
||||
const openConfirmDeleteDialog = React.useCallback(
|
||||
(options: ShowConfirmDeleteDialogOptionsWithCallback) => {
|
||||
if (isCli) {
|
||||
options.callback(false);
|
||||
return;
|
||||
}
|
||||
setConfirmDeleteDialogOpen(true);
|
||||
setConfirmDeleteDialogConfig(options);
|
||||
},
|
||||
[]
|
||||
[isCli]
|
||||
);
|
||||
|
||||
// Confirm
|
||||
// YesNoCancel
|
||||
const [
|
||||
yesNoCancelDialogOpen,
|
||||
setYesNoCancelDialogOpen,
|
||||
@@ -73,10 +88,14 @@ function ConfirmProvider({ children }: Props): React.Node {
|
||||
] = React.useState<?ShowYesNoCancelDialogOptionsWithCallback>(null);
|
||||
const openYesNoCancelDialog = React.useCallback(
|
||||
(options: ShowYesNoCancelDialogOptionsWithCallback) => {
|
||||
if (isCli) {
|
||||
options.callback(2); // Cancel
|
||||
return;
|
||||
}
|
||||
setYesNoCancelDialogOpen(true);
|
||||
setYesNoCancelDialogConfig(options);
|
||||
},
|
||||
[]
|
||||
[isCli]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -245,6 +245,12 @@ export default class Window {
|
||||
return remote.getCurrentWindow().on('close', cb);
|
||||
}
|
||||
|
||||
/** True when the app was launched with `--run-command` (CLI / CI mode). */
|
||||
static isRunningCommandFromCli(): boolean {
|
||||
const args = Window.getArguments();
|
||||
return !!args['run-command'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the arguments passed to the IDE, either from Electron
|
||||
* or from the web-app URL. The resulting object will have a key "_"
|
||||
|
||||
@@ -67,11 +67,11 @@ 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 args = parseArgs(process.argv.slice(isDev ? 2 : 1), {
|
||||
// "Officially" supported arguments and their types:
|
||||
boolean: ['dev-tools', 'disable-update-check'],
|
||||
string: '_', // Files are always strings
|
||||
});
|
||||
const argsParserOptions = {
|
||||
boolean: ['dev-tools', 'disable-update-check', 'keep-open'],
|
||||
string: ['_', 'run-command'],
|
||||
};
|
||||
const args = parseArgs(process.argv.slice(isDev ? 2 : 1), argsParserOptions);
|
||||
|
||||
const devTools = !!args['dev-tools'];
|
||||
|
||||
@@ -85,22 +85,32 @@ if (process.platform === 'win32') {
|
||||
}
|
||||
|
||||
// Single instance lock - prevents multiple Electron processes
|
||||
// This solves Firebase IndexedDB locking issues while still allowing multiple windows
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
// 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.
|
||||
const isCliRunCommand = !!args['run-command'];
|
||||
const gotTheLock = isCliRunCommand ? true : app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
// Second instance attempted - quit immediately
|
||||
app.quit();
|
||||
} else {
|
||||
} else if (!isCliRunCommand) {
|
||||
// First instance - handle second-instance events by creating new windows
|
||||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
||||
// User tried to launch app again - create a new window instead
|
||||
const secondInstanceArgs = parseArgs(commandLine.slice(isDev ? 2 : 1), {
|
||||
boolean: ['dev-tools', 'disable-update-check'],
|
||||
string: '_',
|
||||
});
|
||||
const secondInstanceArgs = parseArgs(
|
||||
commandLine.slice(isDev ? 2 : 1),
|
||||
argsParserOptions
|
||||
);
|
||||
|
||||
// Update the global args so the new window's renderer (which reads them
|
||||
// via remote.getGlobal('args')) picks up the second-instance CLI flags
|
||||
// (e.g. --run-command, positional project file).
|
||||
global['args'] = secondInstanceArgs;
|
||||
|
||||
// Create a new window in the existing process
|
||||
createNewWindow(secondInstanceArgs);
|
||||
});
|
||||
}
|
||||
@@ -191,8 +201,13 @@ function createNewWindow(windowArgs = args) {
|
||||
options.show = false;
|
||||
}
|
||||
|
||||
if (isCliRunCommand && !windowArgs['keep-open']) {
|
||||
options.show = false;
|
||||
options.skipTaskbar = true;
|
||||
}
|
||||
|
||||
const newWindow = new BrowserWindow(options);
|
||||
if (!isIntegrated) newWindow.maximize();
|
||||
if (!isIntegrated && !isCliRunCommand) newWindow.maximize();
|
||||
|
||||
// Capture window ID and whether this is the primary window before it can be destroyed
|
||||
const windowId = newWindow.id;
|
||||
@@ -212,6 +227,19 @@ function createNewWindow(windowArgs = args) {
|
||||
// Enable `@electron/remote` module for renderer process
|
||||
require('@electron/remote/main').enable(newWindow.webContents);
|
||||
|
||||
// Uses process.stdout/stderr directly (not electron-log) to avoid
|
||||
// re-entering the renderer console and causing an infinite loop.
|
||||
if (isCliRunCommand) {
|
||||
newWindow.webContents.on('console-message', (_event, level, message) => {
|
||||
if (level < 1) return;
|
||||
if (message.startsWith('%c')) return;
|
||||
if (message.includes('[renderer:')) return; // break recursion
|
||||
const tag = ['verbose', 'info', 'warning', 'error'][level] || 'log';
|
||||
const stream = level >= 2 ? process.stderr : process.stdout;
|
||||
stream.write(`[renderer:${tag}] ${message}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// Log process ID to verify separate renderer processes
|
||||
newWindow.webContents.once('did-finish-load', () => {
|
||||
newWindow.webContents
|
||||
@@ -389,6 +417,10 @@ app.on('ready', function() {
|
||||
]);
|
||||
}
|
||||
|
||||
ipcMain.on('app-exit', (_event, exitCode) => {
|
||||
app.exit(typeof exitCode === 'number' ? exitCode : 0);
|
||||
});
|
||||
|
||||
ipcMain.on('set-main-menu', (event, mainMenuTemplate) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
Menu.setApplicationMenu(
|
||||
@@ -536,7 +568,9 @@ app.on('ready', function() {
|
||||
const subscriptionId = setupWatcher(
|
||||
folderPath,
|
||||
changedFilePath => {
|
||||
event.sender.send('project-file-changed', changedFilePath);
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('project-file-changed', changedFilePath);
|
||||
}
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
@@ -49,6 +49,10 @@ const config = {
|
||||
},
|
||||
win: {
|
||||
executableName: 'GDevelop',
|
||||
target: [
|
||||
{ target: 'nsis', arch: ['x64'] },
|
||||
{ target: 'zip', arch: ['x64'] },
|
||||
],
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
@@ -81,7 +85,16 @@ const config = {
|
||||
],
|
||||
};
|
||||
|
||||
if (
|
||||
if (process.env.GD_PORTABLE_BUILD === 'true') {
|
||||
config.mac.identity = null;
|
||||
config.mac.hardenedRuntime = false;
|
||||
config.afterSign = undefined;
|
||||
// Exclude .exe from signing so signIf() never reaches signtool.
|
||||
config.win.signExts = ['!.exe'];
|
||||
console.log(
|
||||
'ℹ️ GD_PORTABLE_BUILD is set — skipping macOS signing/notarization and Windows Authenticode (exe signing).'
|
||||
);
|
||||
} else if (
|
||||
process.env.GD_SIGNTOOL_SUBJECT_NAME &&
|
||||
process.env.GD_SIGNTOOL_THUMBPRINT
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user