From d756fb5a179ae3735a89dd6036807abf56ed14a6 Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Sun, 6 Sep 2026 18:44:44 +0200 Subject: [PATCH 1/2] fix(verify): resolve iOS bundle id per target, not first pbxproj match IosParser resolved $(PRODUCT_BUNDLE_IDENTIFIER) with a file-global heuristic (drop values containing "Test", take the first remaining match), which is blind to which target a value belongs to. In a multi-target project (app + notification / widget extensions + tests) an extension's bundle id contains no "Test", so the app's $(PRODUCT_BUNDLE_IDENTIFIER) could resolve to the extension's depending on pbxproj ordering -- the false positive behind the "no local target matches ULink bundle ID" failures, and a wrong bundle id for the Flutter path (which does not use target discovery at all). Resolution is now scoped to the target that owns the Info.plist being parsed: 1. the build configuration whose INFOPLIST_FILE points at that plist, 2. the application target (com.apple.product-type.application), 3. the legacy first-non-test heuristic as a last resort (unchanged behaviour for single-target projects). Adds a small brace-balanced pbxproj micro-parser and multi-target tests, including one where the extension config precedes the app config. --- lib/parsers/ios_parser.dart | 208 +++++++++++++++++++++++-- test/unit/parsers/ios_parser_test.dart | 161 +++++++++++++++++++ 2 files changed, 360 insertions(+), 9 deletions(-) diff --git a/lib/parsers/ios_parser.dart b/lib/parsers/ios_parser.dart index 55d76ef..b42b753 100644 --- a/lib/parsers/ios_parser.dart +++ b/lib/parsers/ios_parser.dart @@ -19,7 +19,7 @@ class IosParser { if (bundleIdentifier != null && _isXcodeVariable(bundleIdentifier)) { final resolvedId = _resolveXcodeVariable( bundleIdentifier, - infoPlistFile.parent.path, + infoPlistFile, ); if (resolvedId != null) { bundleIdentifier = resolvedId; @@ -65,8 +65,19 @@ class IosParser { return value.contains(r'$(') || value.contains(r'${'); } - /// Resolve Xcode build variable to its actual value - static String? _resolveXcodeVariable(String variable, String infoPlistDir) { + /// Resolve an Xcode build variable (e.g. `$(PRODUCT_BUNDLE_IDENTIFIER)`) that + /// appears in [infoPlistFile] to its literal value. + /// + /// For `PRODUCT_BUNDLE_IDENTIFIER` the value is resolved *for the target that + /// owns this Info.plist*, not by grabbing the first match in the pbxproj. + /// A multi-target project (app + notification/widget extensions + tests) holds + /// several `PRODUCT_BUNDLE_IDENTIFIER` values; the extension ones do not + /// contain "Test", so a first-match heuristic could resolve the app's bundle + /// id to an extension's. Resolution order: + /// 1. the build configuration whose `INFOPLIST_FILE` points at this plist, + /// 2. the application target (`product-type.application`), + /// 3. the legacy first-non-test heuristic (also covers other variables). + static String? _resolveXcodeVariable(String variable, File infoPlistFile) { // Extract variable name from $(VAR_NAME) or ${VAR_NAME} final varMatch = RegExp(r'\$[\(\{]([A-Z_]+)[\)\}]').firstMatch(variable); if (varMatch == null) return null; @@ -76,7 +87,7 @@ class IosParser { // Search for the variable in Xcode project files // Try to find project.pbxproj in parent directories - var searchDir = Directory(infoPlistDir); + var searchDir = infoPlistFile.parent; // Walk up to find the iOS project root (look for .xcodeproj) for (var i = 0; i < 5; i++) { @@ -90,12 +101,20 @@ class IosParser { // Found .xcodeproj, look for project.pbxproj for (final xcodeproj in xcodeprojs) { final pbxproj = File(path.join(xcodeproj.path, 'project.pbxproj')); - if (pbxproj.existsSync()) { - final resolved = _extractVariableFromPbxproj(pbxproj, varName); - if (resolved != null) { - return resolved; - } + if (!pbxproj.existsSync()) continue; + + if (varName == 'PRODUCT_BUNDLE_IDENTIFIER') { + // The .xcodeproj's parent directory is SRCROOT; INFOPLIST_FILE + // values in the pbxproj are relative to it. + final srcRoot = xcodeproj.parent.path; + final scoped = _bundleIdForInfoPlist( + pbxproj, srcRoot, infoPlistFile) ?? + _bundleIdForApplicationTarget(pbxproj); + if (scoped != null) return scoped; } + + final resolved = _extractVariableFromPbxproj(pbxproj, varName); + if (resolved != null) return resolved; } } @@ -108,6 +127,177 @@ class IosParser { return null; } + /// Resolve `PRODUCT_BUNDLE_IDENTIFIER` from the build configuration whose + /// `INFOPLIST_FILE` points at [infoPlistFile]. This ties the value to the + /// exact target that owns the plist. Returns null if no build configuration + /// references this plist (e.g. the target uses `GENERATE_INFOPLIST_FILE`). + static String? _bundleIdForInfoPlist( + File pbxproj, + String srcRoot, + File infoPlistFile, + ) { + try { + final content = pbxproj.readAsStringSync(); + final wantRel = path + .relative(infoPlistFile.absolute.path, from: File(srcRoot).absolute.path) + .replaceAll(r'\', '/'); + + for (final block in _buildSettingsBlocks(content)) { + final infoPlist = _setting(block, 'INFOPLIST_FILE'); + if (infoPlist == null) continue; + if (!_infoPlistMatches(_normalizePbxPath(infoPlist), wantRel)) continue; + + final bundleId = _cleanValue(_setting(block, 'PRODUCT_BUNDLE_IDENTIFIER')); + if (bundleId != null && !_isXcodeVariable(bundleId)) return bundleId; + } + } catch (_) { + // fall through to the next strategy + } + return null; + } + + /// Resolve `PRODUCT_BUNDLE_IDENTIFIER` for the application target + /// (`com.apple.product-type.application`) by walking + /// PBXNativeTarget -> XCConfigurationList -> XCBuildConfiguration. + static String? _bundleIdForApplicationTarget(File pbxproj) { + try { + final content = pbxproj.readAsStringSync(); + + String? listUuid; + for (final obj in _objectsByIsa(content, 'PBXNativeTarget')) { + if (obj.contains('com.apple.product-type.application')) { + listUuid = _firstUuid(obj, 'buildConfigurationList'); + break; + } + } + if (listUuid == null) return null; + + final listObj = _objectByUuid(content, listUuid); + if (listObj == null) return null; + + for (final cfgUuid in _uuidList(listObj, 'buildConfigurations')) { + final cfgObj = _objectByUuid(content, cfgUuid); + if (cfgObj == null) continue; + final bundleId = _cleanValue(_setting(cfgObj, 'PRODUCT_BUNDLE_IDENTIFIER')); + if (bundleId != null && !_isXcodeVariable(bundleId)) return bundleId; + } + } catch (_) { + // fall through to the legacy heuristic + } + return null; + } + + // --- pbxproj micro-parser helpers ----------------------------------------- + + /// Yield the text of every `buildSettings = { ... }` block, brace-balanced. + static Iterable _buildSettingsBlocks(String content) sync* { + const marker = 'buildSettings = {'; + var idx = content.indexOf(marker); + while (idx != -1) { + final start = idx + marker.length; + final end = _matchBrace(content, start); + if (end == -1) return; + yield content.substring(start, end); + idx = content.indexOf(marker, end); + } + } + + /// Yield each top-level object block (`{ ... }`) whose `isa` equals [isa]. + static Iterable _objectsByIsa(String content, String isa) sync* { + final needle = 'isa = $isa;'; + var idx = content.indexOf(needle); + while (idx != -1) { + final open = content.lastIndexOf('{', idx); + if (open != -1) { + final end = _matchBrace(content, open + 1); + if (end != -1) yield content.substring(open + 1, end); + } + idx = content.indexOf(needle, idx + needle.length); + } + } + + /// Return the object block (`{ ... }`) defined as ` ... = { ... }`. + static String? _objectByUuid(String content, String uuid) { + final re = RegExp('$uuid' r'\b\s*(?:/\*[^*]*\*/)?\s*=\s*\{'); + final m = re.firstMatch(content); + if (m == null) return null; + final open = content.indexOf('{', m.start); + final end = _matchBrace(content, open + 1); + if (end == -1) return null; + return content.substring(open + 1, end); + } + + /// Index just past the `}` that closes the block opened before [start] + /// (i.e. [start] is the index right after the opening `{`). Returns -1 if + /// unbalanced. + static int _matchBrace(String content, int start) { + var depth = 1; + var j = start; + while (j < content.length && depth > 0) { + final ch = content[j]; + if (ch == '{') { + depth++; + } else if (ch == '}') { + depth--; + } + j++; + } + return depth == 0 ? j - 1 : -1; + } + + /// Read a `KEY = value;` setting from a block. + static String? _setting(String block, String key) { + final m = RegExp('^\\s*${RegExp.escape(key)}' r'\s*=\s*([^;]+);', + multiLine: true) + .firstMatch(block); + return m?.group(1)?.trim(); + } + + /// First UUID referenced by `KEY = ...;` in a block. + static String? _firstUuid(String block, String key) { + final m = RegExp('${RegExp.escape(key)}' r'\s*=\s*([0-9A-Fa-f]{24})') + .firstMatch(block); + return m?.group(1); + } + + /// UUIDs listed in `KEY = ( uuid, uuid, );`. + static List _uuidList(String block, String key) { + final m = RegExp('${RegExp.escape(key)}' r'\s*=\s*\(([^)]*)\)', dotAll: true) + .firstMatch(block); + if (m == null) return const []; + return RegExp(r'[0-9A-Fa-f]{24}') + .allMatches(m.group(1) ?? '') + .map((x) => x.group(0)!) + .toList(); + } + + /// Strip surrounding quotes and whitespace; return null if empty. + static String? _cleanValue(String? raw) { + if (raw == null) return null; + final v = raw.trim().replaceAll('"', '').replaceAll("'", '').trim(); + return v.isEmpty ? null : v; + } + + /// Normalize an INFOPLIST_FILE value to a SRCROOT-relative POSIX path. + static String _normalizePbxPath(String raw) { + var v = _cleanValue(raw) ?? ''; + v = v + .replaceAll(r'$(SRCROOT)/', '') + .replaceAll(r'${SRCROOT}/', '') + .replaceAll('\\', '/'); + if (v.startsWith('./')) v = v.substring(2); + return v; + } + + /// Whether a pbxproj INFOPLIST_FILE path refers to the wanted plist. Uses an + /// exact or suffix match so relative-path differences do not cause misses. + static bool _infoPlistMatches(String pbxPath, String wantRel) { + if (pbxPath.isEmpty || wantRel.isEmpty) return false; + return pbxPath == wantRel || + pbxPath.endsWith('/$wantRel') || + wantRel.endsWith('/$pbxPath'); + } + /// Extract variable value from project.pbxproj file static String? _extractVariableFromPbxproj(File pbxproj, String varName) { try { diff --git a/test/unit/parsers/ios_parser_test.dart b/test/unit/parsers/ios_parser_test.dart index 198752d..09cb5c6 100644 --- a/test/unit/parsers/ios_parser_test.dart +++ b/test/unit/parsers/ios_parser_test.dart @@ -206,6 +206,167 @@ void main() { expect(result!.bundleIdentifier, 'com.example.resolved'); }); + // Multi-target project: the notification-extension build configuration is + // written BEFORE the app's, and its bundle id contains no "Test", so the + // legacy first-non-test heuristic would resolve the app's + // $(PRODUCT_BUNDLE_IDENTIFIER) to the extension. Resolution must be scoped + // to the target that owns each Info.plist. + const multiTargetPbxproj = '''// !\$*UTF8*\$! +{ + objects = { + AAAA1111AAAA1111AAAA1111 /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BBBB2222BBBB2222BBBB2222 /* list for NotificationService */; + productType = "com.apple.product-type.app-extension"; + }; + CCCC3333CCCC3333CCCC3333 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDDD4444DDDD4444DDDD4444 /* list for Runner */; + productType = "com.apple.product-type.application"; + }; + EEEE5555EEEE5555EEEE5555 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = NotificationService/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app.NotificationServiceExtension; + }; + name = Release; + }; + FFFF6666FFFF6666FFFF6666 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = Runner/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app; + }; + name = Release; + }; + BBBB2222BBBB2222BBBB2222 /* list for NotificationService */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEEE5555EEEE5555EEEE5555 /* Release */, + ); + }; + DDDD4444DDDD4444DDDD4444 /* list for Runner */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFF6666FFFF6666FFFF6666 /* Release */, + ); + }; + }; +}'''; + + const variablePlist = ''' + + + + CFBundleIdentifier + \$(PRODUCT_BUNDLE_IDENTIFIER) + +'''; + + test( + 'resolves the application bundle id, not an extension listed earlier ' + 'in the pbxproj', () async { + await TestHelpers.createFile( + tempDir, + 'Runner.xcodeproj/project.pbxproj', + multiTargetPbxproj, + ); + final file = await TestHelpers.createFile( + tempDir, + 'Runner/Info.plist', + variablePlist, + ); + + final result = IosParser.parseInfoPlist(file); + + expect(result, isNotNull); + expect(result!.bundleIdentifier, 'dev.rart.abrezo.app'); + }); + + test('resolves each target\'s own bundle id (extension Info.plist)', + () async { + await TestHelpers.createFile( + tempDir, + 'Runner.xcodeproj/project.pbxproj', + multiTargetPbxproj, + ); + final file = await TestHelpers.createFile( + tempDir, + 'NotificationService/Info.plist', + variablePlist, + ); + + final result = IosParser.parseInfoPlist(file); + + expect(result, isNotNull); + expect(result!.bundleIdentifier, + 'dev.rart.abrezo.app.NotificationServiceExtension'); + }); + + test( + 'falls back to the application target when no INFOPLIST_FILE points ' + 'at the plist', () async { + // No INFOPLIST_FILE anywhere; the extension config is still first. + const pbxproj = '''// !\$*UTF8*\$! +{ + objects = { + AAAA1111AAAA1111AAAA1111 /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BBBB2222BBBB2222BBBB2222 /* list */; + productType = "com.apple.product-type.app-extension"; + }; + CCCC3333CCCC3333CCCC3333 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDDD4444DDDD4444DDDD4444 /* list */; + productType = "com.apple.product-type.application"; + }; + EEEE5555EEEE5555EEEE5555 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app.NotificationServiceExtension; + }; + name = Release; + }; + FFFF6666FFFF6666FFFF6666 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app; + }; + name = Release; + }; + BBBB2222BBBB2222BBBB2222 /* list */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEEE5555EEEE5555EEEE5555 /* Release */, + ); + }; + DDDD4444DDDD4444DDDD4444 /* list */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFF6666FFFF6666FFFF6666 /* Release */, + ); + }; + }; +}'''; + + await TestHelpers.createFile( + tempDir, + 'Runner.xcodeproj/project.pbxproj', + pbxproj, + ); + final file = await TestHelpers.createFile( + tempDir, + 'Runner/Info.plist', + variablePlist, + ); + + final result = IosParser.parseInfoPlist(file); + + expect(result, isNotNull); + expect(result!.bundleIdentifier, 'dev.rart.abrezo.app'); + }); + test('should extract team ID from pbxproj', () async { final pbxproj = '// !\$*UTF8*\$!\n' '{\n' From 8b147f870514d3836b36f758be34878528e93bc6 Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Sun, 6 Sep 2026 21:12:13 +0200 Subject: [PATCH 2/2] fix(ios): match exact application product type in bundle-id fallback The tier-2 fallback (_bundleIdForApplicationTarget, used when no INFOPLIST_FILE points at the plist) matched the substring 'com.apple.product-type.application', which also matches '...application.watchapp2' and '...application.on-demand-install-capable' (App Clip). In an app+watch or app+clip project with no INFOPLIST_FILE and the watch/clip target listed first, PRODUCT_BUNDLE_IDENTIFIER resolved to the wrong target - the same false positive this fix set out to eliminate. Match the exact double-quoted product type instead; Xcode always writes it quoted, so the trailing quote excludes the watch/clip subtypes. Adds a multi-application-target regression test. --- lib/parsers/ios_parser.dart | 7 ++- test/unit/parsers/ios_parser_test.dart | 65 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/parsers/ios_parser.dart b/lib/parsers/ios_parser.dart index b42b753..381cd39 100644 --- a/lib/parsers/ios_parser.dart +++ b/lib/parsers/ios_parser.dart @@ -165,7 +165,12 @@ class IosParser { String? listUuid; for (final obj in _objectsByIsa(content, 'PBXNativeTarget')) { - if (obj.contains('com.apple.product-type.application')) { + // Match the exact quoted product type. A substring match would also + // catch `...application.watchapp2` and `...application.on-demand- + // install-capable` (App Clip), letting a watch app or clip masquerade + // as the main app and yield the wrong bundle id. Xcode always writes + // productType double-quoted, so the trailing quote makes this exact. + if (obj.contains('"com.apple.product-type.application"')) { listUuid = _firstUuid(obj, 'buildConfigurationList'); break; } diff --git a/test/unit/parsers/ios_parser_test.dart b/test/unit/parsers/ios_parser_test.dart index 09cb5c6..020aa04 100644 --- a/test/unit/parsers/ios_parser_test.dart +++ b/test/unit/parsers/ios_parser_test.dart @@ -367,6 +367,71 @@ void main() { expect(result!.bundleIdentifier, 'dev.rart.abrezo.app'); }); + test( + 'falls back to the real application target, not a watch app or clip, ' + 'when no INFOPLIST_FILE points at the plist', () async { + // A Watch App (product-type.application.watchapp2) is listed BEFORE the + // real app. A substring match on "com.apple.product-type.application" + // would pick the watch app and resolve the wrong bundle id. + const pbxproj = '''// !\$*UTF8*\$! +{ + objects = { + AAAA1111AAAA1111AAAA1111 /* WatchApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = BBBB2222BBBB2222BBBB2222 /* list */; + productType = "com.apple.product-type.application.watchapp2"; + }; + CCCC3333CCCC3333CCCC3333 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDDD4444DDDD4444DDDD4444 /* list */; + productType = "com.apple.product-type.application"; + }; + EEEE5555EEEE5555EEEE5555 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app.watchkitapp; + }; + name = Release; + }; + FFFF6666FFFF6666FFFF6666 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = dev.rart.abrezo.app; + }; + name = Release; + }; + BBBB2222BBBB2222BBBB2222 /* list */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEEE5555EEEE5555EEEE5555 /* Release */, + ); + }; + DDDD4444DDDD4444DDDD4444 /* list */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFFF6666FFFF6666FFFF6666 /* Release */, + ); + }; + }; +}'''; + + await TestHelpers.createFile( + tempDir, + 'Runner.xcodeproj/project.pbxproj', + pbxproj, + ); + final file = await TestHelpers.createFile( + tempDir, + 'Runner/Info.plist', + variablePlist, + ); + + final result = IosParser.parseInfoPlist(file); + + expect(result, isNotNull); + expect(result!.bundleIdentifier, 'dev.rart.abrezo.app'); + }); + test('should extract team ID from pbxproj', () async { final pbxproj = '// !\$*UTF8*\$!\n' '{\n'