From e8296ed590633263d172b236e736ad5631a71753 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 28 Jul 2026 16:42:12 -0300 Subject: [PATCH 1/3] fix(ios): let the app's xcconfig win over a plugin's XcconfigService.mergeFiles keeps whichever value is already in the destination and drops the incoming one, and mergeProjectXcconfigFiles merged every plugin before the app's App_Resources/iOS/build.xcconfig. So for any key a plugin set, the app's value was discarded outright -- there was no way to override a plugin from the app, and among plugins the winner was whichever came first in dependency resolution order. Merging the app's file first makes it authoritative while leaving plugins to supply everything the app says nothing about. --- lib/services/ios-project-service.ts | 31 ++++++++------- lib/services/xcconfig-service.ts | 58 ++++++++++++++++++++++++++--- test/ios-project-service.ts | 39 +++++++++++++++++++ test/xcconfig-service.ts | 2 + 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 4950bb9637..1c536a0a62 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -1834,6 +1834,23 @@ export class IOSProjectService this.$fs.deleteFile(pluginsXcconfigFilePath); } + // mergeFiles keeps whichever value is already present, so the app's + // xcconfig is merged before any plugin's to make it authoritative: a + // plugin must not be able to dictate a setting the app has chosen. + const appResourcesXcconfigPath = path.join( + projectData.appResourcesDirectoryPath, + this.getPlatformData(projectData).normalizedPlatformName, + BUILD_XCCONFIG_FILE_NAME, + ); + if (this.$fs.exists(appResourcesXcconfigPath)) { + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { + await this.$xcconfigService.mergeFiles( + appResourcesXcconfigPath, + pluginsXcconfigFilePath, + ); + } + } + const allPlugins: IPluginData[] = this.getAllProductionPlugins(projectData); for (const plugin of allPlugins) { const pluginPlatformsFolderPath = plugin.pluginPlatformsFolderPath( @@ -1853,20 +1870,6 @@ export class IOSProjectService } } - const appResourcesXcconfigPath = path.join( - projectData.appResourcesDirectoryPath, - this.getPlatformData(projectData).normalizedPlatformName, - BUILD_XCCONFIG_FILE_NAME, - ); - if (this.$fs.exists(appResourcesXcconfigPath)) { - for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { - await this.$xcconfigService.mergeFiles( - appResourcesXcconfigPath, - pluginsXcconfigFilePath, - ); - } - } - for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { if (!this.$fs.exists(pluginsXcconfigFilePath)) { // We need the pluginsXcconfig file to exist in platforms dir as it is required in the native template: diff --git a/lib/services/xcconfig-service.ts b/lib/services/xcconfig-service.ts index 1b99ffa962..a27884d173 100644 --- a/lib/services/xcconfig-service.ts +++ b/lib/services/xcconfig-service.ts @@ -10,7 +10,13 @@ import * as _ from "lodash"; import { injector } from "../common/yok"; export class XcconfigService implements IXcconfigService { - constructor(private $childProcess: IChildProcess, private $fs: IFileSystem) {} + private static readonly CONFLICT_MARKER = "NS_XCCONFIG_CONFLICTS:"; + + constructor( + private $childProcess: IChildProcess, + private $fs: IFileSystem, + private $logger: ILogger + ) {} public getPluginsXcconfigFilePaths(projectRoot: string): IStringDictionary { return { @@ -42,14 +48,56 @@ export class XcconfigService implements IXcconfigService { const escapedDestinationFile = destinationFile.replace(/'/g, "\\'"); const escapedSourceFile = sourceFile.replace(/'/g, "\\'"); + // A key already present in the destination wins, so the incoming one is + // dropped. Report the drops whose values actually differ: a silently + // discarded setting is otherwise indistinguishable from one that was + // never written, which makes a plugin pinning e.g. + // CLANG_CXX_LANGUAGE_STANDARD very hard to track down. const mergeScript = `require 'xcodeproj'; + require 'json'; userConfig = Xcodeproj::Config.new('${escapedDestinationFile}') existingConfig = Xcodeproj::Config.new('${escapedSourceFile}') - userConfig.attributes.each do |key,| - existingConfig.attributes.delete(key) if (userConfig.attributes.key?(key) && existingConfig.attributes.key?(key)) + conflicts = [] + userConfig.attributes.each do |key, kept| + if existingConfig.attributes.key?(key) + ignored = existingConfig.attributes[key] + conflicts << { 'key' => key, 'kept' => kept.to_s, 'ignored' => ignored.to_s } if ignored.to_s != kept.to_s + existingConfig.attributes.delete(key) + end end - userConfig.merge(existingConfig).save_as(Pathname.new('${escapedDestinationFile}'))`; - await this.$childProcess.exec(`ruby -e "${mergeScript}"`); + userConfig.merge(existingConfig).save_as(Pathname.new('${escapedDestinationFile}')) + print '${XcconfigService.CONFLICT_MARKER}' + JSON.generate(conflicts)`; + const output = await this.$childProcess.exec(`ruby -e "${mergeScript}"`); + this.warnAboutConflicts(sourceFile, output); + } + + private warnAboutConflicts(sourceFile: string, output: any): void { + const text: string = output === null || output === undefined ? "" : `${output}`; + const markerIndex = text.lastIndexOf(XcconfigService.CONFLICT_MARKER); + if (markerIndex === -1) { + return; + } + + let conflicts: { key: string; kept: string; ignored: string }[]; + try { + conflicts = JSON.parse( + text.substring(markerIndex + XcconfigService.CONFLICT_MARKER.length) + ); + } catch (err) { + // Never let a reporting problem fail the merge itself. + this.$logger.trace( + `Unable to read xcconfig conflicts for ${sourceFile}: ${err}` + ); + return; + } + + for (const conflict of conflicts || []) { + this.$logger.warn( + `Ignoring ${conflict.key} = ${conflict.ignored} from ${sourceFile}: ` + + `already set to ${conflict.kept} by a higher precedence xcconfig. ` + + `The app's App_Resources xcconfig wins over any plugin's.` + ); + } } public readPropertyValue( diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index 20decc96d1..5aa80c57cb 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -1201,6 +1201,45 @@ describe("Merge Project XCConfig files", () => { } }); + it("The app's build.xcconfig wins over a plugin's", async () => { + fs.writeFile( + appResourcesXcconfigPath, + `CLANG_CXX_LANGUAGE_STANDARD = c++20${EOL}`, + ); + + const pluginPlatformsFolderPath = join(projectPath, "somePlugin", "ios"); + fs.writeFile( + join(pluginPlatformsFolderPath, BUILD_XCCONFIG_FILE_NAME), + `CLANG_CXX_LANGUAGE_STANDARD = c++17${EOL}GCC_C_LANGUAGE_STANDARD = gnu17${EOL}`, + ); + + const pluginsService = testInjector.resolve("pluginsService"); + pluginsService.getAllProductionPlugins = () => [ + { + name: "somePlugin", + pluginPlatformsFolderPath: () => pluginPlatformsFolderPath, + }, + ]; + + await (iOSProjectService).mergeProjectXcconfigFiles(projectData); + + _.each( + xcconfigService.getPluginsXcconfigFilePaths(projectRoot), + (destinationFilePath) => { + assertPropertyValues( + { + // The app pinned this, so the plugin's c++17 must not win. + CLANG_CXX_LANGUAGE_STANDARD: "c++20", + // Keys the app says nothing about still come from the plugin. + GCC_C_LANGUAGE_STANDARD: "gnu17", + }, + destinationFilePath, + testInjector, + ); + }, + ); + }); + it("Adds the entitlements property if not set by the user", async () => { for (const release in [true, false]) { const realExistsFunction = testInjector.resolve("fs").exists; diff --git a/test/xcconfig-service.ts b/test/xcconfig-service.ts index a43291f73d..20d9c20ce0 100644 --- a/test/xcconfig-service.ts +++ b/test/xcconfig-service.ts @@ -5,6 +5,7 @@ import * as yok from "../lib/common/yok"; import { IXcconfigService } from "../lib/declarations"; import { IInjector } from "../lib/common/definitions/yok"; import { IReadFileOptions } from "../lib/common/declarations"; +import { LoggerStub } from "./stubs"; // start tracking temporary folders/files @@ -18,6 +19,7 @@ describe("XCConfig Service Tests", () => { }); testInjector.register("childProcess", {}); testInjector.register("xcprojService", {}); + testInjector.register("logger", LoggerStub); testInjector.register("xcconfigService", XcconfigService); From f219cd57a84ab1a3042ab88d7dc32ce024880c1a Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 28 Jul 2026 16:42:12 -0300 Subject: [PATCH 2/3] feat(ios): warn when an xcconfig setting is discarded A dropped key was indistinguishable from one that was never written, so a plugin pinning something like CLANG_CXX_LANGUAGE_STANDARD could hold an entire app below a required standard with no indication of which plugin was responsible. Only conflicts whose values actually differ are reported; two files agreeing on a key is not worth a warning. --- lib/services/xcconfig-service.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/services/xcconfig-service.ts b/lib/services/xcconfig-service.ts index a27884d173..cd7f6d2f79 100644 --- a/lib/services/xcconfig-service.ts +++ b/lib/services/xcconfig-service.ts @@ -15,17 +15,15 @@ export class XcconfigService implements IXcconfigService { constructor( private $childProcess: IChildProcess, private $fs: IFileSystem, - private $logger: ILogger + private $logger: ILogger, ) {} public getPluginsXcconfigFilePaths(projectRoot: string): IStringDictionary { return { - [Configurations.Debug.toLowerCase()]: this.getPluginsDebugXcconfigFilePath( - projectRoot - ), - [Configurations.Release.toLowerCase()]: this.getPluginsReleaseXcconfigFilePath( - projectRoot - ), + [Configurations.Debug.toLowerCase()]: + this.getPluginsDebugXcconfigFilePath(projectRoot), + [Configurations.Release.toLowerCase()]: + this.getPluginsReleaseXcconfigFilePath(projectRoot), }; } @@ -39,7 +37,7 @@ export class XcconfigService implements IXcconfigService { public async mergeFiles( sourceFile: string, - destinationFile: string + destinationFile: string, ): Promise { if (!this.$fs.exists(destinationFile)) { this.$fs.writeFile(destinationFile, ""); @@ -72,7 +70,8 @@ export class XcconfigService implements IXcconfigService { } private warnAboutConflicts(sourceFile: string, output: any): void { - const text: string = output === null || output === undefined ? "" : `${output}`; + const text: string = + output === null || output === undefined ? "" : `${output}`; const markerIndex = text.lastIndexOf(XcconfigService.CONFLICT_MARKER); if (markerIndex === -1) { return; @@ -81,12 +80,12 @@ export class XcconfigService implements IXcconfigService { let conflicts: { key: string; kept: string; ignored: string }[]; try { conflicts = JSON.parse( - text.substring(markerIndex + XcconfigService.CONFLICT_MARKER.length) + text.substring(markerIndex + XcconfigService.CONFLICT_MARKER.length), ); } catch (err) { // Never let a reporting problem fail the merge itself. this.$logger.trace( - `Unable to read xcconfig conflicts for ${sourceFile}: ${err}` + `Unable to read xcconfig conflicts for ${sourceFile}: ${err}`, ); return; } @@ -95,14 +94,14 @@ export class XcconfigService implements IXcconfigService { this.$logger.warn( `Ignoring ${conflict.key} = ${conflict.ignored} from ${sourceFile}: ` + `already set to ${conflict.kept} by a higher precedence xcconfig. ` + - `The app's App_Resources xcconfig wins over any plugin's.` + `The app's App_Resources xcconfig wins over any plugin's.`, ); } } public readPropertyValue( xcconfigFilePath: string, - propertyName: string + propertyName: string, ): string { if (this.$fs.exists(xcconfigFilePath)) { const text = this.$fs.readText(xcconfigFilePath); From 8b839a2620906be93a54a7c5e5d5417781175876 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 28 Jul 2026 17:10:23 -0300 Subject: [PATCH 3/3] fix(ios): pass xcconfig paths to ruby as argv The merge ran through a shell-interpolated `ruby -e "..."` with only apostrophes escaped, so a project or node_modules path containing a command substitution was executed by the shell before ruby started. A directory named `a$(hostname)b` is enough to reproduce it. execFile passes the script and both paths as arguments, which removes the shell from the path entirely and makes the escaping unnecessary. Also corrects the conflict warning: it claimed the app's xcconfig had won, which is wrong for a plugin-vs-plugin conflict, where the earlier plugin wins by dependency order. It now states the ordering instead of naming a winner. --- lib/services/xcconfig-service.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/services/xcconfig-service.ts b/lib/services/xcconfig-service.ts index cd7f6d2f79..a85e34851e 100644 --- a/lib/services/xcconfig-service.ts +++ b/lib/services/xcconfig-service.ts @@ -43,18 +43,20 @@ export class XcconfigService implements IXcconfigService { this.$fs.writeFile(destinationFile, ""); } - const escapedDestinationFile = destinationFile.replace(/'/g, "\\'"); - const escapedSourceFile = sourceFile.replace(/'/g, "\\'"); - // A key already present in the destination wins, so the incoming one is // dropped. Report the drops whose values actually differ: a silently // discarded setting is otherwise indistinguishable from one that was // never written, which makes a plugin pinning e.g. // CLANG_CXX_LANGUAGE_STANDARD very hard to track down. - const mergeScript = `require 'xcodeproj'; - require 'json'; - userConfig = Xcodeproj::Config.new('${escapedDestinationFile}') - existingConfig = Xcodeproj::Config.new('${escapedSourceFile}') + // + // The paths are passed as argv rather than interpolated: they come from + // the project and node_modules layout, and a shell-interpolated command + // would execute anything a directory name expands to. + const mergeScript = `require 'xcodeproj' + require 'json' + destination, source = ARGV + userConfig = Xcodeproj::Config.new(destination) + existingConfig = Xcodeproj::Config.new(source) conflicts = [] userConfig.attributes.each do |key, kept| if existingConfig.attributes.key?(key) @@ -63,9 +65,14 @@ export class XcconfigService implements IXcconfigService { existingConfig.attributes.delete(key) end end - userConfig.merge(existingConfig).save_as(Pathname.new('${escapedDestinationFile}')) + userConfig.merge(existingConfig).save_as(Pathname.new(destination)) print '${XcconfigService.CONFLICT_MARKER}' + JSON.generate(conflicts)`; - const output = await this.$childProcess.exec(`ruby -e "${mergeScript}"`); + const output = await this.$childProcess.execFile("ruby", [ + "-e", + mergeScript, + destinationFile, + sourceFile, + ]); this.warnAboutConflicts(sourceFile, output); } @@ -94,7 +101,8 @@ export class XcconfigService implements IXcconfigService { this.$logger.warn( `Ignoring ${conflict.key} = ${conflict.ignored} from ${sourceFile}: ` + `already set to ${conflict.kept} by a higher precedence xcconfig. ` + - `The app's App_Resources xcconfig wins over any plugin's.`, + `The app's App_Resources xcconfig is applied first, then each ` + + `plugin's in dependency order.`, ); } }