Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/cli/src/platforms/ios/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<P
platform: 'ios',
changes: [...changes, ...generatedResult.changes],
generatedFiles: generatedResult.files,
warnings: generatedResult.warnings,
warnings: [...(generatedResult.warnings ?? []), ...(podfileResult.warnings ?? [])],
}
}

Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/platforms/ios/deploymentTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export const VOLTRA_MIN_IOS_DEPLOYMENT_TARGET = '16.4'

export function compareIOSDeploymentTargets(left: string, right: string): number | undefined {
const leftParts = parseIOSDeploymentTarget(left)
const rightParts = parseIOSDeploymentTarget(right)

if (!leftParts || !rightParts) {
return undefined
}

const length = Math.max(leftParts.length, rightParts.length)

for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] ?? 0
const rightPart = rightParts[index] ?? 0

if (leftPart > 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))
}
110 changes: 108 additions & 2 deletions packages/cli/src/platforms/ios/podfile.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,6 +25,7 @@ export interface EnsurePodfileBlockOptions {
export interface EnsurePodfileBlockResult {
change?: ReportedChange
targetName: string
warnings?: string[]
}

export class IOSPodfileMutationError extends VoltraCliError {
Expand All @@ -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)
Expand All @@ -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<EnsurePodfileDeploymentTargetResult> {
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<string | undefined> {
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<string | undefined> {
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 {
Expand Down
25 changes: 24 additions & 1 deletion packages/cli/src/platforms/ios/xcodeTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -168,7 +170,7 @@ function ensureBuildConfigurations(
target: PBXNativeTarget,
targetName: string,
bundleIdentifier: string,
deploymentTarget: string,
minimumDeploymentTarget: string,
codeSigning: MainAppCodeSigningSettings
): void {
const configurationList = target.props.buildConfigurationList
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions packages/cli/test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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-'))
Expand Down
Loading