From 4bb7bdc7620e20bdaad2d11f7682f640e101175a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 5 Jun 2026 08:23:39 +0200 Subject: [PATCH 1/2] feat(cli): auto-generate main app entitlements Create a standard main-app entitlements file when Voltra needs to mutate entitlements in bare RN CLI projects. --- .../cli/src/platforms/ios/entitlements.ts | 20 +++--- .../src/platforms/ios/mainAppEntitlements.ts | 11 ++++ packages/cli/src/platforms/ios/xcodeTarget.ts | 16 +++-- packages/cli/test/cli.test.js | 65 +++++++++++++++++++ 4 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/platforms/ios/mainAppEntitlements.ts diff --git a/packages/cli/src/platforms/ios/entitlements.ts b/packages/cli/src/platforms/ios/entitlements.ts index b465ddd9..17522b73 100644 --- a/packages/cli/src/platforms/ios/entitlements.ts +++ b/packages/cli/src/platforms/ios/entitlements.ts @@ -1,8 +1,9 @@ -import { readTextFile, writeTextFile } from '../../fs/readWrite' +import { pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' import { buildPlistXml, parsePlistFile } from './plist' +import { resolveMainAppEntitlementsPath } from './mainAppEntitlements' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -33,22 +34,17 @@ export class IOSEntitlementsMutationError extends VoltraCliError { export async function ensureEntitlements(options: EnsureEntitlementsOptions): Promise { const { projectRoot, ios, discovery } = options + const entitlementsPath = resolveMainAppEntitlementsPath(discovery) if (!discovery.entitlementsPath) { if (!needsEntitlementsMutation(ios)) { return {} } - - throw new IOSEntitlementsMutationError( - `Could not determine the main app entitlements file. Set ios.project.entitlementsPath to update entitlements for target '${discovery.mainTargetName}'.` - ) } - const entitlements = await parsePlistFile( - discovery.entitlementsPath, - 'main app entitlements', - createEntitlementsError - ) + const entitlements: Record = (await pathExists(entitlementsPath)) + ? await parsePlistFile(entitlementsPath, 'main app entitlements', createEntitlementsError) + : {} const previousVoltraValues = await readPreviousVoltraEntitlementValues(discovery.infoPlistPath) ensureStringArrayValue( @@ -70,7 +66,7 @@ export async function ensureEntitlements(options: EnsureEntitlementsOptions): Pr } const nextContent = buildPlistXml(entitlements, createEntitlementsError) - const change = await writeEntitlementsIfChanged(projectRoot, discovery.entitlementsPath, nextContent) + const change = await writeEntitlementsIfChanged(projectRoot, entitlementsPath, nextContent) return { change } } @@ -120,7 +116,7 @@ async function writeEntitlementsIfChanged( entitlementsPath: string, nextContent: string ): Promise { - const previousContent = await readTextFile(entitlementsPath) + const previousContent = (await pathExists(entitlementsPath)) ? await readTextFile(entitlementsPath) : undefined if (previousContent === nextContent) { return undefined diff --git a/packages/cli/src/platforms/ios/mainAppEntitlements.ts b/packages/cli/src/platforms/ios/mainAppEntitlements.ts new file mode 100644 index 00000000..1bf83436 --- /dev/null +++ b/packages/cli/src/platforms/ios/mainAppEntitlements.ts @@ -0,0 +1,11 @@ +import path from 'node:path' + +import type { IOSProjectDiscovery } from '../../discovery/ios' + +export function resolveMainAppEntitlementsPath(discovery: IOSProjectDiscovery): string { + if (discovery.entitlementsPath) { + return discovery.entitlementsPath + } + + return path.join(path.dirname(discovery.infoPlistPath), `${discovery.mainTargetName}.entitlements`) +} diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 1f22c651..08723cb2 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -12,9 +12,11 @@ import { } from '@bacons/xcode' import { normalizeRelativePath, toRelativePath } from '../../fs/path' +import { pathExists } from '../../fs/readWrite' import { VoltraCliError } from '../../reporting/summary' import { resolveIOSWidgetTargetName } from './targetName' import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode' +import { resolveMainAppEntitlementsPath } from './mainAppEntitlements' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -65,7 +67,7 @@ export async function ensureIOSWidgetTarget( const staleTargetNames = getStaleWidgetTargetNames(previousWidgetFiles, targetName) const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName) const codeSigning = getMainAppCodeSigningSettings(context) - const mainAppEntitlementsPath = getMainAppEntitlementsBuildSetting(projectRoot, discovery) + const mainAppEntitlementsPath = await getMainAppEntitlementsBuildSetting(discovery) removeStaleWidgetTargets(context, staleTargetNames) ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath) @@ -739,12 +741,18 @@ function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainApp } } -function getMainAppEntitlementsBuildSetting(projectRoot: string, discovery: IOSProjectDiscovery): string | undefined { - if (!discovery.entitlementsPath) { +async function getMainAppEntitlementsBuildSetting(discovery: IOSProjectDiscovery): Promise { + if (discovery.entitlementsPath) { + return normalizeRelativePath(path.relative(discovery.iosRoot, discovery.entitlementsPath)) + } + + const entitlementsPath = resolveMainAppEntitlementsPath(discovery) + + if (!(await pathExists(entitlementsPath))) { return undefined } - return normalizeRelativePath(path.relative(discovery.iosRoot, discovery.entitlementsPath)) + return normalizeRelativePath(path.relative(discovery.iosRoot, entitlementsPath)) } function ensureMainAppEntitlementsBuildSetting( diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index e2a1e50e..97567405 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -11,6 +11,10 @@ function loadCliModule() { return require(path.join(packageRoot, 'build/cjs/index.js')) } +function loadIosMainAppEntitlementsModule() { + return require(path.join(packageRoot, 'build/cjs/platforms/ios/mainAppEntitlements.js')) +} + function writeFakePackage(projectRoot, packageName) { const packagePath = path.join(projectRoot, 'node_modules', ...packageName.split('/'), 'package.json') fs.mkdirSync(path.dirname(packagePath), { recursive: true }) @@ -130,6 +134,67 @@ test('ios preflight reports missing client package when renderer is installed', assert.doesNotMatch(result.issues[0].message, /@use-voltra\/ios and/) }) +test('resolves the standard main app entitlements path when discovery is missing one', () => { + const { resolveMainAppEntitlementsPath } = loadIosMainAppEntitlementsModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + + const discovery = { + iosRoot: path.join(tempDir, 'ios'), + xcodeprojPath: path.join(tempDir, 'ios', 'TestApp.xcodeproj'), + pbxprojPath: path.join(tempDir, 'ios', 'TestApp.xcodeproj', 'project.pbxproj'), + podfilePath: path.join(tempDir, 'ios', 'Podfile'), + mainTargetName: 'TestApp', + mainTargetCandidates: ['TestApp'], + infoPlistPath: path.join(tempDir, 'ios', 'TestApp', 'Info.plist'), + } + + assert.equal(resolveMainAppEntitlementsPath(discovery), path.join(tempDir, 'ios', 'TestApp', 'TestApp.entitlements')) +}) + +test('ensureEntitlements creates the main app entitlements file when it is missing', async () => { + const { ensureEntitlements } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const iosRoot = path.join(tempDir, 'ios') + const infoPlistPath = path.join(iosRoot, 'TestApp', 'Info.plist') + const entitlementsPath = path.join(iosRoot, 'TestApp', 'TestApp.entitlements') + + fs.mkdirSync(path.dirname(infoPlistPath), { recursive: true }) + fs.writeFileSync( + infoPlistPath, + ` + + + + +` + ) + + const result = await ensureEntitlements({ + projectRoot: tempDir, + ios: { + enablePushNotifications: true, + groupIdentifier: 'group.com.example.app', + project: {}, + }, + discovery: { + iosRoot, + xcodeprojPath: path.join(iosRoot, 'TestApp.xcodeproj'), + pbxprojPath: path.join(iosRoot, 'TestApp.xcodeproj', 'project.pbxproj'), + podfilePath: path.join(iosRoot, 'Podfile'), + mainTargetName: 'TestApp', + mainTargetCandidates: ['TestApp'], + infoPlistPath, + }, + }) + + assert.ok(result.change) + assert.equal(fs.existsSync(entitlementsPath), true) + const entitlements = fs.readFileSync(entitlementsPath, 'utf8') + assert.match(entitlements, /com\.apple\.security\.application-groups/) + assert.match(entitlements, /group\.com\.example\.app/) + assert.match(entitlements, /aps-environment/) +}) + 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-')) From dde6beaacef6855a1f092d9517ed1e94c8e88a8f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 5 Jun 2026 08:28:37 +0200 Subject: [PATCH 2/2] fix(cli): tighten entitlements fallback Only infer main app entitlements when Voltra needs entitlement mutations and report newly generated files as created. --- packages/cli/src/platforms/ios/entitlements.ts | 4 ++-- packages/cli/src/platforms/ios/xcodeTarget.ts | 12 ++++++++++-- packages/cli/test/cli.test.js | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/platforms/ios/entitlements.ts b/packages/cli/src/platforms/ios/entitlements.ts index 17522b73..48232859 100644 --- a/packages/cli/src/platforms/ios/entitlements.ts +++ b/packages/cli/src/platforms/ios/entitlements.ts @@ -71,7 +71,7 @@ export async function ensureEntitlements(options: EnsureEntitlementsOptions): Pr return { change } } -function needsEntitlementsMutation(ios: NormalizedVoltraIOSConfig): boolean { +export function needsEntitlementsMutation(ios: NormalizedVoltraIOSConfig): boolean { return ios.enablePushNotifications || ios.groupIdentifier !== undefined || ios.keychainGroup !== undefined } @@ -125,7 +125,7 @@ async function writeEntitlementsIfChanged( await writeTextFile(entitlementsPath, nextContent) return { - kind: 'updated', + kind: previousContent === undefined ? 'created' : 'updated', path: toRelativePath(projectRoot, entitlementsPath), } } diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 08723cb2..173fc950 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -17,6 +17,7 @@ import { VoltraCliError } from '../../reporting/summary' import { resolveIOSWidgetTargetName } from './targetName' import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode' import { resolveMainAppEntitlementsPath } from './mainAppEntitlements' +import { needsEntitlementsMutation } from './entitlements' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -67,7 +68,7 @@ export async function ensureIOSWidgetTarget( const staleTargetNames = getStaleWidgetTargetNames(previousWidgetFiles, targetName) const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName) const codeSigning = getMainAppCodeSigningSettings(context) - const mainAppEntitlementsPath = await getMainAppEntitlementsBuildSetting(discovery) + const mainAppEntitlementsPath = await getMainAppEntitlementsBuildSetting(discovery, ios) removeStaleWidgetTargets(context, staleTargetNames) ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath) @@ -741,11 +742,18 @@ function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainApp } } -async function getMainAppEntitlementsBuildSetting(discovery: IOSProjectDiscovery): Promise { +async function getMainAppEntitlementsBuildSetting( + discovery: IOSProjectDiscovery, + ios: NormalizedVoltraIOSConfig +): Promise { if (discovery.entitlementsPath) { return normalizeRelativePath(path.relative(discovery.iosRoot, discovery.entitlementsPath)) } + if (!needsEntitlementsMutation(ios)) { + return undefined + } + const entitlementsPath = resolveMainAppEntitlementsPath(discovery) if (!(await pathExists(entitlementsPath))) { diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 97567405..9fcfa0e2 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -188,6 +188,7 @@ test('ensureEntitlements creates the main app entitlements file when it is missi }) assert.ok(result.change) + assert.equal(result.change.kind, 'created') assert.equal(fs.existsSync(entitlementsPath), true) const entitlements = fs.readFileSync(entitlementsPath, 'utf8') assert.match(entitlements, /com\.apple\.security\.application-groups/)