diff --git a/assets/markdown/confirm-change-android-build-method.md.ejs b/assets/markdown/confirm-change-android-build-method.md.ejs new file mode 100644 index 0000000..157d00e --- /dev/null +++ b/assets/markdown/confirm-change-android-build-method.md.ejs @@ -0,0 +1,13 @@ +# Confirm change to export_presets.cfg + +**In order to publish your game on Google Play, an edit must be made to your `export_presets.cfg` file to enable the Gradle build method.** + +This change is necessary because Google Play requires Android App Bundles (AAB files) for new apps, and the Gradle build method is needed to create AAB files. + +You can read more about this in the **ShipThis Documentation** [<%= docsURL %>](<%= docsURL %>) + +You are using Godot version **<%= godotVersion %>**, ShipThis will update the **<%= optionKey %>** option in your `export_presets.cfg` file to enable the Gradle build method. + +## Do you want to proceed with this change? + +Please press **Y** to confirm and proceed with the change or **N** to cancel the operation and exit the Android wizard. \ No newline at end of file diff --git a/docs/util/android-build-method.md b/docs/util/android-build-method.md new file mode 100644 index 0000000..0ac10d4 --- /dev/null +++ b/docs/util/android-build-method.md @@ -0,0 +1,26 @@ +# Command: `util android-build-method` + +## Description + +Gets and sets the Android build method in export_presets.cfg + +## Help Output + +```help +USAGE + $ shipthis util android-build-method [-l] [-g] + +FLAGS + -g, --gradle use gradle build method + -l, --legacy use legacy build method + +DESCRIPTION + Gets and sets the Android build method in export_presets.cfg + +EXAMPLES + $ shipthis util android-build-method + + $ shipthis util android-build-method --legacy + + $ shipthis util android-build-method --gradle +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2079d7d..3ba9a02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,10 @@ "axios": "^1.7.7", "chalk": "^5.4.1", "crypto-js": "^4.2.0", - "deepmerge": "^4.3.1", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "fullscreen-ink": "^0.1.0", - "ini": "^5.0.0", + "godot-export-presets": "^0.1.6", "ink": "^5.0.1", "ink-spinner": "^5.0.0", "isomorphic-git": "^1.27.1", @@ -58,7 +57,6 @@ "@types/crypto-js": "^4.2.2", "@types/ejs": "^3.1.5", "@types/fs-extra": "^11.0.4", - "@types/ini": "^4.1.1", "@types/jsonwebtoken": "^9.0.6", "@types/luxon": "^3.4.2", "@types/mocha": "^10", @@ -5052,13 +5050,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -8747,6 +8738,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/godot-export-presets": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/godot-export-presets/-/godot-export-presets-0.1.6.tgz", + "integrity": "sha512-q1i1C8A991iJ5y3eqbfG6kKsNEEKdFxEwazikolNxP63eN6dd7TZQYHKHfSCF2HiVMt3GXQQtd3QzUdy3LFXjA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9038,15 +9038,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/ink": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ink/-/ink-5.1.0.tgz", diff --git a/package.json b/package.json index cdd2c14..5f45fe5 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,10 @@ "axios": "^1.7.7", "chalk": "^5.4.1", "crypto-js": "^4.2.0", - "deepmerge": "^4.3.1", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "fullscreen-ink": "^0.1.0", - "ini": "^5.0.0", + "godot-export-presets": "^0.1.6", "ink": "^5.0.1", "ink-spinner": "^5.0.0", "isomorphic-git": "^1.27.1", @@ -54,7 +53,6 @@ "@types/crypto-js": "^4.2.2", "@types/ejs": "^3.1.5", "@types/fs-extra": "^11.0.4", - "@types/ini": "^4.1.1", "@types/jsonwebtoken": "^9.0.6", "@types/luxon": "^3.4.2", "@types/mocha": "^10", @@ -108,6 +106,7 @@ "exports": [ "./dist/utils/help.js", "./dist/commands/util/glass.js", + "./dist/commands/util/android-build-method.js", "./dist/commands/apple/apiKey/export.js", "./dist/commands/apple/apiKey/create.js", "./dist/commands/apple/apiKey/status.js", diff --git a/src/baseCommands/baseGameAndroidCommand.ts b/src/baseCommands/baseGameAndroidCommand.ts index c4b2511..d4285f8 100644 --- a/src/baseCommands/baseGameAndroidCommand.ts +++ b/src/baseCommands/baseGameAndroidCommand.ts @@ -39,7 +39,8 @@ export abstract class BaseGameAndroidCommand extends B protected async getAndroidPackageName(gameId: string): Promise { const game = await this.getGame() const generated = generatePackageName(game.name) - const suggested = game.details?.iosBundleId || getGodotAndroidPackageName() || generated || 'com.example.game' + const godotPackageName = await getGodotAndroidPackageName() + const suggested = game.details?.iosBundleId || godotPackageName || generated || 'com.example.game' const question = `Please enter the Android Package Name, or press enter to use ${suggested}: ` const entered = await getInput(question) return entered || suggested diff --git a/src/commands/game/ios/app/create.tsx b/src/commands/game/ios/app/create.tsx index 9eedcd8..72d6937 100644 --- a/src/commands/game/ios/app/create.tsx +++ b/src/commands/game/ios/app/create.tsx @@ -40,8 +40,9 @@ export default class GameIosAppCreate extends BaseGameCommand => { if (bundleId) return bundleId const generatedBundleId = generatePackageName(game.name) + const godotBundleId = await getGodotAppleBundleIdentifier() const suggestedBundleId = - game.details?.iosBundleId || getGodotAppleBundleIdentifier() || generatedBundleId || 'com.example.game' + game.details?.iosBundleId || godotBundleId || generatedBundleId || 'com.example.game' const question = `Please enter the BundleId in the Apple Developer Portal, or press enter to use ${suggestedBundleId}: ` const enteredBundleId = await getInput(question) return enteredBundleId || suggestedBundleId diff --git a/src/commands/util/android-build-method.ts b/src/commands/util/android-build-method.ts new file mode 100644 index 0000000..dfaa3b7 --- /dev/null +++ b/src/commands/util/android-build-method.ts @@ -0,0 +1,40 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '@cli/baseCommands/index.js' +import {isGradleBuildEnabled, setGradleBuildEnabled} from '@cli/utils/godot.js' + +export default class UtilAndroidBuildMethod extends BaseCommand { + static override args = {} + static override description = 'Gets and sets the Android build method in export_presets.cfg' + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --legacy', + '<%= config.bin %> <%= command.id %> --gradle', + ] + + static override flags = { + legacy: Flags.boolean({char: 'l', description: 'use legacy build method'}), + gradle: Flags.boolean({char: 'g', description: 'use gradle build method'}), + } + + public async run(): Promise { + const {flags} = await this.parse(UtilAndroidBuildMethod) + + if (flags.legacy && flags.gradle) { + this.error('Cannot use both --legacy and --gradle flags together') + } + + if (!flags.legacy && !flags.gradle) { + // Show current build method + const buildMethod = (await isGradleBuildEnabled()) ? 'gradle' : 'legacy' + this.log(`Current Android build method: ${buildMethod}`) + return + } + + const isGradle = flags.gradle ? true : false + const buildMethod = flags.legacy ? 'legacy' : 'gradle' + this.log(`Setting Android build method to: ${buildMethod}`) + // Set the build method in the export presets file + await setGradleBuildEnabled(isGradle) + } +} diff --git a/src/components/JobFollow.tsx b/src/components/JobFollow.tsx index bd98259..a650477 100644 --- a/src/components/JobFollow.tsx +++ b/src/components/JobFollow.tsx @@ -1,5 +1,6 @@ import {Job, JobLogEntry, LogLevel} from '@cli/types/api.js' import {useJobWatching} from '@cli/utils/index.js' +import { useStderr, useStdout } from 'ink' interface JobFollowProps { jobId: string @@ -9,18 +10,40 @@ interface JobFollowProps { } // Outputs job logs to stdout/stderr as they come in -export const JobFollow = ({jobId, onComplete, onFailure, projectId}: JobFollowProps) => { +export const JobFollow = ({ + jobId, + onComplete, + onFailure, + projectId, +}: JobFollowProps) => { + const {stdout, write: writeOut} = useStdout(); + const {stderr, write: writeErr} = useStderr(); + + // Only emit ANSI when we're actually writing to a TTY. + const useAnsi = Boolean((stderr as any).isTTY ?? (stdout as any).isTTY); + + const yellow = useAnsi ? '\x1b[33m' : ''; + const red = useAnsi ? '\x1b[31m' : ''; + const reset = useAnsi ? '\x1b[0m' : ''; + useJobWatching({ isWatching: true, jobId, onComplete, onFailure, onNewLogEntry(logEntry: JobLogEntry) { - if (logEntry.level === LogLevel.ERROR) console.error(logEntry.message) - else console.log(logEntry.message) + const msg = logEntry.message + '\n'; + + if (logEntry.level === LogLevel.ERROR) { + writeErr(`${red}${msg}${reset}`); + } else if (logEntry.level === LogLevel.WARN) { + writeErr(`${yellow}${msg}${reset}`); + } else { + writeOut(msg); + } }, projectId, - }) + }); - return null -} + return null; +}; \ No newline at end of file diff --git a/src/components/JobLogTail.tsx b/src/components/JobLogTail.tsx index deff3a3..7c141b3 100644 --- a/src/components/JobLogTail.tsx +++ b/src/components/JobLogTail.tsx @@ -1,39 +1,24 @@ -import {Box, Text} from 'ink' +import {Box} from 'ink' import Spinner from 'ink-spinner' import {JobLogEntry} from '@cli/types' import {JobLogTailProps, useJobLogTail} from '@cli/utils/hooks/index.js' -import {getMessageColor, getShortTime, getStageColor} from '@cli/utils/index.js' import {Title} from './common/Title.js' -import {TruncatedText} from './common/TruncatedText.js' + +import {JobLogLine} from './common/JobLogLine.js' export const JobLogTail = (props: JobLogTailProps) => { const {data, isLoading} = useJobLogTail(props) - // TODO: the is causing issues return ( Job Logs {isLoading && } - {data.map((log: JobLogEntry) => { - const stageColor = getStageColor(log.stage) - const messageColor = getMessageColor(log.level) - return ( - - - {getShortTime(log.sentAt)} - - - {log.stage} - - - {log.message} - - - ) - })} + {data.map((log: JobLogEntry) => ( + + ))} ) diff --git a/src/components/android/AndroidWizard/utils.ts b/src/components/android/AndroidWizard/utils.ts index 5013b55..b4e4ec9 100644 --- a/src/components/android/AndroidWizard/utils.ts +++ b/src/components/android/AndroidWizard/utils.ts @@ -1,6 +1,6 @@ import {getGoogleStatus, getProject, getProjectCredentials} from '@cli/api/index.js' import {BaseCommand} from '@cli/baseCommands/baseCommand.js' -import {CredentialsType, Platform} from '@cli/types/index.js' +import {BuildType, CredentialsType, Platform} from '@cli/types/index.js' import {KeyTestError, KeyTestStatus, fetchKeyTestResult, queryBuilds} from '@cli/utils/index.js' export enum StepStatus { @@ -91,7 +91,9 @@ export const getStatusFlags = async (cmd: BaseCommand): Promise build.platform === Platform.ANDROID)) + const hasInitialBuild = Boolean( + buildsResponse?.data?.some((build) => build.platform === Platform.ANDROID && build.buildType === BuildType.AAB), + ) const testResult = projectId ? await fetchKeyTestResult({projectId}) : null diff --git a/src/components/android/CreateInitialBuild/EnableGradle.tsx b/src/components/android/CreateInitialBuild/EnableGradle.tsx new file mode 100644 index 0000000..5c3c2dd --- /dev/null +++ b/src/components/android/CreateInitialBuild/EnableGradle.tsx @@ -0,0 +1,38 @@ +import {Box} from 'ink' +import {getMajorVersion} from 'godot-export-presets' + +import {Markdown} from '@cli/components/index.js' +import {getGodotVersion, getGradleBuildOptionKey} from '@cli/utils/godot.js' + +import {WEB_URL} from '@cli/constants/index.js' +import {useSafeInput} from '@cli/utils/index.js' + +interface Props extends React.ComponentPropsWithoutRef { + onConfirm?: () => void + onCancel?: () => void +} + +export const EnableGradle = ({onConfirm, onCancel, ...boxProps}: Props) => { + const godotVersion = getGodotVersion() + const majorVersion = getMajorVersion(godotVersion) + + useSafeInput(async (input) => { + if (input == 'y') return onConfirm && onConfirm() + if (input == 'n') return onCancel && onCancel() + }) + + return ( + + + + + + ) +} diff --git a/src/components/android/CreateInitialBuild/InitialAndroidBuild.tsx b/src/components/android/CreateInitialBuild/InitialAndroidBuild.tsx new file mode 100644 index 0000000..598f753 --- /dev/null +++ b/src/components/android/CreateInitialBuild/InitialAndroidBuild.tsx @@ -0,0 +1,99 @@ +import {Box, Text} from 'ink' +import Spinner from 'ink-spinner' +import {useContext, useEffect, useRef, useState} from 'react' + +import {CommandContext, JobLogTail, JobProgress, Markdown, StepProps} from '@cli/components/index.js' +import {WEB_URL} from '@cli/constants/config.js' +import {BuildType, Job, JobStatus, Platform} from '@cli/types/api.js' +import {getShortUUID, useBuilds, useJobs, useShip} from '@cli/utils/index.js' + +export interface InitialAndroidBuildProps extends StepProps { + gameId: string +} + +export const InitialAndroidBuild = ({gameId, onComplete, onError, ...boxProps}: InitialAndroidBuildProps) => { + const {command} = useContext(CommandContext) + const {data: buildData, isLoading: isLoadingBuilds} = useBuilds({pageNumber: 0, projectId: gameId}) + const {data: jobData, isLoading: isLoadingJobs} = useJobs({ + pageNumber: 0, + projectId: gameId, + }) + const prevHasBuild = useRef(false) + const shipMutation = useShip() + const [shipLog, setShipLog] = useState('') + const [failedJob, setFailedJob] = useState(null) + + // Trigger a build if we don't have one + useEffect(() => { + if (isLoadingBuilds || isLoadingJobs) return + if (!buildData) return + if (!jobData) return + if (!command) return + + const hasAndroidBuild = buildData.data.some( + (build) => build.platform === Platform.ANDROID && build.buildType == BuildType.AAB, + ) + // If we now have a build - trigger the onComplete + if (!prevHasBuild.current && hasAndroidBuild) return onComplete() + prevHasBuild.current = hasAndroidBuild + const hasRunningAndroidJob = jobData.data.some( + (job) => job.type === Platform.ANDROID && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status), + ) + // If we don't have a build and we don't have an android job - run the ship command + const shouldRun = !hasAndroidBuild && !hasRunningAndroidJob + if (shouldRun) + shipMutation + .mutateAsync({ + command, + log: setShipLog, + shipFlags: { + platform: 'android', + skipPublish: true, + }, + }) + .catch(onError) + }, [buildData, jobData, command]) + + const androidJob = jobData?.data.find( + (job) => job.type === Platform.ANDROID && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status), + ) + + return ( + <> + + + Create an initial build... + {(isLoadingBuilds || isLoadingJobs || shipMutation.isPending) && } + + {androidJob === null && {shipLog}} + {androidJob && ( + { + setFailedJob(j) + // Wait before triggering the error to allow the job log to be displayed + setTimeout(() => { + onError(new Error(`Job ${j.id} failed`)) + }, 1000) + }} + /> + )} + + {failedJob && ( + <> + + + + + + )} + + + ) +} diff --git a/src/components/android/CreateInitialBuild/index.tsx b/src/components/android/CreateInitialBuild/index.tsx index 6fa6e38..67d30d8 100644 --- a/src/components/android/CreateInitialBuild/index.tsx +++ b/src/components/android/CreateInitialBuild/index.tsx @@ -1,96 +1,39 @@ -import {Box, Text} from 'ink' -import Spinner from 'ink-spinner' -import {useContext, useEffect, useRef, useState} from 'react' +import {useContext, useEffect, useState} from 'react' +import {Text} from 'ink' -import {CommandContext, GameContext, JobLogTail, JobProgress, Markdown, StepProps} from '@cli/components/index.js' -import {WEB_URL} from '@cli/constants/config.js' -import {Job, JobStatus, Platform} from '@cli/types/api.js' -import {getShortUUID, useBuilds, useJobs, useShip} from '@cli/utils/index.js' +import {GameContext, StepProps} from '@cli/components/index.js' +import {isGradleBuildEnabled, setGradleBuildEnabled} from '@cli/utils/godot.js' -export const CreateInitialBuild = (props: StepProps): JSX.Element => { - const {gameId} = useContext(GameContext) - return <>{gameId && } -} +import {InitialAndroidBuild} from './InitialAndroidBuild.js' +import {EnableGradle} from './EnableGradle.js' -interface CreateForGameProps extends StepProps { - gameId: string -} - -const CreateForGame = ({gameId, onComplete, onError, ...boxProps}: CreateForGameProps) => { - const {command} = useContext(CommandContext) - const {data: buildData, isLoading: isLoadingBuilds} = useBuilds({pageNumber: 0, projectId: gameId}) - const {data: jobData, isLoading: isLoadingJobs} = useJobs({ - pageNumber: 0, - projectId: gameId, - }) - const prevHasBuild = useRef(false) - const shipMutation = useShip() - const [shipLog, setShipLog] = useState('') - const [failedJob, setFailedJob] = useState(null) +export const CreateInitialBuild = ({onComplete, onError, ...boxProps}: StepProps): JSX.Element => { + const {gameId} = useContext(GameContext) + const [canBuildAAB, setCanBuildAAB] = useState(null) - // Trigger a build if we don't have one useEffect(() => { - if (isLoadingBuilds || isLoadingJobs) return - if (!buildData) return - if (!jobData) return - if (!command) return - - const hasAndroidBuild = buildData.data.some((build) => build.platform === Platform.ANDROID) - // If we now have a build - trigger the onComplete - if (!prevHasBuild.current && hasAndroidBuild) return onComplete() - prevHasBuild.current = hasAndroidBuild - const hasRunningAndroidJob = jobData.data.some( - (job) => job.type === Platform.ANDROID && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status), - ) - // If we don't have a build and we don't have an android job - run the ship command - const shouldRun = !hasAndroidBuild && !hasRunningAndroidJob - if (shouldRun) - shipMutation - .mutateAsync({ - command, - log: setShipLog, - shipFlags: { - platform: 'android', - skipPublish: true - } - }) - .catch(onError) - }, [buildData, jobData, command]) - - const androidJob = jobData?.data.find( - (job) => job.type === Platform.ANDROID && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status), - ) + // We can only build AABs if Gradle build is enabled + async function fetchBuildMethod() { + const isGradle = await isGradleBuildEnabled() + setCanBuildAAB(isGradle) + } + fetchBuildMethod() + }, []) + + const updateBuildMethod = async () => { + await setGradleBuildEnabled(true) + setCanBuildAAB(true) + } + + if (canBuildAAB === null) { + return Loading... + } + + if (canBuildAAB === false) { + return updateBuildMethod()} onCancel={() => process.exit()} /> + } return ( - <> - - - Create an initial build... - {(isLoadingBuilds || isLoadingJobs || shipMutation.isPending) && } - - {androidJob === null && {shipLog}} - {androidJob && { - setFailedJob(j) - // Wait before triggering the error to allow the job log to be displayed - setTimeout(() => { - onError(new Error(`Job ${j.id} failed`)) - }, 1000) - }}/>} - - {failedJob && ( - <> - - - - - - )} - - + <>{gameId && } ) } diff --git a/src/components/common/JobLogLine.tsx b/src/components/common/JobLogLine.tsx new file mode 100644 index 0000000..5dd6f2f --- /dev/null +++ b/src/components/common/JobLogLine.tsx @@ -0,0 +1,35 @@ +import {Box, Text} from 'ink' + +import {JobLogEntry} from '@cli/types' +import {getMessageColor, getShortTime, getStageColor} from '@cli/utils/index.js' + +import {TruncatedText} from './TruncatedText.js' + +interface Props { + log: JobLogEntry + showTimestamp?: boolean + showStage?: boolean +} + +// A single line in a job log +export const JobLogLine = ({log, showTimestamp = true, showStage = true}: Props) => { + const stageColor = getStageColor(log.stage) + const messageColor = getMessageColor(log.level) + return ( + + {showTimestamp && ( + + {getShortTime(log.sentAt)} + + )} + {showStage && ( + + {log.stage} + + )} + + {log.message} + + + ) +} diff --git a/src/components/common/JobProgress.tsx b/src/components/common/JobProgress.tsx index ead7def..5bcd073 100644 --- a/src/components/common/JobProgress.tsx +++ b/src/components/common/JobProgress.tsx @@ -1,6 +1,9 @@ -import {ProgressSpinner} from '@cli/components/index.js' -import {Job} from '@cli/types/api.js' -import {getPlatformName, useJobWatching} from '@cli/utils/index.js' +import {Box, Text} from 'ink' + +import {JobLogLine, ProgressSpinner} from '@cli/components/index.js' +import {Job, JobLogEntry, LogLevel} from '@cli/types/api.js' +import {getMessageColor, getPlatformName, useJobWatching} from '@cli/utils/index.js' +import {useState} from 'react' interface Props { job: Job @@ -8,20 +11,33 @@ interface Props { onFailure?: (j: Job) => void } +// Progress bar and spinner for a job +// If the job has a warning log entry, it will show the most recent warning export const JobProgress = (props: Props) => { + const [lastWarningLog, setLastWarningLog] = useState(null) + const {progress} = useJobWatching({ isWatching: true, jobId: props.job.id, onComplete: props.onComplete, onFailure: props.onFailure, projectId: props.job.project.id, + onNewLogEntry: (logEntry: JobLogEntry) => { + if (logEntry.level == LogLevel.WARN) setLastWarningLog(logEntry) + }, }) const label = `${getPlatformName(props.job.type)} build progress...` return ( - <> + - + {lastWarningLog && ( + + WARNING + + + )} + ) } diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 47a089e..66461a8 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,4 +1,5 @@ export * from './FormTextInput.js' +export * from './JobLogLine.js' export * from './JobProgress.js' export * from './ListWithTitle.js' export * from './Markdown.js' @@ -9,4 +10,4 @@ export * from './RunWithSpinner.js' export * from './StatusTable.js' export * from './Table.js' export * from './Title.js' -export * from './TruncatedText.js' +export * from './TruncatedText.js' \ No newline at end of file diff --git a/src/utils/godot.ts b/src/utils/godot.ts index 731be63..460b570 100644 --- a/src/utils/godot.ts +++ b/src/utils/godot.ts @@ -1,13 +1,22 @@ import fs from 'node:fs' import path from 'node:path' -import merge from 'deepmerge' -import {parse} from 'ini' +import { + ConfigFile, + findPreset, + getBasePreset, + getMajorVersion, + loadExportPresets, + mergePresets, + type Platform as GodotPlatform, + type GodotMajorVersion, + saveExportPresets, + ExportPresetsFile, +} from 'godot-export-presets' import {CapabilityType} from '@cli/apple/expo.js' import {Platform} from '@cli/types' - // Check if the current working directory is a Godot game // TODO: allow for cwd override export function isCWDGodotGame(): boolean { @@ -33,10 +42,10 @@ export const GODOT_CAPABILITIES = [ ] // Tells us which capabilities are enabled in the Godot project -export function getGodotProjectCapabilities(platform: Platform) { - const exportPresets = getGodotExportPresets(platform) +export async function getGodotProjectCapabilities(platform: Platform) { + const exportPresets = await getGodotExportPresets(platform) - const options: Record = (exportPresets as any).options || {} + const options: Record = exportPresets.options || {} const capabilities = [] @@ -48,36 +57,41 @@ export function getGodotProjectCapabilities(platform: Platform) { return capabilities } -export function getGodotProjectConfig() { +export function getGodotProjectConfig(): ConfigFile { const cwd = process.cwd() const projectGodotPath = path.join(cwd, 'project.godot') const projectGodotContent = fs.readFileSync(projectGodotPath, 'utf8') - return parse(projectGodotContent) + const configFile = new ConfigFile() + const error = configFile.parse(projectGodotContent) + if (error) { + throw error + } + return configFile } export function getGodotProjectName(): null | string { try { const projectGodotConfig = getGodotProjectConfig() - return projectGodotConfig.application['config/name'] + return (projectGodotConfig.get_value('application', 'config/name') as string) || null } catch { return null } } -export function getGodotAppleBundleIdentifier(): null | string { +export async function getGodotAppleBundleIdentifier(): Promise { try { - const preset = getGodotExportPresets(Platform.IOS) - return (preset.options as any)['application/bundle_identifier'] + const preset = await getGodotExportPresets(Platform.IOS) + return (preset.options?.['application/bundle_identifier'] as string) || null } catch (error) { console.log(error) return null } } -export function getGodotAndroidPackageName(): null | string { +export async function getGodotAndroidPackageName(): Promise { try { - const preset = getGodotExportPresets(Platform.ANDROID) - return (preset.options as any)['package/unique_name'] + const preset = await getGodotExportPresets(Platform.ANDROID) + return (preset.options?.['package/unique_name'] as string) || null } catch (error) { console.log(error) return null @@ -87,129 +101,106 @@ export function getGodotAndroidPackageName(): null | string { // TODO: is there a more reliable way to get the Godot version? export function getGodotVersion(): string { const projectGodotConfig = getGodotProjectConfig() - if ('config/features' in projectGodotConfig.application) { - const features = projectGodotConfig.application['config/features'] - // config/features=PackedStringArray("4.3") - // config/features=PackedStringArray("4.2", "GL Compatibility") - const match = features.match(/"(\d+\.\d+)"/) - if (!match) throw new Error("Couldn't find Godot version in project.godot") - return match[1] + const features = projectGodotConfig.get_value('application', 'config/features') as string[] + if (!features || features.length === 0) { + return '3.6' } + const [version] = features + return version as string +} - return '3.6' +export function getExportPresetsPath(): string { + // Get the preset options from any export_presets.cfg if found + const cwd = process.cwd() + const filename = 'export_presets.cfg' + const exportPresetsPath = path.join(cwd, filename) + return exportPresetsPath } // TODO: any differences in the presets between v 3.X and 4.X? -export function getGodotExportPresets(platform: Platform) { +export async function getGodotExportPresets(platform: Platform) { const {warn} = console - // Get the base config for this preset - let presetConfig = platform === Platform.IOS ? getBaseExportPresets_iOS() : getBaseExportPresets_Android() + // Get Godot version to determine which base preset to use + const godotVersion = getGodotVersion() + const majorVersion = getMajorVersion(godotVersion) as GodotMajorVersion + + // Convert Platform enum to Godot platform string + const godotPlatform: GodotPlatform = platform === Platform.IOS ? 'iOS' : 'Android' + + // Get the base preset for this platform and version + let presetConfig = getBasePreset(godotPlatform, majorVersion) // Get the preset options from any export_presets.cfg if found - const cwd = process.cwd() - const filename = 'export_presets.cfg' - const exportPresetsPath = path.join(cwd, filename) + const exportPresetsPath = getExportPresetsPath() const isFound = fs.existsSync(exportPresetsPath) if (isFound) { - const exportPresetsContent = fs.readFileSync(exportPresetsPath, 'utf8') - const exportPresetsIni = parse(exportPresetsContent) - // Find the preset with the same name in the existing config - const presetIndexes = Object.keys(exportPresetsIni.preset || {}) - const presetIndex = presetIndexes.find((index) => { - const current = exportPresetsIni.preset[index] - return `${current.name}`.toUpperCase() === platform - }) - - if (presetIndex) { - // Merge the preset options base config - presetConfig = merge(presetConfig, exportPresetsIni.preset[presetIndex]) as any - } else { - warn(`Preset ${platform} not found in ${filename} - will use defaults`) + try { + const exportPresets = await loadExportPresets(exportPresetsPath) + // Find the preset with the same platform + const foundPreset = findPreset(exportPresets, {platform: godotPlatform}) + + if (foundPreset) { + // Merge the preset with base config + presetConfig = mergePresets(presetConfig, foundPreset) + } else { + warn(`Preset ${platform} not found in ${exportPresetsPath} - will use defaults`) + } + } catch (error) { + warn(`Error loading ${exportPresetsPath}: ${error} - will use defaults`) } } else { - warn(`${filename} not found at ${exportPresetsPath}`) + warn(`Export presets not found at ${exportPresetsPath} - will use defaults`) } return presetConfig } -// TODO: type this properly (including missing options) -function getBaseExportPresets_iOS() { - return { - custom_features: '', - dedicated_server: false, - encrypt_directory: false, - encrypt_pck: false, - encryption_exclude_filters: '', - encryption_include_filters: '', - exclude_filter: '', - export_filter: 'all_resources', - export_path: 'output', - include_filter: '', - name: 'iOS', - options: { - 'application/export_project_only': true, - 'application/icon_interpolation': '4', - 'application/launch_screens_interpolation': '4', - 'application/short_version': '1.0.0', // default version number - 'application/signature': '', - 'architectures/arm64': true, - 'capabilities/access_wifi': false, - 'capabilities/push_notifications': false, - 'custom_template/debug': '', - 'custom_template/release': '', - 'icons/app_store_1024x1024': '', - 'icons/ipad_76x76': '', - 'icons/ipad_152x152': '', - 'icons/ipad_167x167': '', - 'icons/iphone_120x120': '', - 'icons/iphone_180x180': '', - 'icons/notification_40x40': '', - 'icons/notification_60x60': '', - 'icons/settings_58x58': '', - 'icons/settings_87x87': '', - 'icons/spotlight_40x40': '', - 'icons/spotlight_80x80': '', - 'landscape_launch_screens/ipad_1024x768': '', - 'landscape_launch_screens/ipad_2048x1536': '', - 'landscape_launch_screens/iphone_2208x1242': '', - 'landscape_launch_screens/iphone_2436x1125': '', - 'portrait_launch_screens/ipad_768x1024': '', - 'portrait_launch_screens/ipad_1536x2048': '', - 'portrait_launch_screens/iphone_640x960': '', - 'portrait_launch_screens/iphone_640x1136': '', - 'portrait_launch_screens/iphone_750x1334': '', - 'portrait_launch_screens/iphone_1125x2436': '', - 'portrait_launch_screens/iphone_1242x2208': '', - 'privacy/camera_usage_description': '', - 'privacy/camera_usage_description_localized': '{}', - 'privacy/microphone_usage_description': '', - 'privacy/microphone_usage_description_localized': '{}', - 'privacy/photolibrary_usage_description': '', - 'privacy/photolibrary_usage_description_localized': '{}', - 'storyboard/custom_bg_color': 'Color(0, 0, 0, 1)', - 'storyboard/custom_image@2x': '', - 'storyboard/custom_image@3x': '', - 'storyboard/image_scale_mode': '0', - 'storyboard/use_custom_bg_color': false, - 'storyboard/use_launch_screen_storyboard': true, - 'user_data/accessible_from_files_app': false, - 'user_data/accessible_from_itunes_sharing': false, - }, - platform: 'iOS', - runnable: true, - } +export function getGradleBuildOptionKey(majorVersion: GodotMajorVersion): string { + return majorVersion === 4 ? 'gradle_build/use_gradle_build' : 'custom_build/use_custom_build' +} + +export function getExportFormatOptionKey(majorVersion: GodotMajorVersion): string { + return majorVersion === 4 ? 'gradle_build/export_format' : 'custom_build/export_format' } -function getBaseExportPresets_Android() { - return { - name: 'Android', - // TODO - options: { - // TODO - }, - platform: 'Android', +// Tells us if Gradle build is enabled in export_presets.cfg +// This uses getGodotExportPresets which uses the base preset if no config file +// The base preset has Gradle enabled by default +export async function isGradleBuildEnabled(): Promise { + const godotVersion = getGodotVersion() + const majorVersion = getMajorVersion(godotVersion) as GodotMajorVersion + const preset = await getGodotExportPresets(Platform.ANDROID) + const buildOptionKey = getGradleBuildOptionKey(majorVersion) + const isEnabled = preset.options?.[buildOptionKey] + return isEnabled === true || isEnabled === 'true' +} + +// Sets the Gradle build option in export_presets.cfg +// If the file does not exist, it will be created +export async function setGradleBuildEnabled(value: boolean): Promise { + const exportPresetsPath = getExportPresetsPath() + let exportPresets: ExportPresetsFile = {presets: []} + if (fs.existsSync(exportPresetsPath)) { + exportPresets = await loadExportPresets(exportPresetsPath) + } else { + console.warn(`Export presets not found at ${exportPresetsPath} - creating new file`) + } + const godotVersion = getGodotVersion() + const majorVersion = getMajorVersion(godotVersion) as GodotMajorVersion + let androidPreset = findPreset(exportPresets, {platform: 'Android'}) + if (!androidPreset) { + androidPreset = getBasePreset('Android', majorVersion) + exportPresets.presets.push(androidPreset) + } + const buildOptionKey = getGradleBuildOptionKey(majorVersion) + androidPreset.options = androidPreset.options || {} + androidPreset.options[buildOptionKey] = value + // If we are setting to false (legacy build), also change the export format to APK + const exportFormatOptionKey = getExportFormatOptionKey(majorVersion) + if (value === false) { + androidPreset.options[exportFormatOptionKey] = 0; // APK } + await saveExportPresets(exportPresetsPath, exportPresets) } diff --git a/src/utils/query/useAppleBundleId.ts b/src/utils/query/useAppleBundleId.ts index 2c1e5a4..6eb1d92 100644 --- a/src/utils/query/useAppleBundleId.ts +++ b/src/utils/query/useAppleBundleId.ts @@ -58,7 +58,7 @@ export const fetchBundleId = async ({ctx, iosBundleId}: AppleBundleIdQueryProps) if (!bundleId) return empty const bundleIdCapabilities = await getBundleIdCapabilities(bundleId) - const projectCapabilities = getGodotProjectCapabilities(Platform.IOS) + const projectCapabilities = await getGodotProjectCapabilities(Platform.IOS) const capabilitiesTable = GODOT_CAPABILITIES.map((gc) => { const isEnabledInBundle = bundleIdCapabilities.includes(gc.type)