Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions .changeset/godot-plugin.md
Original file line number Diff line number Diff line change
@@ -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.
210 changes: 210 additions & 0 deletions src/shared/libs/plugin-godot/export.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<string> => {
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<typeof exportGodotAction>(
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)
}
)
48 changes: 48 additions & 0 deletions src/shared/libs/plugin-godot/extract.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> =>
extractZipEntries(zipPath, destinationDir, (name) => name)

export const extractZipStripFirst = (zipPath: string, destinationDir: string): Promise<void> =>
extractZipEntries(zipPath, destinationDir, stripFirstPathSegment)
51 changes: 51 additions & 0 deletions src/shared/libs/plugin-godot/godot-registration.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading
Loading