From b8053fcd3468dddcccd7c53aa59822c42d2a1ba2 Mon Sep 17 00:00:00 2001 From: Quentin Goinaud Date: Sun, 16 Aug 2026 07:41:32 +0000 Subject: [PATCH] feat(plugin-godot): add "Export Godot project" action --- .changeset/godot-plugin.md | 12 + src/shared/libs/plugin-godot/export.ts | 210 ++++++++ src/shared/libs/plugin-godot/extract.ts | 48 ++ .../plugin-godot/godot-registration.spec.ts | 51 ++ .../plugin-godot/godot.integration.spec.ts | 89 ++++ src/shared/libs/plugin-godot/godot.spec.ts | 455 ++++++++++++++++++ src/shared/libs/plugin-godot/godot.ts | 344 +++++++++++++ src/shared/libs/plugin-godot/index.ts | 19 + src/shared/plugins.ts | 3 +- 9 files changed, 1230 insertions(+), 1 deletion(-) create mode 100644 .changeset/godot-plugin.md create mode 100644 src/shared/libs/plugin-godot/export.ts create mode 100644 src/shared/libs/plugin-godot/extract.ts create mode 100644 src/shared/libs/plugin-godot/godot-registration.spec.ts create mode 100644 src/shared/libs/plugin-godot/godot.integration.spec.ts create mode 100644 src/shared/libs/plugin-godot/godot.spec.ts create mode 100644 src/shared/libs/plugin-godot/godot.ts create mode 100644 src/shared/libs/plugin-godot/index.ts diff --git a/.changeset/godot-plugin.md b/.changeset/godot-plugin.md new file mode 100644 index 00000000..7c99f1a9 --- /dev/null +++ b/.changeset/godot-plugin.md @@ -0,0 +1,12 @@ +--- +"@pipelab/app": minor +--- + +feat(plugin-godot): add "Export Godot project" action + +Adds a new Godot plugin with a single "Export Godot project" action. It takes a Godot 4.x +project folder, auto-detects matching presets from `export_presets.cfg` (or generates one for +the target platform: Windows, Linux, macOS or Web, defaulting to the host platform), downloads +the Godot 4.4.1 headless editor and export templates on first use, and runs +`godot --headless --export-release`. Output paths are exposed via the `output`, `parentFolder` +and `folder` outputs. diff --git a/src/shared/libs/plugin-godot/export.ts b/src/shared/libs/plugin-godot/export.ts new file mode 100644 index 00000000..3981493a --- /dev/null +++ b/src/shared/libs/plugin-godot/export.ts @@ -0,0 +1,210 @@ +import { createActionRunner } from '@pipelab/plugin-core' +import { + assertGodotTargetPlatform, + buildGodotOutputPath, + ensureUniquePresetName, + findMatchingPreset, + generatePresetConfig, + godotDownloadUrl, + godotEditorAssetName, + godotEditorBinaryPath, + godotExportArgs, + godotPresetPlatform, + godotRunEnv, + godotTemplatesAssetName, + godotTemplatesDir, + GODOT_VERSION, + GodotPreset, + parseExportPresets, + readGodotProjectName, + validateGodotProject, + exportGodotAction +} from './godot' + +export const ensureGodotEditor = async ( + log: typeof console.log, + abortSignal?: AbortSignal +): Promise => { + const { app } = await import('electron') + const userData = app.getPath('userData') + const { chmod, mkdir, rm } = await import('node:fs/promises') + const { dirname, join } = await import('node:path') + const { downloadFile, fileExists } = await import('@@/libs/plugin-core') + const { extractZip, extractZipStripFirst } = await import('./extract') + + const godotFolder = join(userData, 'thirdparty', 'godot', `v${GODOT_VERSION}`) + const editorFolder = join(godotFolder, 'editor') + const editorPath = join(editorFolder, godotEditorBinaryPath(process.platform, process.arch)) + + if (await fileExists(editorPath)) { + return editorPath + } + + const assetName = godotEditorAssetName(process.platform, process.arch) + const url = godotDownloadUrl(assetName) + log('Downloading Godot editor from', url) + + const zipPath = join(godotFolder, assetName) + await mkdir(dirname(zipPath), { recursive: true }) + await downloadFile( + url, + zipPath, + { + onProgress: ({ progress }) => { + log(`Downloading Godot editor: ${progress.toFixed(2)}%`) + } + }, + abortSignal + ) + + if (process.platform === 'darwin') { + await extractZip(zipPath, editorFolder) + } else { + await extractZipStripFirst(zipPath, editorFolder) + } + + await rm(zipPath, { force: true }) + + if (process.platform !== 'win32') { + try { + await chmod(editorPath, 0o755) + } catch { + // Ignore chmod failures (e.g. filesystems without POSIX permissions). + } + } + + return editorPath +} + +export const ensureGodotTemplates = async ( + log: typeof console.log, + abortSignal?: AbortSignal +): Promise => { + const { app } = await import('electron') + const userData = app.getPath('userData') + const { mkdir, rm } = await import('node:fs/promises') + const { join } = await import('node:path') + const { downloadFile } = await import('@@/libs/plugin-core') + const { extractZipStripFirst } = await import('./extract') + + const thirdparty = join(userData, 'thirdparty', 'godot') + const templatesDir = godotTemplatesDir(userData, process.platform) + const assetName = godotTemplatesAssetName() + const url = godotDownloadUrl(assetName) + log('Downloading Godot export templates from', url) + + const zipPath = join(thirdparty, assetName) + await mkdir(thirdparty, { recursive: true }) + await downloadFile( + url, + zipPath, + { + onProgress: ({ progress }) => { + log(`Downloading Godot export templates: ${progress.toFixed(2)}%`) + } + }, + abortSignal + ) + + await extractZipStripFirst(zipPath, templatesDir) + await rm(zipPath, { force: true }) + + return templatesDir +} + +export const ExportGodotRunner = createActionRunner( + async ({ log, inputs, setOutput, cwd, abortSignal }) => { + const { app } = await import('electron') + const userData = app.getPath('userData') + const { mkdir, readFile, writeFile } = await import('node:fs/promises') + const { dirname, join, resolve } = await import('node:path') + const { fileExists, runWithLiveLogs } = await import('@@/libs/plugin-core') + + const target = assertGodotTargetPlatform(inputs['target-platform']) + const project = inputs.project + + log('Godot export', target, 'from', project) + + await validateGodotProject(project) + + const projectName = await readGodotProjectName(project) + log('Godot project name:', projectName) + + const presetsPath = join(project, 'export_presets.cfg') + let presets: GodotPreset[] = [] + if (await fileExists(presetsPath)) { + presets = parseExportPresets(await readFile(presetsPath, 'utf-8')) + log(`Found ${presets.length} export preset(s)`) + } else { + log('No export_presets.cfg found, a preset will be generated') + } + + let preset = findMatchingPreset(presets, target) + if (!preset) { + const presetName = ensureUniquePresetName(presets, `Pipelab ${godotPresetPlatform(target)}`) + const presetIndex = presets.reduce((max, p) => Math.max(max, p.index + 1), 0) + const generatedOutputPath = buildGodotOutputPath(cwd, target, projectName) + const config = generatePresetConfig( + presetIndex, + presetName, + godotPresetPlatform(target), + generatedOutputPath + ) + const existingContent = await readFile(presetsPath, 'utf-8').catch(() => '') + await writeFile(presetsPath, `${existingContent}${existingContent ? '\n' : ''}${config}`) + preset = { + index: presetIndex, + name: presetName, + platform: godotPresetPlatform(target), + exportPath: generatedOutputPath + } + presets = [...presets, preset] + log('Generated preset', presetName, 'for', godotPresetPlatform(target)) + } + + const outputPath = preset.exportPath?.trim() + ? preset.exportPath + : buildGodotOutputPath(cwd, target, projectName) + const resolvedOutput = resolve(project, outputPath) + await mkdir(dirname(resolvedOutput), { recursive: true }) + + const editorPath = await ensureGodotEditor(log, abortSignal) + log('Using Godot editor at', editorPath) + + const templatesDir = godotTemplatesDir(userData, process.platform) + if (!(await fileExists(join(templatesDir, 'version.txt')))) { + await ensureGodotTemplates(log, abortSignal) + } + + log('Exporting to', resolvedOutput) + await runWithLiveLogs( + editorPath, + godotExportArgs(preset.name, resolvedOutput), + { + cwd: project, + cancelSignal: abortSignal, + env: { + ...godotRunEnv(userData, process.platform) + } + }, + log, + { + onStdout(data) { + log(data) + }, + onStderr(data) { + log(data) + } + } + ) + + if (!(await fileExists(resolvedOutput))) { + throw new Error(`Godot export finished but no output found at ${resolvedOutput}`) + } + + setOutput('output', resolvedOutput) + setOutput('parentFolder', dirname(resolvedOutput)) + setOutput('folder', dirname(resolvedOutput)) + log('Godot export complete:', resolvedOutput) + } +) diff --git a/src/shared/libs/plugin-godot/extract.ts b/src/shared/libs/plugin-godot/extract.ts new file mode 100644 index 00000000..3d5d4177 --- /dev/null +++ b/src/shared/libs/plugin-godot/extract.ts @@ -0,0 +1,48 @@ +export const stripFirstPathSegment = (name: string): string => { + const firstSlashIndex = name.indexOf('/') + if (firstSlashIndex === -1) { + return name + } + return name.substring(firstSlashIndex + 1) +} + +const assertSafeTarget = (name: string): void => { + if (name.split('/').some((part) => part === '..' || part === '.')) { + throw new Error(`Refusing to extract entry with an unsafe path: ${name}`) + } +} + +const extractZipEntries = async ( + zipPath: string, + destinationDir: string, + transform: (name: string) => string +): Promise => { + const StreamZip = await import('node-stream-zip') + const { mkdir } = await import('node:fs/promises') + const { dirname, join } = await import('node:path') + + const zip = new StreamZip.default.async({ file: zipPath }) + try { + const entries = await zip.entries() + for (const name of Object.keys(entries)) { + const entry = entries[name] + const target = transform(name) + assertSafeTarget(target) + if (entry.isDirectory) { + await mkdir(join(destinationDir, target), { recursive: true }) + continue + } + const outPath = join(destinationDir, target) + await mkdir(dirname(outPath), { recursive: true }) + await zip.extract(name, outPath) + } + } finally { + await zip.close() + } +} + +export const extractZip = (zipPath: string, destinationDir: string): Promise => + extractZipEntries(zipPath, destinationDir, (name) => name) + +export const extractZipStripFirst = (zipPath: string, destinationDir: string): Promise => + extractZipEntries(zipPath, destinationDir, stripFirstPathSegment) diff --git a/src/shared/libs/plugin-godot/godot-registration.spec.ts b/src/shared/libs/plugin-godot/godot-registration.spec.ts new file mode 100644 index 00000000..e4ab58f8 --- /dev/null +++ b/src/shared/libs/plugin-godot/godot-registration.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, vi } from 'vitest' + +vi.mock('@electron-toolkit/utils', () => ({ + is: { + dev: false + } +})) + +vi.mock('electron', () => { + const osTmpdir = () => + process.platform === 'win32' + ? process.env.TEMP || process.env.TMP || process.env.TMPDIR || 'C:\\Windows\\Temp' + : process.env.TMPDIR || process.env.TMP || process.env.TEMP || '/tmp' + return { + app: { + isPackaged: false, + getPath: () => `${osTmpdir()}/pipelab-godot-registration` + }, + BrowserWindow: class {}, + shell: {}, + session: {}, + ipcMain: {}, + contextBridge: {}, + dialog: {}, + screen: {}, + protocol: {}, + autoUpdater: {} + } +}) + +test('registers the godot plugin as a built-in plugin', async () => { + const { usePlugins } = await import('@@/plugins') + const plugins = usePlugins() + await plugins.registerBuiltIn() + + const godot = plugins.plugins.value.find((plugin) => plugin.id === 'godot') + expect(godot).toBeDefined() + expect(godot?.name).toBe('Godot') + expect(godot?.nodes.some((node) => node.node.id === 'godot-export')).toBe(true) +}) + +test('getFinalPlugins exposes the godot plugin without its runners', async () => { + const { getFinalPlugins } = await import('@main/utils') + const { usePlugins } = await import('@@/plugins') + await usePlugins().registerBuiltIn() + + const finalPlugins = getFinalPlugins() + const godot = finalPlugins.find((plugin) => plugin.id === 'godot') + expect(godot).toBeDefined() + expect(godot?.nodes.some((node) => node.node.id === 'godot-export')).toBe(true) +}) diff --git a/src/shared/libs/plugin-godot/godot.integration.spec.ts b/src/shared/libs/plugin-godot/godot.integration.spec.ts new file mode 100644 index 00000000..79d4ca1f --- /dev/null +++ b/src/shared/libs/plugin-godot/godot.integration.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'vitest' +import { execa } from 'execa' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileExists } from '@@/libs/plugin-core' +import { GODOT_VERSION, generatePresetConfig } from './godot.js' + +interface GodotBinary { + binary: string + version: string +} + +const findGodot = async (): Promise => { + const candidates: string[] = [] + if (process.env.GODOT_BIN) { + candidates.push(process.env.GODOT_BIN) + } else { + candidates.push('godot') + } + for (const binary of candidates) { + try { + const { stdout } = await execa(binary, ['--version'], { timeout: 30_000 }) + const version = stdout.trim() + if (version.startsWith('4.')) { + return { binary, version } + } + } catch { + // binary not runnable, try the next candidate + } + } + return undefined +} + +const skipReason = `No Godot 4.x binary was reachable. Tried "$GODOT_BIN" and "godot" on PATH (none found). Install Godot 4.x (or set GODOT_BIN), and install the export templates for ${GODOT_VERSION} (Editor > Manage Export Templates > Install) to run this integration test for real.` + +describe('plugin-godot integration', () => { + test('exports a minimal Godot 4 project for real', async (ctx) => { + const godot = await findGodot() + if (!godot) { + ctx.skip(skipReason) + return + } + + const dir = await mkdtemp(join(tmpdir(), 'pipelab-godot-it-')) + try { + const projectDir = join(dir, 'project') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'project.godot'), + 'config_version=5\n\n[application]\n\nconfig/name="Pipelab Godot Integration"\n' + ) + await writeFile( + join(projectDir, 'main.tscn'), + '[gd_scene format=3]\n\n[node name="Main" type="Node2D"]\n' + ) + const outputPath = join(dir, 'godot-export', 'web', 'index.html') + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile( + join(projectDir, 'export_presets.cfg'), + generatePresetConfig(0, 'Pipelab Web', 'Web', outputPath) + ) + + const result = await execa( + godot.binary, + ['--headless', '--export-release', 'Pipelab Web', outputPath], + { + cwd: projectDir, + reject: false, + timeout: 600_000 + } + ) + + const output = `${result.stdout || ''}\n${result.stderr || ''}` + if (/template/i.test(output)) { + ctx.skip( + `Godot ${godot.version} was found, but the export templates are not installed. Install them for ${GODOT_VERSION} (Editor > Manage Export Templates > Install) to run this integration test for real.` + ) + return + } + + expect(result.exitCode).toBe(0) + expect(output).not.toMatch(/ERROR/i) + expect(await fileExists(outputPath)).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 600_000) +}) diff --git a/src/shared/libs/plugin-godot/godot.spec.ts b/src/shared/libs/plugin-godot/godot.spec.ts new file mode 100644 index 00000000..b3d68be0 --- /dev/null +++ b/src/shared/libs/plugin-godot/godot.spec.ts @@ -0,0 +1,455 @@ +import { afterAll, beforeAll, beforeEach, expect, test, vi } from 'vitest' +import { dirname, join } from 'node:path' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { browserWindow } from '@@/tests/helpers.js' +import { ExportGodotRunner } from './export.js' +import { + GODOT_VERSION, + assertGodotTargetPlatform, + buildGodotOutputPath, + ensureUniquePresetName, + findMatchingPreset, + generatePresetConfig, + godotDownloadUrl, + godotEditorAssetName, + godotEditorBinaryPath, + godotExportArgs, + godotOutputFileName, + godotPresetPlatform, + godotRunEnv, + godotTemplatesAssetName, + godotTemplatesDir, + hostTargetPlatform, + parseExportPresets, + readGodotProjectName, + validateGodotProject, + GodotTargetPlatform +} from './godot.js' +import { stripFirstPathSegment } from './extract.js' + +const { userDataDir, downloadMock, runWithLiveLogsMock } = vi.hoisted(() => { + const osTmpdir = () => + process.platform === 'win32' + ? process.env.TEMP || process.env.TMP || process.env.TMPDIR || 'C:\\Windows\\Temp' + : process.env.TMPDIR || process.env.TMP || process.env.TEMP || '/tmp' + return { + userDataDir: `${osTmpdir()}/pipelab-godot-test/userdata-${process.pid}`, + downloadMock: vi.fn(), + runWithLiveLogsMock: vi.fn() + } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataDir + } +})) + +vi.mock('@@/libs/plugin-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + downloadFile: downloadMock, + runWithLiveLogs: runWithLiveLogsMock + } +}) + +vi.mock('./extract.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + extractZip: vi.fn(), + extractZipStripFirst: vi.fn() + } +}) + +const editorFolder = join(userDataDir, 'thirdparty', 'godot', `v${GODOT_VERSION}`, 'editor') +const editorBinaryPath = () => + join(editorFolder, godotEditorBinaryPath(process.platform, process.arch)) +const templatesVersionMarker = () => + join(godotTemplatesDir(userDataDir, process.platform), 'version.txt') + +let projectCounter = 0 +const makeProject = async (options?: { presets?: string; emptyPresets?: boolean }) => { + const projectDir = join(userDataDir, `project-${process.pid}-${projectCounter++}`) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'project.godot'), + 'config_version=5\n\n[application]\n\nconfig/name="My Game"\n' + ) + if (options?.presets) { + await writeFile(join(projectDir, 'export_presets.cfg'), options.presets) + } else if (options?.emptyPresets) { + await writeFile(join(projectDir, 'export_presets.cfg'), '') + } + return projectDir +} + +const installGodot = async () => { + await mkdir(editorFolder, { recursive: true }) + await writeFile(editorBinaryPath(), '') + await mkdir(dirname(templatesVersionMarker()), { recursive: true }) + await writeFile(templatesVersionMarker(), GODOT_VERSION) +} + +const installEditorOnly = async () => { + await mkdir(editorFolder, { recursive: true }) + await writeFile(editorBinaryPath(), '') +} + +const makeWorkspace = async () => mkdtemp(join(userDataDir, 'workspace-')) + +const runExport = async (projectDir: string, target: GodotTargetPlatform, cwd: string) => { + const outputs: Record = {} + await ExportGodotRunner({ + inputs: { + project: projectDir, + 'target-platform': target + }, + log: () => {}, + setOutput: (key, value) => { + outputs[key] = value + }, + meta: {}, + setMeta: () => {}, + cwd, + paths: { + unpack: '', + assets: '', + cache: '', + pnpm: '', + node: '' + }, + api: undefined as never, + browserWindow, + abortSignal: new AbortController().signal + }) + return outputs +} + +beforeAll(async () => { + await mkdir(userDataDir, { recursive: true }) +}) + +afterAll(async () => { + await rm(userDataDir, { recursive: true, force: true }) +}) + +beforeEach(async () => { + downloadMock.mockReset() + runWithLiveLogsMock.mockReset() + await rm(join(userDataDir, 'thirdparty'), { recursive: true, force: true }) +}) + +test('maps target platforms to Godot preset platforms', () => { + expect(godotPresetPlatform('windows')).toBe('Windows Desktop') + expect(godotPresetPlatform('linux')).toBe('Linux') + expect(godotPresetPlatform('macos')).toBe('macOS') + expect(godotPresetPlatform('web')).toBe('Web') +}) + +test('detects the host target platform and defaults unknown hosts to windows', () => { + expect(hostTargetPlatform('win32')).toBe('windows') + expect(hostTargetPlatform('linux')).toBe('linux') + expect(hostTargetPlatform('darwin')).toBe('macos') + expect(() => hostTargetPlatform('freebsd')).toThrow() +}) + +test('asserts valid target platforms and rejects unknown ones', () => { + expect(assertGodotTargetPlatform('windows')).toBe('windows') + expect(assertGodotTargetPlatform('web')).toBe('web') + expect(() => assertGodotTargetPlatform('android')).toThrow() + expect(() => assertGodotTargetPlatform(null)).toThrow() +}) + +test('maps host platforms and architectures to Godot editor assets', () => { + expect(godotEditorAssetName('win32', 'x64')).toBe(`Godot_v${GODOT_VERSION}-stable_win64.exe.zip`) + expect(godotEditorAssetName('win32', 'arm64')).toBe( + `Godot_v${GODOT_VERSION}-stable_windows_arm64.exe.zip` + ) + expect(godotEditorAssetName('linux', 'x64')).toBe( + `Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip` + ) + expect(godotEditorAssetName('linux', 'arm64')).toBe( + `Godot_v${GODOT_VERSION}-stable_linux.arm64.zip` + ) + expect(godotEditorAssetName('darwin', 'arm64')).toBe( + `Godot_v${GODOT_VERSION}-stable_macos.universal.zip` + ) + expect(() => godotEditorAssetName('freebsd', 'x64')).toThrow() +}) + +test('resolves the editor binary path inside the extracted archives', () => { + expect(godotEditorBinaryPath('linux', 'x64')).toBe(`Godot_v${GODOT_VERSION}-stable_linux.x86_64`) + expect(godotEditorBinaryPath('win32', 'x64')).toBe(`Godot_v${GODOT_VERSION}-stable_win64.exe`) + expect(godotEditorBinaryPath('darwin', 'arm64')).toBe( + join('Godot.app', 'Contents', 'MacOS', 'Godot') + ) +}) + +test('builds download URLs from the Godot release tag', () => { + expect(godotDownloadUrl(godotEditorAssetName('linux', 'x64'))).toBe( + `https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip` + ) +}) + +test('builds the godot export command line', () => { + expect(godotExportArgs('Pipelab Web', '/out/index.html')).toEqual([ + '--headless', + '--export-release', + 'Pipelab Web', + '/out/index.html' + ]) +}) + +test('builds output file names and paths per target platform', () => { + expect(godotOutputFileName('windows', 'My Game')).toBe('My Game.exe') + expect(godotOutputFileName('linux', 'My Game')).toBe('My Game.x86_64') + expect(godotOutputFileName('macos', 'My Game')).toBe('My Game.app') + expect(godotOutputFileName('web', 'My Game')).toBe('index.html') + expect(buildGodotOutputPath('/workspace', 'web', 'My Game')).toBe( + join('/workspace', 'godot-export', 'web', 'index.html') + ) +}) + +test('points Godot data dirs at the thirdparty folder', () => { + const userData = '/user-data' + expect(godotTemplatesDir(userData, 'linux')).toBe( + join( + userData, + 'thirdparty', + 'godot', + 'xdg-data', + 'godot', + 'export_templates', + `${GODOT_VERSION}.stable` + ) + ) + expect(godotRunEnv(userData, 'linux')).toEqual({ + XDG_DATA_HOME: join(userData, 'thirdparty', 'godot', 'xdg-data') + }) + expect(godotRunEnv(userData, 'win32')).toEqual({ + APPDATA: join(userData, 'thirdparty', 'godot', 'appdata') + }) + expect(godotRunEnv(userData, 'darwin')).toEqual({ + HOME: join(userData, 'thirdparty', 'godot', 'home') + }) +}) + +test('parses export_presets.cfg into presets', () => { + const content = ` +[preset.0] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/windows.exe" + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" + +[preset.1] + +name="Web" +platform="Web" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="" +` + const presets = parseExportPresets(content) + expect(presets).toHaveLength(2) + expect(presets[0]).toMatchObject({ + index: 0, + name: 'Windows Desktop', + platform: 'Windows Desktop', + exportPath: 'build/windows.exe' + }) + expect(presets[1]).toMatchObject({ index: 1, name: 'Web', platform: 'Web' }) +}) + +test('finds the preset matching a target platform', () => { + const presets = [ + { index: 0, name: 'Windows Desktop', platform: 'Windows Desktop' }, + { index: 1, name: 'Web', platform: 'Web' } + ] + expect(findMatchingPreset(presets, 'web')).toMatchObject({ name: 'Web' }) + expect(findMatchingPreset(presets, 'linux')).toBeUndefined() +}) + +test('generates unique preset names', () => { + const presets = [{ index: 0, name: 'Pipelab Web', platform: 'Web' }] + expect(ensureUniquePresetName([], 'Pipelab Web')).toBe('Pipelab Web') + expect(ensureUniquePresetName(presets, 'Pipelab Web')).toBe('Pipelab Web 2') + expect( + ensureUniquePresetName( + [...presets, { index: 1, name: 'Pipelab Web 2', platform: 'Web' }], + 'Pipelab Web' + ) + ).toBe('Pipelab Web 3') +}) + +test('generates a minimal export preset config', () => { + const config = generatePresetConfig(0, 'Pipelab Web', 'Web', 'C:\\out\\index.html') + expect(config).toContain('[preset.0]') + expect(config).toContain('name="Pipelab Web"') + expect(config).toContain('platform="Web"') + expect(config).toContain('export_path="C:/out/index.html"') +}) + +test('strips the first path segment of archive entries', () => { + expect(stripFirstPathSegment('templates/version.txt')).toBe('version.txt') + expect(stripFirstPathSegment('Godot_v4.4.1-stable_linux.x86_64')).toBe( + 'Godot_v4.4.1-stable_linux.x86_64' + ) +}) + +test('reads the project name from project.godot', async () => { + const projectDir = await makeProject() + expect(await readGodotProjectName(projectDir)).toBe('My Game') +}) + +test('validates a Godot 4 project and rejects missing or older projects', async () => { + const valid = await makeProject() + await expect(validateGodotProject(valid)).resolves.toBeUndefined() + + const missing = join(userDataDir, `no-project-${projectCounter++}`) + await expect(validateGodotProject(missing)).rejects.toThrow(/project.godot/) + + const older = join(userDataDir, `older-project-${projectCounter++}`) + await mkdir(older, { recursive: true }) + await writeFile(join(older, 'project.godot'), 'config_version=4\n') + await expect(validateGodotProject(older)).rejects.toThrow(/config_version/) +}) + +test('exports using an existing preset', async () => { + await installGodot() + const projectDir = await makeProject({ + presets: `[preset.0] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +export_filter="all_resources" +export_path="out/MyGame.exe" +` + }) + const workspace = await makeWorkspace() + const outputPath = join(projectDir, 'out', 'MyGame.exe') + const abortSignal = new AbortController().signal + + runWithLiveLogsMock.mockImplementation(async () => { + await writeFile(outputPath, '') + }) + + const outputs = await runExport(projectDir, 'windows', workspace) + + expect(runWithLiveLogsMock).toHaveBeenCalledTimes(1) + const [command, args, options] = runWithLiveLogsMock.mock.calls[0] + expect(command).toBe(editorBinaryPath()) + expect(args).toEqual(['--headless', '--export-release', 'Windows Desktop', outputPath]) + expect(options).toMatchObject({ + cwd: projectDir, + cancelSignal: abortSignal, + env: godotRunEnv(userDataDir, process.platform) + }) + + expect(outputs).toEqual({ + output: outputPath, + parentFolder: dirname(outputPath), + folder: dirname(outputPath) + }) +}) + +test('generates a preset when none matches the target platform', async () => { + await installGodot() + const projectDir = await makeProject({ emptyPresets: true }) + const workspace = await makeWorkspace() + const outputPath = join(workspace, 'godot-export', 'web', 'index.html') + + runWithLiveLogsMock.mockImplementation(async () => { + await writeFile(outputPath, '') + }) + + const outputs = await runExport(projectDir, 'web', workspace) + + const config = await readFile(join(projectDir, 'export_presets.cfg'), 'utf-8') + expect(config).toContain('[preset.0]') + expect(config).toContain('name="Pipelab Web"') + expect(config).toContain('platform="Web"') + expect(config).toContain(`export_path="${outputPath.replace(/\\/g, '/')}"`) + + const [command, args] = runWithLiveLogsMock.mock.calls[0] + expect(command).toBe(editorBinaryPath()) + expect(args).toEqual(['--headless', '--export-release', 'Pipelab Web', outputPath]) + + expect(outputs).toEqual({ + output: outputPath, + parentFolder: dirname(outputPath), + folder: dirname(outputPath) + }) +}) + +test('downloads the Godot editor when it is missing', async () => { + await mkdir(dirname(templatesVersionMarker()), { recursive: true }) + await writeFile(templatesVersionMarker(), GODOT_VERSION) + + const extract = await import('./extract.js') + const extractZipStripFirst = vi.mocked(extract.extractZipStripFirst) + const extractZip = vi.mocked(extract.extractZip) + const installEditor = async () => { + await mkdir(editorFolder, { recursive: true }) + await writeFile(editorBinaryPath(), '') + } + extractZipStripFirst.mockImplementation(installEditor) + extractZip.mockImplementation(installEditor) + + const projectDir = await makeProject({ emptyPresets: true }) + const workspace = await makeWorkspace() + const outputPath = join(workspace, 'godot-export', 'web', 'index.html') + + runWithLiveLogsMock.mockImplementation(async () => { + await writeFile(outputPath, '') + }) + + await runExport(projectDir, 'web', workspace) + + expect(downloadMock).toHaveBeenCalledTimes(1) + expect(downloadMock.mock.calls[0][0]).toBe( + godotDownloadUrl(godotEditorAssetName(process.platform, process.arch)) + ) + expect(runWithLiveLogsMock.mock.calls[0][0]).toBe(editorBinaryPath()) +}) + +test('downloads the export templates when they are missing', async () => { + await installEditorOnly() + + const projectDir = await makeProject({ emptyPresets: true }) + const workspace = await makeWorkspace() + const outputPath = join(workspace, 'godot-export', 'web', 'index.html') + + runWithLiveLogsMock.mockImplementation(async () => { + await writeFile(outputPath, '') + }) + + await runExport(projectDir, 'web', workspace) + + expect(downloadMock).toHaveBeenCalledTimes(1) + expect(downloadMock.mock.calls[0][0]).toBe(godotDownloadUrl(godotTemplatesAssetName())) +}) + +test('throws when the export produces no output file', async () => { + await installGodot() + const projectDir = await makeProject({ emptyPresets: true }) + const workspace = await makeWorkspace() + + await expect(runExport(projectDir, 'web', workspace)).rejects.toThrow(/no output found/) +}) diff --git a/src/shared/libs/plugin-godot/godot.ts b/src/shared/libs/plugin-godot/godot.ts new file mode 100644 index 00000000..5e853c6c --- /dev/null +++ b/src/shared/libs/plugin-godot/godot.ts @@ -0,0 +1,344 @@ +import { createAction, createPathParam } from '@pipelab/plugin-core' +import { basename, join } from 'node:path' + +export const ID = 'godot-export' + +export const GODOT_VERSION = '4.4.1' +export const GODOT_TAG = `${GODOT_VERSION}-stable` +export const GODOT_RELEASE_URL = 'https://github.com/godotengine/godot/releases/download' + +export type GodotTargetPlatform = 'windows' | 'linux' | 'macos' | 'web' + +export const GODOT_PLATFORMS: ReadonlyArray<{ label: string; value: GodotTargetPlatform }> = [ + { label: 'Windows', value: 'windows' }, + { label: 'Linux', value: 'linux' }, + { label: 'macOS', value: 'macos' }, + { label: 'Web', value: 'web' } +] + +export const godotPresetPlatform = (target: GodotTargetPlatform): string => { + switch (target) { + case 'windows': + return 'Windows Desktop' + case 'linux': + return 'Linux' + case 'macos': + return 'macOS' + case 'web': + return 'Web' + } +} + +export const hostTargetPlatform = (platform: NodeJS.Platform): GodotTargetPlatform => { + switch (platform) { + case 'win32': + return 'windows' + case 'linux': + return 'linux' + case 'darwin': + return 'macos' + default: + throw new Error(`Godot export is not supported on platform ${platform}`) + } +} + +export const defaultTargetPlatform = (platform: NodeJS.Platform): GodotTargetPlatform => { + try { + return hostTargetPlatform(platform) + } catch { + return 'windows' + } +} + +export const assertGodotTargetPlatform = ( + value: string | null | undefined +): GodotTargetPlatform => { + if (value === 'windows' || value === 'linux' || value === 'macos' || value === 'web') { + return value + } + throw new Error(`Invalid Godot target platform: ${value}`) +} + +export const godotDownloadUrl = (assetName: string): string => + `${GODOT_RELEASE_URL}/${GODOT_TAG}/${assetName}` + +export const godotEditorAssetName = ( + platform: NodeJS.Platform, + arch: NodeJS.Architecture +): string => { + if (platform === 'win32') { + return arch === 'arm64' + ? `Godot_v${GODOT_VERSION}-stable_windows_arm64.exe.zip` + : `Godot_v${GODOT_VERSION}-stable_win64.exe.zip` + } + if (platform === 'linux') { + return arch === 'arm64' + ? `Godot_v${GODOT_VERSION}-stable_linux.arm64.zip` + : `Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip` + } + if (platform === 'darwin') { + return `Godot_v${GODOT_VERSION}-stable_macos.universal.zip` + } + throw new Error(`Godot editor is not available for ${platform}-${arch}`) +} + +export const godotEditorFileName = ( + platform: NodeJS.Platform, + arch: NodeJS.Architecture +): string => { + if (platform === 'win32') { + return arch === 'arm64' + ? `Godot_v${GODOT_VERSION}-stable_windows_arm64.exe` + : `Godot_v${GODOT_VERSION}-stable_win64.exe` + } + if (platform === 'linux') { + return arch === 'arm64' + ? `Godot_v${GODOT_VERSION}-stable_linux.arm64` + : `Godot_v${GODOT_VERSION}-stable_linux.x86_64` + } + throw new Error(`Godot editor binary is not a single file on ${platform}`) +} + +export const godotEditorBinaryPath = ( + platform: NodeJS.Platform, + arch: NodeJS.Architecture +): string => { + if (platform === 'darwin') { + return join('Godot.app', 'Contents', 'MacOS', 'Godot') + } + return godotEditorFileName(platform, arch) +} + +export const godotTemplatesAssetName = (): string => + `Godot_v${GODOT_VERSION}-stable_export_templates.tpz` + +export const godotOutputFileName = (target: GodotTargetPlatform, projectName: string): string => { + switch (target) { + case 'windows': + return `${projectName}.exe` + case 'linux': + return `${projectName}.x86_64` + case 'macos': + return `${projectName}.app` + case 'web': + return 'index.html' + } +} + +export const buildGodotOutputPath = ( + cwd: string, + target: GodotTargetPlatform, + projectName: string +): string => join(cwd, 'godot-export', target, godotOutputFileName(target, projectName)) + +export const godotExportArgs = (presetName: string, outputPath: string): string[] => [ + '--headless', + '--export-release', + presetName, + outputPath +] + +export const godotTemplatesVersionDir = (): string => `${GODOT_VERSION}.stable` + +export const godotDataDir = (userData: string, platform: NodeJS.Platform): string => { + const thirdparty = join(userData, 'thirdparty', 'godot') + switch (platform) { + case 'win32': + return join(thirdparty, 'appdata', 'Godot') + case 'darwin': + return join(thirdparty, 'home', 'Library', 'Application Support', 'Godot') + default: + return join(thirdparty, 'xdg-data', 'godot') + } +} + +export const godotTemplatesDir = (userData: string, platform: NodeJS.Platform): string => + join(godotDataDir(userData, platform), 'export_templates', godotTemplatesVersionDir()) + +export const godotRunEnv = ( + userData: string, + platform: NodeJS.Platform +): Record => { + const thirdparty = join(userData, 'thirdparty', 'godot') + switch (platform) { + case 'win32': + return { APPDATA: join(thirdparty, 'appdata') } + case 'darwin': + return { HOME: join(thirdparty, 'home') } + default: + return { XDG_DATA_HOME: join(thirdparty, 'xdg-data') } + } +} + +export interface GodotPreset { + index: number + name: string + platform: string + exportPath?: string +} + +export const parseExportPresets = (content: string): GodotPreset[] => { + const presets: GodotPreset[] = [] + let current: GodotPreset | undefined + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (line.startsWith('[')) { + if (current) { + presets.push(current) + current = undefined + } + const match = /^\[preset\.(\d+)\]$/.exec(line) + if (match) { + current = { index: parseInt(match[1], 10), name: '', platform: '' } + } + continue + } + if (!current || line === '' || line.startsWith('#') || line.startsWith(';')) { + continue + } + const eqIndex = line.indexOf('=') + if (eqIndex === -1) { + continue + } + const key = line.slice(0, eqIndex).trim() + let value = line.slice(eqIndex + 1).trim() + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1) + } + if (key === 'name') { + current.name = value + } else if (key === 'platform') { + current.platform = value + } else if (key === 'export_path') { + current.exportPath = value + } + } + if (current) { + presets.push(current) + } + return presets +} + +export const findMatchingPreset = ( + presets: GodotPreset[], + target: GodotTargetPlatform +): GodotPreset | undefined => + presets.find((preset) => preset.platform === godotPresetPlatform(target)) + +export const ensureUniquePresetName = (presets: GodotPreset[], desired: string): string => { + const names = new Set(presets.map((preset) => preset.name)) + if (!names.has(desired)) { + return desired + } + let suffix = 2 + while (names.has(`${desired} ${suffix}`)) { + suffix += 1 + } + return `${desired} ${suffix}` +} + +export const generatePresetConfig = ( + index: number, + name: string, + platform: string, + exportPath: string +): string => { + const normalizedPath = exportPath.replace(/\\/g, '/') + return `[preset.${index}] + +name="${name}" +platform="${platform}" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="${normalizedPath}" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.${index}.options] + +custom_template/debug="" +custom_template/release="" +` +} + +export const readGodotProjectName = async (projectPath: string): Promise => { + const { readFile } = await import('node:fs/promises') + const content = await readFile(join(projectPath, 'project.godot'), 'utf-8') + const match = /^\s*config\/name\s*=\s*"(.*)"\s*$/m.exec(content) + return match ? match[1] : basename(projectPath) +} + +export const validateGodotProject = async (projectPath: string): Promise => { + const { fileExists } = await import('@@/libs/plugin-core') + const projectFile = join(projectPath, 'project.godot') + if (!(await fileExists(projectFile))) { + throw new Error(`Godot project not found: no project.godot in ${projectPath}`) + } + const { readFile } = await import('node:fs/promises') + const content = await readFile(projectFile, 'utf-8') + const versionMatch = /^\s*config_version\s*=\s*(\d+)\s*$/m.exec(content) + if (versionMatch && parseInt(versionMatch[1], 10) !== 5) { + throw new Error( + `Unsupported Godot project config_version "${versionMatch[1]}". Expected 5 (Godot 4.x).` + ) + } +} + +export const exportGodotAction = createAction({ + id: ID, + name: 'Export Godot project', + description: + 'Exports a Godot 4.x project for Windows, Linux, macOS or Web using the official headless editor. Godot and the export templates are downloaded on first use.', + icon: 'mdi-gamepad-variant', + displayString: + '`Export Godot project ${fmt.param(params["target-platform"], "primary", "for host platform")}`', + meta: {}, + params: { + project: createPathParam('', { + required: true, + label: 'Godot project folder', + control: { + type: 'path', + options: { + properties: ['openDirectory'] + } + } + }), + 'target-platform': { + value: defaultTargetPlatform(process.platform), + required: false, + label: 'Target platform', + description: + 'The platform to export for. When no matching preset exists in export_presets.cfg, one is generated automatically. Defaults to the host platform.', + control: { + type: 'select', + options: { + placeholder: 'Target platform', + options: [...GODOT_PLATFORMS] + } + } + } + }, + outputs: { + output: { + value: '', + label: 'Output file' + }, + parentFolder: { + value: '', + label: 'Parent folder' + }, + folder: { + value: '', + label: 'Folder', + deprecated: true + } + } +}) diff --git a/src/shared/libs/plugin-godot/index.ts b/src/shared/libs/plugin-godot/index.ts new file mode 100644 index 00000000..86424c25 --- /dev/null +++ b/src/shared/libs/plugin-godot/index.ts @@ -0,0 +1,19 @@ +import { createNodeDefinition } from '@pipelab/plugin-core' +import { exportGodotAction } from './godot' +import { ExportGodotRunner } from './export' + +export default createNodeDefinition({ + description: 'Godot', + name: 'Godot', + id: 'godot', + icon: { + type: 'icon', + icon: 'mdi-gamepad-variant' + }, + nodes: [ + { + node: exportGodotAction, + runner: ExportGodotRunner + } + ] +}) diff --git a/src/shared/plugins.ts b/src/shared/plugins.ts index cb932894..1245558a 100644 --- a/src/shared/plugins.ts +++ b/src/shared/plugins.ts @@ -13,7 +13,8 @@ const builtInPlugins = async () => { (await import('./libs/plugin-discord')).default, (await import('./libs/plugin-poki')).default, (await import('./libs/plugin-nvpatch')).default, - (await import('./libs/plugin-tauri')).default + (await import('./libs/plugin-tauri')).default, + (await import('./libs/plugin-godot')).default ] if (is.dev) {