diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts
index 60832cd2..c0ebc1b2 100644
--- a/packages/cli/src/platforms/ios/apply.ts
+++ b/packages/cli/src/platforms/ios/apply.ts
@@ -103,7 +103,7 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise
rightPart) {
+ return 1
+ }
+
+ if (leftPart < rightPart) {
+ return -1
+ }
+ }
+
+ return 0
+}
+
+export function isIOSDeploymentTargetAtLeast(value: string, minimum: string): boolean | undefined {
+ const comparison = compareIOSDeploymentTargets(value, minimum)
+ return comparison === undefined ? undefined : comparison >= 0
+}
+
+export function maxIOSDeploymentTarget(left: string, right: string): string {
+ const comparison = compareIOSDeploymentTargets(left, right)
+ return comparison !== undefined && comparison >= 0 ? left : right
+}
+
+function parseIOSDeploymentTarget(value: string): number[] | undefined {
+ const normalizedValue = value.trim().replace(/^"|"$/g, '').replace(/^'|'$/g, '')
+
+ if (!/^\d+(?:\.\d+){0,2}$/.test(normalizedValue)) {
+ return undefined
+ }
+
+ return normalizedValue.split('.').map((part) => Number(part))
+}
diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts
index 85791244..d66fa04c 100644
--- a/packages/cli/src/platforms/ios/podfile.ts
+++ b/packages/cli/src/platforms/ios/podfile.ts
@@ -1,7 +1,11 @@
+import path from 'node:path'
+import { createRequire } from 'node:module'
+
import { readTextFile, writeTextFile } from '../../fs/readWrite'
import { toRelativePath } from '../../fs/path'
import { VoltraCliError } from '../../reporting/summary'
import { resolveIOSWidgetTargetName } from './targetName'
+import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, isIOSDeploymentTargetAtLeast } from './deploymentTarget'
import type { IOSProjectDiscovery } from '../../discovery/ios'
import type { NormalizedVoltraIOSConfig } from '../../config/types'
@@ -21,6 +25,7 @@ export interface EnsurePodfileBlockOptions {
export interface EnsurePodfileBlockResult {
change?: ReportedChange
targetName: string
+ warnings?: string[]
}
export class IOSPodfileMutationError extends VoltraCliError {
@@ -35,10 +40,11 @@ export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Pr
const targetName = resolveIOSWidgetTargetName(ios, discovery)
const currentContent = await readPodfile(discovery.podfilePath)
const nextBlock = buildManagedPodfileBlock(targetName)
- const nextContent = reconcilePodfile(currentContent, nextBlock, targetName)
+ const platformResult = await ensurePodfileDeploymentTarget(currentContent, projectRoot)
+ const nextContent = reconcilePodfile(platformResult.content, nextBlock, targetName)
if (currentContent === nextContent) {
- return { targetName }
+ return { targetName, warnings: platformResult.warnings }
}
await writeTextFile(discovery.podfilePath, nextContent)
@@ -49,7 +55,107 @@ export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Pr
path: toRelativePath(projectRoot, discovery.podfilePath),
},
targetName,
+ warnings: platformResult.warnings,
+ }
+}
+
+interface EnsurePodfileDeploymentTargetResult {
+ content: string
+ warnings?: string[]
+}
+
+async function ensurePodfileDeploymentTarget(
+ content: string,
+ projectRoot: string
+): Promise {
+ const lines = content.split('\n')
+ const platformLineIndex = lines.findIndex((line) => /^\s*platform\s+:ios\s*,/.test(line))
+
+ if (platformLineIndex === -1) {
+ return {
+ content,
+ warnings: [
+ `Could not find an iOS platform declaration in the Podfile. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.`,
+ ],
+ }
+ }
+
+ const line = lines[platformLineIndex]
+ const match = line.match(/^(\s*platform\s+:ios\s*,\s*)(.*?)(\s*(?:#.*)?)$/)
+
+ if (!match) {
+ return {
+ content,
+ warnings: [getUnknownPodfilePlatformWarning()],
+ }
+ }
+
+ const [, prefix, rawValue, suffix] = match
+ const platformValue = rawValue.trim()
+ const nextValue = await resolveNextPodfilePlatformValue(platformValue, projectRoot)
+
+ if (nextValue === undefined) {
+ return {
+ content,
+ warnings: [getUnknownPodfilePlatformWarning()],
+ }
+ }
+
+ if (nextValue === platformValue) {
+ return { content }
+ }
+
+ lines[platformLineIndex] = `${prefix}${nextValue}${suffix}`
+ return { content: lines.join('\n') }
+}
+
+async function resolveNextPodfilePlatformValue(
+ platformValue: string,
+ projectRoot: string
+): Promise {
+ const literalVersion = parseRubyStringLiteral(platformValue)
+
+ if (literalVersion) {
+ return isIOSDeploymentTargetAtLeast(literalVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET)
+ ? platformValue
+ : `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'`
}
+
+ if (platformValue === 'min_ios_version_supported') {
+ const reactNativeMinVersion = await resolveReactNativeMinIOSVersion(projectRoot)
+
+ if (
+ reactNativeMinVersion &&
+ isIOSDeploymentTargetAtLeast(reactNativeMinVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET)
+ ) {
+ return platformValue
+ }
+
+ return `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'`
+ }
+
+ return undefined
+}
+
+function parseRubyStringLiteral(value: string): string | undefined {
+ const match = value.match(/^(['"])(\d+(?:\.\d+){0,2})\1$/)
+ return match?.[2]
+}
+
+async function resolveReactNativeMinIOSVersion(projectRoot: string): Promise {
+ try {
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'))
+ const reactNativePackagePath = requireFromProject.resolve('react-native/package.json')
+ const helpersPath = path.join(path.dirname(reactNativePackagePath), 'scripts', 'cocoapods', 'helpers.rb')
+ const helpersContent = await readTextFile(helpersPath)
+ return helpersContent.match(/def self\.min_ios_version_supported[\s\S]*?return ['"]([^'"]+)['"]/)?.[1]
+ } catch {
+ return undefined
+ }
+}
+
+function getUnknownPodfilePlatformWarning(): string {
+ return `Could not verify the Podfile iOS platform declaration. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.`
}
function buildManagedPodfileBlock(targetName: string): string {
diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts
index 173fc950..680be266 100644
--- a/packages/cli/src/platforms/ios/xcodeTarget.ts
+++ b/packages/cli/src/platforms/ios/xcodeTarget.ts
@@ -18,6 +18,7 @@ import { resolveIOSWidgetTargetName } from './targetName'
import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode'
import { resolveMainAppEntitlementsPath } from './mainAppEntitlements'
import { needsEntitlementsMutation } from './entitlements'
+import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, maxIOSDeploymentTarget } from './deploymentTarget'
import type { IOSProjectDiscovery } from '../../discovery/ios'
import type { NormalizedVoltraIOSConfig } from '../../config/types'
@@ -72,6 +73,7 @@ export async function ensureIOSWidgetTarget(
removeStaleWidgetTargets(context, staleTargetNames)
ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath)
+ ensureMainAppDeploymentTarget(context)
ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning)
const widgetTarget = getWidgetTarget(context, targetName)
@@ -168,7 +170,7 @@ function ensureBuildConfigurations(
target: PBXNativeTarget,
targetName: string,
bundleIdentifier: string,
- deploymentTarget: string,
+ minimumDeploymentTarget: string,
codeSigning: MainAppCodeSigningSettings
): void {
const configurationList = target.props.buildConfigurationList
@@ -180,6 +182,8 @@ function ensureBuildConfigurations(
}
for (const config of configurationList.props.buildConfigurations) {
+ const deploymentTarget = resolveBuildConfigurationDeploymentTarget(config, minimumDeploymentTarget)
+
Object.assign(
config.props.buildSettings,
buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name)
@@ -777,6 +781,25 @@ function ensureMainAppEntitlementsBuildSetting(
}
}
+function ensureMainAppDeploymentTarget(context: IOSXcodeProjectContext): void {
+ for (const config of context.mainAppTarget.buildConfigurations.all) {
+ config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = resolveBuildConfigurationDeploymentTarget(
+ config,
+ VOLTRA_MIN_IOS_DEPLOYMENT_TARGET
+ )
+ }
+}
+
+function resolveBuildConfigurationDeploymentTarget(
+ config: XCBuildConfiguration,
+ minimumDeploymentTarget: string
+): string {
+ const currentDeploymentTarget = readBuildSettingString(config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET)
+ return currentDeploymentTarget
+ ? maxIOSDeploymentTarget(currentDeploymentTarget, minimumDeploymentTarget)
+ : minimumDeploymentTarget
+}
+
interface MainAppCodeSigningSettings {
codeSignStyle?: string
developmentTeam?: string
diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js
index 9fcfa0e2..275d2e11 100644
--- a/packages/cli/test/cli.test.js
+++ b/packages/cli/test/cli.test.js
@@ -21,6 +21,52 @@ function writeFakePackage(projectRoot, packageName) {
fs.writeFileSync(packagePath, `${JSON.stringify({ name: packageName, version: '0.0.0' }, null, 2)}\n`)
}
+function writeFakeReactNativeMinIOSVersion(projectRoot, version) {
+ writeFakePackage(projectRoot, 'react-native')
+ const helpersPath = path.join(projectRoot, 'node_modules', 'react-native', 'scripts', 'cocoapods', 'helpers.rb')
+ fs.mkdirSync(path.dirname(helpersPath), { recursive: true })
+ fs.writeFileSync(
+ helpersPath,
+ `module Helpers
+ class Constants
+ def self.min_ios_version_supported
+ return '${version}'
+ end
+ end
+end
+`
+ )
+}
+
+function createIOSPodfileTestOptions(projectRoot, podfileContent) {
+ const iosRoot = path.join(projectRoot, 'ios')
+ const podfilePath = path.join(iosRoot, 'Podfile')
+
+ fs.mkdirSync(iosRoot, { recursive: true })
+ fs.writeFileSync(podfilePath, podfileContent)
+
+ return {
+ projectRoot,
+ ios: {
+ deploymentTarget: '17.0',
+ enablePushNotifications: false,
+ fonts: [],
+ project: {},
+ userImagesPath: path.join(projectRoot, 'assets', 'voltra'),
+ widgets: [],
+ },
+ discovery: {
+ iosRoot,
+ xcodeprojPath: path.join(iosRoot, 'TestApp.xcodeproj'),
+ pbxprojPath: path.join(iosRoot, 'TestApp.xcodeproj', 'project.pbxproj'),
+ podfilePath,
+ mainTargetName: 'TestApp',
+ mainTargetCandidates: ['TestApp'],
+ infoPlistPath: path.join(iosRoot, 'TestApp', 'Info.plist'),
+ },
+ }
+}
+
test('apply help documents the yes flag', () => {
const { getApplyHelpText } = loadCliModule()
const helpText = getApplyHelpText()
@@ -196,6 +242,83 @@ test('ensureEntitlements creates the main app entitlements file when it is missi
assert.match(entitlements, /aps-environment/)
})
+test('ensurePodfileBlock bumps literal iOS platform below Voltra minimum', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ const options = createIOSPodfileTestOptions(tempDir, "platform :ios, '15.1'\n")
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.equal(result.warnings, undefined)
+ assert.match(podfile, /^platform :ios, '16\.4'$/m)
+ assert.match(podfile, /VOLTRA MANAGED BLOCK/)
+})
+
+test('ensurePodfileBlock leaves compatible literal iOS platform untouched', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, "17.2" # custom floor\n')
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.equal(result.warnings, undefined)
+ assert.match(podfile, /^platform :ios, "17\.2" # custom floor$/m)
+})
+
+test('ensurePodfileBlock compares iOS platform versions numerically', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ const options = createIOSPodfileTestOptions(tempDir, "platform :ios, '16.10'\n")
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.equal(result.warnings, undefined)
+ assert.match(podfile, /^platform :ios, '16\.10'$/m)
+})
+
+test('ensurePodfileBlock bumps React Native min_ios_version_supported when it is below Voltra minimum', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ writeFakeReactNativeMinIOSVersion(tempDir, '15.1')
+ const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, min_ios_version_supported\n')
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.equal(result.warnings, undefined)
+ assert.match(podfile, /^platform :ios, '16\.4'$/m)
+})
+
+test('ensurePodfileBlock leaves React Native min_ios_version_supported when it is already compatible', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ writeFakeReactNativeMinIOSVersion(tempDir, '17.0')
+ const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, min_ios_version_supported\n')
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.equal(result.warnings, undefined)
+ assert.match(podfile, /^platform :ios, min_ios_version_supported$/m)
+})
+
+test('ensurePodfileBlock warns for unknown iOS platform expressions', async () => {
+ const { ensurePodfileBlock } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, ENV.fetch("IOS_DEPLOYMENT_TARGET")\n')
+
+ const result = await ensurePodfileBlock(options)
+ const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8')
+
+ assert.deepEqual(result.warnings, [
+ 'Could not verify the Podfile iOS platform declaration. Ensure it resolves to iOS 16.4 or newer.',
+ ])
+ assert.match(podfile, /^platform :ios, ENV\.fetch\("IOS_DEPLOYMENT_TARGET"\)$/m)
+})
+
test('android preflight reports missing client package when renderer is installed', async () => {
const { createAndroidPreflightRunner } = loadCliModule()
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))