From bc79d43b86e0cabc1904366065c92136b0d17799 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:54:28 +0300 Subject: [PATCH 01/49] Give an app extension its build settings, so UIKit links Mirrors the cloud builder fix. The extension target's settings block is parsed out of a pbxproj-shaped string, and the parser sliced the last character of the value INSTEAD of dropping it, so every "KEY = YES;" line produced ";". Xcode reads that as off: CLANG_ENABLE_MODULES never took, clang compiled the extension without -fmodules, and without modules there is no autolinking. The target's frameworks phase carries Foundation alone, so an extension importing UIKit reached ld with nothing to resolve _OBJC_CLASS_$_UIView against. CLANG_ENABLE_OBJC_ARC was off for the same reason, so the extension built as MRC and leaked. Dropping the semicolon exposes the second half, which never ran before: a quoted value like "gnu++14" is re-emitted inside a Ruby string literal in the project fixup script, where the kept quotes are a syntax error. So unwrap those too. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f44e664c65e..cb15b4051b0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4819,20 +4819,7 @@ public void usesClassMethod(String cls, String method) { + " CLANG_WARN_UNREACHABLE_CODE = YES;\n" + " CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;"; - Map buildSettingsMap = new HashMap(); - String[] lines = buildSettingsStr.split("\n"); - for (String line : lines) { - if (line.trim().isEmpty()) { - continue; - } - String key = line.substring(0, line.indexOf("=")).trim(); - String val = line.substring(line.indexOf("=") + 1).trim(); - if (val.endsWith(";")) { - val = val.substring(val.length() - 1); - } - buildSettingsMap.put(key, val); - - } + Map buildSettingsMap = parseXcodeBuildSettings(buildSettingsStr); String extensionName = appExtension.getName(); String codeSignEntitlements = "$(NS_CODE_SIGN_ENTITLEMENTS)"; @@ -5680,6 +5667,43 @@ private void ensureTopLevelWorkspace(BuildRequest request) throws BuildException } } + /** + * Parses the pbxproj-shaped block of build settings an app extension target is + * seeded with -- one {@code KEY = VALUE;} per line -- into the map that is written + * back out as Ruby string literals in the project fixup script. + * + * Two details the value has to lose, both of which failed silently here. The + * trailing semicolon: keeping it (the old code sliced the last character INSTEAD + * of dropping it, so every value became ";") left CLANG_ENABLE_MODULES off, which + * drops -fmodules, which drops clang's autolinking, which is why an extension that + * imports UIKit reached ld with Foundation alone and died on + * _OBJC_CLASS_$_UIView. And the quotes Xcode wraps a non-identifier value in: + * re-emitted inside the Ruby literal those become ""gnu++14"", a syntax error that + * takes the whole fixup script down with it. + */ + static Map parseXcodeBuildSettings(String buildSettingsStr) { + Map buildSettingsMap = new LinkedHashMap(); + for (String line : buildSettingsStr.split("\n")) { + if (line.trim().isEmpty()) { + continue; + } + int equals = line.indexOf("="); + if (equals < 0) { + continue; + } + String key = line.substring(0, equals).trim(); + String val = line.substring(equals + 1).trim(); + if (val.endsWith(";")) { + val = val.substring(0, val.length() - 1).trim(); + } + if (val.length() > 1 && val.startsWith("\"") && val.endsWith("\"")) { + val = val.substring(1, val.length() - 1); + } + buildSettingsMap.put(key, val); + } + return buildSettingsMap; + } + static void appendFilesToXcodeProjGroup(StringBuilder sb, File dir, String serviceGroupVarName, String serviceTargetVarName, File baseDir) { String basePath = baseDir.getAbsolutePath(); From ee7eb951e59971e66db79f6e42805ad47115bb3d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:16:54 +0300 Subject: [PATCH 02/49] Give a brought-in app extension a bundle identity Mirrors the cloud builder fix. Past the frameworks problem the archive dies in the app's own target with "Embedded binary's bundle identifier is not prefixed with the parent app's bundle identifier -- Embedded Binary Bundle Identifier: (null)". Not prefixed wrongly: absent. A modern Xcode target keeps CFBundleIdentifier and the version strings in build settings and generates them into the plist, so an extension folder exported from such a project ships an Info.plist without those keys, and builtin-infoPlistUtility only expands $(...) references that are already there -- it does not add the key. Every extension the builder generates itself writes CFBundleIdentifier = $(PRODUCT_BUNDLE_IDENTIFIER) into its plist; the generic .ios.appext path trusted whatever the archive carried. It now adds the same reference when the key is missing and aligns CFBundleShortVersionString / CFBundleVersion with the app, which Apple requires of an embedded extension. A correct value, and one written as a $(...) reference, are left alone; anything changed is logged. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index cb15b4051b0..9f0791e6849 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4833,6 +4833,8 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." +extensionName); + stampAppExtensionInfoPlist(appExtension, + request.getArg("ios.bundleVersion", buildVersion)); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); buildSettingsMap.put("PROVISIONING_PROFILE", "$(NS_PROVISIONING_PROFILE)"); buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", codeSignEntitlements); @@ -5667,6 +5669,109 @@ private void ensureTopLevelWorkspace(BuildRequest request) throws BuildException } } + /** + * Fills in the bundle identity a brought-in {@code .ios.appext} usually leaves to Xcode, + * because nothing in the archive supplies it here. + * + *

A modern Xcode target keeps CFBundleIdentifier and the two version strings in build + * settings and generates them into the plist, so an extension folder exported from such a + * project ships an Info.plist with those keys simply absent. The target gets + * PRODUCT_BUNDLE_IDENTIFIER, but nothing copies it into the plist: + * {@code builtin-infoPlistUtility} expands {@code $(...)} references that are already there, + * it does not add the key. The .appex is then built with no identifier and the archive fails + * at the very end, in the app's own target, with "Embedded binary's bundle identifier is not + * prefixed with the parent app's bundle identifier -- Embedded Binary Bundle Identifier: + * (null)".

+ * + *

Apple also requires an embedded extension to carry the same version strings as the app + * containing it, so a stale or absent version is the same failure one step later. Both are + * aligned here, and every change is logged: this edits a file the developer supplied. A value + * that is already correct, and one written as a {@code $(...)} reference, are left alone.

+ */ + private void stampAppExtensionInfoPlist(File appExtension, String bundleVersion) throws IOException { + File infoPlist = new File(appExtension, "Info.plist"); + if (!infoPlist.isFile()) { + debug("The " + appExtension.getName() + " app extension has no Info.plist. Xcode cannot " + + "build an extension target without one; add it to the .ios.appext archive."); + return; + } + String plist = readFileToString(infoPlist); + List changes = new ArrayList(); + String stamped = stampInfoPlistIdentity(plist, buildVersion, bundleVersion, changes); + if (changes.isEmpty()) { + return; + } + if (stamped == null) { + debug("Could not read " + appExtension.getName() + "/Info.plist as an XML property list, " + + "so its bundle identity was left as it is. If the build fails on the embedded " + + "binary's bundle identifier, convert the file with " + + "'plutil -convert xml1 Info.plist' and rebuild."); + return; + } + createFile(infoPlist, stamped.getBytes("UTF-8")); + for (String change : changes) { + debug("Adjusted " + appExtension.getName() + "/Info.plist: " + change); + } + } + + /** + * The text half of {@link #stampAppExtensionInfoPlist}, kept separate so it can be tested. + * + * @param changes collects a human-readable line per edit; empty means the plist was already + * right and must not be rewritten + * @return the new plist text, or null when this is not an XML plist we can edit -- in which + * case {@code changes} carries the reason and the file is left alone + */ + static String stampInfoPlistIdentity(String plist, String shortVersion, String bundleVersion, + List changes) { + if (plist == null || plist.lastIndexOf("") < 0) { + changes.add("not an XML property list"); + return null; + } + String result = plist; + result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", false, changes); + result = setPlistString(result, "CFBundleShortVersionString", shortVersion, true, changes); + result = setPlistString(result, "CFBundleVersion", bundleVersion, true, changes); + return result; + } + + /** + * Sets one string key in an XML plist, adding it when absent. + * + * @param overwrite whether an existing literal value is replaced when it differs; false only + * adds the key when it is missing + */ + private static String setPlistString(String plist, String key, String value, boolean overwrite, + List changes) { + if (value == null || value.length() == 0) { + return plist; + } + String keyTag = "" + key + ""; + int keyAt = plist.indexOf(keyTag); + if (keyAt < 0) { + int dictEnd = plist.lastIndexOf(""); + changes.add("added " + key + " = " + value); + return plist.substring(0, dictEnd) + + "\t" + key + "\n\t" + value + "\n" + + plist.substring(dictEnd); + } + if (!overwrite) { + return plist; + } + int valueStart = plist.indexOf("", keyAt + keyTag.length()); + int valueEnd = valueStart < 0 ? -1 : plist.indexOf("", valueStart); + if (valueStart < 0 || valueEnd < 0) { + // Not a string value (an extension is free to use whatever type it likes); leave it. + return plist; + } + String current = plist.substring(valueStart + "".length(), valueEnd); + if (current.equals(value) || current.contains("$(")) { + return plist; + } + changes.add("set " + key + " to " + value + " to match the app (was " + current + ")"); + return plist.substring(0, valueStart + "".length()) + value + plist.substring(valueEnd); + } + /** * Parses the pbxproj-shaped block of build settings an app extension target is * seeded with -- one {@code KEY = VALUE;} per line -- into the map that is written From 687ec047e0baebf1933f7973d5e320ed41b30488 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:24:46 +0300 Subject: [PATCH 03/49] Stop shipping the .ios.appext archive inside the app Mirrors the cloud builder fix. The archive was unpacked into dist/ and deleted from the resources directory at that point -- after the translator has already copied that directory into
-src, where every file becomes an app resource. An unbuilt second copy of the extension therefore rode along inside the .app beside the .appex it was unpacked into, adding its weight to the IPA and putting the extension's sources in the bundle. Unpacking cannot move earlier: it wires Xcode targets, and the project does not exist yet. The move out of resDir can, and that is all that was needed -- every other archive kind consumed out of resDir (.lproj.zip, .placeindist.zip, .framework.zip) deletes itself in the same early pass for exactly this reason. The archives are now staged into tmp/appext before the resources are walked. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 9f0791e6849..f5afcc2a2c4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -128,6 +128,9 @@ public class IPhoneBuilder extends Executor { private boolean runSpm=false; private boolean photoLibraryUsage; private String buildVersion; + // Where the .ios.appext archives are parked between being taken out of the resources + // directory and being unpacked into dist/. Null when the app brought none. + private File appExtensionArchiveDir; private boolean usesLocalNotifications; private boolean usesPurchaseAPI; private boolean usesAppReview; @@ -984,6 +987,15 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException // We must now go through and extract this tar file into a separate directory so that we can copy them // into the project folder after ByteCodeTranslator has created the Xcode project. + // Before anything walks the resources: an .ios.appext is unpacked much later, once the + // Xcode project exists, but it has to leave resDir now. See stageAppExtensionArchives. + try { + appExtensionArchiveDir = stageAppExtensionArchives(resDir, new File(tmpFile, "appext")); + } catch (IOException ex) { + throw new BuildException("Failed to stage the app extension archives out of the " + + "resources directory", ex); + } + // Look for frameworks and localized strings Set variantGroups = new HashSet(); for (File child : resDir.listFiles()) { @@ -4730,7 +4742,7 @@ public void usesClassMethod(String cls, String method) { // the ruby xcodeproj gem even when CocoaPods isn't otherwise needed. boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled || surfacesExtensionEnabled || matterExtensionEnabled - || hasAppExtensionArchives(resDir); + || hasAppExtensionArchives(appExtensionArchiveDir); if (needsXcodeProjectMutation) { try { List podSpecFileList = new ArrayList(); @@ -4788,7 +4800,9 @@ public void usesClassMethod(String cls, String method) { // Let's extract and add app extensions here - File[] appExtensions = extractAppExtensions(resDir, new File(tmpFile, "dist")); + File[] appExtensions = appExtensionArchiveDir == null + ? new File[0] + : extractAppExtensions(appExtensionArchiveDir, new File(tmpFile, "dist")); StringBuilder appExtensionsBuilder = new StringBuilder(); { StringBuilder sb = appExtensionsBuilder; @@ -6880,6 +6894,40 @@ private void appendWidgetExtensionRuby(StringBuilder sb, BuildRequest request, sb.append("end\n"); } + /** + * Moves every {@code .ios.appext} archive out of the resources directory, into a staging + * directory the extension wiring reads much later. + * + *

The unpacking cannot happen this early -- it wants the Xcode project that does not + * exist yet -- but the move cannot happen any later. The resources directory is handed to + * the translator, which copies it into {@code

-src}, and every file in there becomes + * an app resource: the archive shipped inside the .app, an unbuilt second copy of the + * extension sitting next to the .appex it had been unpacked into. Deleting it from resDir + * at unpack time is too late to stop that copy. Every other archive kind consumed out of + * resDir deletes itself for the same reason.

+ * + * @return the staging directory, or null when the app brought no extension archive + */ + static File stageAppExtensionArchives(File resDir, File stagingDir) throws IOException { + File[] entries = resDir == null ? null : resDir.listFiles(); + if (entries == null) { + return null; + } + File staged = null; + for (File child : entries) { + if (!child.isFile() || !child.getName().endsWith(".ios.appext")) { + continue; + } + if (staged == null) { + staged = stagingDir; + staged.mkdirs(); + } + Files.move(child.toPath(), new File(staged, child.getName()).toPath(), + StandardCopyOption.REPLACE_EXISTING); + } + return staged; + } + private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) throws IOException { if (sourceDirectory == null || !sourceDirectory.isDirectory()) { throw new IllegalArgumentException("extractAppExtensions sourceDirectory must be an existing directory but received "+sourceDirectory); From 115bf161986c96375312f122a32bf4b26a23aadc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:29:55 +0300 Subject: [PATCH 04/49] Resolve extension versions the way the app resolves its own Mirrors the cloud builder fix. The stamper took buildVersion and the ios.bundleVersion hint, but the app's own Info.plist does not: ios.plistInject wins for CFBundleShortVersionString and CFBundleVersion, and the injection only falls back to buildVersion when it says nothing. An app that injects its version ships that value, while the extension was stamped with the raw one -- so an extension whose version already MATCHED its app could be rewritten into one that does not, which is the embedded-bundle validation failure the stamping exists to prevent. The Matter extension already resolved both keys correctly, inline. That resolution is now two named helpers shared by both callers. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f5afcc2a2c4..f1031125d00 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4847,8 +4847,7 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." +extensionName); - stampAppExtensionInfoPlist(appExtension, - request.getArg("ios.bundleVersion", buildVersion)); + stampAppExtensionInfoPlist(appExtension, request); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); buildSettingsMap.put("PROVISIONING_PROFILE", "$(NS_PROVISIONING_PROFILE)"); buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", codeSignEntitlements); @@ -5683,6 +5682,36 @@ private void ensureTopLevelWorkspace(BuildRequest request) throws BuildException } } + /** + * The marketing version an embedded extension must declare. + * + *

Apple validates an embedded extension's versions against its containing app, so a + * hard-coded pair fails archive validation for every release that is not literally 1.0. + * Resolved exactly as the watch builder resolves the same two keys, including the + * injected-plist override -- which is the whole point: an app that sets + * CFBundleShortVersionString through ios.plistInject ships THAT version, and the raw build + * version is then the wrong answer for every extension beside it.

+ * + *

Used by the Matter extension and by every brought-in .ios.appext.

+ */ + static String embeddedExtensionShortVersion(BuildRequest request) { + String injected = WatchNativeBuilder.injectedPlistString(request, + "CFBundleShortVersionString"); + return injected != null ? injected : WatchNativeBuilder.shortVersion(request); + } + + /** + * The build version an embedded extension must declare. + * + *

The fallback is shortVersion, NOT the marketing version resolved above: the two keys + * are independent, and deriving one from the other is what produced the watch mismatch.

+ */ + static String embeddedExtensionBundleVersion(BuildRequest request) { + String injected = WatchNativeBuilder.injectedPlistString(request, "CFBundleVersion"); + return injected != null ? injected + : request.getArg("ios.bundleVersion", WatchNativeBuilder.shortVersion(request)); + } + /** * Fills in the bundle identity a brought-in {@code .ios.appext} usually leaves to Xcode, * because nothing in the archive supplies it here. @@ -5702,7 +5731,7 @@ private void ensureTopLevelWorkspace(BuildRequest request) throws BuildException * aligned here, and every change is logged: this edits a file the developer supplied. A value * that is already correct, and one written as a {@code $(...)} reference, are left alone.

*/ - private void stampAppExtensionInfoPlist(File appExtension, String bundleVersion) throws IOException { + private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) throws IOException { File infoPlist = new File(appExtension, "Info.plist"); if (!infoPlist.isFile()) { debug("The " + appExtension.getName() + " app extension has no Info.plist. Xcode cannot " @@ -5711,7 +5740,13 @@ private void stampAppExtensionInfoPlist(File appExtension, String bundleVersion) } String plist = readFileToString(infoPlist); List changes = new ArrayList(); - String stamped = stampInfoPlistIdentity(plist, buildVersion, bundleVersion, changes); + // Through the shared resolvers rather than buildVersion / the ios.bundleVersion hint + // directly: an app that sets either version key through ios.plistInject ships that value, + // and stamping the raw hint here would rewrite an extension version that already matched + // its app into one that does not -- the very validation failure this method exists to + // prevent. + String stamped = stampInfoPlistIdentity(plist, embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request), changes); if (changes.isEmpty()) { return; } @@ -6130,15 +6165,8 @@ private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, // The host's own versions, through the helpers the watch builder uses // for the same rule: an embedded extension whose marketing or build // version differs from its containing app fails archive validation. - String injectedShort = WatchNativeBuilder.injectedPlistString(request, - "CFBundleShortVersionString"); - String extShort = injectedShort != null ? injectedShort - : WatchNativeBuilder.shortVersion(request); - String injectedBundle = WatchNativeBuilder.injectedPlistString(request, - "CFBundleVersion"); - String extBundle = injectedBundle != null ? injectedBundle - : request.getArg("ios.bundleVersion", - WatchNativeBuilder.shortVersion(request)); + String extShort = embeddedExtensionShortVersion(request); + String extBundle = embeddedExtensionBundleVersion(request); // The hint is an override, not the only way in: an app whose // setCommissionToThisApp(true) the scanner saw needs no hint, and one // that reaches the API through reflection has no other way to say so. From 0cff60fd97e563e0ffc42a0c91431389715a509f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:41:04 +0300 Subject: [PATCH 05/49] Stamp the plist the target builds, and only the key's own value Mirrors the cloud builder fix for two review catches, both cases where the stamping edits the wrong thing. INFOPLIST_FILE: an archive's buildSettings.properties may point the target at a plist other than /Info.plist, and those properties are folded into the target's build settings further down, so Xcode processes that file into the .appex. Stamping the default left the plist that actually ships without the identifier and versions. The effective path is now resolved first (relative to the project directory, with $(SRCROOT) and $(PROJECT_DIR) understood); a reference this build cannot resolve returns null and the build says so rather than editing a file nothing reads. The value scan: indexOf("") from the key found the next string ANYWHERE after it, so a CFBundleVersion given 7, or the valid empty form , sent the rewrite into an unrelated later value -- CFBundleName, or a field inside the NSExtension dict, stamped with a version number. This is the trap injectedPlistString's comment records, so the fix is its machinery: the key's own value element, with whitespace, comments and CDATA skipped. A non-string value is left alone; is this key's own empty value and gets filled. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 104 +++++++++++++++--- .../builders/WatchNativeBuilder.java | 12 +- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f1031125d00..60e3758ffe1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5732,10 +5732,18 @@ static String embeddedExtensionBundleVersion(BuildRequest request) { * that is already correct, and one written as a {@code $(...)} reference, are left alone.

*/ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) throws IOException { - File infoPlist = new File(appExtension, "Info.plist"); + File infoPlist = appExtensionInfoPlist(appExtension); + if (infoPlist == null) { + debug("The " + appExtension.getName() + " app extension points INFOPLIST_FILE at a path " + + "this build cannot resolve, so its bundle identifier and versions were left " + + "as they are. If the archive fails on the embedded binary's bundle " + + "identifier, write the path relative to the project directory."); + return; + } if (!infoPlist.isFile()) { - debug("The " + appExtension.getName() + " app extension has no Info.plist. Xcode cannot " - + "build an extension target without one; add it to the .ios.appext archive."); + debug("The " + appExtension.getName() + " app extension has no " + infoPlist.getName() + + ". Xcode cannot build an extension target without one; add it to the " + + ".ios.appext archive."); return; } String plist = readFileToString(infoPlist); @@ -5751,7 +5759,8 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) return; } if (stamped == null) { - debug("Could not read " + appExtension.getName() + "/Info.plist as an XML property list, " + debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + + " as an XML property list, " + "so its bundle identity was left as it is. If the build fails on the embedded " + "binary's bundle identifier, convert the file with " + "'plutil -convert xml1 Info.plist' and rebuild."); @@ -5759,7 +5768,7 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) } createFile(infoPlist, stamped.getBytes("UTF-8")); for (String change : changes) { - debug("Adjusted " + appExtension.getName() + "/Info.plist: " + change); + debug("Adjusted " + appExtension.getName() + "/" + infoPlist.getName() + ": " + change); } } @@ -5795,9 +5804,14 @@ private static String setPlistString(String plist, String key, String value, boo if (value == null || value.length() == 0) { return plist; } - String keyTag = "" + key + ""; - int keyAt = plist.indexOf(keyTag); - if (keyAt < 0) { + // The key's OWN value, through the watch builder's scanners, which is not the same as the + // next after it. A key whose value is , 1 or the valid + // empty form has no of its own, and scanning forward from the key lands + // on an unrelated later one -- CFBundleName, or something inside the NSExtension dict -- + // which this method would then rewrite with a version number. The same trap the comment on + // injectedPlistString records, in a real plist rather than a hint fragment. + int afterKey = WatchNativeBuilder.injectedValueAt(plist, key); + if (afterKey < 0) { int dictEnd = plist.lastIndexOf(""); changes.add("added " + key + " = " + value); return plist.substring(0, dictEnd) @@ -5807,18 +5821,80 @@ private static String setPlistString(String plist, String key, String value, boo if (!overwrite) { return plist; } - int valueStart = plist.indexOf("", keyAt + keyTag.length()); - int valueEnd = valueStart < 0 ? -1 : plist.indexOf("", valueStart); - if (valueStart < 0 || valueEnd < 0) { - // Not a string value (an extension is free to use whatever type it likes); leave it. + int element = WatchNativeBuilder.nextElementAt(plist, afterKey); + if (element < 0 || !"string".equals(WatchNativeBuilder.tagAt(plist, element))) { + // Not a string value. An extension is free to use whatever type it likes, and + // rewriting a type we did not expect is worse than leaving a version alone. + return plist; + } + int openEnd = plist.indexOf('>', element); + if (openEnd < 0) { + return plist; + } + if (plist.charAt(openEnd - 1) == '/') { + // : an empty value of the right type, so it is this key's and ours to fill. + changes.add("set " + key + " to " + value + " to match the app (was empty)"); + return plist.substring(0, element) + "" + value + "" + + plist.substring(openEnd + 1); + } + int valueEnd = WatchNativeBuilder.closeOfElement(plist, openEnd + 1, ""); + if (valueEnd < 0) { return plist; } - String current = plist.substring(valueStart + "".length(), valueEnd); + String current = plist.substring(openEnd + 1, valueEnd); if (current.equals(value) || current.contains("$(")) { return plist; } changes.add("set " + key + " to " + value + " to match the app (was " + current + ")"); - return plist.substring(0, valueStart + "".length()) + value + plist.substring(valueEnd); + return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); + } + + /// The Info.plist the extension target is actually built with. + /// + /// {@code /Info.plist} is only the default: the archive's buildSettings.properties + /// may point INFOPLIST_FILE at another file, and that is the one Xcode processes into the + /// .appex. Stamping the default in that case leaves the plist that ships without the + /// identifier and versions the stamping is there to supply. + /// + /// The path is written the way Xcode reads it: relative to the project directory, which is + /// the extension folder's parent. A value that still holds a build-setting reference after + /// the two obvious project-root spellings is not resolvable here, and null says so rather + /// than guessing at a file to edit. + static File appExtensionInfoPlist(File extensionFolder) { + File settings = new File(extensionFolder, "buildSettings.properties"); + String override = null; + if (settings.isFile()) { + Properties props = new Properties(); + FileInputStream fis = null; + try { + fis = new FileInputStream(settings); + props.load(fis); + override = props.getProperty("INFOPLIST_FILE"); + } catch (IOException ex) { + override = null; + } finally { + if (fis != null) { + try { fis.close(); } catch (Throwable t) {} + } + } + } + if (override == null || override.trim().length() == 0) { + return new File(extensionFolder, "Info.plist"); + } + String path = override.trim(); + if (path.length() > 1 && path.startsWith("\"") && path.endsWith("\"")) { + path = path.substring(1, path.length() - 1).trim(); + } + for (String projectRoot : new String[]{"$(SRCROOT)/", "$(PROJECT_DIR)/"}) { + if (path.startsWith(projectRoot)) { + path = path.substring(projectRoot.length()); + } + } + if (path.contains("$(")) { + return null; + } + File resolved = new File(path); + return resolved.isAbsolute() ? resolved : new File(extensionFolder.getParentFile(), path); } /** diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index e7e548e9e51..3f5f0f959f4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -946,7 +946,11 @@ static java.util.List injectedPlistStringArray(BuildRequest request, Str /// Where the value belonging to {@code key} begins -- just past its {@code } -- or -1 /// when the fragment does not carry the key. - private static int injectedValueAt(String inject, String key) { + /// Shared with IPhoneBuilder's app-extension Info.plist stamping, which has to find a + /// key's own value in a real plist for the same reason the comment on + /// {@link #injectedPlistString} gives: the next `` after a key is very often some + /// other key's. + static int injectedValueAt(String inject, String key) { int at = 0; while (true) { int content = contentAfterOpenTag(inject, "key", at); @@ -969,7 +973,7 @@ private static int injectedValueAt(String inject, String key) { /// The {@code <} of the next real element at or after {@code from}, or -1 when what follows is /// text or nothing. Whitespace, comments and CDATA sit between a key and its value in real /// fragments and none of them is the value. - private static int nextElementAt(String inject, int from) { + static int nextElementAt(String inject, int from) { int i = from; while (i < inject.length()) { if (Character.isWhitespace(inject.charAt(i))) { @@ -990,7 +994,7 @@ private static int nextElementAt(String inject, int from) { } /// The element name at an opening tag, lowercased. Empty for an end tag, which is not one. - private static String tagAt(String inject, int element) { + static String tagAt(String inject, int element) { StringBuilder tag = new StringBuilder(); for (int j = element + 1; j < inject.length() && Character.isLetterOrDigit(inject.charAt(j)); j++) { @@ -1116,7 +1120,7 @@ private static int nextMarkup(String inject, String tag, int from) { } /// The end tag that closes an element, skipping over CDATA sections and comments. - private static int closeOfElement(String inject, int from, String closeTag) { + static int closeOfElement(String inject, int from, String closeTag) { // `` closes the same element as ``, so the tag is matched as a pattern rather // than as literal text -- the same reason the opening tags are. java.util.regex.Matcher m = java.util.regex.Pattern From f2253687ca08174205e818e04c36c1c059afff34 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:49:18 +0300 Subject: [PATCH 06/49] Read the bundle's identity off the root dict, and fill an empty one Mirrors the cloud builder fix. The key lookup was a whole-file search, so a key of the same name inside a nested dictionary -- NSExtensionAttributes, CFBundleURLTypes and CFBundleDocumentTypes all carry dictionaries of their own -- answered first when it came earlier in the file. That reads as "the bundle already has an identifier" while the real key is still missing, or sends the version rewrite into an unrelated nested value. The three keys are now looked up among the DIRECT children of the root dict: a small walker that steps over each value whole (depth counted on the element's own name) and skips comments and CDATA rather than reading a < inside either as a tag. And CFBundleIdentifier is filled when it is present but empty. It is set with overwrite off, because an explicit identifier is the extension's own business -- but is not an explicit identifier, it is no identifier, and it fails the embedded-binary check exactly like a missing one. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 220 ++++++++++++++++-- .../builders/WatchNativeBuilder.java | 2 +- 2 files changed, 199 insertions(+), 23 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 60e3758ffe1..9c4ef366541 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5782,11 +5782,11 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) */ static String stampInfoPlistIdentity(String plist, String shortVersion, String bundleVersion, List changes) { - if (plist == null || plist.lastIndexOf("") < 0) { + if (plist == null || rootDictAt(plist) < 0) { changes.add("not an XML property list"); return null; } - String result = plist; + String result = openEmptyRootDict(plist); result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", false, changes); result = setPlistString(result, "CFBundleShortVersionString", shortVersion, true, changes); result = setPlistString(result, "CFBundleVersion", bundleVersion, true, changes); @@ -5794,34 +5794,39 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b } /** - * Sets one string key in an XML plist, adding it when absent. + * Sets one string key among the ROOT dict's direct children, adding it when absent. * - * @param overwrite whether an existing literal value is replaced when it differs; false only - * adds the key when it is missing + *

Every lookup here is anchored to the top level because a plist is full of nested + * dictionaries that carry keys of their own -- NSExtension, CFBundleURLTypes, + * CFBundleDocumentTypes -- and a repository-wide text search finds whichever comes first in + * the file, not the bundle's identity. Reading a nested one as "already present" leaves the + * real key absent, and writing to it stamps a version number into an unrelated value.

+ * + * @param overwriteNonEmpty whether a value that is already there and not empty is replaced + * when it differs. False fills only what is missing or empty, which is what an identifier + * wants: an explicit one is the extension's own business, an empty one is no identifier at + * all and fails the same embedded-binary validation as a missing one. */ - private static String setPlistString(String plist, String key, String value, boolean overwrite, - List changes) { + private static String setPlistString(String plist, String key, String value, + boolean overwriteNonEmpty, List changes) { if (value == null || value.length() == 0) { return plist; } - // The key's OWN value, through the watch builder's scanners, which is not the same as the - // next after it. A key whose value is , 1 or the valid - // empty form has no of its own, and scanning forward from the key lands - // on an unrelated later one -- CFBundleName, or something inside the NSExtension dict -- - // which this method would then rewrite with a version number. The same trap the comment on - // injectedPlistString records, in a real plist rather than a hint fragment. - int afterKey = WatchNativeBuilder.injectedValueAt(plist, key); + int afterKey = topLevelKeyEnd(plist, key); if (afterKey < 0) { - int dictEnd = plist.lastIndexOf(""); + int dictEnd = rootDictCloseAt(plist); + if (dictEnd < 0) { + return plist; + } changes.add("added " + key + " = " + value); return plist.substring(0, dictEnd) + "\t" + key + "\n\t" + value + "\n" + plist.substring(dictEnd); } - if (!overwrite) { - return plist; - } - int element = WatchNativeBuilder.nextElementAt(plist, afterKey); + // The key's OWN value, not the next anywhere after it. A key whose value is + // or 1 has no string of its own, and scanning forward lands on + // an unrelated later one -- the trap the comment on injectedPlistString records. + int element = nextMarkupAt(plist, afterKey); if (element < 0 || !"string".equals(WatchNativeBuilder.tagAt(plist, element))) { // Not a string value. An extension is free to use whatever type it likes, and // rewriting a type we did not expect is worse than leaving a version alone. @@ -5832,8 +5837,11 @@ private static String setPlistString(String plist, String key, String value, boo return plist; } if (plist.charAt(openEnd - 1) == '/') { - // : an empty value of the right type, so it is this key's and ours to fill. - changes.add("set " + key + " to " + value + " to match the app (was empty)"); + // The empty form, or : XML puts the slash against the '>' whatever + // whitespace precedes it, so one test covers both spellings. It is this key's own + // value and it is empty, which is never a usable identifier or version -- filled + // regardless of overwriteNonEmpty, since there is nothing here to preserve. + changes.add("set " + key + " to " + value + " (was empty)"); return plist.substring(0, element) + "" + value + "" + plist.substring(openEnd + 1); } @@ -5842,13 +5850,181 @@ private static String setPlistString(String plist, String key, String value, boo return plist; } String current = plist.substring(openEnd + 1, valueEnd); - if (current.equals(value) || current.contains("$(")) { + if (current.trim().length() == 0) { + changes.add("set " + key + " to " + value + " (was empty)"); + return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); + } + if (!overwriteNonEmpty || current.equals(value) || current.contains("$(")) { return plist; } changes.add("set " + key + " to " + value + " to match the app (was " + current + ")"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } + /// A root written as {@code } carries no keys and has nowhere to put one, so it is + /// opened into a pair before anything is added to it. + private static String openEmptyRootDict(String plist) { + int at = rootDictAt(plist); + int openEnd = at < 0 ? -1 : plist.indexOf('>', at); + if (openEnd < 0 || plist.charAt(openEnd - 1) != '/') { + return plist; + } + return plist.substring(0, at) + "\n" + plist.substring(openEnd + 1); + } + + /// Index just past the {@code } of {@code key}, when that key is a DIRECT child of the + /// root dict; -1 when the root dict has no such child. Nested dictionaries are stepped over + /// whole, so a key of the same name inside one is not mistaken for the bundle's own. + private static int topLevelKeyEnd(String plist, String key) { + int at = rootDictAt(plist); + int i = at < 0 ? -1 : plist.indexOf('>', at); + if (i < 0 || plist.charAt(i - 1) == '/') { + return -1; + } + i++; + while (true) { + int element = nextMarkupAt(plist, i); + if (element < 0 || plist.startsWith("', element); + if (openEnd < 0) { + return -1; + } + if (!"key".equals(WatchNativeBuilder.tagAt(plist, element))) { + // A value with no key of ours in front of it. Step over it whole. + i = endOfElement(plist, element); + if (i < 0) { + return -1; + } + continue; + } + if (plist.charAt(openEnd - 1) == '/') { + i = openEnd + 1; + continue; + } + int close = WatchNativeBuilder.closeOfElement(plist, openEnd + 1, ""); + int afterKey = close < 0 ? -1 : plist.indexOf('>', close); + if (afterKey < 0) { + return -1; + } + afterKey++; + // The key's CONTENT resolved, so a name spelled with CDATA or wrapped in a comment + // still matches -- an XML parser reads all of those as the same key. + String name = WatchNativeBuilder.plistStringContent(plist.substring(openEnd + 1, close)); + if (key.equals(name)) { + return afterKey; + } + int valueElement = nextMarkupAt(plist, afterKey); + if (valueElement < 0) { + return -1; + } + i = endOfElement(plist, valueElement); + if (i < 0) { + return -1; + } + } + } + + /// The {@code <} of the plist's root dict, or -1 when this is not a dict-rooted XML plist. + /// The declaration, the doctype and the {@code } wrapper are stepped past. + private static int rootDictAt(String plist) { + int i = 0; + while (true) { + int element = nextMarkupAt(plist, i); + if (element < 0) { + return -1; + } + int gt = plist.indexOf('>', element); + if (gt < 0) { + return -1; + } + String tag = WatchNativeBuilder.tagAt(plist, element); + if ("dict".equals(tag)) { + return element; + } + if (tag.length() > 0 && !"plist".equals(tag)) { + // A plist rooted in an array or a bare value. It has no keys to stamp. + return -1; + } + i = gt + 1; + } + } + + /// The {@code <} of the tag that closes the root dict, which is where a missing key is added. + private static int rootDictCloseAt(String plist) { + int at = rootDictAt(plist); + int end = at < 0 ? -1 : endOfElement(plist, at); + return end < 0 ? -1 : plist.lastIndexOf('<', end); + } + + /// Index just past the element opening at {@code element}, everything nested inside it + /// included. Depth is counted on the element's own name, so a dict inside a dict closes in + /// the right place. + private static int endOfElement(String plist, int element) { + String tag = WatchNativeBuilder.tagAt(plist, element); + int openEnd = plist.indexOf('>', element); + if (openEnd < 0) { + return -1; + } + if (plist.charAt(openEnd - 1) == '/') { + return openEnd + 1; + } + int depth = 1; + int i = openEnd + 1; + while (depth > 0) { + int at = nextMarkupAt(plist, i); + if (at < 0) { + return -1; + } + int gt = plist.indexOf('>', at); + if (gt < 0) { + return -1; + } + if (plist.startsWith("/Info.plist} is only the default: the archive's buildSettings.properties diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index 3f5f0f959f4..697c17e0d75 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -1078,7 +1078,7 @@ private static int contentAfterOpenTag(String inject, String element, int from) /// /// Returns `at` when it is already outside both, the position just past the enclosing /// construct when it is not, and -1 when that construct never ends. - private static int skipMarkupBefore(String inject, int at, int from) { + static int skipMarkupBefore(String inject, int at, int from) { int cdata = inject.indexOf(CDATA_OPEN, from); int comment = inject.indexOf(COMMENT_OPEN, from); boolean cdataFirst = cdata >= 0 && (comment < 0 || cdata < comment); From da6420bb2b291a8ddb1f84bc730ddc46df802022 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:56:55 +0300 Subject: [PATCH 07/49] Resolve the build settings the plist path is actually written with Mirrors the cloud builder fix. Stripping a leading $(SRCROOT)/ handled the prefix and nothing else, so INFOPLIST_FILE = $(SRCROOT)/$(TARGET_NAME)/Info.plist -- how an Xcode project writes the plist that sits in the extension's own folder, i.e. the common case -- was left holding $(TARGET_NAME) and refused as unresolvable, and the stamper skipped a plist it could have found. Every setting in that path is known here: SRCROOT and PROJECT_DIR are the directory the extension folders are extracted into, and TARGET_NAME is the folder's name, because that is the name the target is created with. PRODUCT_NAME follows TARGET_NAME unless the archive overrode it with a literal. Both spellings are substituted, $(NAME) and ${NAME}. A path still holding a $ afterwards is refused, because a half-resolved path names some file and editing whichever one it lands on is worse than saying so. The properties reader is now shared with the other callers. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 94 ++++++++++++++----- 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 9c4ef366541..18de99a5f7b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6037,42 +6037,86 @@ private static int nextMarkupAt(String plist, int from) { /// the two obvious project-root spellings is not resolvable here, and null says so rather /// than guessing at a file to edit. static File appExtensionInfoPlist(File extensionFolder) { - File settings = new File(extensionFolder, "buildSettings.properties"); - String override = null; - if (settings.isFile()) { - Properties props = new Properties(); - FileInputStream fis = null; - try { - fis = new FileInputStream(settings); - props.load(fis); - override = props.getProperty("INFOPLIST_FILE"); - } catch (IOException ex) { - override = null; - } finally { - if (fis != null) { - try { fis.close(); } catch (Throwable t) {} - } - } - } - if (override == null || override.trim().length() == 0) { + String override = appExtensionBuildSetting(extensionFolder, "INFOPLIST_FILE"); + if (override == null) { return new File(extensionFolder, "Info.plist"); } - String path = override.trim(); + String path = override; if (path.length() > 1 && path.startsWith("\"") && path.endsWith("\"")) { path = path.substring(1, path.length() - 1).trim(); } - for (String projectRoot : new String[]{"$(SRCROOT)/", "$(PROJECT_DIR)/"}) { - if (path.startsWith(projectRoot)) { - path = path.substring(projectRoot.length()); - } - } - if (path.contains("$(")) { + path = resolveXcodeSettingsInPath(path, extensionFolder); + if (path == null || path.length() == 0) { return null; } File resolved = new File(path); return resolved.isAbsolute() ? resolved : new File(extensionFolder.getParentFile(), path); } + /// One build setting as the extension's own buildSettings.properties overrides it, or null + /// when the archive carries no such override. + /// + /// Read from the file rather than from the settings map because the callers run before the + /// properties are folded into it -- and a setting that decides which files the build touches + /// has to be known before we touch them. + static String appExtensionBuildSetting(File extensionFolder, String key) { + File settings = new File(extensionFolder, "buildSettings.properties"); + if (!settings.isFile()) { + return null; + } + Properties props = new Properties(); + FileInputStream fis = null; + try { + fis = new FileInputStream(settings); + props.load(fis); + } catch (IOException ex) { + return null; + } finally { + if (fis != null) { + try { fis.close(); } catch (Throwable t) {} + } + } + String value = props.getProperty(key); + if (value == null || value.trim().length() == 0) { + return null; + } + return value.trim(); + } + + /// Substitutes the build settings whose values this build already knows, so that the ordinary + /// Xcode spelling of an extension's plist path resolves. + /// + /// {@code $(SRCROOT)/$(TARGET_NAME)/Info.plist} is what an Xcode project writes for the file + /// that sits in the extension's own folder, and every part of it is known here: SRCROOT and + /// PROJECT_DIR are the project directory, which is where the extension folders are extracted, + /// and TARGET_NAME is the folder's name, because that is the name the target is created with. + /// PRODUCT_NAME follows TARGET_NAME unless the archive overrode it with a literal. + /// + /// Both spellings, {@code $(NAME)} and {@code ${NAME}}. Anything still holding a {@code $} + /// afterwards is a setting this build cannot evaluate -- CONFIGURATION, an SDK-dependent + /// value -- and null says so, because the alternative is editing whichever file the + /// half-resolved path happens to name. + private static String resolveXcodeSettingsInPath(String path, File extensionFolder) { + String targetName = extensionFolder.getName(); + String productName = appExtensionBuildSetting(extensionFolder, "PRODUCT_NAME"); + if (productName == null || productName.indexOf('$') >= 0) { + productName = targetName; + } + File projectDir = extensionFolder.getParentFile(); + String projectPath = projectDir == null ? "." : projectDir.getAbsolutePath(); + String out = path; + out = replaceBuildSetting(out, "SRCROOT", projectPath); + out = replaceBuildSetting(out, "PROJECT_DIR", projectPath); + out = replaceBuildSetting(out, "TARGET_NAME", targetName); + out = replaceBuildSetting(out, "PRODUCT_NAME", productName); + return out.indexOf('$') >= 0 ? null : out; + } + + /// One build setting, in either of the two spellings Xcode accepts for a reference. + private static String replaceBuildSetting(String path, String name, String value) { + return path.replace("$(" + name + ")", value).replace("${" + name + "}", value); + } + /** * Parses the pbxproj-shaped block of build settings an app extension target is * seeded with -- one {@code KEY = VALUE;} per line -- into the map that is written From 2f01b91b416534687c74059ec7878c5c1e0a3fc7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:05:13 +0300 Subject: [PATCH 08/49] Judge a version reference by what it resolves to Mirrors the cloud builder fix, and the exemption was wrong in both directions. A version written as $(MARKETING_VERSION) was left standing on the grounds that a build-setting reference is the project saying it knows what it is doing. It is not: the archive's buildSettings.properties are copied into this target's build configurations further down, so the reference resolves to whatever they say -- a stale 1.0 under an app at 5.4. And a reference to a setting they do NOT define resolves to nothing at all, because the generated target carries no version settings of its own. A reference is now resolved against those same properties and judged by the result: one that already lands on the app's version is left alone, anything else is replaced with the literal, and the log says what it resolved to. The identifier is untouched by this -- it is written with overwrite off, so an explicit $(PRODUCT_BUNDLE_IDENTIFIER) still stands. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 18de99a5f7b..b874406dcdf 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5754,7 +5754,8 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) // its app into one that does not -- the very validation failure this method exists to // prevent. String stamped = stampInfoPlistIdentity(plist, embeddedExtensionShortVersion(request), - embeddedExtensionBundleVersion(request), changes); + embeddedExtensionBundleVersion(request), appExtensionBuildSettings(appExtension), + changes); if (changes.isEmpty()) { return; } @@ -5781,15 +5782,18 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) * case {@code changes} carries the reason and the file is left alone */ static String stampInfoPlistIdentity(String plist, String shortVersion, String bundleVersion, - List changes) { + Map archiveSettings, List changes) { if (plist == null || rootDictAt(plist) < 0) { changes.add("not an XML property list"); return null; } String result = openEmptyRootDict(plist); - result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", false, changes); - result = setPlistString(result, "CFBundleShortVersionString", shortVersion, true, changes); - result = setPlistString(result, "CFBundleVersion", bundleVersion, true, changes); + result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", + false, archiveSettings, changes); + result = setPlistString(result, "CFBundleShortVersionString", shortVersion, + true, archiveSettings, changes); + result = setPlistString(result, "CFBundleVersion", bundleVersion, + true, archiveSettings, changes); return result; } @@ -5808,7 +5812,7 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b * all and fails the same embedded-binary validation as a missing one. */ private static String setPlistString(String plist, String key, String value, - boolean overwriteNonEmpty, List changes) { + boolean overwriteNonEmpty, Map archiveSettings, List changes) { if (value == null || value.length() == 0) { return plist; } @@ -5854,10 +5858,23 @@ private static String setPlistString(String plist, String key, String value, changes.add("set " + key + " to " + value + " (was empty)"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } - if (!overwriteNonEmpty || current.equals(value) || current.contains("$(")) { + if (!overwriteNonEmpty || current.equals(value)) { return plist; } - changes.add("set " + key + " to " + value + " to match the app (was " + current + ")"); + // A value written as $(MARKETING_VERSION) is judged by what it RESOLVES to, not by being a + // reference. The archive's buildSettings.properties are copied into this target's build + // configurations further down, so the reference lands on whatever they say -- a stale 1.0 + // under an app at 5.4 -- and a setting they do not define resolves to nothing at all, + // since the target this build generates has no version settings of its own. Both fail the + // embedded-bundle check; only a reference that already lands on the app's own version is + // left standing. + String resolved = resolveSettingsInValue(current, archiveSettings); + if (value.equals(resolved)) { + return plist; + } + changes.add("set " + key + " to " + value + " to match the app (was " + current + + (resolved.equals(current) ? "" : ", which resolves to '" + resolved + "' here") + + ")"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } @@ -6060,9 +6077,23 @@ static File appExtensionInfoPlist(File extensionFolder) { /// properties are folded into it -- and a setting that decides which files the build touches /// has to be known before we touch them. static String appExtensionBuildSetting(File extensionFolder, String key) { + String value = appExtensionBuildSettings(extensionFolder).get(key); + if (value == null || value.trim().length() == 0) { + return null; + } + return value.trim(); + } + + /// Every build setting the archive overrides, as its buildSettings.properties declares them. + /// + /// These are not advisory: further down each one is written into the extension target's build + /// configurations, so they decide what a {@code $(...)} reference in the extension's own + /// Info.plist resolves to when Xcode processes it. + static Map appExtensionBuildSettings(File extensionFolder) { + Map out = new LinkedHashMap(); File settings = new File(extensionFolder, "buildSettings.properties"); if (!settings.isFile()) { - return null; + return out; } Properties props = new Properties(); FileInputStream fis = null; @@ -6070,17 +6101,18 @@ static String appExtensionBuildSetting(File extensionFolder, String key) { fis = new FileInputStream(settings); props.load(fis); } catch (IOException ex) { - return null; + return out; } finally { if (fis != null) { try { fis.close(); } catch (Throwable t) {} } } - String value = props.getProperty(key); - if (value == null || value.trim().length() == 0) { - return null; + for (Object key : props.keySet()) { + if (key instanceof String) { + out.put((String) key, props.getProperty((String) key)); + } } - return value.trim(); + return out; } /// Substitutes the build settings whose values this build already knows, so that the ordinary @@ -6112,6 +6144,23 @@ private static String resolveXcodeSettingsInPath(String path, File extensionFold return out.indexOf('$') >= 0 ? null : out; } + /// A plist value with the archive's own build settings substituted, so a {@code $(...)} + /// reference can be compared with the version it will actually resolve to on the device. + /// + /// A reference to a setting the archive does not define resolves to the empty string, which is + /// what Xcode does with it too: the extension target this build generates carries only the + /// settings written here, and no version among them. + private static String resolveSettingsInValue(String value, Map archiveSettings) { + String out = value; + if (archiveSettings != null) { + for (Map.Entry setting : archiveSettings.entrySet()) { + out = replaceBuildSetting(out, setting.getKey(), + setting.getValue() == null ? "" : setting.getValue().trim()); + } + } + return out.replaceAll("\\$[({][A-Za-z0-9_]+[)}]", "").trim(); + } + /// One build setting, in either of the two spellings Xcode accepts for a reference. private static String replaceBuildSetting(String path, String name, String value) { return path.replace("$(" + name + ")", value).replace("${" + name + "}", value); From d7f112196532deb519b149f651540d0b968b08bc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:10:49 +0300 Subject: [PATCH 09/49] Read an identifier's value, not its spelling Mirrors the cloud builder fix. Emptiness was tested on the raw text between the tags, so and counted as values that are already there: a nonzero run of characters and an empty value. The identifier was then preserved and the extension shipped without one. The content is now resolved with plistStringContent first -- CDATA read, comments stripped, entities decoded -- and emptiness, equality and the build-setting resolution all run on that, so a version written under an app at 5.4 is recognised as already right and left as the archive wrote it. Padding is not accepted as right: a plist parser keeps the spaces in 5.4 , so Apple compares " 5.4 " with the app's "5.4" and rejects the pair. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b874406dcdf..c31eb289f07 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5854,11 +5854,22 @@ private static String setPlistString(String plist, String key, String value, return plist; } String current = plist.substring(openEnd + 1, valueEnd); - if (current.trim().length() == 0) { + if (current.equals(value)) { + return plist; + } + // What the value IS, not how it is spelled: a CDATA section resolved, a comment stripped, + // entities decoded. is a nonzero run of text and + // an empty value, and reading it as "an identifier is already here" leaves the extension + // with none -- the same failure as the plainly empty forms above. + String currentText = WatchNativeBuilder.plistStringContent(current); + if (currentText == null) { + currentText = ""; + } + if (currentText.length() == 0) { changes.add("set " + key + " to " + value + " (was empty)"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } - if (!overwriteNonEmpty || current.equals(value)) { + if (!overwriteNonEmpty) { return plist; } // A value written as $(MARKETING_VERSION) is judged by what it RESOLVES to, not by being a @@ -5868,12 +5879,16 @@ private static String setPlistString(String plist, String key, String value, // since the target this build generates has no version settings of its own. Both fail the // embedded-bundle check; only a reference that already lands on the app's own version is // left standing. - String resolved = resolveSettingsInValue(current, archiveSettings); - if (value.equals(resolved)) { + String resolved = resolveSettingsInValue(currentText, archiveSettings); + // Accepted as it stands only when it lands on the app's version AND carries no padding of + // its own: a plist parser keeps the spaces in 5.4 , so Apple compares + // " 5.4 " against the app's "5.4" and rejects the pair. Spelling that resolves cleanly -- + // CDATA, entities, a build-setting reference -- is left as the archive wrote it. + if (value.equals(resolved) && current.equals(current.trim())) { return plist; } - changes.add("set " + key + " to " + value + " to match the app (was " + current - + (resolved.equals(current) ? "" : ", which resolves to '" + resolved + "' here") + changes.add("set " + key + " to " + value + " to match the app (was " + currentText + + (resolved.equals(currentText) ? "" : ", which resolves to '" + resolved + "' here") + ")"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } From b569dbc893e3aab8413c2be6f63898a243009405 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:19:27 +0300 Subject: [PATCH 10/49] Expand a build setting to a fixed point, not one pass Mirrors the cloud builder fix. A setting's value may name another setting and Xcode keeps expanding until none is left; one traversal of the map does that only when the iteration order happens to be the dependency order, and Properties hands them over in hash order. With VERSION_SUFFIX = 1 and MARKETING_VERSION = 5.4$(VERSION_SUFFIX), visiting MARKETING_VERSION first left $(VERSION_SUFFIX) behind, the strip deleted it as though nothing defined it, and a version the device resolves to 5.41 read as the app's own 5.4. Now it expands until nothing changes or nothing is left to expand, capped so a cycle settles instead of spinning; what survives is treated as resolving to nothing, which is what Xcode does with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index c31eb289f07..1fd948888b8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6168,14 +6168,39 @@ private static String resolveXcodeSettingsInPath(String path, File extensionFold private static String resolveSettingsInValue(String value, Map archiveSettings) { String out = value; if (archiveSettings != null) { - for (Map.Entry setting : archiveSettings.entrySet()) { - out = replaceBuildSetting(out, setting.getKey(), - setting.getValue() == null ? "" : setting.getValue().trim()); + // To a fixed point, not one pass. A setting's value may name another setting and Xcode + // keeps expanding until none is left, while one traversal of the map expands nested + // references only when the iteration order happens to be the dependency order -- and + // for Properties that is hash order. MARKETING_VERSION = 5.4$(VERSION_SUFFIX) visited + // before VERSION_SUFFIX left the inner reference behind, the strip below deleted it as + // though nothing defined it, and a version the device resolves to 5.41 was judged to + // be the app's own 5.4 and left standing. + for (int pass = 0; pass < MAX_SETTING_EXPANSIONS + && BUILD_SETTING_REFERENCE.matcher(out).find(); pass++) { + String before = out; + for (Map.Entry setting : archiveSettings.entrySet()) { + out = replaceBuildSetting(out, setting.getKey(), + setting.getValue() == null ? "" : setting.getValue().trim()); + } + if (out.equals(before)) { + // Nothing left that this archive defines; the strip below handles the rest. + break; + } } } - return out.replaceAll("\\$[({][A-Za-z0-9_]+[)}]", "").trim(); + // What survives names a setting the archive does not define, or sits in a cycle that never + // settles. Xcode resolves those to nothing, and so does this. + return BUILD_SETTING_REFERENCE.matcher(out).replaceAll("").trim(); } + /// A build-setting reference in either spelling Xcode accepts. + private static final Pattern BUILD_SETTING_REFERENCE = + Pattern.compile("\\$[({][A-Za-z0-9_]+[)}]"); + + /// Expansion passes before a value is called unresolvable. Settings nest a level or two in + /// practice; the cap is what stops A = $(B), B = $(A) from spinning. + private static final int MAX_SETTING_EXPANSIONS = 16; + /// One build setting, in either of the two spellings Xcode accepts for a reference. private static String replaceBuildSetting(String path, String name, String value) { return path.replace("$(" + name + ")", value).replace("${" + name + "}", value); From db507a7946354c9dae7ef8ecd8f6819255f27eb7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:27:13 +0300 Subject: [PATCH 11/49] Refuse to stamp a plist outside the project directory Mirrors the cloud builder fix. INFOPLIST_FILE arrives inside the developer's .ios.appext and the stamper WRITES to whatever it names, so an absolute path or a ../../ traversal had the builder rewriting a file outside the project. The resolved path is now compared, canonically, against the project directory, and anything landing outside is refused with a log line rather than edited. Canonical because an archive can carry symlinks: a path that sits inside the project can still point out of it. The default /Info.plist goes through the same check, since a zip may plant a symlink at that very name. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 1fd948888b8..2de3af6c5ef 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5735,9 +5735,11 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) File infoPlist = appExtensionInfoPlist(appExtension); if (infoPlist == null) { debug("The " + appExtension.getName() + " app extension points INFOPLIST_FILE at a path " - + "this build cannot resolve, so its bundle identifier and versions were left " - + "as they are. If the archive fails on the embedded binary's bundle " - + "identifier, write the path relative to the project directory."); + + "this build will not edit -- it either names a build setting that cannot be " + + "resolved here, or it lands outside the project directory -- so its bundle " + + "identifier and versions were left as they are. If the archive fails on the " + + "embedded binary's bundle identifier, write the path relative to the project " + + "directory."); return; } if (!infoPlist.isFile()) { @@ -6071,7 +6073,10 @@ private static int nextMarkupAt(String plist, int from) { static File appExtensionInfoPlist(File extensionFolder) { String override = appExtensionBuildSetting(extensionFolder, "INFOPLIST_FILE"); if (override == null) { - return new File(extensionFolder, "Info.plist"); + // Confined like an overridden path, not trusted for sitting at the default name: a zip + // may carry symlinks, so /Info.plist can still land outside the project. + File byDefault = new File(extensionFolder, "Info.plist"); + return insideProjectDir(byDefault, extensionFolder.getParentFile()) ? byDefault : null; } String path = override; if (path.length() > 1 && path.startsWith("\"") && path.endsWith("\"")) { @@ -6082,7 +6087,36 @@ static File appExtensionInfoPlist(File extensionFolder) { return null; } File resolved = new File(path); - return resolved.isAbsolute() ? resolved : new File(extensionFolder.getParentFile(), path); + if (!resolved.isAbsolute()) { + resolved = new File(extensionFolder.getParentFile(), path); + } + return insideProjectDir(resolved, extensionFolder.getParentFile()) ? resolved : null; + } + + /// Whether a path an uploaded archive chose is one this build is willing to write to. + /// + /// INFOPLIST_FILE arrives inside a customer's .ios.appext and the stamper WRITES to whatever + /// it names, so an absolute path, a {@code ../../} traversal or a symlink planted in the + /// archive would have this daemon rewriting a file outside the build -- another build's + /// project, or anything else the account can write. The comparison is on canonical paths, so + /// a symlink that leaves the project is judged by where it lands rather than by where it sits. + /// + /// Everything under the project directory is fair game: an extension may legitimately share a + /// plist that sits beside its folder rather than inside it. + static boolean insideProjectDir(File candidate, File projectDir) { + if (candidate == null || projectDir == null) { + return false; + } + try { + String root = projectDir.getCanonicalPath(); + if (!root.endsWith(File.separator)) { + root += File.separator; + } + return candidate.getCanonicalPath().startsWith(root); + } catch (IOException cannotResolve) { + // A path this process cannot even canonicalize is not one to write to. + return false; + } } /// One build setting as the extension's own buildSettings.properties overrides it, or null From 886e7791b1c25c603a07c226ff52866ebd611f8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:43:23 +0300 Subject: [PATCH 12/49] Say at the check why every empty spelling is already empty Mirrors the cloud builder comment. Two review rounds proposed the same fix in different spellings -- that is not recognised, that survives -- and both were already handled, for a reason that sat one line away and was not written down: plistStringContent trims on both of its paths, so whitespace, a comment, an empty CDATA section and any mix arrive as "", and the self-closing test above catches both and because XML puts the slash against the '>' whatever precedes it. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/codename1/builders/IPhoneBuilder.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 2de3af6c5ef..a3a88fcb5e2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5860,9 +5860,17 @@ private static String setPlistString(String plist, String key, String value, return plist; } // What the value IS, not how it is spelled: a CDATA section resolved, a comment stripped, - // entities decoded. is a nonzero run of text and - // an empty value, and reading it as "an identifier is already here" leaves the extension - // with none -- the same failure as the plainly empty forms above. + // entities decoded, and TRIMMED -- both of plistStringContent's paths end in .trim(). + // is a nonzero run of text and an empty value, + // and reading it as "an identifier is already here" leaves the extension with none. + // + // So every spelling of empty arrives here as "" and needs no test of its own: whitespace + // between the tags, a comment, an empty or whitespace-only CDATA section, and any mix. + // and are handled further up -- XML puts the slash against the '>' + // whatever whitespace precedes it, so the character before '>' identifies both. Please do + // not add another emptiness special case here without a failing test first; several + // proposed ones were already covered, and the daemon's AppExtensionInfoPlistTest pins + // each form. String currentText = WatchNativeBuilder.plistStringContent(current); if (currentText == null) { currentText = ""; From cf156d4c328bd6a424ec2c95349467fda2cf01d1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:53:31 +0300 Subject: [PATCH 13/49] Stamp every plist the target may build, in the encoding it was written in Mirrors the cloud builder fix for two review catches. A qualified setting names a plist too. Xcode honours INFOPLIST_FILE[sdk=iphoneos*], the archive's properties are copied into the target verbatim, and a qualified value beats the base one for the builds it matches, so the device build shipped the one plist the stamper had not touched. All of them are stamped now; which applies depends on the sdk, configuration and arch, and stamping is idempotent. Only the ESCAPED spelling gets this far -- an unescaped one splits on the = inside the brackets and leaves a key Xcode does not recognise -- so the filter requires the closing bracket. And the plist is read as its own bytes declare -- byte order mark first, then the encoding in its XML declaration -- and written back the same way. Reading with the platform default charset left a UTF-16 plist as noise that would not parse, and turned a Latin-1 one's accented characters into replacements that would then have been written back. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 251 +++++++++++++++--- 1 file changed, 216 insertions(+), 35 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index a3a88fcb5e2..4001d18e003 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -47,6 +47,10 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; +import java.nio.charset.Charset; +import java.util.LinkedHashSet; +import java.util.Set; +import java.io.DataInputStream; import java.util.regex.Pattern; /** @@ -5732,47 +5736,218 @@ static String embeddedExtensionBundleVersion(BuildRequest request) { * that is already correct, and one written as a {@code $(...)} reference, are left alone.

*/ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) throws IOException { - File infoPlist = appExtensionInfoPlist(appExtension); - if (infoPlist == null) { - debug("The " + appExtension.getName() + " app extension points INFOPLIST_FILE at a path " - + "this build will not edit -- it either names a build setting that cannot be " - + "resolved here, or it lands outside the project directory -- so its bundle " - + "identifier and versions were left as they are. If the archive fails on the " - + "embedded binary's bundle identifier, write the path relative to the project " - + "directory."); - return; - } - if (!infoPlist.isFile()) { - debug("The " + appExtension.getName() + " app extension has no " + infoPlist.getName() - + ". Xcode cannot build an extension target without one; add it to the " - + ".ios.appext archive."); - return; + Map plists = appExtensionInfoPlists(appExtension); + Set stamped = new LinkedHashSet(); + for (Map.Entry candidate : plists.entrySet()) { + File infoPlist = candidate.getValue(); + if (infoPlist == null) { + debug("The " + appExtension.getName() + " app extension names '" + candidate.getKey() + + "' as an Info.plist this build will not edit -- it either holds a build " + + "setting that cannot be resolved here, or it lands outside the project " + + "directory -- so that plist was left as it is. If the archive fails on " + + "the embedded binary's bundle identifier, write the path relative to the " + + "project directory."); + continue; + } + if (!infoPlist.isFile()) { + debug("The " + appExtension.getName() + " app extension names '" + candidate.getKey() + + "' as an Info.plist, and there is no such file. Xcode cannot build an " + + "extension target without the plist its settings point at; add it to the " + + ".ios.appext archive."); + continue; + } + if (!stamped.add(infoPlist.getCanonicalPath())) { + // Two settings naming the same file. Stamping is idempotent, but saying so twice + // in the log reads like two files were touched. + continue; + } + // Through the shared resolvers rather than buildVersion / the ios.bundleVersion hint + // directly: an app that sets either version key through ios.plistInject ships that + // value, and stamping the raw hint here would rewrite an extension version that + // already matched its app into one that does not -- the very validation failure this + // method exists to prevent. + List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request), appExtensionBuildSettings(appExtension)); + if (changes == null) { + debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + + " as an XML property list, so its bundle identity was left as it is. If " + + "the build fails on the embedded binary's bundle identifier, convert the " + + "file with 'plutil -convert xml1 " + infoPlist.getName() + "' and rebuild."); + continue; + } + for (String change : changes) { + debug("Adjusted " + appExtension.getName() + "/" + infoPlist.getName() + ": " + change); + } } - String plist = readFileToString(infoPlist); + } + + /// Stamps one Info.plist in place, in the encoding it was written in. + /// + /// @return what changed, empty when the plist was already right and must not be rewritten, or + /// null when this is not an XML plist this build can edit + static List stampPlistFile(File infoPlist, String shortVersion, String bundleVersion, + Map archiveSettings) throws IOException { + PlistText original = readPlistText(infoPlist); List changes = new ArrayList(); - // Through the shared resolvers rather than buildVersion / the ios.bundleVersion hint - // directly: an app that sets either version key through ios.plistInject ships that value, - // and stamping the raw hint here would rewrite an extension version that already matched - // its app into one that does not -- the very validation failure this method exists to - // prevent. - String stamped = stampInfoPlistIdentity(plist, embeddedExtensionShortVersion(request), - embeddedExtensionBundleVersion(request), appExtensionBuildSettings(appExtension), - changes); + String result = stampInfoPlistIdentity(original.text, shortVersion, bundleVersion, + archiveSettings, changes); if (changes.isEmpty()) { - return; + return changes; } - if (stamped == null) { - debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() - + " as an XML property list, " - + "so its bundle identity was left as it is. If the build fails on the embedded " - + "binary's bundle identifier, convert the file with " - + "'plutil -convert xml1 Info.plist' and rebuild."); - return; + if (result == null) { + return null; + } + writePlistText(infoPlist, original, result); + return changes; + } + + /// An Info.plist decoded the way its own bytes say it is encoded, so it can be written back + /// the same way. + private static final class PlistText { + final String text; + final Charset charset; + final byte[] bom; + + PlistText(String text, Charset charset, byte[] bom) { + this.text = text; + this.charset = charset; + this.bom = bom; + } + } + + /// Reads a plist as text, honouring its byte order mark or its XML declaration. + /// + /// The default charset is not good enough for a file that arrives from someone else's machine: + /// a UTF-16 plist read as UTF-8 is noise, so the stamper would decline to parse it and the + /// extension would ship unstamped, and a Latin-1 plist read as UTF-8 loses every accented + /// character -- which this method would then write back, corrupting a display name to fix an + /// identifier. + private static PlistText readPlistText(File infoPlist) throws IOException { + byte[] data = readFileBytes(infoPlist); + byte[] bom = bomOf(data); + Charset charset = charsetOf(data, bom); + int from = bom == null ? 0 : bom.length; + return new PlistText(new String(data, from, data.length - from, charset), charset, bom); + } + + /// Writes the stamped text back in the charset it was read in, byte order mark included, so + /// the file's own XML declaration stays true. + private static void writePlistText(File infoPlist, PlistText original, String text) + throws IOException { + byte[] body = text.getBytes(original.charset); + byte[] out = body; + if (original.bom != null) { + out = new byte[original.bom.length + body.length]; + System.arraycopy(original.bom, 0, out, 0, original.bom.length); + System.arraycopy(body, 0, out, original.bom.length, body.length); + } + FileOutputStream stream = new FileOutputStream(infoPlist); + try { + stream.write(out); + } finally { + try { stream.close(); } catch (Throwable t) {} + } + } + + private static byte[] readFileBytes(File file) throws IOException { + byte[] data = new byte[(int) file.length()]; + DataInputStream in = new DataInputStream(new FileInputStream(file)); + try { + in.readFully(data); + } finally { + try { in.close(); } catch (Throwable t) {} + } + return data; + } + + private static final byte[] BOM_UTF8 = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + private static final byte[] BOM_UTF16BE = {(byte) 0xFE, (byte) 0xFF}; + private static final byte[] BOM_UTF16LE = {(byte) 0xFF, (byte) 0xFE}; + + private static byte[] bomOf(byte[] data) { + for (byte[] bom : new byte[][]{BOM_UTF8, BOM_UTF16BE, BOM_UTF16LE}) { + if (data.length >= bom.length) { + boolean match = true; + for (int i = 0; i < bom.length; i++) { + match &= data[i] == bom[i]; + } + if (match) { + return bom; + } + } + } + return null; + } + + /// The charset a plist's bytes declare: its byte order mark first, then the encoding named in + /// its XML declaration, and UTF-8 when it says neither -- which is what an XML parser does. + private static Charset charsetOf(byte[] data, byte[] bom) { + if (bom == BOM_UTF16BE) { + return StandardCharsets.UTF_16BE; + } + if (bom == BOM_UTF16LE) { + return StandardCharsets.UTF_16LE; } - createFile(infoPlist, stamped.getBytes("UTF-8")); - for (String change : changes) { - debug("Adjusted " + appExtension.getName() + "/" + infoPlist.getName() + ": " + change); + // The declaration is ASCII-compatible in every encoding that can carry one, except the + // UTF-16 forms, which the marks above have already answered for. + String head = new String(data, 0, Math.min(data.length, 512), StandardCharsets.ISO_8859_1); + Matcher declared = XML_ENCODING.matcher(head); + if (declared.find()) { + try { + return Charset.forName(declared.group(1)); + } catch (Exception unsupported) { + // An encoding this JVM does not know. UTF-8 is the better guess than the platform + // default, and a plist that then fails to parse is left alone rather than rewritten. + } } + return StandardCharsets.UTF_8; + } + + private static final Pattern XML_ENCODING = Pattern.compile( + "<\\?xml[^>]*encoding\\s*=\\s*[\"\']([A-Za-z0-9_.:-]+)[\"\']"); + + /// Every Info.plist this extension's target might be built with, by the setting that names it. + /// + /// Not just INFOPLIST_FILE: Xcode honours a qualified setting -- INFOPLIST_FILE[sdk=iphoneos*] + /// -- and the archive's buildSettings.properties are copied into the target verbatim, so a + /// qualified one takes precedence for the builds it matches while the base value serves the + /// rest. Which one applies depends on the sdk, configuration and arch of the build Xcode is + /// running, so every one of them is stamped: they are all plists this extension may ship, and + /// stamping is idempotent. + /// + /// (An UNescaped `INFOPLIST_FILE[sdk=iphoneos*] = x` in a .properties file is not one of + /// these. Properties splits on that first `=`, leaving the key `INFOPLIST_FILE[sdk`, which + /// Xcode does not recognise as a setting at all -- so the base value still decides, and this + /// map is right to ignore it.) + /// + /// @return the raw setting value that named each plist, mapped to the resolved file, or to + /// null when that value is unresolvable or lands outside the project directory + static Map appExtensionInfoPlists(File extensionFolder) { + Map settings = appExtensionBuildSettings(extensionFolder); + Map out = new LinkedHashMap(); + String base = settings.get("INFOPLIST_FILE"); + if (base == null || base.trim().length() == 0) { + File byDefault = new File(extensionFolder, "Info.plist"); + out.put("Info.plist", + insideProjectDir(byDefault, extensionFolder.getParentFile()) ? byDefault : null); + } else { + out.put(base.trim(), resolveInfoPlistPath(base.trim(), extensionFolder)); + } + for (Map.Entry setting : settings.entrySet()) { + String key = setting.getKey(); + // Closing bracket included: an unescaped conditional leaves Properties with the key + // INFOPLIST_FILE[sdk and the rest of the line as its value, and that is not a setting + // Xcode honours -- picking it up here would send the stamper after a path built out of + // the wreckage. + if (!key.startsWith("INFOPLIST_FILE[") || !key.endsWith("]")) { + continue; + } + String value = setting.getValue() == null ? "" : setting.getValue().trim(); + if (value.length() > 0 && !out.containsKey(value)) { + out.put(value, resolveInfoPlistPath(value, extensionFolder)); + } + } + return out; } /** @@ -6086,6 +6261,12 @@ static File appExtensionInfoPlist(File extensionFolder) { File byDefault = new File(extensionFolder, "Info.plist"); return insideProjectDir(byDefault, extensionFolder.getParentFile()) ? byDefault : null; } + return resolveInfoPlistPath(override, extensionFolder); + } + + /// One INFOPLIST_FILE value as a file this build may write to, or null when it holds a setting + /// that cannot be resolved here or lands outside the project directory. + private static File resolveInfoPlistPath(String override, File extensionFolder) { String path = override; if (path.length() > 1 && path.startsWith("\"") && path.endsWith("\"")) { path = path.substring(1, path.length() - 1).trim(); From 2b4ae2026a4574bde19be53d2f62be7b3695c026 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:11:49 +0300 Subject: [PATCH 14/49] Declare the extension's binary, so the IPA is a valid bundle Mirrors the cloud builder fix. A build that succeeds and an archive that exports can still be rejected on upload: Invalid bundle structure. The ".appex/WalletNonUIExtension" binary file is not permitted. Your app cannot contain standalone executables or libraries, other than a valid CFBundleExecutable of supported bundles. The .appex never claimed its own binary: its plist has no CFBundleExecutable, so validation reads the executable inside it as a loose program rather than the bundle's own. Every extension this builder generates itself writes CFBundleExecutable, CFBundlePackageType, CFBundleName, CFBundleInfoDictionaryVersion and CFBundleDevelopmentRegion into its plist; a brought-in archive that leaves those to GENERATE_INFOPLIST_FILE arrives without them, so the generic path now fills the same set when they are missing and keeps whatever the extension declares itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4001d18e003..6b6902263fc 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5971,6 +5971,24 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b true, archiveSettings, changes); result = setPlistString(result, "CFBundleVersion", bundleVersion, true, archiveSettings, changes); + // The rest of what makes a directory an app-extension BUNDLE rather than a folder with a + // program in it. Without CFBundleExecutable the .appex does not claim its own binary, and + // App Store validation rejects the upload -- "the ... binary file is not permitted. Your + // app cannot contain standalone executables or libraries, other than a valid + // CFBundleExecutable of supported bundles" -- after a build that succeeded and an archive + // that exported cleanly. Every extension this builder generates itself writes exactly + // these; a brought-in one whose plist leaves them to GENERATE_INFOPLIST_FILE arrives + // without them, and nothing downstream puts them back. + result = setPlistString(result, "CFBundleExecutable", "$(EXECUTABLE_NAME)", + false, archiveSettings, changes); + result = setPlistString(result, "CFBundlePackageType", "XPC!", + false, archiveSettings, changes); + result = setPlistString(result, "CFBundleName", "$(PRODUCT_NAME)", + false, archiveSettings, changes); + result = setPlistString(result, "CFBundleInfoDictionaryVersion", "6.0", + false, archiveSettings, changes); + result = setPlistString(result, "CFBundleDevelopmentRegion", "en", + false, archiveSettings, changes); return result; } From 98c2b5e9edaf9917e6b23bbe5b3dda63b06822d5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:33:44 +0300 Subject: [PATCH 15/49] Take the development region from the extension, not from us Mirrors the cloud builder fix. CFBundleDevelopmentRegion was filled with a literal "en", so an extension whose development language is not English advertised the wrong fallback localization -- and it need not have, because the archive's buildSettings.properties are copied into this target, so $(DEVELOPMENT_LANGUAGE) lands on whatever that extension set. It is also how the builder writes this key for the extensions it generates itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/com/codename1/builders/IPhoneBuilder.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 6b6902263fc..8a76b599bb8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5987,7 +5987,11 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b false, archiveSettings, changes); result = setPlistString(result, "CFBundleInfoDictionaryVersion", "6.0", false, archiveSettings, changes); - result = setPlistString(result, "CFBundleDevelopmentRegion", "en", + // The reference rather than a literal "en", as the generated Wallet and push extensions + // both write it: the archive's own DEVELOPMENT_LANGUAGE is copied into this target's build + // settings, so an extension whose development language is not English gets its own value + // here instead of advertising the wrong fallback localization. + result = setPlistString(result, "CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)", false, archiveSettings, changes); return result; } From a62805064cda26bc29390e582159025f08881a78 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:33:33 +0300 Subject: [PATCH 16/49] Give a brought-in extension a minimum iOS it can ship with Mirrors the cloud builder fix. Every generic .ios.appext target was created with '10.0', hard-coded. Xcode writes that into the .appex as MinimumOSVersion, so an extension calling iOS 14 APIs shipped claiming iOS 10 and App Store validation rejected the upload -- "Please ensure the MinimumOSVersion value of your extension is 14 or later" -- after a build that succeeded. 10.0 is also below the floor the current SDK builds against at all. The target now takes, in order: what the archive's buildSettings.properties says; 14.0 when its entitlements ask for payment-pass-provisioning, since PKIssuerProvisioningExtensionHandler is an iOS 14 API; otherwise the app's own deployment target, never below 12.0. A bare major from the app's hint is normalised to major.minor. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 8a76b599bb8..ac001a6096e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4841,10 +4841,12 @@ public void usesClassMethod(String cls, String method) { String extensionName = appExtension.getName(); String codeSignEntitlements = "$(NS_CODE_SIGN_ENTITLEMENTS)"; + File extEntitlementsFile = null; if (appExtension.isDirectory()) { for (File f : appExtension.listFiles()) { if (f.getName().endsWith(".entitlements")) { codeSignEntitlements = extensionName + "/" + f.getName(); + extEntitlementsFile = f; } } } @@ -4875,10 +4877,19 @@ public void usesClassMethod(String cls, String method) { + // The minimum iOS this extension declares, which App Store validation + // reads out of the built .appex as MinimumOSVersion. Computed after the + // properties are folded in, so an archive that states its own wins. + String extDeploymentTarget = appExtensionDeploymentTarget( + buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), + extEntitlementsFile, + request.getArg("ios.deployment_target", null)); + buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); + // Guarded so the post-dependency re-run of fix_xcode_schemes.rb // doesn't create duplicate extension targets. sb.append("\nif xcproj.targets.find{|e| e.name=='" + extensionName + "'}.nil?\n" - + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '10.0')\n" + + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '" + extDeploymentTarget + "')\n" + "xcproj.targets.find{|e|e.name=='" + request.getMainClass() + "'}.build_configurations.each{|e| \n" + " e.build_settings['PROVISIONING_PROFILE']='$(APP_PROVISIONING_PROFILE)'\n" + " e.build_settings['CODE_SIGN_ENTITLEMENTS']='$(APP_CODE_SIGN_ENTITLEMENTS)'\n" @@ -6275,6 +6286,91 @@ private static int nextMarkupAt(String plist, int from) { /// the extension folder's parent. A value that still holds a build-setting reference after /// the two obvious project-root spellings is not resolvable here, and null says so rather /// than guessing at a file to edit. + /// The minimum iOS version a brought-in app extension declares. + /// + /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as + /// MinimumOSVersion, and App Store validation reads it there: an extension below what its own + /// APIs require is rejected on upload, after a build that succeeded and an archive that + /// exported. The generic path used to hand every extension 10.0, which is below the floor the + /// current SDK will even build against, let alone what a Wallet extension needs. + /// + /// In order: what the archive says, because an extension knows its own APIs; then 14.0 for an + /// issuer-provisioning Wallet extension, since PKIssuerProvisioningExtensionHandler arrived in + /// iOS 14 and Apple rejects anything lower ("Please ensure the MinimumOSVersion value of your + /// extension is 14 or later"); then the app's own target, never below the 12.0 the SDK + /// supports. An extension is allowed to require MORE than the app that carries it -- widgets + /// have done that since iOS 14 -- so raising it here costs the app nothing. + /// + /// @param declared the extension's own IPHONEOS_DEPLOYMENT_TARGET, or null + /// @param entitlements the extension's .entitlements, or null when it has none + /// @param appTarget the ios.deployment_target build hint, or null + static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget) { + if (declared != null && declared.trim().length() > 0) { + return declared.trim(); + } + if (fileContains(entitlements, PAYMENT_PASS_PROVISIONING)) { + return "14.0"; + } + String floor = "12.0"; + return isDeploymentTargetBelow(appTarget, floor) ? floor : normalizeVersion(appTarget.trim()); + } + + /// A bare major ("12") as the major.minor Apple's plists carry ("12.0"). The app's own hint is + /// written either way, and MinimumOSVersion is read by App Store validation -- not the place + /// to find out which spellings its parser accepts. + private static String normalizeVersion(String version) { + return version.indexOf('.') < 0 ? version + ".0" : version; + } + + /// Whether {@code target} names an iOS version below {@code floor}. A missing or unreadable + /// value counts as below: the floor is then what the extension gets. + private static boolean isDeploymentTargetBelow(String target, String floor) { + if (target == null || target.trim().length() == 0) { + return true; + } + String[] one = target.trim().split("\\."); + String[] two = floor.split("\\."); + for (int i = 0; i < Math.max(one.length, two.length); i++) { + int a = i < one.length ? parseVersionPart(one[i]) : 0; + int b = i < two.length ? parseVersionPart(two[i]) : 0; + if (a != b) { + return a < b; + } + } + return false; + } + + private static int parseVersionPart(String part) { + try { + return Integer.parseInt(part.trim()); + } catch (NumberFormatException notANumber) { + return -1; + } + } + + private static final String PAYMENT_PASS_PROVISIONING = + "com.apple.developer.payment-pass-provisioning"; + + /// Whether a file's text holds a string, for the entitlement keys read out of a plist without + /// parsing it. A missing or unreadable file holds nothing. + private static boolean fileContains(File file, String needle) { + if (file == null || !file.isFile()) { + return false; + } + try { + byte[] data = new byte[(int) file.length()]; + DataInputStream in = new DataInputStream(new FileInputStream(file)); + try { + in.readFully(data); + } finally { + in.close(); + } + return new String(data, StandardCharsets.UTF_8).contains(needle); + } catch (IOException cannotRead) { + return false; + } + } + static File appExtensionInfoPlist(File extensionFolder) { String override = appExtensionBuildSetting(extensionFolder, "INFOPLIST_FILE"); if (override == null) { From c487df605fdd42fb92c717f9860b28550c89187a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:43:02 +0300 Subject: [PATCH 17/49] Enforce the floor, and close the rest of the parity gaps Mirrors the cloud builder fix. The declared deployment target won unconditionally, so an archive exported from an old project carrying IPHONEOS_DEPLOYMENT_TARGET = 10.0 reproduced the rejection the change exists to prevent. The floor is now a floor -- 14.0 for a payment-pass extension, 12.0 otherwise -- and the declared value wins above it. SWIFT_VERSION: the project's Swift settings are applied to the app target alone, so a brought-in extension holding .swift reached the compiler with none and died on "SWIFT_VERSION '' is unsupported", after its sources had been added to the target. Set from ios.swiftVersion (5.0 by default) with ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES when there is Swift to compile. TARGETED_DEVICE_FAMILY and SKIP_INSTALL: every generated extension sets both and the generic path set neither. And extraction now refuses an archive whose symlinks leave the extension folder: unzip creates links happily, and everything under that folder is handed to Xcode, copied into the bundle and swept into the sources tarball. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 106 +++++++++++++++--- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ac001a6096e..355339c861b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4859,6 +4859,22 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", codeSignEntitlements); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("INFOPLIST_FILE", extensionName + "/Info.plist"); + // Both of these every extension this builder generates sets, and the + // generic path did not. An extension that supports fewer device + // families than the app is an App Store rejection on upload, and + // without SKIP_INSTALL the .appex is installed into the archive's + // Products as a second copy of a bundle already inside the .app. + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("SKIP_INSTALL", "YES"); + if (containsSwiftSource(appExtension)) { + // The project's Swift settings are applied to the app target + // alone, so a brought-in extension with .swift in it reached the + // compiler with no SWIFT_VERSION and failed on "SWIFT_VERSION '' + // is unsupported" -- after its sources had been added to the + // target. Apple's own Wallet extension templates are Swift. + buildSettingsMap.put("SWIFT_VERSION", request.getArg("ios.swiftVersion", "5.0")); + buildSettingsMap.put("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES", "YES"); + } File buildSettingsProps = new File(appExtension, "buildSettings.properties"); if (buildSettingsProps.exists()) { @@ -6286,6 +6302,60 @@ private static int nextMarkupAt(String plist, int from) { /// the extension folder's parent. A value that still holds a build-setting reference after /// the two obvious project-root spellings is not resolvable here, and null says so rather /// than guessing at a file to edit. + /// The first symbolic link under {@code dir} that resolves outside {@code root}, or null. + /// + /// unzip refuses an absolute path or a ../ traversal in an entry NAME, but it happily creates + /// a symlink, and the entry that plants one is an ordinary-looking file. Everything under an + /// extension folder is then handed to Xcode -- added to the target, copied into the bundle, + /// swept into the sources tarball -- so a link pointing at the build machine's provisioning + /// profiles or another build's directory would be read through and shipped. The build stops + /// rather than following it. + static File symlinkEscaping(File dir, File root) throws IOException { + File[] entries = dir == null ? null : dir.listFiles(); + if (entries == null) { + return null; + } + String rootPath = root.getCanonicalPath(); + if (!rootPath.endsWith(File.separator)) { + rootPath += File.separator; + } + for (File f : entries) { + if (!f.getCanonicalPath().startsWith(rootPath)) { + return f; + } + if (Files.isSymbolicLink(f.toPath())) { + // Inside the folder, so harmless as a path -- but a link to a directory would let + // the walk below leave through it, and it is not something an archive needs. + continue; + } + if (f.isDirectory()) { + File escaping = symlinkEscaping(f, root); + if (escaping != null) { + return escaping; + } + } + } + return null; + } + + /// Whether an extension folder holds Swift anywhere in it. + static boolean containsSwiftSource(File dir) { + File[] entries = dir == null ? null : dir.listFiles(); + if (entries == null) { + return false; + } + for (File f : entries) { + if (f.isDirectory()) { + if (containsSwiftSource(f)) { + return true; + } + } else if (f.getName().endsWith(".swift")) { + return true; + } + } + return false; + } + /// The minimum iOS version a brought-in app extension declares. /// /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as @@ -6294,25 +6364,25 @@ private static int nextMarkupAt(String plist, int from) { /// exported. The generic path used to hand every extension 10.0, which is below the floor the /// current SDK will even build against, let alone what a Wallet extension needs. /// - /// In order: what the archive says, because an extension knows its own APIs; then 14.0 for an - /// issuer-provisioning Wallet extension, since PKIssuerProvisioningExtensionHandler arrived in - /// iOS 14 and Apple rejects anything lower ("Please ensure the MinimumOSVersion value of your - /// extension is 14 or later"); then the app's own target, never below the 12.0 the SDK - /// supports. An extension is allowed to require MORE than the app that carries it -- widgets - /// have done that since iOS 14 -- so raising it here costs the app nothing. + /// What the archive says wins, because an extension knows which APIs it calls -- but only + /// above the floor, which is 14.0 when its entitlements ask for payment-pass-provisioning + /// (PKIssuerProvisioningExtensionHandler is an iOS 14 API and Apple rejects anything lower) + /// and 12.0 otherwise, the lowest the current SDK builds against. With nothing declared the + /// app's own target is used, under the same floor. /// /// @param declared the extension's own IPHONEOS_DEPLOYMENT_TARGET, or null /// @param entitlements the extension's .entitlements, or null when it has none /// @param appTarget the ios.deployment_target build hint, or null static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget) { - if (declared != null && declared.trim().length() > 0) { - return declared.trim(); - } - if (fileContains(entitlements, PAYMENT_PASS_PROVISIONING)) { - return "14.0"; - } - String floor = "12.0"; - return isDeploymentTargetBelow(appTarget, floor) ? floor : normalizeVersion(appTarget.trim()); + // The floor is a floor, not a default. An archive exported from an old project may carry + // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would + // reproduce the very rejection this exists to prevent -- 10.0 does not even build against + // the current SDK, and an issuer-provisioning Wallet extension is refused below 14. + String floor = fileContains(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; + String chosen = declared != null && declared.trim().length() > 0 + ? declared.trim() + : appTarget; + return isDeploymentTargetBelow(chosen, floor) ? floor : normalizeVersion(chosen.trim()); } /// A bare major ("12") as the major.minor Apple's plists carry ("12.0"). The app's own hint is @@ -7704,6 +7774,14 @@ private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) throw new IOException("Failed to unzip appExtension "+appExtension); } + File escaping = symlinkEscaping(extractedDir, extractedDir); + if (escaping != null) { + throw new IOException("The " + extractedDir.getName() + " app extension " + + "contains a symbolic link, " + escaping.getName() + ", that points " + + "outside the extension. Xcode copies what an extension folder holds " + + "into the app, so a link out of it would put a file from the build " + + "machine into your app. Remove the link and rebuild."); + } out.add(extractedDir); } catch (IOException ex) { throw ex; From 79d1cc668ed8505e4886f2009c7d67d81f3fd10b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:00:18 +0300 Subject: [PATCH 18/49] Pick the entitlements by name, not by directory order Mirrors the cloud builder fix, found by running the code against a real customer archive. An extension folder exported from Xcode often carries two entitlements files -- .entitlements beside Release. entitlements -- and the scan kept overwriting its pick as it walked, so the file the target is SIGNED with was whichever listFiles() returned last. The pick is now .entitlements when present, else the first by name, and a folder carrying more than one says so in the log. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 355339c861b..722446b30dd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -44,6 +44,8 @@ import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.*; +import java.util.Comparator; +import java.util.Collections; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -4841,14 +4843,22 @@ public void usesClassMethod(String cls, String method) { String extensionName = appExtension.getName(); String codeSignEntitlements = "$(NS_CODE_SIGN_ENTITLEMENTS)"; - File extEntitlementsFile = null; - if (appExtension.isDirectory()) { - for (File f : appExtension.listFiles()) { - if (f.getName().endsWith(".entitlements")) { - codeSignEntitlements = extensionName + "/" + f.getName(); - extEntitlementsFile = f; - } - } + // An extension folder exported from Xcode often carries a pair -- + // WalletNonUIExtension.entitlements beside + // WalletNonUIExtensionRelease.entitlements -- and this used to take + // whichever listFiles() returned last, which is filesystem order. The + // file picked here is the one the target is SIGNED with, so which of + // them wins must not be luck. + List entitlements = extensionFilesEndingWith(appExtension, ".entitlements"); + File extEntitlementsFile = preferredExtensionFile(entitlements, extensionName, ".entitlements"); + if (entitlements.size() > 1) { + debug("The " + extensionName + " app extension carries " + + entitlements.size() + " .entitlements files; signing with " + + extEntitlementsFile.getName() + ". Name the one you mean " + + extensionName + ".entitlements."); + } + if (extEntitlementsFile != null) { + codeSignEntitlements = extensionName + "/" + extEntitlementsFile.getName(); } @@ -6302,6 +6312,41 @@ private static int nextMarkupAt(String plist, int from) { /// the extension folder's parent. A value that still holds a build-setting reference after /// the two obvious project-root spellings is not resolvable here, and null says so rather /// than guessing at a file to edit. + /// The extension folder's files with the given suffix, in a fixed order. + static List extensionFilesEndingWith(File extensionFolder, String suffix) { + List out = new ArrayList(); + File[] entries = extensionFolder == null ? null : extensionFolder.listFiles(); + if (entries == null) { + return out; + } + for (File f : entries) { + if (f.isFile() && f.getName().endsWith(suffix)) { + out.add(f); + } + } + Collections.sort(out, new Comparator() { + public int compare(File a, File b) { + return a.getName().compareTo(b.getName()); + } + }); + return out; + } + + /// Which of them to use: the one named after the extension if it is there, else the first by + /// name. Never the accident of directory order, because this file decides how the target is + /// signed and what it is signed to allow. + static File preferredExtensionFile(List candidates, String extensionName, String suffix) { + if (candidates.isEmpty()) { + return null; + } + for (File f : candidates) { + if (f.getName().equals(extensionName + suffix)) { + return f; + } + } + return candidates.get(0); + } + /// The first symbolic link under {@code dir} that resolves outside {@code root}, or null. /// /// unzip refuses an absolute path or a ../ traversal in an entry NAME, but it happily creates From bdeffa8fafd4d94eee7d4bf22aeee0aa9b68316c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:43:04 +0300 Subject: [PATCH 19/49] Six review catches on the plist stamper Mirrors the cloud builder fixes for the review on this PR. Insertion point: endOfElement returns the index just PAST the closing '>', which in a compact plist ending "
" is the '<' of , so the inclusive lastIndexOf picked that one and the keys went between the two closing tags, outside the dict they belong to. Padding inside markup: parses as " 5.4 " and was judged on trimmed text, so it counted as matching an app at "5.4". Emptiness still uses the trimmed value; equality and the reference resolution use an exact decode, which is what a plist parser sees. Same for a setting value's own trailing space, which Xcode expands verbatim. A stale literal identifier: an archive reused under a renamed package carries an identifier that is not under the host's bundle id, and PRODUCT_BUNDLE_IDENTIFIER cannot save it because the literal is what ships. A literal that could be this app's extension is kept; one that could not is replaced with the reference. The entitlements the floor is read from: buildSettings.properties may point CODE_SIGN_ENTITLEMENTS at another file, and that is the one Xcode signs against, so that is the one whose payment-pass-provisioning decides whether the floor is 14.0. BOM-less UTF-16: legal, and its declaration is NUL-interleaved, so the ISO-8859-1 probe fell through to UTF-8 and the plist went unstamped. The first characters of an XML document are " --- .../com/codename1/builders/IPhoneBuilder.java | 115 ++++++++++++++++-- .../builders/WatchNativeBuilder.java | 12 +- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 722446b30dd..f7c40a9848c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4906,9 +4906,15 @@ public void usesClassMethod(String cls, String method) { // The minimum iOS this extension declares, which App Store validation // reads out of the built .appex as MinimumOSVersion. Computed after the // properties are folded in, so an archive that states its own wins. + // The entitlements the TARGET IS SIGNED WITH, which is not + // necessarily the one picked by name: buildSettings.properties may set + // CODE_SIGN_ENTITLEMENTS at another file, and it is that file's + // payment-pass-provisioning that decides whether iOS 14 is the floor. + File signedEntitlements = appExtensionSignedEntitlements(appExtension, + buildSettingsMap.get("CODE_SIGN_ENTITLEMENTS"), extEntitlementsFile); String extDeploymentTarget = appExtensionDeploymentTarget( buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), - extEntitlementsFile, + signedEntitlements, request.getArg("ios.deployment_target", null)); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); @@ -5804,7 +5810,8 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) // already matched its app into one that does not -- the very validation failure this // method exists to prevent. List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), - embeddedExtensionBundleVersion(request), appExtensionBuildSettings(appExtension)); + embeddedExtensionBundleVersion(request), request.getPackageName(), + appExtensionBuildSettings(appExtension)); if (changes == null) { debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + " as an XML property list, so its bundle identity was left as it is. If " @@ -5824,10 +5831,15 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) /// null when this is not an XML plist this build can edit static List stampPlistFile(File infoPlist, String shortVersion, String bundleVersion, Map archiveSettings) throws IOException { + return stampPlistFile(infoPlist, shortVersion, bundleVersion, null, archiveSettings); + } + + static List stampPlistFile(File infoPlist, String shortVersion, String bundleVersion, + String hostBundleId, Map archiveSettings) throws IOException { PlistText original = readPlistText(infoPlist); List changes = new ArrayList(); String result = stampInfoPlistIdentity(original.text, shortVersion, bundleVersion, - archiveSettings, changes); + hostBundleId, archiveSettings, changes); if (changes.isEmpty()) { return changes; } @@ -5925,6 +5937,15 @@ private static Charset charsetOf(byte[] data, byte[] bom) { if (bom == BOM_UTF16LE) { return StandardCharsets.UTF_16LE; } + // UTF-16 without a byte order mark: its declaration is NUL-interleaved, so the probe below + // reads gibberish and falls through to UTF-8, and the plist then fails to parse and goes + // unstamped. The first characters of an XML document are "= 4 && data[0] == 0 && data[1] == '<' && data[2] == 0 && data[3] == '?') { + return StandardCharsets.UTF_16BE; + } + if (data.length >= 4 && data[0] == '<' && data[1] == 0 && data[2] == '?' && data[3] == 0) { + return StandardCharsets.UTF_16LE; + } // The declaration is ASCII-compatible in every encoding that can carry one, except the // UTF-16 forms, which the marks above have already answered for. String head = new String(data, 0, Math.min(data.length, 512), StandardCharsets.ISO_8859_1); @@ -5997,13 +6018,23 @@ static Map appExtensionInfoPlists(File extensionFolder) { */ static String stampInfoPlistIdentity(String plist, String shortVersion, String bundleVersion, Map archiveSettings, List changes) { + return stampInfoPlistIdentity(plist, shortVersion, bundleVersion, null, archiveSettings, changes); + } + + /// @param hostBundleId the containing app's bundle identifier, so a literal identifier that + /// could never be one of its extensions can be recognised; null skips that check + static String stampInfoPlistIdentity(String plist, String shortVersion, String bundleVersion, + String hostBundleId, Map archiveSettings, List changes) { if (plist == null || rootDictAt(plist) < 0) { changes.add("not an XML property list"); return null; } String result = openEmptyRootDict(plist); + // A literal left over from another project is not prefixed by the host's bundle id, and + // PRODUCT_BUNDLE_IDENTIFIER cannot save it: the literal is what ships. One that could be + // this app's extension is kept; one that could not is replaced. result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", - false, archiveSettings, changes); + !identifierBelongsToApp(result, hostBundleId), archiveSettings, changes); result = setPlistString(result, "CFBundleShortVersionString", shortVersion, true, archiveSettings, changes); result = setPlistString(result, "CFBundleVersion", bundleVersion, @@ -6033,6 +6064,35 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b return result; } + /// Whether the identifier the plist already carries can be an extension of this app: absent, + /// a build-setting reference, or a literal under the host's own bundle id. + static boolean identifierBelongsToApp(String plist, String hostBundleId) { + if (hostBundleId == null || hostBundleId.length() == 0) { + return true; + } + int afterKey = topLevelKeyEnd(plist, "CFBundleIdentifier"); + if (afterKey < 0) { + return true; + } + int element = nextMarkupAt(plist, afterKey); + if (element < 0 || !"string".equals(WatchNativeBuilder.tagAt(plist, element))) { + return true; + } + int openEnd = plist.indexOf('>', element); + if (openEnd < 0 || plist.charAt(openEnd - 1) == '/') { + return true; + } + int valueEnd = WatchNativeBuilder.closeOfElement(plist, openEnd + 1, ""); + if (valueEnd < 0) { + return true; + } + String current = WatchNativeBuilder.plistStringContent(plist.substring(openEnd + 1, valueEnd)); + if (current == null || current.length() == 0 || current.contains("$(") || current.contains("${")) { + return true; + } + return current.startsWith(hostBundleId + "."); + } + /** * Sets one string key among the ROOT dict's direct children, adding it when absent. * @@ -6109,10 +6169,21 @@ private static String setPlistString(String plist, String key, String value, if (currentText == null) { currentText = ""; } + // And the same content untrimmed, because a plist parser keeps padding wherever it is + // written -- 5.4 and both parse as + // " 5.4 ", which Apple compares against the app's "5.4" and rejects. Emptiness is judged + // on the trimmed text; everything else on the exact one. + String currentExact = WatchNativeBuilder.plistStringContentExact(current); + if (currentExact == null) { + currentExact = ""; + } if (currentText.length() == 0) { changes.add("set " + key + " to " + value + " (was empty)"); return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); } + if (currentExact.equals(value)) { + return plist; + } if (!overwriteNonEmpty) { return plist; } @@ -6123,12 +6194,10 @@ private static String setPlistString(String plist, String key, String value, // since the target this build generates has no version settings of its own. Both fail the // embedded-bundle check; only a reference that already lands on the app's own version is // left standing. - String resolved = resolveSettingsInValue(currentText, archiveSettings); - // Accepted as it stands only when it lands on the app's version AND carries no padding of - // its own: a plist parser keeps the spaces in 5.4 , so Apple compares - // " 5.4 " against the app's "5.4" and rejects the pair. Spelling that resolves cleanly -- - // CDATA, entities, a build-setting reference -- is left as the archive wrote it. - if (value.equals(resolved) && current.equals(current.trim())) { + // Resolved from the exact text, so padding written inside CDATA or as entities counts + // exactly as padding written outside it would. + String resolved = resolveSettingsInValue(currentExact, archiveSettings); + if (value.equals(resolved)) { return plist; } changes.add("set " + key + " to " + value + " to match the app (was " + currentText @@ -6231,7 +6300,10 @@ private static int rootDictAt(String plist) { private static int rootDictCloseAt(String plist) { int at = rootDictAt(plist); int end = at < 0 ? -1 : endOfElement(plist, at); - return end < 0 ? -1 : plist.lastIndexOf('<', end); + // end - 1, not end: endOfElement returns the index just PAST the closing '>', which in a + // compact plist ending "" is the '<' of . An inclusive search from + // there picked that one and inserted the keys between the two closing tags. + return end < 1 ? -1 : plist.lastIndexOf('<', end - 1); } /// Index just past the element opening at {@code element}, everything nested inside it @@ -6401,6 +6473,21 @@ static boolean containsSwiftSource(File dir) { return false; } + /// The entitlements file the extension target is signed with. + /// + /// CODE_SIGN_ENTITLEMENTS is a path relative to the project directory, the same shape as + /// INFOPLIST_FILE, and the archive may point it at a file other than the one named after the + /// extension. What it names is what Xcode signs against, so it is also what decides the + /// entitlement-driven deployment floor. Falls back to the named pick when the setting is + /// absent, still a placeholder, or points somewhere this build will not read. + static File appExtensionSignedEntitlements(File extensionFolder, String configured, File byName) { + if (configured == null || configured.trim().length() == 0 || configured.contains("$(NS_")) { + return byName; + } + File resolved = resolveInfoPlistPath(configured.trim(), extensionFolder); + return resolved != null && resolved.isFile() ? resolved : byName; + } + /// The minimum iOS version a brought-in app extension declares. /// /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as @@ -6636,7 +6723,7 @@ private static String resolveSettingsInValue(String value, Map a String before = out; for (Map.Entry setting : archiveSettings.entrySet()) { out = replaceBuildSetting(out, setting.getKey(), - setting.getValue() == null ? "" : setting.getValue().trim()); + setting.getValue() == null ? "" : setting.getValue()); } if (out.equals(before)) { // Nothing left that this archive defines; the strip below handles the rest. @@ -6646,7 +6733,9 @@ private static String resolveSettingsInValue(String value, Map a } // What survives names a setting the archive does not define, or sits in a cycle that never // settles. Xcode resolves those to nothing, and so does this. - return BUILD_SETTING_REFERENCE.matcher(out).replaceAll("").trim(); + // Not trimmed: the properties file's own trailing whitespace is written into the Xcode + // setting verbatim, so MARKETING_VERSION = "5.4 " really does expand to "5.4 ". + return BUILD_SETTING_REFERENCE.matcher(out).replaceAll(""); } /// A build-setting reference in either spelling Xcode accepts. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index 697c17e0d75..5298b31e425 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -1204,11 +1204,19 @@ private static String stripComments(String value) { /// entity decoding applies as before. The assembled value is trimmed, matching what this did /// before CDATA was understood at all. static String plistStringContent(String raw) { + String exact = plistStringContentExact(raw); + return exact == null ? null : exact.trim(); + } + + /// The same content WITHOUT the trim, for a caller that has to see the value a plist parser + /// would: 5.4 and both carry padding + /// that Apple compares and this method must not throw away. + static String plistStringContentExact(String raw) { if (raw == null) { return null; } if (raw.indexOf(CDATA_OPEN) < 0) { - return decodeXmlEntities(stripComments(raw).trim()); + return decodeXmlEntities(stripComments(raw)); } StringBuilder out = new StringBuilder(raw.length()); int i = 0; @@ -1229,7 +1237,7 @@ static String plistStringContent(String raw) { out.append(raw, body, end); i = end + CDATA_CLOSE.length(); } - return out.toString().trim(); + return out.toString(); } /// Turns the five predefined XML entities back into their characters. From ff50725d56ab62680550dbda147fd3879d2a63a4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:25:34 +0300 Subject: [PATCH 20/49] Three more review catches on the stamper Mirrors the cloud builder fixes. An identifier reference is only as good as what it lands on: the archive may override PRODUCT_BUNDLE_IDENTIFIER with the identifier from the project the extension was exported from, and that override is written onto this target, so the ordinary CFBundleIdentifier=$(PRODUCT_BUNDLE_IDENTIFIER) can still resolve outside the app. The value is resolved against the settings the target will carry before it is preserved. Paths resolve from the settings map, not the properties file: that file is loaded into the map and deleted before the entitlements lookup runs, so a CODE_SIGN_ENTITLEMENTS holding $(PRODUCT_NAME) resolved against an override that was no longer readable and named a different file -- and the deployment floor was read from entitlements the target is not signed with. And a key's own non-string value is replaced rather than left: every key this stamper manages must be a string, so 7 for CFBundleVersion is not a version to preserve. Wandering off to a later key's remains prevented by the anchored lookup. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f7c40a9848c..2d49d9b42a8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4911,7 +4911,8 @@ public void usesClassMethod(String cls, String method) { // CODE_SIGN_ENTITLEMENTS at another file, and it is that file's // payment-pass-provisioning that decides whether iOS 14 is the floor. File signedEntitlements = appExtensionSignedEntitlements(appExtension, - buildSettingsMap.get("CODE_SIGN_ENTITLEMENTS"), extEntitlementsFile); + buildSettingsMap.get("CODE_SIGN_ENTITLEMENTS"), extEntitlementsFile, + buildSettingsMap); String extDeploymentTarget = appExtensionDeploymentTarget( buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), signedEntitlements, @@ -5809,9 +5810,14 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) // value, and stamping the raw hint here would rewrite an extension version that // already matched its app into one that does not -- the very validation failure this // method exists to prevent. + // The settings the TARGET will carry, so a $(PRODUCT_BUNDLE_IDENTIFIER) in the plist + // is judged by the identifier it will actually resolve to. + Map settings = appExtensionBuildSettings(appExtension); + String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", declaredId != null ? declaredId + : request.getPackageName() + "." + appExtension.getName()); List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), - embeddedExtensionBundleVersion(request), request.getPackageName(), - appExtensionBuildSettings(appExtension)); + embeddedExtensionBundleVersion(request), request.getPackageName(), settings); if (changes == null) { debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + " as an XML property list, so its bundle identity was left as it is. If " @@ -6034,7 +6040,7 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b // PRODUCT_BUNDLE_IDENTIFIER cannot save it: the literal is what ships. One that could be // this app's extension is kept; one that could not is replaced. result = setPlistString(result, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)", - !identifierBelongsToApp(result, hostBundleId), archiveSettings, changes); + !identifierBelongsToApp(result, hostBundleId, archiveSettings), archiveSettings, changes); result = setPlistString(result, "CFBundleShortVersionString", shortVersion, true, archiveSettings, changes); result = setPlistString(result, "CFBundleVersion", bundleVersion, @@ -6066,7 +6072,8 @@ static String stampInfoPlistIdentity(String plist, String shortVersion, String b /// Whether the identifier the plist already carries can be an extension of this app: absent, /// a build-setting reference, or a literal under the host's own bundle id. - static boolean identifierBelongsToApp(String plist, String hostBundleId) { + static boolean identifierBelongsToApp(String plist, String hostBundleId, + Map archiveSettings) { if (hostBundleId == null || hostBundleId.length() == 0) { return true; } @@ -6087,10 +6094,18 @@ static boolean identifierBelongsToApp(String plist, String hostBundleId) { return true; } String current = WatchNativeBuilder.plistStringContent(plist.substring(openEnd + 1, valueEnd)); - if (current == null || current.length() == 0 || current.contains("$(") || current.contains("${")) { + if (current == null || current.length() == 0) { return true; } - return current.startsWith(hostBundleId + "."); + // Through the settings, because $(PRODUCT_BUNDLE_IDENTIFIER) is not automatically safe: + // the archive may override PRODUCT_BUNDLE_IDENTIFIER itself, with the identifier from the + // project the extension was exported from, and those overrides are written onto this + // target. A reference is only as good as what it lands on. + String resolved = resolveSettingsInValue(current, archiveSettings); + if (resolved.length() == 0 || resolved.contains("$(") || resolved.contains("${")) { + return true; + } + return resolved.startsWith(hostBundleId + "."); } /** @@ -6127,11 +6142,25 @@ private static String setPlistString(String plist, String key, String value, // or 1 has no string of its own, and scanning forward lands on // an unrelated later one -- the trap the comment on injectedPlistString records. int element = nextMarkupAt(plist, afterKey); - if (element < 0 || !"string".equals(WatchNativeBuilder.tagAt(plist, element))) { - // Not a string value. An extension is free to use whatever type it likes, and - // rewriting a type we did not expect is worse than leaving a version alone. + if (element < 0) { return plist; } + if (!"string".equals(WatchNativeBuilder.tagAt(plist, element))) { + // The key's OWN value, of a type it may not have: every key this stamper manages is a + // bundle identity key and Apple requires a string. 7 for + // CFBundleVersion is not a version to preserve, it is an invalid bundle -- and leaving + // it while reporting success is how the stamper would hand back one that still fails. + // (Wandering off to some LATER key's is the different mistake, and the search + // above is anchored to this key precisely so that cannot happen.) + int valueEnds = endOfElement(plist, element); + if (valueEnds < 0) { + return plist; + } + changes.add("set " + key + " to " + value + " (was " + + WatchNativeBuilder.tagAt(plist, element) + ", which is not a string)"); + return plist.substring(0, element) + "" + value + "" + + plist.substring(valueEnds); + } int openEnd = plist.indexOf('>', element); if (openEnd < 0) { return plist; @@ -6480,11 +6509,15 @@ static boolean containsSwiftSource(File dir) { /// extension. What it names is what Xcode signs against, so it is also what decides the /// entitlement-driven deployment floor. Falls back to the named pick when the setting is /// absent, still a placeholder, or points somewhere this build will not read. - static File appExtensionSignedEntitlements(File extensionFolder, String configured, File byName) { + static File appExtensionSignedEntitlements(File extensionFolder, String configured, File byName, + Map settings) { if (configured == null || configured.trim().length() == 0 || configured.contains("$(NS_")) { return byName; } - File resolved = resolveInfoPlistPath(configured.trim(), extensionFolder); + // From the settings MAP, not from buildSettings.properties: that file is loaded into the + // map and deleted before this runs, so a path holding $(PRODUCT_NAME) would resolve + // against an override that is no longer readable and quietly name a different file. + File resolved = resolveInfoPlistPath(configured.trim(), extensionFolder, settings); return resolved != null && resolved.isFile() ? resolved : byName; } @@ -6587,11 +6620,16 @@ static File appExtensionInfoPlist(File extensionFolder) { /// One INFOPLIST_FILE value as a file this build may write to, or null when it holds a setting /// that cannot be resolved here or lands outside the project directory. private static File resolveInfoPlistPath(String override, File extensionFolder) { + return resolveInfoPlistPath(override, extensionFolder, null); + } + + private static File resolveInfoPlistPath(String override, File extensionFolder, + Map settings) { String path = override; if (path.length() > 1 && path.startsWith("\"") && path.endsWith("\"")) { path = path.substring(1, path.length() - 1).trim(); } - path = resolveXcodeSettingsInPath(path, extensionFolder); + path = resolveXcodeSettingsInPath(path, extensionFolder, settings); if (path == null || path.length() == 0) { return null; } @@ -6687,8 +6725,17 @@ static Map appExtensionBuildSettings(File extensionFolder) { /// value -- and null says so, because the alternative is editing whichever file the /// half-resolved path happens to name. private static String resolveXcodeSettingsInPath(String path, File extensionFolder) { + return resolveXcodeSettingsInPath(path, extensionFolder, null); + } + + /// @param settings the target's settings when they are already gathered, since the properties + /// file they came from is deleted once it is loaded + private static String resolveXcodeSettingsInPath(String path, File extensionFolder, + Map settings) { String targetName = extensionFolder.getName(); - String productName = appExtensionBuildSetting(extensionFolder, "PRODUCT_NAME"); + String productName = settings != null && settings.get("PRODUCT_NAME") != null + ? settings.get("PRODUCT_NAME").trim() + : appExtensionBuildSetting(extensionFolder, "PRODUCT_NAME"); if (productName == null || productName.indexOf('$') >= 0) { productName = targetName; } From d8144e42b159fdc337dd512ff115ea648efe7c85 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:49:54 +0300 Subject: [PATCH 21/49] An unknown reference is not an identifier, and grep is not a plist parser Mirrors the cloud builder fixes for two review catches, both where "we cannot tell" was being read as "it is fine". CFBundleIdentifier = $(EXTENSION_BUNDLE_ID) with nothing defining that setting does not fall back to the target's identifier: Xcode expands it to the empty string and the .appex ships without one. The reference that IS safe, $(PRODUCT_BUNDLE_IDENTIFIER), resolves through the settings because the caller puts the target's own identifier there. And the deployment floor was decided by searching the entitlements file's bytes. Entitlements may be UTF-16, where that finds nothing and a Wallet extension keeps the 12.0 floor Apple rejects it for; and the same text in a comment or an unrelated value is not a granted entitlement -- taking it for one drops the extension off every iOS 12 and 13 device. It is read as a property list now, through the encoding-aware reader, and the key has to be a top-level . Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 2d49d9b42a8..a5567ed23e1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6102,8 +6102,12 @@ static boolean identifierBelongsToApp(String plist, String hostBundleId, // project the extension was exported from, and those overrides are written onto this // target. A reference is only as good as what it lands on. String resolved = resolveSettingsInValue(current, archiveSettings); - if (resolved.length() == 0 || resolved.contains("$(") || resolved.contains("${")) { - return true; + if (resolved.length() == 0) { + // $(EXTENSION_BUNDLE_ID) with nothing defining it does not fall back to the target's + // identifier -- Xcode expands it to the empty string and the .appex ships with no + // identifier at all. The one reference that IS safe, $(PRODUCT_BUNDLE_IDENTIFIER), + // resolves through the settings because the caller puts the target's own there. + return false; } return resolved.startsWith(hostBundleId + "."); } @@ -6521,6 +6525,34 @@ static File appExtensionSignedEntitlements(File extensionFolder, String configur return resolved != null && resolved.isFile() ? resolved : byName; } + /// Whether an entitlements plist grants a boolean entitlement at its top level. + /// + /// Read as a property list, not searched as text: entitlements may be UTF-16, in which case a + /// byte search for the key finds nothing and a Wallet extension keeps the 12.0 floor it will + /// be rejected for; and the same string sitting in a comment, or in some unrelated value, is + /// not a granted entitlement -- taking it for one pushes an extension to iOS 14 and drops it + /// off the 12 and 13 devices it would have run on. + static boolean entitlementIsTrue(File entitlements, String key) { + if (entitlements == null || !entitlements.isFile()) { + return false; + } + String text; + try { + text = readPlistText(entitlements).text; + } catch (IOException cannotRead) { + return false; + } + if (rootDictAt(text) < 0) { + return false; + } + int afterKey = topLevelKeyEnd(text, key); + if (afterKey < 0) { + return false; + } + int element = nextMarkupAt(text, afterKey); + return element >= 0 && "true".equals(WatchNativeBuilder.tagAt(text, element)); + } + /// The minimum iOS version a brought-in app extension declares. /// /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as @@ -6543,7 +6575,7 @@ static String appExtensionDeploymentTarget(String declared, File entitlements, S // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would // reproduce the very rejection this exists to prevent -- 10.0 does not even build against // the current SDK, and an issuer-provisioning Wallet extension is refused below 14. - String floor = fileContains(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; + String floor = entitlementIsTrue(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; String chosen = declared != null && declared.trim().length() > 0 ? declared.trim() : appTarget; From de24da07cd5ec4af5957c29e2a015aabc5ce1a74 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:46:52 +0300 Subject: [PATCH 22/49] Test the builder here too, not only in the cloud daemon Every fix in this PR was mirrored from the cloud builder, and every test stayed behind in it. That is exactly how two copies of the same logic drift: this one compiles, nothing checks what it does, and the next divergence is found by a customer. The six suites are ported to com.codename1.builders, where 41 other JUnit 4 tests already run under the vintage engine and AppExtensionResourcesTest already covers the file grouping: the settings parser, the identity stamping (root-dict anchoring, empty forms, nested keys, compact plists, references judged by what they resolve to, non-string values), plist path resolution and confinement, encodings and qualified INFOPLIST_FILE settings, the deployment floor including the entitlement parse, and the archive staging and symlink refusal. 70 tests, run against this module's own IPhoneBuilder rather than the daemon's -- which is the point. They pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../AppExtensionBuildSettingsTest.java | 70 +++ .../AppExtensionDeploymentTargetTest.java | 189 +++++++ .../AppExtensionInfoPlistPathTest.java | 167 ++++++ .../builders/AppExtensionInfoPlistTest.java | 479 ++++++++++++++++++ .../builders/AppExtensionPlistFileTest.java | 185 +++++++ .../builders/AppExtensionStagingTest.java | 133 +++++ 6 files changed, 1223 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java new file mode 100644 index 00000000000..7bb239750d7 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class AppExtensionBuildSettingsTest { + + /** + * The block is written the way it appears in a pbxproj: tab-indented, one + * {@code KEY = VALUE;} per line. + */ + private static final String BLOCK = "CLANG_ANALYZER_NONNULL = YES;\n" + + "\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n" + + "\t\t\t\tCLANG_ENABLE_MODULES = YES;\n" + + "\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n" + + "\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;"; + + @Test + public void valueLosesItsTrailingSemicolon() { + Map settings = IPhoneBuilder.parseXcodeBuildSettings(BLOCK); + // A value of ";" is what CLANG_ENABLE_MODULES used to get, and Xcode reads that + // as "off": no -fmodules, no clang autolinking, and an extension importing UIKit + // reaches ld with Foundation alone and fails on _OBJC_CLASS_$_UIView. + assertEquals("YES", settings.get("CLANG_ENABLE_MODULES")); + assertEquals("YES", settings.get("CLANG_ENABLE_OBJC_ARC")); + assertEquals("YES", settings.get("CLANG_ANALYZER_NONNULL")); + assertEquals("YES_AGGRESSIVE", settings.get("CLANG_WARN_UNGUARDED_AVAILABILITY")); + } + + @Test + public void quotedValueIsUnwrappedBeforeItBecomesARubyLiteral() { + Map settings = IPhoneBuilder.parseXcodeBuildSettings(BLOCK); + // Kept quotes would be emitted as e.build_settings['...'] = ""gnu++14"", which + // is a Ruby syntax error that takes the whole project fixup script with it. + assertEquals("gnu++14", settings.get("CLANG_CXX_LANGUAGE_STANDARD")); + } + + @Test + public void blankAndMalformedLinesAreSkipped() { + Map settings = IPhoneBuilder.parseXcodeBuildSettings( + "\n \nCLANG_ENABLE_MODULES = YES;\nnot a setting\n"); + assertEquals(1, settings.size()); + assertEquals("YES", settings.get("CLANG_ENABLE_MODULES")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java new file mode 100644 index 00000000000..f5da630bca1 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class AppExtensionDeploymentTargetTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void theArchivesOwnValueWinsAboveTheFloor() throws Exception { + // An extension knows which APIs it calls; nothing here should second-guess it. + assertEquals("16.1", IPhoneBuilder.appExtensionDeploymentTarget("16.1", null, "11")); + assertEquals("15.0", IPhoneBuilder.appExtensionDeploymentTarget("15.0", walletEntitlements(), "11")); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget("14", walletEntitlements(), "11")); + } + + @Test + public void aDeclaredValueBelowTheFloorIsRaised() throws Exception { + // An archive exported from an old project carries its own legacy target. Honouring that + // unconditionally reproduces the rejection this exists to prevent. + assertEquals("14.0", + IPhoneBuilder.appExtensionDeploymentTarget("10.0", walletEntitlements(), "11")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget("10.0", null, "11")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(" ", null, "9.0")); + } + + @Test + public void anIssuerProvisioningWalletExtensionNeeds14() throws Exception { + // PKIssuerProvisioningExtensionHandler arrived in iOS 14, and App Store validation says so + // on upload: "Please ensure the MinimumOSVersion value of your extension is 14 or later". + assertEquals("14.0", + IPhoneBuilder.appExtensionDeploymentTarget(null, walletEntitlements(), "11")); + } + + @Test + public void anythingElseSitsOnTheAppsTargetOrTheSdkFloor() throws Exception { + // 10.0, which this used to hand out, is below what the current SDK will build against. + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, null, "11")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, null, null)); + assertEquals("15.4", IPhoneBuilder.appExtensionDeploymentTarget(null, null, "15.4")); + } + + @Test + public void versionsCompareByNumberNotByText() throws Exception { + // "9.0" is not above "12.0", and a two-part value is not below its own major. + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, null, "9.0")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, null, "12")); + assertEquals("12.4", IPhoneBuilder.appExtensionDeploymentTarget(null, null, "12.4")); + } + + private File walletEntitlements() throws Exception { + File file = new File(tmp.getRoot(), "WalletNonUIExtension.entitlements"); + write(file, "\n" + + "com.apple.developer.payment-pass-provisioning\n\n" + + "\n"); + return file; + } + + private static void write(File file, String contents) throws Exception { + OutputStream out = new FileOutputStream(file); + try { + out.write(contents.getBytes("UTF-8")); + } finally { + out.close(); + } + } + + @Test + public void theFloorReadsTheEntitlementsTheTargetIsSignedWith() throws Exception { + File dist = tmp.newFolder("dist2"); + File extension = new File(dist, "WalletNonUIExtension"); + assertTrue(extension.mkdirs()); + // Named after the extension, but NOT what the archive signs with. + write(new File(extension, "WalletNonUIExtension.entitlements"), ""); + File configured = new File(extension, "Release.entitlements"); + write(configured, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + + File signed = IPhoneBuilder.appExtensionSignedEntitlements(extension, + "WalletNonUIExtension/Release.entitlements", + new File(extension, "WalletNonUIExtension.entitlements"), + new java.util.HashMap()); + assertEquals(configured, signed); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget(null, signed, "11")); + } + + @Test + public void withNoConfiguredEntitlementsTheNamedOneStands() throws Exception { + File byName = walletEntitlements(); + java.util.Map none = new java.util.HashMap(); + assertEquals(byName, IPhoneBuilder.appExtensionSignedEntitlements(tmp.getRoot(), + "$(NS_CODE_SIGN_ENTITLEMENTS)", byName, none)); + assertEquals(byName, IPhoneBuilder.appExtensionSignedEntitlements(tmp.getRoot(), null, byName, none)); + } + + @Test + public void aPathThroughProductNameUsesTheSettingsNotTheDeletedFile() throws Exception { + File dist = tmp.newFolder("dist3"); + File extension = new File(dist, "WalletNonUIExtension"); + assertTrue(extension.mkdirs()); + File renamed = new File(extension, "Renamed.entitlements"); + write(renamed, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + // buildSettings.properties is loaded into the map and DELETED before this runs, so the + // override has to come from the map or $(PRODUCT_NAME) resolves to the folder name. + java.util.Map settings = new java.util.HashMap(); + settings.put("PRODUCT_NAME", "Renamed"); + + File signed = IPhoneBuilder.appExtensionSignedEntitlements(extension, + "WalletNonUIExtension/$(PRODUCT_NAME).entitlements", null, settings); + + assertEquals(renamed, signed); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget(null, signed, "11")); + } + + @Test + public void aUtf16EntitlementsFileIsStillRead() throws Exception { + File file = new File(tmp.getRoot(), "utf16.entitlements"); + String xml = "\n\n" + + "com.apple.developer.payment-pass-provisioning\n\n\n"; + java.io.OutputStream out = new java.io.FileOutputStream(file); + try { + out.write(new byte[]{(byte) 0xFF, (byte) 0xFE}); + out.write(xml.getBytes("UTF-16LE")); + } finally { + out.close(); + } + // A byte search for the key finds nothing here, and the extension keeps a floor Apple + // rejects it for. + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget(null, file, "11")); + } + + @Test + public void theEntitlementMustBeGrantedNotJustMentioned() throws Exception { + File commented = new File(tmp.getRoot(), "commented.entitlements"); + write(commented, "\n" + + "\n" + + "com.apple.security.application-groups\n"); + // Pushing an extension to iOS 14 on the strength of a comment drops it off every 12 and 13 + // device it would have run on. + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, commented, "11")); + + File denied = new File(tmp.getRoot(), "denied.entitlements"); + write(denied, "\n" + + "com.apple.developer.payment-pass-provisioning\n\n"); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, denied, "11")); + } + + @Test + public void aNestedMentionIsNotAGrant() throws Exception { + File nested = new File(tmp.getRoot(), "nested.entitlements"); + write(nested, "\ncom.apple.developer.associated-domains\n\n" + + "com.apple.developer.payment-pass-provisioning\n\n\n" + + ""); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, nested, "11")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java new file mode 100644 index 00000000000..5a10922f05f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class AppExtensionInfoPlistPathTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void theDefaultIsTheFoldersOwnInfoPlist() throws Exception { + File extension = extension(); + assertEquals(new File(extension, "Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void anOverriddenPathIsReadRelativeToTheProjectDirectory() throws Exception { + File extension = extension(); + // INFOPLIST_FILE is what Xcode processes into the .appex, so it is the file that has to + // carry the identifier -- stamping the folder's Info.plist would edit a file nothing + // builds. + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/Release-Info.plist\n"); + assertEquals(new File(extension, "Release-Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void theProjectRootPrefixIsUnderstood() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = \"$(SRCROOT)/WalletUIExtension/Release-Info.plist\"\n"); + assertEquals(new File(extension, "Release-Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void theSettingsThisBuildKnowsAreSubstituted() throws Exception { + File extension = extension(); + // What an Xcode project actually writes for the plist in the extension's own folder. Every + // part of it is known here: SRCROOT is the project directory the folders are extracted + // into, and TARGET_NAME is the folder's name, because that is the name the target is + // created with. + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = $(SRCROOT)/$(TARGET_NAME)/Info.plist\n"); + assertEquals(new File(extension, "Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void theBraceSpellingResolvesToo() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = ${PROJECT_DIR}/${PRODUCT_NAME}/Custom-Info.plist\n"); + assertEquals(new File(extension, "Custom-Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void anOverriddenProductNameIsUsedForItsOwnReference() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "PRODUCT_NAME = Renamed\nINFOPLIST_FILE = $(PRODUCT_NAME)/Info.plist\n"); + assertEquals(new File(extension.getParentFile(), "Renamed/Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void anAbsolutePathOutsideTheProjectIsRefused() throws Exception { + File extension = extension(); + File outside = new File(tmp.getRoot(), "outside.plist"); + write(outside, ""); + // The archive is a customer upload and the stamper WRITES to whatever this names, so an + // absolute path would have the daemon rewriting a file outside the build. + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = " + outside.getAbsolutePath() + "\n"); + assertNull(IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void aTraversalOutOfTheProjectIsRefused() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = ../../shared.plist\n"); + assertNull(IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void aSymlinkOutOfTheProjectIsRefused() throws Exception { + File extension = extension(); + File outside = new File(tmp.getRoot(), "outside.plist"); + write(outside, ""); + // A zip may carry symlinks, so a path that sits inside the project can still land outside. + Files.createSymbolicLink(new File(extension, "Info.plist").toPath(), outside.toPath()); + assertNull(IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void aPlistBesideTheExtensionFolderIsStillAllowed() throws Exception { + File extension = extension(); + // Under the project directory but outside the extension's own folder: legitimate, an + // extension may share a plist with the rest of the project. + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = Shared-Info.plist\n"); + assertEquals(new File(extension.getParentFile(), "Shared-Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void anUnresolvableReferenceIsRefusedRatherThanGuessed() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = $(CONFIGURATION)/Info.plist\n"); + // Null makes the caller say so and leave every file alone; editing the default here would + // be editing a plist the build does not use. + assertNull(IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + private File extension() throws Exception { + File dist = tmp.newFolder("dist"); + File extension = new File(dist, "WalletUIExtension"); + extension.mkdirs(); + return extension; + } + + private static void write(File file, String contents) throws Exception { + OutputStream out = new FileOutputStream(file); + try { + out.write(contents.getBytes("UTF-8")); + } finally { + out.close(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java new file mode 100644 index 00000000000..3b1e69fde8b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -0,0 +1,479 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class AppExtensionInfoPlistTest { + + /** An archive that overrides no build settings of its own. */ + private static final Map NO_SETTINGS = new HashMap(); + + /** What an extension folder exported from a modern Xcode target actually ships. */ + private static final String NO_IDENTITY = "\n" + + "\n" + + "\n" + + "\tCFBundleName\n" + + "\tWalletUIExtension\n" + + "\tNSExtension\n" + + "\t\n" + + "\t\tNSExtensionPointIdentifier\n" + + "\t\tcom.apple.PassKit.issuer-provisioning.authorization\n" + + "\t\n" + + "\n" + + "\n"; + + @Test + public void missingIdentifierIsAddedAsABuildSettingReference() { + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(NO_IDENTITY, "5.4", "5.4", NO_SETTINGS, changes); + // Without this the .appex is built with no identifier at all and the archive fails in the + // app's own target: "Embedded Binary Bundle Identifier: (null)". + assertTrue(out.contains("CFBundleIdentifier\n\t$(PRODUCT_BUNDLE_IDENTIFIER)")); + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void theBundleIsGivenTheKeysThatMakeItABundle() { + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(NO_IDENTITY, "5.4", "5.4", NO_SETTINGS, + changes); + // Without CFBundleExecutable the .appex does not claim its own binary, and App Store + // validation rejects the upload after a build that succeeded: "the ... binary file is not + // permitted ... other than a valid CFBundleExecutable of supported bundles". + assertTrue(out.contains("CFBundleExecutable\n\t$(EXECUTABLE_NAME)")); + assertTrue(out.contains("CFBundlePackageType\n\tXPC!")); + assertTrue(out.contains("CFBundleName")); + assertTrue(out.contains("CFBundleInfoDictionaryVersion\n\t6.0")); + // The reference, not a literal: an extension whose development language is not English + // carries DEVELOPMENT_LANGUAGE in its own settings, and those reach this target. + assertTrue(out.contains("CFBundleDevelopmentRegion\n\t" + + "$(DEVELOPMENT_LANGUAGE)")); + } + + @Test + public void anExtensionsOwnBundleKeysAreKept() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleExecutable\n\tTheirName\n" + + "\tCFBundlePackageType\n\tXPC!\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("TheirName")); + assertFalse(changes.toString().contains("CFBundleExecutable")); + } + + @Test + public void addedKeysStayInsideTheTopLevelDict() { + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(NO_IDENTITY, "5.4", "5.4", NO_SETTINGS, changes); + // The nested NSExtension dict closes first, so appending at the LAST is what keeps + // the new keys out of it. + assertTrue(out.indexOf("CFBundleIdentifier") + > out.indexOf("NSExtensionPointIdentifier")); + assertTrue(out.endsWith("\n\n")); + } + + @Test + public void aStaleVersionIsAlignedWithTheApp() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t1.0\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + // Apple requires an embedded extension to carry the version of the app containing it. + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString().contains("was 1.0")); + } + + @Test + public void aPlistThatIsAlreadyRightIsNotRewritten() { + // Everything a target built by Xcode would have generated: the identity AND the keys that + // make the directory a bundle. Nothing here is ours to change. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\tcom.example.app.Ext\n" + + "\tCFBundleShortVersionString\n\t5.4\n" + + "\tCFBundleVersion\n\t5.4\n" + + "\tCFBundleExecutable\n\t$(EXECUTABLE_NAME)\n" + + "\tCFBundlePackageType\n\tXPC!\n" + + "\tCFBundleInfoDictionaryVersion\n\t6.0\n" + + "\tCFBundleDevelopmentRegion\n\t$(DEVELOPMENT_LANGUAGE)\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(changes.toString(), changes.isEmpty()); + assertEquals(plist, out); + } + + @Test + public void aReferenceThatAlreadyResolvesToTheAppsVersionIsLeftAlone() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "5.4"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out.contains("$(MARKETING_VERSION)")); + assertFalse(changes.toString().contains("CFBundleShortVersionString")); + } + + @Test + public void aReferenceToAStaleSettingIsReplaced() { + // The archive's buildSettings.properties are copied into this target's build + // configurations, so the reference lands on 1.0 and the extension ships a version the + // containing app does not have. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "1.0"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '1.0'")); + } + + @Test + public void aNestedReferenceIsExpandedBeforeItIsJudged() { + // MARKETING_VERSION names another setting. Expanding the map once, in whatever order + // Properties hands it over, can leave $(VERSION_SUFFIX) behind and read the version as the + // app's own 5.4 -- while the device resolves it to 5.41 and validation rejects the pair. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("VERSION_SUFFIX", "1"); + settings.put("MARKETING_VERSION", "5.4$(VERSION_SUFFIX)"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '5.41'")); + } + + @Test + public void aNestedReferenceThatLandsOnTheAppsVersionStillStands() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("VERSION_MAJOR", "5"); + settings.put("MARKETING_VERSION", "$(VERSION_MAJOR).4"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out.contains("$(MARKETING_VERSION)")); + assertFalse(changes.toString().contains("CFBundleShortVersionString")); + } + + @Test + public void aCycleSettlesAsUnresolvableRatherThanSpinning() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t$(A)\n\tCFBundleName"); + Map settings = new HashMap(); + settings.put("A", "$(B)"); + settings.put("B", "$(A)"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void aReferenceToNothingIsReplacedToo() { + // Nothing defines CURRENT_PROJECT_VERSION here: the target this build generates carries no + // version settings, so Xcode resolves the reference to the empty string. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t$(CURRENT_PROJECT_VERSION)\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void anExplicitIdentifierReferenceIsStillNeverTouched() { + // The identifier is never overwritten when it is there and not empty, reference or not: + // $(PRODUCT_BUNDLE_IDENTIFIER) is what this build sets on the target anyway. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t$(PRODUCT_BUNDLE_IDENTIFIER)\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + assertFalse(changes.toString().contains("CFBundleIdentifier")); + } + + @Test + public void aNonStringValueOfTheKeyIsReplacedAndOthersLeftAlone() { + // Apple requires these keys to be strings, so 7 is not a version to + // preserve -- it is an invalid bundle. What must NOT happen is the rewrite wandering off + // to CFBundleName's , which is the different bug the anchored lookup prevents. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t7\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out, out.contains("CFBundleVersion\n\t5.4")); + assertTrue(out.contains("CFBundleName\n\tWalletUIExtension")); + assertTrue(changes.toString(), changes.toString().contains("not a string")); + } + + @Test + public void aValueInsideANestedDictIsNotMistakenForTheKeys() { + // The NSExtension dict holds a of its own further down the file. + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(NO_IDENTITY, "5.4", "5.4", NO_SETTINGS, + changes); + assertTrue(out.contains("com.apple.PassKit.issuer-provisioning.authorization")); + } + + @Test + public void anEmptyIdentifierIsFilledEvenThoughAnExplicitOneIsKept() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + // An empty identifier is no identifier: it fails the embedded-binary check exactly like a + // missing one, so "do not overwrite an explicit value" must not cover it. + assertTrue(out.contains("CFBundleIdentifier\n\t" + + "$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void theOpenAndCloseEmptyFormIsFilledToo() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void aMarkupOnlyIdentifierIsEmptyAndGetsFilled() { + // is a nonzero run of text and an empty value. Reading it as + // an identifier that is already there leaves the extension with none. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + assertTrue(changes.toString(), changes.toString().contains("was empty")); + } + + @Test + public void aWhitespaceOnlyIdentifierIsEmptyAndGetsFilled() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t \n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + assertTrue(changes.toString(), changes.toString().contains("was empty")); + } + + @Test + public void whitespaceInsideCdataIsEmptyToo() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void anEmptyCdataSectionIsEmptyToo() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void aCdataSpellingOfTheRightVersionIsLeftAsWritten() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("")); + assertFalse(changes.toString().contains("CFBundleVersion")); + } + + @Test + public void paddingRoundTheRightVersionIsNormalised() { + // A plist parser keeps those spaces, so Apple compares " 5.4 " with the app's "5.4". + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t 5.4 \n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void whitespaceBeforeTheSlashIsStillTheEmptyForm() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void anExplicitIdentifierIsNeverOverwritten() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\tcom.example.Own\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("com.example.Own")); + assertFalse(changes.toString().contains("CFBundleIdentifier")); + } + + @Test + public void aNestedKeyOfTheSameNameIsNotTheBundlesIdentity() { + // NSExtensionAttributes comes before the top-level keys and carries a key of the same + // name. A whole-file text search finds that one first: the stamper would then read the + // bundle as already identified, or write the app's version into an extension attribute. + String plist = NO_IDENTITY.replace("\t\tNSExtensionPointIdentifier\n", + "\t\tNSExtensionAttributes\n\t\t\n" + + "\t\t\tCFBundleIdentifier\n\t\t\tcom.nested.value\n" + + "\t\t\tCFBundleVersion\n\t\t\t0.1\n\t\t\n" + + "\t\tNSExtensionPointIdentifier\n"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("com.nested.value")); + assertTrue(out.contains("CFBundleVersion\n\t\t\t0.1")); + // and the bundle's own identity was added at the top level, after the NSExtension dict + assertTrue(out.contains("CFBundleIdentifier\n\t" + + "$(PRODUCT_BUNDLE_IDENTIFIER)")); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void aCommentedOutKeyIsNotTheKey() { + String plist = NO_IDENTITY.replace("CFBundleName", + "\n\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out.contains("")); + assertTrue(out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void aCompactPlistGetsItsKeysInsideTheDict() { + // No newline between the closing tags, which is legal and which a generator may well emit. + String plist = "" + + "CFBundleNameExt"; + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + // Inserting at the wrong closing tag puts the keys between and , which is + // not a property list at all. + assertTrue(out, out.indexOf("CFBundleIdentifier") < out.indexOf("")); + assertTrue(out.endsWith("")); + } + + @Test + public void paddingInsideCdataCountsAsPaddingToo() { + // plutil parses as " 5.4 ", which Apple compares with + // the app's "5.4" and rejects. Judging it on trimmed text called it a match. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleVersion\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", NO_SETTINGS, changes); + assertTrue(out, out.contains("CFBundleVersion\n\t5.4")); + } + + @Test + public void aSettingsOwnTrailingSpaceIsNotNormalisedAway() { + // The properties file's value is written into the Xcode setting verbatim, so this really + // does expand to "5.4 " on the device. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "5.4 "); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", settings, changes); + assertTrue(out, out.contains("CFBundleShortVersionString\n\t5.4")); + } + + @Test + public void anIdentifierReferenceIsJudgedByWhatItResolvesTo() { + // The archive overrides PRODUCT_BUNDLE_IDENTIFIER with the identifier from the project it + // was exported from, so the usual reference lands outside this app and the embedded bundle + // is refused for not being prefixed by its container. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t$(PRODUCT_BUNDLE_IDENTIFIER)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.old.project.WalletUIExtension"); + List changes = new ArrayList(); + assertFalse(IPhoneBuilder.identifierBelongsToApp(plist, "com.new.app", settings)); + assertTrue(IPhoneBuilder.identifierBelongsToApp(plist, "com.old.project", settings)); + // and a reference nothing defines resolves to the empty string, which is not an + // identifier either -- Xcode ships the .appex with none. + assertFalse(IPhoneBuilder.identifierBelongsToApp(plist, "com.new.app", NO_SETTINGS)); + } + + @Test + public void aLiteralIdentifierFromAnotherProjectIsReplaced() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\tcom.old.project.Ext\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void aLiteralIdentifierUnderTheAppIsKept() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\tcom.new.app.Ext\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + assertTrue(out.contains("com.new.app.Ext")); + assertFalse(changes.toString().contains("CFBundleIdentifier")); + } + + @Test + public void aBinaryPlistIsReportedRatherThanMangled() { + List changes = new ArrayList(); + assertNull(IPhoneBuilder.stampInfoPlistIdentity("bplist00 ", "5.4", "5.4", NO_SETTINGS, changes)); + assertEquals(1, changes.size()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java new file mode 100644 index 00000000000..3ceb1faa136 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** The file half of the stamping: which plists get stamped, and in what encoding. */ +public class AppExtensionPlistFileTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final Map NO_SETTINGS = new HashMap(); + + private static String plist(String declaration, String extra) { + return declaration + + "\n" + + "\n" + + "\tCFBundleName\n" + + "\t" + extra + "\n" + + "\n" + + "\n"; + } + + @Test + public void aUtf16PlistIsStampedAndStaysUtf16() throws Exception { + File extension = extension(); + File infoPlist = new File(extension, "Info.plist"); + String source = plist("\n", "WalletUIExtension"); + // With a BOM, as a UTF-16 file is written. Read as UTF-8 this is noise, so the plist would + // not parse and the extension would ship with no identifier at all. + writeBytes(infoPlist, concat(new byte[]{(byte) 0xFF, (byte) 0xFE}, + source.getBytes(StandardCharsets.UTF_16LE))); + + List changes = IPhoneBuilder.stampPlistFile(infoPlist, "5.4", "5.4", NO_SETTINGS); + + assertFalse(changes.isEmpty()); + byte[] written = readBytes(infoPlist); + assertEquals((byte) 0xFF, written[0]); + assertEquals((byte) 0xFE, written[1]); + String out = new String(written, 2, written.length - 2, StandardCharsets.UTF_16LE); + assertTrue(out.contains("CFBundleIdentifier")); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void aLatin1PlistKeepsItsAccentsAndItsDeclaration() throws Exception { + File extension = extension(); + File infoPlist = new File(extension, "Info.plist"); + Charset latin1 = Charset.forName("ISO-8859-1"); + String source = plist("\n", "Café Wallet"); + writeBytes(infoPlist, source.getBytes(latin1)); + + IPhoneBuilder.stampPlistFile(infoPlist, "5.4", "5.4", NO_SETTINGS); + + // Decoding with the wrong charset and writing the result back would turn the display name + // into replacement characters -- corrupting a name in order to fix an identifier. + String out = new String(readBytes(infoPlist), latin1); + assertTrue(out, out.contains("Café Wallet")); + assertTrue(out.contains("encoding=\"ISO-8859-1\"")); + assertTrue(out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void aQualifiedSettingNamesAPlistToStampToo() throws Exception { + File extension = extension(); + writeText(new File(extension, "Info.plist"), + plist("\n", "Base")); + writeText(new File(extension, "Device-Info.plist"), + plist("\n", "Device")); + // Escaped, which is how a conditional key survives Properties -- and Xcode then honours it + // over the base value for device builds, so the archive would ship the unstamped one. + writeText(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/Info.plist\n" + + "INFOPLIST_FILE[sdk\\=iphoneos*] = WalletUIExtension/Device-Info.plist\n"); + + Map plists = IPhoneBuilder.appExtensionInfoPlists(extension); + + assertEquals(2, plists.size()); + assertTrue(plists.values().contains(new File(extension, "Info.plist"))); + assertTrue(plists.values().contains(new File(extension, "Device-Info.plist"))); + } + + @Test + public void aQualifiedSettingBesideNoBaseValueKeepsTheDefault() throws Exception { + File extension = extension(); + writeText(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE[sdk\\=iphoneos*] = WalletUIExtension/Device-Info.plist\n"); + + Map plists = IPhoneBuilder.appExtensionInfoPlists(extension); + + // The base value still decides for every build the condition does not match. + assertEquals(2, plists.size()); + assertTrue(plists.values().contains(new File(extension, "Info.plist"))); + assertTrue(plists.values().contains(new File(extension, "Device-Info.plist"))); + } + + @Test + public void anUnescapedConditionIsNotASettingAtAll() throws Exception { + File extension = extension(); + // Properties splits on the = inside the brackets, so the key becomes INFOPLIST_FILE[sdk, + // which Xcode does not recognise: the base value decides and there is nothing else to + // stamp. + writeText(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE[sdk=iphoneos*] = WalletUIExtension/Device-Info.plist\n"); + + Map plists = IPhoneBuilder.appExtensionInfoPlists(extension); + + assertEquals(1, plists.size()); + assertTrue(plists.values().contains(new File(extension, "Info.plist"))); + } + + private File extension() throws Exception { + File dist = tmp.newFolder("dist"); + File extension = new File(dist, "WalletUIExtension"); + extension.mkdirs(); + return extension; + } + + private static byte[] concat(byte[] head, byte[] tail) { + byte[] out = new byte[head.length + tail.length]; + System.arraycopy(head, 0, out, 0, head.length); + System.arraycopy(tail, 0, out, head.length, tail.length); + return out; + } + + private static void writeText(File file, String contents) throws Exception { + writeBytes(file, contents.getBytes("UTF-8")); + } + + private static void writeBytes(File file, byte[] contents) throws Exception { + OutputStream out = new FileOutputStream(file); + try { + out.write(contents); + } finally { + out.close(); + } + } + + private static byte[] readBytes(File file) throws Exception { + byte[] data = new byte[(int) file.length()]; + java.io.DataInputStream in = new java.io.DataInputStream(new java.io.FileInputStream(file)); + try { + in.readFully(data); + } finally { + in.close(); + } + return data; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java new file mode 100644 index 00000000000..96cb2659d98 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class AppExtensionStagingTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void archivesLeaveTheResourcesDirectory() throws Exception { + File res = tmp.newFolder("res"); + write(new File(res, "WalletUIExtension.ios.appext")); + write(new File(res, "WalletNonUIExtension.ios.appext")); + write(new File(res, "theme.res")); + + File staged = IPhoneBuilder.stageAppExtensionArchives(res, new File(tmp.getRoot(), "appext")); + + // resDir is handed to the translator, which copies it into
-src and turns every + // file into an app resource. An archive left here ships inside the .app. + assertFalse(new File(res, "WalletUIExtension.ios.appext").exists()); + assertFalse(new File(res, "WalletNonUIExtension.ios.appext").exists()); + assertTrue(new File(staged, "WalletUIExtension.ios.appext").isFile()); + assertTrue(new File(staged, "WalletNonUIExtension.ios.appext").isFile()); + } + + @Test + public void everythingElseStaysWhereItIs() throws Exception { + File res = tmp.newFolder("res"); + write(new File(res, "WalletUIExtension.ios.appext")); + write(new File(res, "theme.res")); + write(new File(res, "notes.appext.txt")); + + IPhoneBuilder.stageAppExtensionArchives(res, new File(tmp.getRoot(), "appext")); + + assertTrue(new File(res, "theme.res").isFile()); + assertTrue(new File(res, "notes.appext.txt").isFile()); + assertEquals(2, res.listFiles().length); + } + + @Test + public void noArchiveMeansNoStagingDirectory() throws Exception { + File res = tmp.newFolder("res"); + write(new File(res, "theme.res")); + + File stagingDir = new File(tmp.getRoot(), "appext"); + assertNull(IPhoneBuilder.stageAppExtensionArchives(res, stagingDir)); + assertFalse(stagingDir.exists()); + } + + @Test + public void aSymlinkOutOfTheExtensionIsFound() throws Exception { + File extension = tmp.newFolder("dist", "WalletUIExtension"); + File outside = new File(tmp.getRoot(), "secret.mobileprovision"); + write(outside); + // Everything under an extension folder is handed to Xcode and copied into the app, so a + // link out of it would ship a file from the build machine inside the customer's IPA. + Files.createSymbolicLink(new File(extension, "notes.txt").toPath(), outside.toPath()); + + File found = IPhoneBuilder.symlinkEscaping(extension, extension); + + assertTrue(found != null && "notes.txt".equals(found.getName())); + } + + @Test + public void aSymlinkFoundDeeperDownIsFoundToo() throws Exception { + File extension = tmp.newFolder("dist", "WalletUIExtension"); + File nested = new File(extension, "Resources"); + assertTrue(nested.mkdirs()); + File outside = new File(tmp.getRoot(), "secret.mobileprovision"); + write(outside); + Files.createSymbolicLink(new File(nested, "logo.png").toPath(), outside.toPath()); + + assertTrue(IPhoneBuilder.symlinkEscaping(extension, extension) != null); + } + + @Test + public void anOrdinaryExtensionPasses() throws Exception { + File extension = tmp.newFolder("dist", "WalletUIExtension"); + write(new File(extension, "Info.plist")); + File nested = new File(extension, "Base.lproj"); + assertTrue(nested.mkdirs()); + write(new File(nested, "MainInterface.storyboard")); + // A link that stays inside the extension is not an escape. + Files.createSymbolicLink(new File(extension, "alias.plist").toPath(), + new File(extension, "Info.plist").toPath()); + + assertNull(IPhoneBuilder.symlinkEscaping(extension, extension)); + } + + private static void write(File file) throws Exception { + OutputStream out = new FileOutputStream(file); + try { + out.write("PK".getBytes("UTF-8")); + } finally { + out.close(); + } + } +} From 894b2d2c0a5eb46691b9fa06ad0a3e37d28910bc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:59:28 +0300 Subject: [PATCH 23/49] Expand the archive's own settings in a plist path; read a binary entitlements file Two review catches, mirrored from the cloud builder. INFOPLIST_FILE = $(PLIST_DIR)/Info.plist, with PLIST_DIR defined two lines above it in the same buildSettings.properties, is a path Xcode resolves: both settings are copied onto the target. Expanding only SRCROOT, PROJECT_DIR, TARGET_NAME and PRODUCT_NAME called it unresolvable and left the plist Xcode actually builds unstamped. Every declared setting now expands, to a fixed point, before those four. And an entitlements file may be binary -- Xcode writes those as readily as XML and they sign identically, but the XML walk cannot read one, so an issuer-provisioning extension was handed the 12.0 floor Apple rejects it for. Binary files go through plutil where there is one, and fall back to a byte search where there is not: the old behaviour, kept only for the file kind that has no comments to be fooled by. XML still goes through the parser. The two affected suites are re-ported with the new cases; 74 tests run against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 76 ++++++++++++++++++- .../AppExtensionDeploymentTargetTest.java | 26 +++++++ .../AppExtensionInfoPlistPathTest.java | 21 +++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index a5567ed23e1..b8f515f9495 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5995,7 +5995,7 @@ static Map appExtensionInfoPlists(File extensionFolder) { out.put("Info.plist", insideProjectDir(byDefault, extensionFolder.getParentFile()) ? byDefault : null); } else { - out.put(base.trim(), resolveInfoPlistPath(base.trim(), extensionFolder)); + out.put(base.trim(), resolveInfoPlistPath(base.trim(), extensionFolder, settings)); } for (Map.Entry setting : settings.entrySet()) { String key = setting.getKey(); @@ -6008,7 +6008,7 @@ static Map appExtensionInfoPlists(File extensionFolder) { } String value = setting.getValue() == null ? "" : setting.getValue().trim(); if (value.length() > 0 && !out.containsKey(value)) { - out.put(value, resolveInfoPlistPath(value, extensionFolder)); + out.put(value, resolveInfoPlistPath(value, extensionFolder, settings)); } } return out; @@ -6543,7 +6543,10 @@ static boolean entitlementIsTrue(File entitlements, String key) { return false; } if (rootDictAt(text) < 0) { - return false; + // Not XML we can walk -- most often a binary plist, which Xcode writes as readily as + // it writes XML and which signs exactly the same. Answering "no entitlement" here puts + // an issuer-provisioning extension back on the 12.0 floor Apple rejects it for. + return binaryEntitlementGrants(entitlements, key); } int afterKey = topLevelKeyEnd(text, key); if (afterKey < 0) { @@ -6553,6 +6556,54 @@ static boolean entitlementIsTrue(File entitlements, String key) { return element >= 0 && "true".equals(WatchNativeBuilder.tagAt(text, element)); } + /// A binary entitlements plist, read through plutil where there is one. + /// + /// The fallback is a search of the bytes, which is what this used to do to every entitlements + /// file and which was wrong for XML: there a mention inside a comment or an unrelated value + /// reads as a grant. A binary plist has no comments, and these entitlement names do not appear + /// as ordinary text, so on the file kind that is left it is a fair answer -- and erring toward + /// the 14.0 floor costs iOS 12 and 13 availability, while erring the other way costs the + /// upload. + private static boolean binaryEntitlementGrants(File entitlements, String key) { + String xml = plutilAsXml(entitlements); + if (xml != null && rootDictAt(xml) >= 0) { + int afterKey = topLevelKeyEnd(xml, key); + if (afterKey < 0) { + return false; + } + int element = nextMarkupAt(xml, afterKey); + return element >= 0 && "true".equals(WatchNativeBuilder.tagAt(xml, element)); + } + return fileContains(entitlements, key); + } + + /// The file as XML through /usr/bin/plutil, or null where that is not available or it refuses. + private static String plutilAsXml(File file) { + File plutil = new File("/usr/bin/plutil"); + if (!plutil.canExecute()) { + return null; + } + try { + Process p = new ProcessBuilder(plutil.getAbsolutePath(), "-convert", "xml1", "-o", "-", + file.getAbsolutePath()).redirectErrorStream(false).start(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream in = p.getInputStream(); + try { + byte[] buffer = new byte[4096]; + int read = in.read(buffer); + while (read > 0) { + out.write(buffer, 0, read); + read = in.read(buffer); + } + } finally { + try { in.close(); } catch (Throwable t) {} + } + return p.waitFor() == 0 ? new String(out.toByteArray(), StandardCharsets.UTF_8) : null; + } catch (Exception cannotRun) { + return null; + } + } + /// The minimum iOS version a brought-in app extension declares. /// /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as @@ -6774,6 +6825,25 @@ private static String resolveXcodeSettingsInPath(String path, File extensionFold File projectDir = extensionFolder.getParentFile(); String projectPath = projectDir == null ? "." : projectDir.getAbsolutePath(); String out = path; + // The archive's own settings first, and to a fixed point: INFOPLIST_FILE may be written as + // $(PLIST_DIR)/Info.plist with PLIST_DIR defined two lines above it in the same properties + // file. Both are copied onto the target, so Xcode resolves that path -- and expanding only + // the four names below called it unresolvable and left the plist Xcode actually builds + // unstamped. + Map declared = settings != null ? settings + : appExtensionBuildSettings(extensionFolder); + for (int pass = 0; pass < MAX_SETTING_EXPANSIONS + && BUILD_SETTING_REFERENCE.matcher(out).find(); pass++) { + String before = out; + for (Map.Entry setting : declared.entrySet()) { + if (setting.getValue() != null) { + out = replaceBuildSetting(out, setting.getKey(), setting.getValue().trim()); + } + } + if (out.equals(before)) { + break; + } + } out = replaceBuildSetting(out, "SRCROOT", projectPath); out = replaceBuildSetting(out, "PROJECT_DIR", projectPath); out = replaceBuildSetting(out, "TARGET_NAME", targetName); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index f5da630bca1..6e86eeb7901 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -186,4 +186,30 @@ public void aNestedMentionIsNotAGrant() throws Exception { + ""); assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, nested, "11")); } + + @Test + public void aBinaryEntitlementsPlistIsStillRead() throws Exception { + File file = new File(tmp.getRoot(), "binary.entitlements"); + // A real binary plist on a machine with plutil, and on one without it the byte fallback + // sees the same key. Either way an issuer-provisioning extension must not fall back to + // the 12.0 floor Apple rejects it for. + java.io.OutputStream out = new java.io.FileOutputStream(file); + try { + out.write("bplist00".getBytes("UTF-8")); + out.write("com.apple.developer.payment-pass-provisioning".getBytes("UTF-8")); + } finally { + out.close(); + } + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget(null, file, "11")); + } + + @Test + public void anXmlPlistIsStillJudgedByItsKeys() throws Exception { + // The byte fallback is for binary files only: XML still goes through the parser, where a + // mention in a comment is not a grant. + File commented = new File(tmp.getRoot(), "xml-comment.entitlements"); + write(commented, "\n\n" + + ""); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, commented, "11")); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java index 5a10922f05f..e36da0ecc74 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java @@ -139,6 +139,27 @@ public void aPlistBesideTheExtensionFolderIsStillAllowed() throws Exception { IPhoneBuilder.appExtensionInfoPlist(extension)); } + @Test + public void theArchivesOwnSettingsExpandInThePath() throws Exception { + File extension = extension(); + // Both settings are copied onto the target, so Xcode resolves this path; expanding only + // the built-in four called it unresolvable and left the real plist unstamped. + write(new File(extension, "buildSettings.properties"), + "PLIST_DIR = WalletUIExtension\nINFOPLIST_FILE = $(PLIST_DIR)/Info.plist\n"); + assertEquals(new File(extension, "Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + + @Test + public void aSettingThatNamesAnotherSettingExpandsToo() throws Exception { + File extension = extension(); + write(new File(extension, "buildSettings.properties"), + "ROOT = $(SRCROOT)/WalletUIExtension\nPLIST_DIR = $(ROOT)\n" + + "INFOPLIST_FILE = $(PLIST_DIR)/Custom-Info.plist\n"); + assertEquals(new File(extension, "Custom-Info.plist"), + IPhoneBuilder.appExtensionInfoPlist(extension)); + } + @Test public void anUnresolvableReferenceIsRefusedRatherThanGuessed() throws Exception { File extension = extension(); From 441081b44af62453fff45fe887de5ecc1686b0a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:43:57 +0300 Subject: [PATCH 24/49] Clamp the conditional settings, not just the plain ones Mirrors the cloud builder fix for two review catches of the same shape. Xcode honours IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] over the plain setting for the build it matches, and every entry in buildSettings.properties is copied onto the target verbatim -- so an archive pinning a qualified 10.0 got exactly that on the device archive while the floor computed for the base key sat unused beside it. Same for a qualified PRODUCT_BUNDLE_IDENTIFIER carrying the identifier from the project the extension was exported from: the base check saw the value this builder had just set and was satisfied. A qualified deployment target below the floor is raised to it; a qualified identifier that cannot be an extension of this app is dropped so the base value governs. Both are logged. A key whose '=' was not escaped is left alone: Properties mangles it and Xcode does not recognise it. 77 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 54 ++++++++++++++++++- .../AppExtensionDeploymentTargetTest.java | 51 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b8f515f9495..8732f6d6b47 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4918,6 +4918,11 @@ public void usesClassMethod(String cls, String method) { signedEntitlements, request.getArg("ios.deployment_target", null)); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); + for (String note : repairQualifiedExtensionSettings(buildSettingsMap, + request.getPackageName(), + appExtensionDeploymentFloor(signedEntitlements))) { + debug("The " + extensionName + " app extension: " + note + "."); + } // Guarded so the post-dependency re-run of fix_xcode_schemes.rb // doesn't create duplicate extension targets. @@ -6604,6 +6609,53 @@ private static String plutilAsXml(File file) { } } + /// The lowest iOS an extension with these entitlements may declare. + static String appExtensionDeploymentFloor(File entitlements) { + return entitlementIsTrue(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; + } + + /// Brings the archive's CONDITIONAL settings in line with the ones computed above, and says + /// what it changed. + /// + /// Xcode honours IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] over the plain setting for the + /// build it matches, and every entry in buildSettings.properties is copied onto the target + /// verbatim -- so an archive that pins a qualified 10.0, or a qualified identifier from the + /// project it was exported from, gets exactly that on the device archive while the values + /// computed for the base key sit unused beside them. Clamping the base alone fixed the build + /// nobody was shipping. + /// + /// A qualified deployment target below the floor is raised to it; a qualified identifier that + /// cannot be an extension of this app is dropped, which leaves the base value -- the one this + /// builder set -- to govern. + /// + /// @return a note per change, for the log + static List repairQualifiedExtensionSettings(Map settings, + String hostPackage, String floor) { + List notes = new ArrayList(); + for (Map.Entry setting : new ArrayList>( + settings.entrySet())) { + String key = setting.getKey(); + String value = setting.getValue() == null ? "" : setting.getValue().trim(); + if (isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") + && isDeploymentTargetBelow(value, floor)) { + settings.put(key, floor); + notes.add(key + " raised from " + value + " to " + floor); + } else if (isQualified(key, "PRODUCT_BUNDLE_IDENTIFIER") && hostPackage != null + && value.length() > 0 && !value.startsWith(hostPackage + ".")) { + settings.remove(key); + notes.add(key + " = " + value + " dropped, since an embedded extension must be " + + "under " + hostPackage); + } + } + return notes; + } + + /// Whether a settings key is the conditional form of {@code name}, as Xcode writes it and as + /// Properties preserves it only when the '=' inside the brackets is escaped. + private static boolean isQualified(String key, String name) { + return key.startsWith(name + "[") && key.endsWith("]"); + } + /// The minimum iOS version a brought-in app extension declares. /// /// Xcode writes the target's IPHONEOS_DEPLOYMENT_TARGET into the built .appex as @@ -6626,7 +6678,7 @@ static String appExtensionDeploymentTarget(String declared, File entitlements, S // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would // reproduce the very rejection this exists to prevent -- 10.0 does not even build against // the current SDK, and an issuer-provisioning Wallet extension is refused below 14. - String floor = entitlementIsTrue(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; + String floor = appExtensionDeploymentFloor(entitlements); String chosen = declared != null && declared.trim().length() > 0 ? declared.trim() : appTarget; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 6e86eeb7901..1df1ce3c04c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -31,6 +31,7 @@ import java.io.OutputStream; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class AppExtensionDeploymentTargetTest { @@ -212,4 +213,54 @@ public void anXmlPlistIsStillJudgedByItsKeys() throws Exception { + ""); assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget(null, commented, "11")); } + + @Test + public void aQualifiedDeploymentTargetIsClampedToo() throws Exception { + // Xcode picks the qualified value for the device build, so clamping only the base left the + // archive shipping 10.0 -- the very rejection the floor exists to prevent. + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]", "15.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0"); + + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals("15.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]")); + assertEquals(1, notes.size()); + } + + @Test + public void aQualifiedIdentifierFromAnotherProjectIsDropped() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "com.old.project.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]", "com.example.app.Ext.sim"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0"); + + // Dropped, so the base value -- the one this builder set -- governs the device build. + assertFalse(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); + assertEquals("com.example.app.Ext.sim", + settings.get("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]")); + assertEquals("com.example.app.Ext", settings.get("PRODUCT_BUNDLE_IDENTIFIER")); + assertEquals(1, notes.size()); + } + + @Test + public void settingsThatAreAlreadyFineAreLeftAlone() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "16.0"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "com.example.app.Ext"); + // and a mangled key, which Xcode does not honour and which nothing here should touch + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0"); + + assertTrue(notes.toString(), notes.isEmpty()); + assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk")); + } } From 4f527a2ac72c94d28ff341c40b27fad24959912c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:46:25 +0300 Subject: [PATCH 25/49] Refuse an extension identifier outside the app, don't just say so Mirrors the cloud builder fix. An out-of-namespace PRODUCT_BUNDLE_IDENTIFIER meant the same value went onto the target and the .appex reached Apple with an identifier the embedded-bundle check refuses. Nothing downstream rescues it -- Apple requires an embedded bundle to sit under its container's identifier, and no profile of this app's can sign one that does not -- so the build stops with a message naming the value, the app it must sit under, the file to change and the default it would take instead. 79 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 31 +++++++++++++++++++ .../AppExtensionDeploymentTargetTest.java | 20 ++++++++++++ 2 files changed, 51 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 8732f6d6b47..dedb3694ec3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4863,6 +4863,18 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." +extensionName); + String outOfNamespace = outOfNamespaceExtensionIdMessage(extensionName, + appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER") != null + ? appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER") + : request.getPackageName() + "." + extensionName, + request.getPackageName()); + if (outOfNamespace != null) { + // Refused rather than logged: Apple requires an embedded bundle to + // sit under its container's identifier, no profile of this app's + // can sign one that does not, and building on costs a full archive + // and upload to be told the same thing later. + throw new BuildException(outOfNamespace); + } stampAppExtensionInfoPlist(appExtension, request); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); buildSettingsMap.put("PROVISIONING_PROFILE", "$(NS_PROVISIONING_PROFILE)"); @@ -6609,6 +6621,25 @@ private static String plutilAsXml(File file) { } } + /// Why an extension's identifier cannot ship, or null when it can. + /// + /// An embedded bundle must sit under the identifier of the app that carries it -- Apple's + /// rule, checked on upload -- so an archive whose PRODUCT_BUNDLE_IDENTIFIER points somewhere + /// else describes an extension this app can never ship, whatever the rest of the build does. + static String outOfNamespaceExtensionIdMessage(String extensionName, String effectiveId, + String hostPackage) { + if (hostPackage == null || hostPackage.length() == 0 || effectiveId == null + || effectiveId.startsWith(hostPackage + ".")) { + return null; + } + return "The " + extensionName + " app extension is set to build as '" + effectiveId + + "', which is not under the app's own '" + hostPackage + "'. An embedded " + + "extension must be, or Apple refuses the upload and no profile of this app's " + + "can sign it. Fix PRODUCT_BUNDLE_IDENTIFIER in " + extensionName + + "/buildSettings.properties, or remove it to take the default of " + hostPackage + + "." + extensionName + "."; + } + /// The lowest iOS an extension with these entitlements may declare. static String appExtensionDeploymentFloor(File entitlements) { return entitlementIsTrue(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 1df1ce3c04c..cb95ec710df 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -31,6 +31,7 @@ import java.io.OutputStream; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -263,4 +264,23 @@ public void settingsThatAreAlreadyFineAreLeftAlone() throws Exception { assertTrue(notes.toString(), notes.isEmpty()); assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk")); } + + @Test + public void anIdentifierOutsideTheAppStopsTheBuild() throws Exception { + // The message is the build's last word on it, so it has to say what to change. + String message = IPhoneBuilder.outOfNamespaceExtensionIdMessage("WalletUIExtension", + "com.old.project.WalletUIExtension", "com.example.app"); + assertTrue(message, message.contains("com.old.project.WalletUIExtension")); + assertTrue(message, message.contains("buildSettings.properties")); + assertTrue(message, message.contains("com.example.app.WalletUIExtension")); + } + + @Test + public void anIdentifierUnderTheAppIsNoProblem() throws Exception { + assertNull(IPhoneBuilder.outOfNamespaceExtensionIdMessage("WalletUIExtension", + "com.example.app.WalletUIExtension", "com.example.app")); + // and with no package to judge against, this is not the check that should fail the build + assertNull(IPhoneBuilder.outOfNamespaceExtensionIdMessage("WalletUIExtension", + "com.anything.Ext", null)); + } } From 5c674a6d4cd896c85a1c46f5b7b64f57cd33aeaf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:40:22 +0300 Subject: [PATCH 26/49] Judge a setting by what it resolves to, before clamping or refusing it Mirrors the cloud builder fix for two review catches in yesterday's checks, both of which compared raw text. IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] = $(EXTENSION_MIN), with EXTENSION_MIN = 16.0 beside it, is a good iOS 16 target; the reference parsed as no version, read as below the floor, and was overwritten with 12.0. PRODUCT_BUNDLE_IDENTIFIER = $(EXTENSION_ID) is a good identifier; the namespace check refused the build because the raw text does not start with the app's package. Both resolve through the archive's own settings first. The target keeps the reference, since Xcode resolves it there. A reference this build cannot resolve is left as written -- not clamped, not dropped, not refused. 83 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 33 +++++++++--- .../AppExtensionDeploymentTargetTest.java | 54 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index dedb3694ec3..f71f016cf97 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4863,11 +4863,19 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." +extensionName); - String outOfNamespace = outOfNamespaceExtensionIdMessage(extensionName, - appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER") != null - ? appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER") - : request.getPackageName() + "." + extensionName, - request.getPackageName()); + // The identifier as Xcode will see it: an archive may write + // PRODUCT_BUNDLE_IDENTIFIER = $(EXTENSION_ID) with EXTENSION_ID beside + // it, which resolves to a perfectly good identifier. Judging the raw + // reference would refuse a build that works, and a reference this + // build cannot resolve is not judged at all. + Map declaredSettings = appExtensionBuildSettings(appExtension); + String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); + String resolvedBundleId = resolveSettingsInValue(declaredId != null + ? declaredId : request.getPackageName() + "." + extensionName, + declaredSettings); + String outOfNamespace = resolvedBundleId.length() == 0 ? null + : outOfNamespaceExtensionIdMessage(extensionName, resolvedBundleId, + request.getPackageName()); if (outOfNamespace != null) { // Refused rather than logged: Apple requires an embedded bundle to // sit under its container's identifier, no profile of this app's @@ -6667,12 +6675,23 @@ static List repairQualifiedExtensionSettings(Map setting settings.entrySet())) { String key = setting.getKey(); String value = setting.getValue() == null ? "" : setting.getValue().trim(); + // What the value RESOLVES to, since a qualified setting may be written through another + // one -- IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] = $(EXTENSION_MIN) with + // EXTENSION_MIN = 16.0 is a perfectly good iOS 16 target. Comparing the raw text made + // "$(EXTENSION_MIN)" parse as no version at all, read as below the floor, and be + // overwritten with 12.0 -- taking an extension that compiles against iOS 16 APIs down + // with it. A reference that resolves to nothing here is left exactly as written: this + // build cannot evaluate it, which is not the same as knowing it is wrong. + String resolved = resolveSettingsInValue(value, settings); + if (resolved.length() == 0) { + continue; + } if (isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") - && isDeploymentTargetBelow(value, floor)) { + && isDeploymentTargetBelow(resolved, floor)) { settings.put(key, floor); notes.add(key + " raised from " + value + " to " + floor); } else if (isQualified(key, "PRODUCT_BUNDLE_IDENTIFIER") && hostPackage != null - && value.length() > 0 && !value.startsWith(hostPackage + ".")) { + && !resolved.startsWith(hostPackage + ".")) { settings.remove(key); notes.add(key + " = " + value + " dropped, since an embedded extension must be " + "under " + hostPackage); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index cb95ec710df..9013c48ef7c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -283,4 +283,58 @@ public void anIdentifierUnderTheAppIsNoProblem() throws Exception { assertNull(IPhoneBuilder.outOfNamespaceExtensionIdMessage("WalletUIExtension", "com.anything.Ext", null)); } + + @Test + public void aQualifiedTargetWrittenThroughAnotherSettingIsKept() throws Exception { + // $(EXTENSION_MIN) is not a number, and reading that as "below the floor" overwrote an + // extension's iOS 16 target with 12.0 -- taking its iOS 16 APIs down with it. + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "16.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(EXTENSION_MIN)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0"); + + assertEquals("$(EXTENSION_MIN)", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertTrue(notes.toString(), notes.isEmpty()); + } + + @Test + public void aQualifiedTargetResolvingBelowTheFloorIsStillClamped() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(EXTENSION_MIN)"); + + IPhoneBuilder.repairQualifiedExtensionSettings(settings, "com.example.app", "14.0"); + + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + } + + @Test + public void aReferenceThisBuildCannotResolveIsLeftAsWritten() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(SOMETHING_ELSE)"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "$(SOMETHING_ELSE)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0"); + + // Not evaluable here is not the same as known to be wrong. + assertEquals("$(SOMETHING_ELSE)", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); + assertTrue(notes.isEmpty()); + } + + @Test + public void aQualifiedIdentifierWrittenThroughAnotherSettingIsKept() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_ID", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "$(EXTENSION_ID)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0"); + + assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); + assertTrue(notes.isEmpty()); + } } From 0f4463f4f1e29c490c336dac73ca50dd41db07ae Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:49:37 +0300 Subject: [PATCH 27/49] Resolve through Xcode's own settings, and read every entitlements file Mirrors the cloud builder fix for three review catches. A base IPHONEOS_DEPLOYMENT_TARGET may be written through another setting, not only a qualified one: $(EXTENSION_MIN) parsed as no version, read as below the floor, and was replaced by it. An identifier may be written through a setting XCODE defines rather than one the archive does -- com.example.app.$(TARGET_NAME) is an ordinary way to spell it -- and resolving with the archive's settings alone deleted the reference and recorded "com.example.app." as the export-options key, which matches nothing in the archive. TARGET_NAME, PRODUCT_NAME, SRCROOT and PROJECT_DIR now take part, since this build knows all four. And CODE_SIGN_ENTITLEMENTS has qualified forms like everything else: an archive granting payment-pass-provisioning only in its device entitlements was read as granting nothing and kept the 12.0 floor. Every file the target may be signed with is read, and the highest floor any of them asks for wins. 87 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 111 ++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 50 ++++++++ 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f71f016cf97..80434cb2e2f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4872,7 +4872,7 @@ public void usesClassMethod(String cls, String method) { String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); String resolvedBundleId = resolveSettingsInValue(declaredId != null ? declaredId : request.getPackageName() + "." + extensionName, - declaredSettings); + extensionSettingsWithBuiltIns(appExtension, declaredSettings)); String outOfNamespace = resolvedBundleId.length() == 0 ? null : outOfNamespaceExtensionIdMessage(extensionName, resolvedBundleId, request.getPackageName()); @@ -4930,17 +4930,17 @@ public void usesClassMethod(String cls, String method) { // necessarily the one picked by name: buildSettings.properties may set // CODE_SIGN_ENTITLEMENTS at another file, and it is that file's // payment-pass-provisioning that decides whether iOS 14 is the floor. - File signedEntitlements = appExtensionSignedEntitlements(appExtension, - buildSettingsMap.get("CODE_SIGN_ENTITLEMENTS"), extEntitlementsFile, - buildSettingsMap); + List signingEntitlements = appExtensionEntitlementsCandidates( + appExtension, buildSettingsMap, extEntitlementsFile); String extDeploymentTarget = appExtensionDeploymentTarget( buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), - signedEntitlements, - request.getArg("ios.deployment_target", null)); + signingEntitlements, + request.getArg("ios.deployment_target", null), + appExtension, buildSettingsMap); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); for (String note : repairQualifiedExtensionSettings(buildSettingsMap, request.getPackageName(), - appExtensionDeploymentFloor(signedEntitlements))) { + appExtensionDeploymentFloor(signingEntitlements))) { debug("The " + extensionName + " app extension: " + note + "."); } @@ -6648,6 +6648,78 @@ static String outOfNamespaceExtensionIdMessage(String extensionName, String effe + "." + extensionName + "."; } + /// The archive's settings plus the ones Xcode defines for this target itself. + /// + /// A value written through TARGET_NAME or PRODUCT_NAME -- com.example.app.$(TARGET_NAME) is + /// an ordinary way to write an extension's identifier -- resolves on the build machine and + /// must resolve here too. Without them the reference is simply deleted, and what was recorded + /// for the export-options dictionary was "com.example.app.", a key matching nothing in the + /// archive. + static Map extensionSettingsWithBuiltIns(File extensionFolder, + Map settings) { + Map out = new LinkedHashMap(); + if (settings != null) { + out.putAll(settings); + } + String targetName = extensionFolder.getName(); + out.put("TARGET_NAME", targetName); + String productName = out.get("PRODUCT_NAME"); + if (productName == null || productName.indexOf('$') >= 0) { + // PRODUCT_NAME is $(TARGET_NAME) unless the archive says otherwise, which is what this + // builder writes onto the target. + out.put("PRODUCT_NAME", targetName); + } + File projectDir = extensionFolder.getParentFile(); + String projectPath = projectDir == null ? "." : projectDir.getAbsolutePath(); + if (!out.containsKey("SRCROOT")) { + out.put("SRCROOT", projectPath); + } + if (!out.containsKey("PROJECT_DIR")) { + out.put("PROJECT_DIR", projectPath); + } + return out; + } + + /// Every entitlements file this target may be signed with: the plain CODE_SIGN_ENTITLEMENTS + /// and each qualified one. + /// + /// Xcode honours CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] over the plain setting for the device + /// archive, so an archive that grants payment-pass-provisioning only in its device + /// entitlements was read as granting nothing and kept the 12.0 floor Apple rejects it for. + static List appExtensionEntitlementsCandidates(File extensionFolder, + Map settings, File byName) { + List out = new ArrayList(); + if (settings != null) { + for (Map.Entry setting : settings.entrySet()) { + String key = setting.getKey(); + if (!"CODE_SIGN_ENTITLEMENTS".equals(key) + && !isQualified(key, "CODE_SIGN_ENTITLEMENTS")) { + continue; + } + File resolved = appExtensionSignedEntitlements(extensionFolder, setting.getValue(), + null, settings); + if (resolved != null && !out.contains(resolved)) { + out.add(resolved); + } + } + } + if (out.isEmpty() && byName != null) { + out.add(byName); + } + return out; + } + + /// The floor for an extension that may be signed with any of these: the highest any of them + /// asks for. An extension whose DEVICE entitlements need iOS 14 needs iOS 14. + static String appExtensionDeploymentFloor(List entitlements) { + for (File file : entitlements) { + if (entitlementIsTrue(file, PAYMENT_PASS_PROVISIONING)) { + return "14.0"; + } + } + return "12.0"; + } + /// The lowest iOS an extension with these entitlements may declare. static String appExtensionDeploymentFloor(File entitlements) { return entitlementIsTrue(entitlements, PAYMENT_PASS_PROVISIONING) ? "14.0" : "12.0"; @@ -6724,6 +6796,22 @@ private static boolean isQualified(String key, String name) { /// @param entitlements the extension's .entitlements, or null when it has none /// @param appTarget the ios.deployment_target build hint, or null static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget) { + return appExtensionDeploymentTarget(declared, entitlements, appTarget, null, null); + } + + /// @param extensionFolder and {@code settings}, so a declared target written as + /// $(EXTENSION_MIN) is judged by the version it resolves to rather than parsed as none + static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget, + File extensionFolder, Map settings) { + return appExtensionDeploymentTarget(declared, + entitlements == null ? new ArrayList() : Arrays.asList(entitlements), + appTarget, extensionFolder, settings); + } + + /// @param entitlements every file this target may be signed with, since a qualified + /// CODE_SIGN_ENTITLEMENTS can be the one that carries the Wallet entitlement + static String appExtensionDeploymentTarget(String declared, List entitlements, + String appTarget, File extensionFolder, Map settings) { // The floor is a floor, not a default. An archive exported from an old project may carry // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would // reproduce the very rejection this exists to prevent -- 10.0 does not even build against @@ -6732,6 +6820,15 @@ static String appExtensionDeploymentTarget(String declared, File entitlements, S String chosen = declared != null && declared.trim().length() > 0 ? declared.trim() : appTarget; + if (chosen != null && chosen.indexOf('$') >= 0) { + // Written through another setting. What it resolves to decides whether it clears the + // floor; the declared text is what the target keeps, because Xcode resolves it there + // and a reference this build cannot evaluate is not one to overwrite with a guess. + String resolved = extensionFolder == null ? "" : resolveSettingsInValue(chosen, + extensionSettingsWithBuiltIns(extensionFolder, settings)); + return resolved.length() > 0 && isDeploymentTargetBelow(resolved, floor) + ? floor : chosen; + } return isDeploymentTargetBelow(chosen, floor) ? floor : normalizeVersion(chosen.trim()); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 9013c48ef7c..a607ef3ac05 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -337,4 +337,54 @@ public void aQualifiedIdentifierWrittenThroughAnotherSettingIsKept() throws Exce assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); assertTrue(notes.isEmpty()); } + + @Test + public void aBaseTargetWrittenThroughAnotherSettingIsKept() throws Exception { + File extension = tmp.newFolder("dist9", "WalletUIExtension"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "16.0"); + // The reference parsed as no version at all, so the floor overwrote an iOS 16 target. + assertEquals("$(EXTENSION_MIN)", IPhoneBuilder.appExtensionDeploymentTarget( + "$(EXTENSION_MIN)", (File) null, "11", extension, settings)); + } + + @Test + public void aBaseTargetResolvingBelowTheFloorIsStillClamped() throws Exception { + File extension = tmp.newFolder("dist10", "WalletUIExtension"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "10.0"); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentTarget( + "$(EXTENSION_MIN)", (File) null, "11", extension, settings)); + } + + @Test + public void anIdentifierThroughTargetNameResolvesRatherThanTruncating() throws Exception { + File extension = tmp.newFolder("dist11", "WalletUIExtension"); + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, new java.util.LinkedHashMap()); + // Deleting $(TARGET_NAME) recorded "com.example.app." as the export-options key, matching + // nothing in the archive. + assertEquals("WalletUIExtension", settings.get("TARGET_NAME")); + assertEquals("WalletUIExtension", settings.get("PRODUCT_NAME")); + } + + @Test + public void aQualifiedEntitlementsFileCanRaiseTheFloor() throws Exception { + File extension = tmp.newFolder("dist12", "WalletUIExtension"); + File plain = new File(extension, "Plain.entitlements"); + write(plain, ""); + File device = new File(extension, "Device.entitlements"); + write(device, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/Plain.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "WalletUIExtension/Device.entitlements"); + + java.util.List candidates = IPhoneBuilder.appExtensionEntitlementsCandidates( + extension, settings, null); + + // The device archive is signed with the qualified file, so its entitlement decides. + assertEquals(2, candidates.size()); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentFloor(candidates)); + } } From e87bb8d92a43a7b88c5445f0f09ce8a33249e66d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:56:57 +0300 Subject: [PATCH 28/49] Expand a PRODUCT_NAME chain; ignore conditions this archive never uses Mirrors the cloud builder fix for two review catches. PRODUCT_NAME = $(EXTENSION_NAME), with EXTENSION_NAME beside it, is a chain Xcode expands; seeing a '$' and flattening it to the folder name recorded an identifier the archive does not contain. The chain resolves now, with the folder name as the fallback when nothing does. And a qualified entitlements file only decides this archive's floor if this archive is signed with it: CODE_SIGN_ENTITLEMENTS[config=Debug] or [sdk=iphonesimulator*] was raising a release device build to iOS 14 and dropping the extension off iOS 12 and 13 for an entitlement it is never signed with. Conditions are matched against the sdk and configuration being built; anything this build cannot answer counts as applicable, since guessing it away risks shipping without an entitlement. 91 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 68 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 65 ++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 80434cb2e2f..4ab7149ed47 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4931,7 +4931,9 @@ public void usesClassMethod(String cls, String method) { // CODE_SIGN_ENTITLEMENTS at another file, and it is that file's // payment-pass-provisioning that decides whether iOS 14 is the floor. List signingEntitlements = appExtensionEntitlementsCandidates( - appExtension, buildSettingsMap, extEntitlementsFile); + appExtension, buildSettingsMap, extEntitlementsFile, "iphoneos", + request.getArg("ios.buildType", "debug").equals("debug") + ? "Debug" : "Release"); String extDeploymentTarget = appExtensionDeploymentTarget( buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), signingEntitlements, @@ -6663,12 +6665,14 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, } String targetName = extensionFolder.getName(); out.put("TARGET_NAME", targetName); + // PRODUCT_NAME is $(TARGET_NAME) unless the archive says otherwise -- but "otherwise" may + // itself be a reference: PRODUCT_NAME = $(EXTENSION_NAME) with EXTENSION_NAME beside it is + // a chain Xcode expands, and flattening it to the folder name here recorded an identifier + // the archive does not contain. String productName = out.get("PRODUCT_NAME"); - if (productName == null || productName.indexOf('$') >= 0) { - // PRODUCT_NAME is $(TARGET_NAME) unless the archive says otherwise, which is what this - // builder writes onto the target. - out.put("PRODUCT_NAME", targetName); - } + String resolvedProductName = productName == null ? "" + : resolveSettingsInValue(productName, out); + out.put("PRODUCT_NAME", resolvedProductName.length() > 0 ? resolvedProductName : targetName); File projectDir = extensionFolder.getParentFile(); String projectPath = projectDir == null ? "." : projectDir.getAbsolutePath(); if (!out.containsKey("SRCROOT")) { @@ -6688,6 +6692,15 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, /// entitlements was read as granting nothing and kept the 12.0 floor Apple rejects it for. static List appExtensionEntitlementsCandidates(File extensionFolder, Map settings, File byName) { + return appExtensionEntitlementsCandidates(extensionFolder, settings, byName, null, null); + } + + /// @param sdk the SDK this build archives against ("iphoneos" or "iphonesimulator"), and + /// {@code configuration} its configuration ("Release" or "Debug"); a qualified setting whose + /// condition names a different one is not part of THIS archive and does not decide its floor. + /// Null for either means "cannot tell", and then every condition counts. + static List appExtensionEntitlementsCandidates(File extensionFolder, + Map settings, File byName, String sdk, String configuration) { List out = new ArrayList(); if (settings != null) { for (Map.Entry setting : settings.entrySet()) { @@ -6696,6 +6709,12 @@ static List appExtensionEntitlementsCandidates(File extensionFolder, && !isQualified(key, "CODE_SIGN_ENTITLEMENTS")) { continue; } + if (!conditionApplies(key, sdk, configuration)) { + // A Debug-only or simulator-only entitlement is not signed into the release + // device archive, so raising its minimum iOS for one costs the extension every + // iOS 12 and 13 device for nothing. + continue; + } File resolved = appExtensionSignedEntitlements(extensionFolder, setting.getValue(), null, settings); if (resolved != null && !out.contains(resolved)) { @@ -6772,6 +6791,43 @@ && isDeploymentTargetBelow(resolved, floor)) { return notes; } + /// Whether a qualified setting's condition can apply to the build being made. + /// + /// Only the two conditions this build knows its own answer to are judged -- sdk and config. + /// Anything else (arch, variant, a spelling not seen here) counts as applicable: guessing that + /// a condition does not apply risks signing an extension without an entitlement it needs, + /// which fails the upload, while over-counting only costs iOS 12 and 13 availability. + static boolean conditionApplies(String key, String sdk, String configuration) { + int open = key.indexOf('['); + if (open < 0) { + return true; + } + for (String condition : key.substring(open).split("[\\[\\],]")) { + int equals = condition.indexOf('='); + if (equals < 0) { + continue; + } + String name = condition.substring(0, equals).trim(); + String value = condition.substring(equals + 1).trim(); + if ("sdk".equals(name) && sdk != null && !matchesCondition(value, sdk)) { + return false; + } + if ("config".equals(name) && configuration != null + && !matchesCondition(value, configuration)) { + return false; + } + } + return true; + } + + /// A condition value against what this build is, with Xcode's trailing {@code *}. + private static boolean matchesCondition(String value, String actual) { + if (value.endsWith("*")) { + return actual.regionMatches(true, 0, value, 0, value.length() - 1); + } + return value.equalsIgnoreCase(actual); + } + /// Whether a settings key is the conditional form of {@code name}, as Xcode writes it and as /// Properties preserves it only when the '=' inside the brackets is escaped. private static boolean isQualified(String key, String name) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index a607ef3ac05..8afa8978e0b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -387,4 +387,69 @@ public void aQualifiedEntitlementsFileCanRaiseTheFloor() throws Exception { assertEquals(2, candidates.size()); assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentFloor(candidates)); } + + @Test + public void anIndirectProductNameKeepsItsChain() throws Exception { + File extension = tmp.newFolder("dist13", "WalletUIExtension"); + java.util.Map declared = new java.util.LinkedHashMap(); + declared.put("EXTENSION_NAME", "WalletKit"); + declared.put("PRODUCT_NAME", "$(EXTENSION_NAME)"); + + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, declared); + + // Xcode expands the chain; flattening it to the folder name recorded an identifier the + // archive does not contain. + assertEquals("WalletKit", settings.get("PRODUCT_NAME")); + assertEquals("WalletUIExtension", settings.get("TARGET_NAME")); + } + + @Test + public void anUnresolvableProductNameFallsBackToTheTarget() throws Exception { + File extension = tmp.newFolder("dist14", "WalletUIExtension"); + java.util.Map declared = new java.util.LinkedHashMap(); + declared.put("PRODUCT_NAME", "$(TARGET_NAME)"); + assertEquals("WalletUIExtension", IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, declared).get("PRODUCT_NAME")); + } + + @Test + public void aConditionForAnotherBuildDoesNotRaiseThisFloor() throws Exception { + File extension = tmp.newFolder("dist15", "WalletUIExtension"); + File release = new File(extension, "Release.entitlements"); + write(release, ""); + File debug = new File(extension, "Debug.entitlements"); + write(debug, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/Release.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[config=Debug]", "WalletUIExtension/Debug.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]", + "WalletUIExtension/Debug.entitlements"); + + java.util.List forRelease = IPhoneBuilder.appExtensionEntitlementsCandidates( + extension, settings, null, "iphoneos", "Release"); + + // The release device archive is not signed with either of those, so neither decides its + // minimum iOS -- raising it would drop the extension off iOS 12 and 13 for nothing. + assertEquals(1, forRelease.size()); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentFloor(forRelease)); + } + + @Test + public void aConditionForThisBuildStillCounts() throws Exception { + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", + "iphoneos", "Release")); + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[config=Release]", + "iphoneos", "Release")); + assertFalse(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]", + "iphoneos", "Release")); + assertFalse(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[config=Debug]", + "iphoneos", "Release")); + // a condition this build has no answer for, and one it cannot read, both count + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[arch=arm64]", + "iphoneos", "Release")); + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", + null, null)); + } } From d20771c38dc27f923f2e9308e4b27d5ea952f396 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:05:30 +0300 Subject: [PATCH 29/49] A qualified setting overrides the plain one; it does not add to it Mirrors the cloud builder fix for three review catches that were all one misunderstanding: a conditional setting is the VALUE for the builds it matches, not extra information about them. Xcode signs the device archive with CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] alone, so reading it alongside the plain file and taking the stricter answer raised an extension to iOS 14 for an entitlement it never carries. The same for the identifier: a stale plain PRODUCT_BUNDLE_IDENTIFIER beside a correct device value builds fine, and refusing on the plain value stopped a build Xcode would have got right. And a [config=...] condition is matched against the configuration handed to xcodebuild, which is Release for a device archive whatever ios.buildType says. All three go through one rule now -- the most specific applicable condition wins, the plain setting being the least specific -- which is Xcode's own. 95 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 78 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 62 +++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4ab7149ed47..6c830b7e66e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4870,8 +4870,16 @@ public void usesClassMethod(String cls, String method) { // build cannot resolve is not judged at all. Map declaredSettings = appExtensionBuildSettings(appExtension); String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); - String resolvedBundleId = resolveSettingsInValue(declaredId != null - ? declaredId : request.getPackageName() + "." + extensionName, + // The identifier THIS archive gets: a qualified setting overrides the + // plain one, so a stale base beside a right device value is not a + // reason to refuse a build Xcode would have got right. + String governingId = winningSetting(declaredSettings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos", "Release"); + String resolvedBundleId = resolveSettingsInValue( + governingId != null && governingId.trim().length() > 0 + ? governingId.trim() + : (declaredId != null ? declaredId + : request.getPackageName() + "." + extensionName), extensionSettingsWithBuiltIns(appExtension, declaredSettings)); String outOfNamespace = resolvedBundleId.length() == 0 ? null : outOfNamespaceExtensionIdMessage(extensionName, resolvedBundleId, @@ -4930,12 +4938,17 @@ public void usesClassMethod(String cls, String method) { // necessarily the one picked by name: buildSettings.properties may set // CODE_SIGN_ENTITLEMENTS at another file, and it is that file's // payment-pass-provisioning that decides whether iOS 14 is the floor. - List signingEntitlements = appExtensionEntitlementsCandidates( - appExtension, buildSettingsMap, extEntitlementsFile, "iphoneos", - request.getArg("ios.buildType", "debug").equals("debug") - ? "Debug" : "Release"); + // The configuration this build hands to xcodebuild is Release for a + // device archive whatever ios.buildType says, so that is what a + // [config=...] condition must be matched against. + String archiveSdk = "iphoneos"; + String archiveConfiguration = "Release"; + File signingEntitlements = appExtensionSigningEntitlements(appExtension, + buildSettingsMap, extEntitlementsFile, archiveSdk, + archiveConfiguration); String extDeploymentTarget = appExtensionDeploymentTarget( - buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), + winningSetting(buildSettingsMap, "IPHONEOS_DEPLOYMENT_TARGET", + archiveSdk, archiveConfiguration), signingEntitlements, request.getArg("ios.deployment_target", null), appExtension, buildSettingsMap); @@ -6695,6 +6708,16 @@ static List appExtensionEntitlementsCandidates(File extensionFolder, return appExtensionEntitlementsCandidates(extensionFolder, settings, byName, null, null); } + /// The one entitlements file this archive is signed with. + static File appExtensionSigningEntitlements(File extensionFolder, Map settings, + File byName, String sdk, String configuration) { + String winner = winningSetting(settings, "CODE_SIGN_ENTITLEMENTS", sdk, configuration); + if (winner == null || winner.trim().length() == 0) { + return byName; + } + return appExtensionSignedEntitlements(extensionFolder, winner, byName, settings); + } + /// @param sdk the SDK this build archives against ("iphoneos" or "iphonesimulator"), and /// {@code configuration} its configuration ("Release" or "Debug"); a qualified setting whose /// condition names a different one is not part of THIS archive and does not decide its floor. @@ -6791,6 +6814,47 @@ && isDeploymentTargetBelow(resolved, floor)) { return notes; } + /// The value of {@code name} that governs THIS archive. + /// + /// Xcode does not merge a qualified setting with the plain one, it OVERRIDES it: with both + /// CODE_SIGN_ENTITLEMENTS and CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] present, the device + /// archive is signed with the qualified file alone. Reading both and taking the stricter + /// answer raised an extension to iOS 14 for an entitlement in a file it is not signed with; + /// reading only the plain one missed the entitlement that is. The most specific applicable + /// condition wins, which is Xcode's own rule, and the plain setting is the least specific + /// thing there is. + /// + /// @return the winning value, or null when nothing applicable is declared + static String winningSetting(Map settings, String name, String sdk, + String configuration) { + if (settings == null) { + return null; + } + String winner = null; + int winningSpecificity = -1; + for (Map.Entry setting : settings.entrySet()) { + String key = setting.getKey(); + boolean qualified = isQualified(key, name); + if (!qualified && !name.equals(key)) { + continue; + } + if (qualified && !conditionApplies(key, sdk, configuration)) { + continue; + } + int specificity = 0; + for (int i = 0; qualified && i < key.length(); i++) { + if (key.charAt(i) == '=') { + specificity++; + } + } + if (specificity > winningSpecificity) { + winningSpecificity = specificity; + winner = setting.getValue(); + } + } + return winner; + } + /// Whether a qualified setting's condition can apply to the build being made. /// /// Only the two conditions this build knows its own answer to are judged -- sdk and config. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 8afa8978e0b..2230239431d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -452,4 +452,66 @@ public void aConditionForThisBuildStillCounts() throws Exception { assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", null, null)); } + + @Test + public void aQualifiedEntitlementsFileOverridesTheBaseRatherThanAddingToIt() throws Exception { + File extension = tmp.newFolder("dist16", "WalletUIExtension"); + File base = new File(extension, "Base.entitlements"); + write(base, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + File device = new File(extension, "Device.entitlements"); + write(device, ""); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/Base.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "WalletUIExtension/Device.entitlements"); + + File signing = IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, null, + "iphoneos", "Release"); + + // Xcode signs the device archive with the qualified file ALONE. Reading both and taking + // the stricter answer raised the extension to iOS 14 for an entitlement it never carries. + assertEquals(device, signing); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentFloor(signing)); + } + + @Test + public void theWinningEntitlementsFileStillRaisesTheFloorWhenItGrants() throws Exception { + File extension = tmp.newFolder("dist17", "WalletUIExtension"); + File base = new File(extension, "Base.entitlements"); + write(base, ""); + File device = new File(extension, "Device.entitlements"); + write(device, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/Base.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "WalletUIExtension/Device.entitlements"); + + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentFloor( + IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, null, + "iphoneos", "Release"))); + } + + @Test + public void theMostSpecificApplicableConditionWins() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.old.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]", "com.example.app.Sim"); + + // The device archive uses the device value, so a stale base is not a reason to refuse it. + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos", "Release")); + assertEquals("com.example.app.Sim", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphonesimulator", "Release")); + assertNull(IPhoneBuilder.winningSetting(settings, "SOMETHING_ELSE", "iphoneos", "Release")); + } + + @Test + public void withNoApplicableConditionThePlainSettingGoverns() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[config=Debug]", "com.example.app.Ext.debug"); + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos", "Release")); + } } From 52a2f05834d800ecc75380b686ef6a078b215cdc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:13:27 +0300 Subject: [PATCH 30/49] Match a condition against the SDK and arch this archive really uses Mirrors the cloud builder fix. The SDK name xcodebuild is given is versioned -- iphoneos14.4 -- and conditions were being matched against a bare "iphoneos" made up at the call site, so a [sdk=iphoneos14.4] qualifier read as inapplicable. A condition and an SDK naming the same platform now match whether or not either carries a version, erring toward applicable as everywhere in this matching. And arch conditions were not evaluated at all, so [arch=arm64] and [arch=x86_64] were equally applicable and the winner was whichever the map handed over first. A device archive is arm64; that is what they are matched against now, and with no architecture to judge by both still count. 97 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 69 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 32 +++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 6c830b7e66e..4b347f7c0c3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4874,7 +4874,9 @@ public void usesClassMethod(String cls, String method) { // plain one, so a stale base beside a right device value is not a // reason to refuse a build Xcode would have got right. String governingId = winningSetting(declaredSettings, - "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos", "Release"); + "PRODUCT_BUNDLE_IDENTIFIER", + "iphoneos" + request.getArg("ios.sdk", "14.4"), "Release", + "arm64"); String resolvedBundleId = resolveSettingsInValue( governingId != null && governingId.trim().length() > 0 ? governingId.trim() @@ -4941,14 +4943,18 @@ public void usesClassMethod(String cls, String method) { // The configuration this build hands to xcodebuild is Release for a // device archive whatever ios.buildType says, so that is what a // [config=...] condition must be matched against. - String archiveSdk = "iphoneos"; + // The same SDK name xcodebuild is given -- versioned -- and the + // architecture the archive is built for, so [arch=arm64] beside + // [arch=x86_64] is decided by what this build is, not by map order. + String archiveSdk = "iphoneos" + request.getArg("ios.sdk", "14.4"); String archiveConfiguration = "Release"; + String archiveArch = "arm64"; File signingEntitlements = appExtensionSigningEntitlements(appExtension, buildSettingsMap, extEntitlementsFile, archiveSdk, - archiveConfiguration); + archiveConfiguration, archiveArch); String extDeploymentTarget = appExtensionDeploymentTarget( winningSetting(buildSettingsMap, "IPHONEOS_DEPLOYMENT_TARGET", - archiveSdk, archiveConfiguration), + archiveSdk, archiveConfiguration, archiveArch), signingEntitlements, request.getArg("ios.deployment_target", null), appExtension, buildSettingsMap); @@ -6711,7 +6717,13 @@ static List appExtensionEntitlementsCandidates(File extensionFolder, /// The one entitlements file this archive is signed with. static File appExtensionSigningEntitlements(File extensionFolder, Map settings, File byName, String sdk, String configuration) { - String winner = winningSetting(settings, "CODE_SIGN_ENTITLEMENTS", sdk, configuration); + return appExtensionSigningEntitlements(extensionFolder, settings, byName, sdk, + configuration, null); + } + + static File appExtensionSigningEntitlements(File extensionFolder, Map settings, + File byName, String sdk, String configuration, String arch) { + String winner = winningSetting(settings, "CODE_SIGN_ENTITLEMENTS", sdk, configuration, arch); if (winner == null || winner.trim().length() == 0) { return byName; } @@ -6827,6 +6839,11 @@ && isDeploymentTargetBelow(resolved, floor)) { /// @return the winning value, or null when nothing applicable is declared static String winningSetting(Map settings, String name, String sdk, String configuration) { + return winningSetting(settings, name, sdk, configuration, null); + } + + static String winningSetting(Map settings, String name, String sdk, + String configuration, String arch) { if (settings == null) { return null; } @@ -6838,7 +6855,7 @@ static String winningSetting(Map settings, String name, String s if (!qualified && !name.equals(key)) { continue; } - if (qualified && !conditionApplies(key, sdk, configuration)) { + if (qualified && !conditionApplies(key, sdk, configuration, arch)) { continue; } int specificity = 0; @@ -6862,6 +6879,12 @@ static String winningSetting(Map settings, String name, String s /// a condition does not apply risks signing an extension without an entitlement it needs, /// which fails the upload, while over-counting only costs iOS 12 and 13 availability. static boolean conditionApplies(String key, String sdk, String configuration) { + return conditionApplies(key, sdk, configuration, null); + } + + /// @param arch the architecture the archive is built for, so [arch=arm64] and [arch=x86_64] + /// are not both counted applicable and then decided by map order + static boolean conditionApplies(String key, String sdk, String configuration, String arch) { int open = key.indexOf('['); if (open < 0) { return true; @@ -6873,17 +6896,49 @@ static boolean conditionApplies(String key, String sdk, String configuration) { } String name = condition.substring(0, equals).trim(); String value = condition.substring(equals + 1).trim(); - if ("sdk".equals(name) && sdk != null && !matchesCondition(value, sdk)) { + if ("sdk".equals(name) && sdk != null && !matchesSdkCondition(value, sdk)) { return false; } if ("config".equals(name) && configuration != null && !matchesCondition(value, configuration)) { return false; } + if ("arch".equals(name) && arch != null && !matchesCondition(value, arch)) { + return false; + } } return true; } + /// An sdk condition against the SDK this build names, which is versioned: xcodebuild is given + /// iphoneos14.4, not iphoneos. [sdk=iphoneos*] and [sdk=iphoneos14.4] both mean this archive, + /// and so does [sdk=iphoneos] -- a condition and an SDK that name the same platform match, + /// and a version is only compared when both carry one. Erring toward applicable, as + /// everywhere in this matching: excluding a condition that does apply loses an entitlement. + private static boolean matchesSdkCondition(String value, String sdk) { + if (value.endsWith("*")) { + return matchesCondition(value, sdk); + } + String conditionPlatform = platformOf(value); + String sdkPlatform = platformOf(sdk); + if (!conditionPlatform.equalsIgnoreCase(sdkPlatform)) { + return false; + } + String conditionVersion = value.substring(conditionPlatform.length()); + String sdkVersion = sdk.substring(sdkPlatform.length()); + return conditionVersion.length() == 0 || sdkVersion.length() == 0 + || conditionVersion.equals(sdkVersion); + } + + /// The letters an SDK name starts with, which is its platform: "iphoneos" of "iphoneos14.4". + private static String platformOf(String sdk) { + int i = 0; + while (i < sdk.length() && Character.isLetter(sdk.charAt(i))) { + i++; + } + return sdk.substring(0, i); + } + /// A condition value against what this build is, with Xcode's trailing {@code *}. private static boolean matchesCondition(String value, String actual) { if (value.endsWith("*")) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 2230239431d..8f109d8e59a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -514,4 +514,36 @@ public void withNoApplicableConditionThePlainSettingGoverns() throws Exception { assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(settings, "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos", "Release")); } + + @Test + public void aVersionedSdkQualifierMatchesTheArchivesSdk() throws Exception { + // xcodebuild is given iphoneos14.4, not iphoneos, so a condition naming the version is + // the one Xcode picks -- and rejecting it aborted on a stale base identifier. + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos14.4]", + "iphoneos14.4", "Release")); + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos]", + "iphoneos14.4", "Release")); + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", + "iphoneos14.4", "Release")); + // a different platform still does not + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator14.4]", + "iphoneos14.4", "Release")); + // and a different version of the same platform, when both name one + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos13.0]", + "iphoneos14.4", "Release")); + } + + @Test + public void anArchitectureQualifierIsDecidedByTheArchiveNotByMapOrder() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // x86_64 first, so map order would pick it. + settings.put("CODE_SIGN_ENTITLEMENTS[arch=x86_64]", "WalletUIExtension/Sim.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[arch=arm64]", "WalletUIExtension/Device.entitlements"); + + assertEquals("WalletUIExtension/Device.entitlements", IPhoneBuilder.winningSetting( + settings, "CODE_SIGN_ENTITLEMENTS", "iphoneos14.4", "Release", "arm64")); + // and with no architecture to judge by, both still count rather than one being guessed away + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[arch=x86_64]", + "iphoneos14.4", "Release", null)); + } } From 6d2099e20312de01efb5f2447e57ee93ace29389 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:51:59 +0300 Subject: [PATCH 31/49] Resolve with the archive's own context, and stop assuming an SDK version Four review catches. The SDK: a local device build passes no -sdk and lets the destination pick the active one, so there is no version to assume -- and assuming the hint's stale 14.4 default made an exact qualifier like [sdk=iphoneos26.0] read as some other build's, so the entitlements that set the floor went unread. The hint answers when set, xcrun when it does not, and failing both the bare platform name, which matches any version of it. $(CONFIGURATION)/Extension.entitlements is a standard way to write that path and the resolver knew only the archive's settings and four path built-ins, so it returned null and a different file was read. CONFIGURATION, SDK_NAME, PLATFORM_NAME and CURRENT_ARCH now take part. A partially resolvable identifier was recorded as a truncation of itself: com.example.app.$(SOMETHING_UNKNOWN) became "com.example.app.", a key for a bundle the archive does not contain. Fully, or not at all. And the archive's context is now decided in one place, before anything matches a condition against it, rather than being assembled twice. 100 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 135 ++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 43 ++++++ 2 files changed, 164 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4b347f7c0c3..732a1a4393b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4868,21 +4868,31 @@ public void usesClassMethod(String cls, String method) { // it, which resolves to a perfectly good identifier. Judging the raw // reference would refuse a build that works, and a reference this // build cannot resolve is not judged at all. + // What this archive IS: the SDK name xcodebuild will use (versioned), + // the configuration it builds, and the architecture it builds for. + // Everything below matches conditional settings against these. + String archiveSdk = activeIosSdkName(request); + String archiveConfiguration = "Release"; + String archiveArch = "arm64"; Map declaredSettings = appExtensionBuildSettings(appExtension); String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); // The identifier THIS archive gets: a qualified setting overrides the // plain one, so a stale base beside a right device value is not a // reason to refuse a build Xcode would have got right. String governingId = winningSetting(declaredSettings, - "PRODUCT_BUNDLE_IDENTIFIER", - "iphoneos" + request.getArg("ios.sdk", "14.4"), "Release", - "arm64"); - String resolvedBundleId = resolveSettingsInValue( - governingId != null && governingId.trim().length() > 0 + "PRODUCT_BUNDLE_IDENTIFIER", archiveSdk, archiveConfiguration, + archiveArch); + String declaredIdForArchive = governingId != null + && governingId.trim().length() > 0 ? governingId.trim() : (declaredId != null ? declaredId - : request.getPackageName() + "." + extensionName), - extensionSettingsWithBuiltIns(appExtension, declaredSettings)); + : request.getPackageName() + "." + extensionName); + // Fully, or not at all: a partially expanded identifier is a + // truncation, and it would name a bundle the archive does not contain. + String fullyResolvedId = resolveSettingsFully(declaredIdForArchive, + extensionSettingsWithBuiltIns(appExtension, declaredSettings, + archiveConfiguration, archiveSdk, archiveArch)); + String resolvedBundleId = fullyResolvedId == null ? "" : fullyResolvedId; String outOfNamespace = resolvedBundleId.length() == 0 ? null : outOfNamespaceExtensionIdMessage(extensionName, resolvedBundleId, request.getPackageName()); @@ -4943,12 +4953,6 @@ public void usesClassMethod(String cls, String method) { // The configuration this build hands to xcodebuild is Release for a // device archive whatever ios.buildType says, so that is what a // [config=...] condition must be matched against. - // The same SDK name xcodebuild is given -- versioned -- and the - // architecture the archive is built for, so [arch=arm64] beside - // [arch=x86_64] is decided by what this build is, not by map order. - String archiveSdk = "iphoneos" + request.getArg("ios.sdk", "14.4"); - String archiveConfiguration = "Release"; - String archiveArch = "arm64"; File signingEntitlements = appExtensionSigningEntitlements(appExtension, buildSettingsMap, extEntitlementsFile, archiveSdk, archiveConfiguration, archiveArch); @@ -6678,6 +6682,14 @@ static String outOfNamespaceExtensionIdMessage(String extensionName, String effe /// archive. static Map extensionSettingsWithBuiltIns(File extensionFolder, Map settings) { + return extensionSettingsWithBuiltIns(extensionFolder, settings, null, null, null); + } + + /// @param configuration, {@code sdk} and {@code arch} the archive's own, since + /// $(CONFIGURATION) in a path or an identifier is as ordinary as $(TARGET_NAME) and this + /// build knows all three + static Map extensionSettingsWithBuiltIns(File extensionFolder, + Map settings, String configuration, String sdk, String arch) { Map out = new LinkedHashMap(); if (settings != null) { out.putAll(settings); @@ -6700,6 +6712,25 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, if (!out.containsKey("PROJECT_DIR")) { out.put("PROJECT_DIR", projectPath); } + if (configuration != null && !out.containsKey("CONFIGURATION")) { + out.put("CONFIGURATION", configuration); + } + if (sdk != null) { + if (!out.containsKey("SDK_NAME")) { + out.put("SDK_NAME", sdk); + } + if (!out.containsKey("PLATFORM_NAME")) { + out.put("PLATFORM_NAME", platformOf(sdk)); + } + } + if (arch != null) { + if (!out.containsKey("CURRENT_ARCH")) { + out.put("CURRENT_ARCH", arch); + } + if (!out.containsKey("arch")) { + out.put("arch", arch); + } + } return out; } @@ -6727,7 +6758,11 @@ static File appExtensionSigningEntitlements(File extensionFolder, Map 0) { + return "iphoneos" + declared.trim(); + } + try { + Process p = new ProcessBuilder("/usr/bin/xcrun", "--sdk", "iphoneos", + "--show-sdk-version").redirectErrorStream(false).start(); + java.io.BufferedReader in = new java.io.BufferedReader( + new java.io.InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)); + String version; + try { + version = in.readLine(); + } finally { + in.close(); + } + if (p.waitFor() == 0 && version != null && version.trim().length() > 0) { + return "iphoneos" + version.trim(); + } + } catch (Exception noXcrun) { + // Not a Mac, or no Xcode: the bare platform name still matches every version of it. + } + return "iphoneos"; + } + /// The value of {@code name} that governs THIS archive. /// /// Xcode does not merge a qualified setting with the plain one, it OVERRIDES it: with both @@ -7225,6 +7292,46 @@ private static String resolveXcodeSettingsInPath(String path, File extensionFold return out.indexOf('$') >= 0 ? null : out; } + /// The same as {@link #resolveSettingsInValue}, but null when a reference is left over. + /// + /// resolveSettingsInValue deletes what it cannot expand, which is right when the question is + /// "what will this be on the device" -- Xcode deletes it too. It is wrong when the answer is + /// about to be RECORDED: com.example.app.$(SOMETHING_UNKNOWN) came out as "com.example.app.", + /// and that partial string went into the export-options dictionary as the key for a bundle + /// the archive does not contain, so a manual export could not pair the extension with its + /// profile. A value this build cannot resolve completely is better left alone than recorded + /// as a truncation of itself. + static String resolveSettingsFully(String value, Map settings) { + if (value == null) { + return null; + } + String resolved = resolveSettingsInValue(value, settings); + return BUILD_SETTING_REFERENCE.matcher(value).find() + && BUILD_SETTING_REFERENCE.matcher(stripResolved(value, settings)).find() + ? null : resolved; + } + + /// The value with every reference this build CAN expand already expanded, so what remains is + /// exactly what it cannot. + private static String stripResolved(String value, Map settings) { + String out = value; + if (settings != null) { + for (int pass = 0; pass < MAX_SETTING_EXPANSIONS + && BUILD_SETTING_REFERENCE.matcher(out).find(); pass++) { + String before = out; + for (Map.Entry setting : settings.entrySet()) { + if (setting.getValue() != null) { + out = replaceBuildSetting(out, setting.getKey(), setting.getValue()); + } + } + if (out.equals(before)) { + break; + } + } + } + return out; + } + /// A plist value with the archive's own build settings substituted, so a {@code $(...)} /// reference can be compared with the version it will actually resolve to on the device. /// diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 8f109d8e59a..87bb3836f85 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -546,4 +546,47 @@ public void anArchitectureQualifierIsDecidedByTheArchiveNotByMapOrder() throws E assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[arch=x86_64]", "iphoneos14.4", "Release", null)); } + + @Test + public void theArchivesConfigurationSdkAndArchResolve() throws Exception { + File extension = tmp.newFolder("dist18", "WalletUIExtension"); + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, new java.util.LinkedHashMap(), "Release", + "iphoneos14.4", "arm64"); + assertEquals("Release", settings.get("CONFIGURATION")); + assertEquals("iphoneos14.4", settings.get("SDK_NAME")); + assertEquals("iphoneos", settings.get("PLATFORM_NAME")); + assertEquals("arm64", settings.get("CURRENT_ARCH")); + } + + @Test + public void anEntitlementsPathThroughTheConfigurationResolves() throws Exception { + File extension = tmp.newFolder("dist19", "WalletUIExtension"); + File release = new File(extension, "Release.entitlements"); + write(release, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/$(CONFIGURATION).entitlements"); + + File signing = IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, null, + "iphoneos14.4", "Release", "arm64"); + + // Falling back to a by-name file here left a payment-pass extension on the 12.0 floor. + assertEquals(release, signing); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentFloor(signing)); + } + + @Test + public void aPartiallyResolvableValueIsNotRecordedAsATruncation() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CONFIGURATION", "Release"); + // Known: expands. Unknown: the whole answer is withheld rather than truncated, because + // "com.example.app." as an export-options key names no bundle in the archive. + assertEquals("com.example.app.Release", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(CONFIGURATION)", settings)); + assertNull(IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(SOMETHING_UNKNOWN)", settings)); + assertEquals("com.example.app.Ext", IPhoneBuilder.resolveSettingsFully( + "com.example.app.Ext", settings)); + } } From 6e4f152c1be3748cc7c2df4a423656837f2209a4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:26:39 +0300 Subject: [PATCH 32/49] An unwildcarded sdk condition matches exactly, as Xcode matches it Mirrors the cloud builder fix, correcting a deliberate choice: I had [sdk=iphoneos] match an archive built with iphoneos14.4 on the grounds that erring toward applicable is safe. It is not, here -- Xcode matches an unwildcarded condition against the versioned SDK_NAME exactly, so that condition never applies, and selecting on it picks settings the archive is not built with. Erring toward applicable is for a condition this build cannot evaluate, which is still handled: when the SDK version is unknown, because a local archive lets the destination choose it, a versioned condition counts. 100 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 15 +++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 9 ++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 732a1a4393b..e8c1aad9873 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6993,8 +6993,19 @@ private static boolean matchesSdkCondition(String value, String sdk) { } String conditionVersion = value.substring(conditionPlatform.length()); String sdkVersion = sdk.substring(sdkPlatform.length()); - return conditionVersion.length() == 0 || sdkVersion.length() == 0 - || conditionVersion.equals(sdkVersion); + if (sdkVersion.length() == 0) { + // This build does not know its own SDK version -- a local archive lets the destination + // choose it -- so a versioned condition may or may not be this one. Counted, by the + // same rule as any condition that cannot be evaluated here. + return true; + } + // Xcode matches an unwildcarded condition against the versioned SDK_NAME exactly, so + // [sdk=iphoneos] does NOT apply to an archive built with iphoneos14.4; [sdk=iphoneos*] is + // the spelling that does. Treating the bare one as a match picked settings Xcode ignores: + // an entitlements file the target is not signed with, or an identifier it is not built + // with. Erring toward applicable is for what this build cannot evaluate, not for what it + // can evaluate and Xcode says no to. + return conditionVersion.equals(sdkVersion); } /// The letters an SDK name starts with, which is its platform: "iphoneos" of "iphoneos14.4". diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 87bb3836f85..dd039eda342 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -521,8 +521,15 @@ public void aVersionedSdkQualifierMatchesTheArchivesSdk() throws Exception { // the one Xcode picks -- and rejecting it aborted on a stale base identifier. assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos14.4]", "iphoneos14.4", "Release")); - assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos]", + // Not this one: Xcode matches an unwildcarded condition against the versioned SDK_NAME + // exactly, so [sdk=iphoneos] never applies to iphoneos14.4 and selecting on it would pick + // a setting Xcode ignores. [sdk=iphoneos*] is the spelling that applies. + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos]", "iphoneos14.4", "Release")); + // But when THIS build does not know its own SDK version, a versioned condition still + // counts, because it cannot be evaluated either way. + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos26.0]", + "iphoneos", "Release")); assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "iphoneos14.4", "Release")); // a different platform still does not From 5288b0ef65f8512c5f1e88e2c0f82fada2c91119 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:13:25 +0300 Subject: [PATCH 33/49] Ask the selected Xcode for its SDK, and fill the context first Two review catches. The SDK version was read with a bare /usr/bin/xcrun while resolveXcodebuild honours XCODEBUILD, DEVELOPER_DIR and XCODE_APP, so a build using a non-default Xcode could be told another installation's SDK version -- and an exact [sdk=iphoneosNN] condition would then match, or fail to match, on a version this archive never sees. The query now runs the xcrun beside the selected xcodebuild, with that installation's DEVELOPER_DIR. And extensionSettingsWithBuiltIns resolved PRODUCT_NAME before adding CONFIGURATION, SDK_NAME and CURRENT_ARCH, so PRODUCT_NAME = $(CONFIGURATION)-Wallet lost its reference and became "-Wallet", which then went into an identifier and into the export-options key. The context is filled first and PRODUCT_NAME resolved last. 101 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 64 +++++++++++++++---- .../AppExtensionDeploymentTargetTest.java | 16 +++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index e8c1aad9873..8003ad8b587 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6696,14 +6696,6 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, } String targetName = extensionFolder.getName(); out.put("TARGET_NAME", targetName); - // PRODUCT_NAME is $(TARGET_NAME) unless the archive says otherwise -- but "otherwise" may - // itself be a reference: PRODUCT_NAME = $(EXTENSION_NAME) with EXTENSION_NAME beside it is - // a chain Xcode expands, and flattening it to the folder name here recorded an identifier - // the archive does not contain. - String productName = out.get("PRODUCT_NAME"); - String resolvedProductName = productName == null ? "" - : resolveSettingsInValue(productName, out); - out.put("PRODUCT_NAME", resolvedProductName.length() > 0 ? resolvedProductName : targetName); File projectDir = extensionFolder.getParentFile(); String projectPath = projectDir == null ? "." : projectDir.getAbsolutePath(); if (!out.containsKey("SRCROOT")) { @@ -6731,6 +6723,15 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, out.put("arch", arch); } } + // PRODUCT_NAME last, and only now: it is $(TARGET_NAME) unless the archive says otherwise, + // and "otherwise" may be a chain through any of the settings above -- + // PRODUCT_NAME = $(CONFIGURATION)-Wallet is one. Resolving it before CONFIGURATION, + // SDK_NAME and CURRENT_ARCH were in the map deleted the reference and left "-Wallet", + // which then went into an identifier and into the export-options key. + String productName = out.get("PRODUCT_NAME"); + String resolvedProductName = productName == null ? "" + : resolveSettingsInValue(productName, out); + out.put("PRODUCT_NAME", resolvedProductName.length() > 0 ? resolvedProductName : targetName); return out; } @@ -6868,14 +6869,23 @@ && isDeploymentTargetBelow(resolved, floor)) { /// hint) made an exact qualifier like [sdk=iphoneos26.0] read as some other build's. The hint /// answers when it is set; otherwise xcrun is asked, and if that cannot answer either the /// bare platform name is used, which matches any version of it. - static String activeIosSdkName(BuildRequest request) { + String activeIosSdkName(BuildRequest request) { String declared = request.getArg("ios.sdk", null); if (declared != null && declared.trim().length() > 0) { return "iphoneos" + declared.trim(); } try { - Process p = new ProcessBuilder("/usr/bin/xcrun", "--sdk", "iphoneos", - "--show-sdk-version").redirectErrorStream(false).start(); + // Through the Xcode this build actually uses. resolveXcodebuild honours XCODEBUILD, + // DEVELOPER_DIR and XCODE_APP, and asking the system default instead can report a + // different installation's SDK -- which then makes an exact [sdk=iphoneosNN] condition + // match, or fail to match, on a version this archive never sees. + ProcessBuilder builder = new ProcessBuilder(xcrunForSelectedXcode(), "--sdk", + "iphoneos", "--show-sdk-version"); + String developerDir = selectedDeveloperDir(); + if (developerDir != null) { + builder.environment().put("DEVELOPER_DIR", developerDir); + } + Process p = builder.redirectErrorStream(false).start(); java.io.BufferedReader in = new java.io.BufferedReader( new java.io.InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)); String version; @@ -6893,6 +6903,38 @@ static String activeIosSdkName(BuildRequest request) { return "iphoneos"; } + /// The xcrun beside the xcodebuild this build selected, or the system one. + private String xcrunForSelectedXcode() { + String selected = resolveXcodebuild(); + if (selected != null) { + File beside = new File(new File(selected).getParentFile(), "xcrun"); + if (beside.canExecute()) { + return beside.getAbsolutePath(); + } + } + return "/usr/bin/xcrun"; + } + + /// The developer directory of the selected Xcode -- /Contents/Developer -- so a + /// tool run through the system xcrun still resolves inside it. Null when it cannot be told. + private String selectedDeveloperDir() { + String fromEnvironment = System.getenv("DEVELOPER_DIR"); + if (fromEnvironment != null && fromEnvironment.length() > 0) { + return fromEnvironment; + } + String selected = resolveXcodebuild(); + if (selected == null) { + return null; + } + // .../Contents/Developer/usr/bin/xcodebuild -> .../Contents/Developer + File developer = new File(selected).getParentFile(); + for (int i = 0; i < 2 && developer != null; i++) { + developer = developer.getParentFile(); + } + return developer != null && new File(developer, "usr/bin").isDirectory() + ? developer.getAbsolutePath() : null; + } + /// The value of {@code name} that governs THIS archive. /// /// Xcode does not merge a qualified setting with the plain one, it OVERRIDES it: with both diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index dd039eda342..ea2f0dfda12 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -596,4 +596,20 @@ public void aPartiallyResolvableValueIsNotRecordedAsATruncation() throws Excepti assertEquals("com.example.app.Ext", IPhoneBuilder.resolveSettingsFully( "com.example.app.Ext", settings)); } + + @Test + public void aProductNameThroughTheConfigurationResolvesToo() throws Exception { + File extension = tmp.newFolder("dist20", "WalletUIExtension"); + java.util.Map declared = new java.util.LinkedHashMap(); + declared.put("PRODUCT_NAME", "$(CONFIGURATION)-Wallet"); + + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, declared, "Release", "iphoneos14.4", "arm64"); + + // Resolving PRODUCT_NAME before the context was in the map left "-Wallet", and that went + // into the identifier and into the export-options key. + assertEquals("Release-Wallet", settings.get("PRODUCT_NAME")); + assertEquals("com.example.app.Release-Wallet", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME)", settings)); + } } From 02dfe484062007917e60c6f1ba491304b50dfc32 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:46:23 +0300 Subject: [PATCH 34/49] Take the SDK from Xcode, and repair only this archive's settings Two review catches. The ios.sdk hint was driving exact [sdk=...] matching, and it controls nothing here: this builder passes no -sdk to xcodebuild, the destination picks the active SDK, and the hint has no other use in the module. Under Xcode 26 a stale ios.sdk=14.4 therefore selected settings the archive never applies. The SDK now comes from the Xcode this build uses, and from the bare platform name when even that cannot answer. And the repairs were applying THIS archive's floor -- computed from the entitlements it is signed with -- to conditional entries belonging to Debug and simulator builds. The edit lives on in the generated project, so a later Debug build would lose iOS 12 and 13 for a Wallet entitlement it never carried. Repairs are scoped to the conditions this archive uses. 103 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 25 +++++++++++++---- .../AppExtensionDeploymentTargetTest.java | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 8003ad8b587..c81a944db9e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4965,7 +4965,8 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); for (String note : repairQualifiedExtensionSettings(buildSettingsMap, request.getPackageName(), - appExtensionDeploymentFloor(signingEntitlements))) { + appExtensionDeploymentFloor(signingEntitlements), + archiveSdk, archiveConfiguration, archiveArch)) { debug("The " + extensionName + " app extension: " + note + "."); } @@ -6832,10 +6833,24 @@ static String appExtensionDeploymentFloor(File entitlements) { /// @return a note per change, for the log static List repairQualifiedExtensionSettings(Map settings, String hostPackage, String floor) { + return repairQualifiedExtensionSettings(settings, hostPackage, floor, null, null, null); + } + + /// @param sdk, {@code configuration} and {@code arch} the archive being built, so a setting + /// belonging to some OTHER build is left exactly as its author wrote it. The floor here was + /// computed from the entitlements THIS archive is signed with, and applying it to a Debug or + /// simulator condition raised a target that has nothing to do with those entitlements -- the + /// edit then lives on in the generated project and in sources.tar.bz2, so a later Debug build + /// loses iOS 12 and 13 for a Wallet entitlement it never carried. + static List repairQualifiedExtensionSettings(Map settings, + String hostPackage, String floor, String sdk, String configuration, String arch) { List notes = new ArrayList(); for (Map.Entry setting : new ArrayList>( settings.entrySet())) { String key = setting.getKey(); + if (!conditionApplies(key, sdk, configuration, arch)) { + continue; + } String value = setting.getValue() == null ? "" : setting.getValue().trim(); // What the value RESOLVES to, since a qualified setting may be written through another // one -- IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] = $(EXTENSION_MIN) with @@ -6870,10 +6885,10 @@ && isDeploymentTargetBelow(resolved, floor)) { /// answers when it is set; otherwise xcrun is asked, and if that cannot answer either the /// bare platform name is used, which matches any version of it. String activeIosSdkName(BuildRequest request) { - String declared = request.getArg("ios.sdk", null); - if (declared != null && declared.trim().length() > 0) { - return "iphoneos" + declared.trim(); - } + // Deliberately NOT the ios.sdk hint. This builder passes no -sdk to xcodebuild -- the + // destination picks the active SDK -- and the hint has no other use in this module, so it + // does not control the archive. Matching an exact [sdk=iphoneos14.4] condition against a + // stale hint under Xcode 26 selects settings the build never applies. try { // Through the Xcode this build actually uses. resolveXcodebuild honours XCODEBUILD, // DEVELOPER_DIR and XCODE_APP, and asking the system default instead can report a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index ea2f0dfda12..8bf9102c82e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -612,4 +612,32 @@ public void aProductNameThroughTheConfigurationResolvesToo() throws Exception { assertEquals("com.example.app.Release-Wallet", IPhoneBuilder.resolveSettingsFully( "com.example.app.$(PRODUCT_NAME)", settings)); } + + @Test + public void repairsLeaveOtherBuildsSettingsAlone() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[config=Debug]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", "iphoneos14.4", "Release", "arm64"); + + // The floor came from the entitlements THIS archive is signed with. Applying it to a + // simulator or Debug condition edits a target those entitlements have nothing to do with, + // and the edit lives on in the generated project. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]")); + assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[config=Debug]")); + assertEquals(1, notes.size()); + } + + @Test + public void anIdentifierForAnotherBuildIsNotDroppedEither() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]", "com.other.Sim"); + IPhoneBuilder.repairQualifiedExtensionSettings(settings, "com.example.app", "12.0", + "iphoneos14.4", "Release", "arm64"); + assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]")); + } } From da061b554850d8a94934b44c006fb050ec1e0376 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:29:55 +0300 Subject: [PATCH 35/49] Three review catches: modifiers, the shared base target, and a cycle Mirrors the cloud builder fixes. ${PRODUCT_NAME:rfc1034identifier} is the ordinary way to write an extension's identifier, and the reference pattern did not recognise the colon form -- so the literal expression was recorded as the export-options key while Xcode archived its expansion. Modifiers are recognised now, and the ones Xcode defines that can be reproduced here are applied; an unknown one keeps the value unresolved. The base deployment target is copied into every Xcode configuration, so writing the applicable qualifier's answer there handed Debug a minimum belonging to Release. The base is clamped on its own; the archive's answer is what the target is created with. And an in-tree directory symlink is a cycle: sub/loop -> . escapes nothing, so the escape check waved it through, while every other walk over the folder follows it until the stack ends the build. Directory symlinks are refused at extraction; file symlinks inside the folder are still fine. 107 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 69 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 20 ++++++ .../builders/AppExtensionStagingTest.java | 21 ++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index c81a944db9e..8f291fc7707 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4956,7 +4956,16 @@ public void usesClassMethod(String cls, String method) { File signingEntitlements = appExtensionSigningEntitlements(appExtension, buildSettingsMap, extEntitlementsFile, archiveSdk, archiveConfiguration, archiveArch); + // The BASE setting is copied into every Xcode configuration, so + // writing the archive's answer there hands Debug a minimum belonging + // to Release; it gets the base value, clamped on its own. The + // archive's own answer is what the target is created with. String extDeploymentTarget = appExtensionDeploymentTarget( + buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), + signingEntitlements, + request.getArg("ios.deployment_target", null), + appExtension, buildSettingsMap); + String archiveDeploymentTarget = appExtensionDeploymentTarget( winningSetting(buildSettingsMap, "IPHONEOS_DEPLOYMENT_TARGET", archiveSdk, archiveConfiguration, archiveArch), signingEntitlements, @@ -4973,7 +4982,7 @@ public void usesClassMethod(String cls, String method) { // Guarded so the post-dependency re-run of fix_xcode_schemes.rb // doesn't create duplicate extension targets. sb.append("\nif xcproj.targets.find{|e| e.name=='" + extensionName + "'}.nil?\n" - + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '" + extDeploymentTarget + "')\n" + + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '" + archiveDeploymentTarget + "')\n" + "xcproj.targets.find{|e|e.name=='" + request.getMainClass() + "'}.build_configurations.each{|e| \n" + " e.build_settings['PROVISIONING_PROFILE']='$(APP_PROVISIONING_PROFILE)'\n" + " e.build_settings['CODE_SIGN_ENTITLEMENTS']='$(APP_CODE_SIGN_ENTITLEMENTS)'\n" @@ -6525,8 +6534,12 @@ static File symlinkEscaping(File dir, File root) throws IOException { return f; } if (Files.isSymbolicLink(f.toPath())) { - // Inside the folder, so harmless as a path -- but a link to a directory would let - // the walk below leave through it, and it is not something an archive needs. + if (f.isDirectory()) { + // In-tree, so it escapes nothing -- but sub/loop -> . is a cycle, and every + // other walk over this folder follows it until the stack ends the build. A + // directory symlink is not something an extension needs. + return f; + } continue; } if (f.isDirectory()) { @@ -7437,8 +7450,12 @@ private static String resolveSettingsInValue(String value, Map a } /// A build-setting reference in either spelling Xcode accepts. + /// A build-setting reference, modifiers included: $(NAME), ${NAME} and + /// ${NAME:rfc1034identifier}. Without the modifier form an identifier written that way looked + /// like plain text -- "fully resolved" -- and the literal expression was recorded as a bundle + /// id while Xcode archived the expansion of it. private static final Pattern BUILD_SETTING_REFERENCE = - Pattern.compile("\\$[({][A-Za-z0-9_]+[)}]"); + Pattern.compile("\\$[({][A-Za-z0-9_]+(?::[A-Za-z0-9_]+)*[)}]"); /// Expansion passes before a value is called unresolvable. Settings nest a level or two in /// practice; the cap is what stops A = $(B), B = $(A) from spinning. @@ -7446,7 +7463,49 @@ private static String resolveSettingsInValue(String value, Map a /// One build setting, in either of the two spellings Xcode accepts for a reference. private static String replaceBuildSetting(String path, String name, String value) { - return path.replace("$(" + name + ")", value).replace("${" + name + "}", value); + String out = path.replace("$(" + name + ")", value).replace("${" + name + "}", value); + return applyModifiers(out, name, value); + } + + /// Expands $(NAME:modifier) for the modifiers Xcode defines and this can reproduce. + /// + /// rfc1034identifier is the one that matters here: it is how an extension's identifier is + /// ordinarily written from a product name, ${PRODUCT_NAME:rfc1034identifier}, and Xcode + /// archives the expansion. A modifier this does not know is left in place, which keeps the + /// value "not fully resolved" rather than recording an expression as an identifier. + private static String applyModifiers(String value, String name, String settingValue) { + Matcher reference = Pattern.compile("\\$[({]" + Pattern.quote(name) + + "((?::[A-Za-z0-9_]+)+)[)}]").matcher(value); + StringBuffer out = new StringBuffer(); + while (reference.find()) { + String expanded = settingValue; + boolean known = true; + for (String modifier : reference.group(1).split(":")) { + if (modifier.length() == 0) { + continue; + } + if ("lower".equalsIgnoreCase(modifier)) { + expanded = expanded.toLowerCase(java.util.Locale.ENGLISH); + } else if ("upper".equalsIgnoreCase(modifier)) { + expanded = expanded.toUpperCase(java.util.Locale.ENGLISH); + } else if ("rfc1034identifier".equalsIgnoreCase(modifier)) { + // Anything outside a host-name label becomes a hyphen, which is what Xcode + // does to make a product name usable in a bundle identifier. + expanded = expanded.replaceAll("[^A-Za-z0-9.-]", "-"); + } else if ("identifier".equalsIgnoreCase(modifier) + || "c99extidentifier".equalsIgnoreCase(modifier)) { + expanded = expanded.replaceAll("[^A-Za-z0-9_]", "_"); + } else { + known = false; + break; + } + } + reference.appendReplacement(out, known + ? Matcher.quoteReplacement(expanded) + : Matcher.quoteReplacement(reference.group())); + } + reference.appendTail(out); + return out.toString(); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 8bf9102c82e..34e0c4dd278 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -640,4 +640,24 @@ public void anIdentifierForAnotherBuildIsNotDroppedEither() throws Exception { "iphoneos14.4", "Release", "arm64"); assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]")); } + + @Test + public void aModifierReferenceResolvesAsXcodeExpandsIt() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_NAME", "Wallet UI"); + // ${PRODUCT_NAME:rfc1034identifier} is the ordinary way to write this, and treating it as + // plain text recorded the expression itself as the bundle identifier. + assertEquals("com.example.app.Wallet-UI", IPhoneBuilder.resolveSettingsFully( + "com.example.app.${PRODUCT_NAME:rfc1034identifier}", settings)); + assertEquals("com.example.app.wallet ui", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME:lower)", settings)); + } + + @Test + public void aModifierThisBuildDoesNotKnowIsNotCalledResolved() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_NAME", "Wallet"); + assertNull(IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME:somethingNew)", settings)); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java index 96cb2659d98..ae63bc36a1b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java @@ -130,4 +130,25 @@ private static void write(File file) throws Exception { out.close(); } } + + @Test + public void anInTreeDirectoryCycleIsRefused() throws Exception { + File extension = tmp.newFolder("dist", "WalletUIExtension"); + File sub = new File(extension, "sub"); + assertTrue(sub.mkdirs()); + // sub/loop -> . escapes nothing, and every walk over the folder follows it until the + // stack ends the build. + Files.createSymbolicLink(new File(sub, "loop").toPath(), extension.toPath()); + + assertTrue(IPhoneBuilder.symlinkEscaping(extension, extension) != null); + } + + @Test + public void anInTreeFileLinkIsStillFine() throws Exception { + File extension = tmp.newFolder("dist2", "WalletUIExtension"); + write(new File(extension, "Info.plist")); + Files.createSymbolicLink(new File(extension, "alias.plist").toPath(), + new File(extension, "Info.plist").toPath()); + assertNull(IPhoneBuilder.symlinkEscaping(extension, extension)); + } } From a76dc866add7dcaf40972fc1ea81ffdbbf9dd6fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:15:40 +0300 Subject: [PATCH 36/49] Rank an exact condition above a wildcard; take the arch from the build Mirrors the cloud builder fixes. Specificity was the number of '=' characters, so [sdk=iphoneos26.0] and [sdk=iphoneos*] scored equal and the winner was whichever Properties returned first -- silently the wrong entitlements file, floor or identifier. Xcode ranks the exact condition higher, and so does this now; more conditions still beat fewer. And the architecture was hard-coded arm64 while ARCHS is derived: a debug build with ios.debug.archs=armv7 gets ARCHS=armv7, and Xcode would pick an [arch=armv7] setting while this read the arm64 one. 108 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 37 +++++++++++++++---- .../AppExtensionDeploymentTargetTest.java | 16 ++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 8f291fc7707..5bd970e29bb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4873,7 +4873,12 @@ public void usesClassMethod(String cls, String method) { // Everything below matches conditional settings against these. String archiveSdk = activeIosSdkName(request); String archiveConfiguration = "Release"; - String archiveArch = "arm64"; + // Derived the same way ARCHS is, rather than assumed: a debug build + // with ios.debug.archs=armv7 is handed ARCHS=armv7, and Xcode would + // then pick an [arch=armv7] setting while this read the arm64 one. + String archiveArch = request.getArg("ios.buildType", "debug").equals("debug") + && "armv7".equals(request.getArg("ios.debug.archs", null)) + ? "armv7" : "arm64"; Map declaredSettings = appExtensionBuildSettings(appExtension); String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); // The identifier THIS archive gets: a qualified setting overrides the @@ -6995,12 +7000,7 @@ static String winningSetting(Map settings, String name, String s if (qualified && !conditionApplies(key, sdk, configuration, arch)) { continue; } - int specificity = 0; - for (int i = 0; qualified && i < key.length(); i++) { - if (key.charAt(i) == '=') { - specificity++; - } - } + int specificity = qualified ? conditionSpecificity(key) : 0; if (specificity > winningSpecificity) { winningSpecificity = specificity; winner = setting.getValue(); @@ -7009,6 +7009,29 @@ static String winningSetting(Map settings, String name, String s return winner; } + /// How specific a qualified key is, the way Xcode ranks it. + /// + /// More conditions beat fewer, and an EXACT value beats a wildcard: with + /// [sdk=iphoneos26.0] beside [sdk=iphoneos*], Xcode uses the exact one. Counting the '=' + /// characters alone scored those equal and left the winner to Properties' iteration order -- + /// which could read the wrong entitlements file, compute the wrong floor, or judge the wrong + /// identifier, all silently. + static int conditionSpecificity(String key) { + int open = key.indexOf('['); + if (open < 0) { + return 0; + } + int specificity = 0; + for (String condition : key.substring(open).split("[\\[\\],]")) { + int equals = condition.indexOf('='); + if (equals < 0) { + continue; + } + specificity += condition.substring(equals + 1).trim().endsWith("*") ? 1 : 2; + } + return specificity; + } + /// Whether a qualified setting's condition can apply to the build being made. /// /// Only the two conditions this build knows its own answer to are judged -- sdk and config. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 34e0c4dd278..3da7e7dad84 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -660,4 +660,20 @@ public void aModifierThisBuildDoesNotKnowIsNotCalledResolved() throws Exception assertNull(IPhoneBuilder.resolveSettingsFully( "com.example.app.$(PRODUCT_NAME:somethingNew)", settings)); } + + @Test + public void anExactConditionOutranksAWildcardOne() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // Wildcard first, so iteration order would pick it if the two scored equal. + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "Wildcard.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos26.0]", "Exact.entitlements"); + + assertEquals("Exact.entitlements", IPhoneBuilder.winningSetting(settings, + "CODE_SIGN_ENTITLEMENTS", "iphoneos26.0", "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos26.0]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + // and more conditions still beat fewer + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*,config=Release]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + } } From dd5d46574117a2ab080474c54426355e73657616 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:52:00 +0300 Subject: [PATCH 37/49] Rank wildcards by width, refuse a foreign variant, clamp an empty target Mirrors the cloud builder fixes. Wildcards were all equally specific, so [sdk=iphoneos*] and [sdk=iphoneos14.*] tied for an iphoneos14.4 archive and iteration order decided. Xcode picks the narrower pattern; the score reads the prefix now, with an exact value above every wildcard. [variant=profile] was accepted unconditionally and, being more specific than the plain setting, won. This builder archives the normal variant and nothing else, so it is judged against "normal" rather than parameterised. And a base deployment target written through a setting nothing defines was kept as written; Xcode expands it to the empty string, so the extension declared no minimum at all. An empty resolution is the floor's answer. 110 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 51 ++++++++++++++---- .../AppExtensionDeploymentTargetTest.java | 52 ++++++++++++++----- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 5bd970e29bb..324780fbe6e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6990,7 +6990,7 @@ static String winningSetting(Map settings, String name, String s return null; } String winner = null; - int winningSpecificity = -1; + long winningSpecificity = -1; for (Map.Entry setting : settings.entrySet()) { String key = setting.getKey(); boolean qualified = isQualified(key, name); @@ -7000,7 +7000,7 @@ static String winningSetting(Map settings, String name, String s if (qualified && !conditionApplies(key, sdk, configuration, arch)) { continue; } - int specificity = qualified ? conditionSpecificity(key) : 0; + long specificity = qualified ? conditionSpecificity(key) : 0; if (specificity > winningSpecificity) { winningSpecificity = specificity; winner = setting.getValue(); @@ -7016,22 +7016,37 @@ static String winningSetting(Map settings, String name, String s /// characters alone scored those equal and left the winner to Properties' iteration order -- /// which could read the wrong entitlements file, compute the wrong floor, or judge the wrong /// identifier, all silently. - static int conditionSpecificity(String key) { + static long conditionSpecificity(String key) { int open = key.indexOf('['); if (open < 0) { return 0; } - int specificity = 0; + long conditions = 0; + long precision = 0; for (String condition : key.substring(open).split("[\\[\\],]")) { int equals = condition.indexOf('='); if (equals < 0) { continue; } - specificity += condition.substring(equals + 1).trim().endsWith("*") ? 1 : 2; + conditions++; + String value = condition.substring(equals + 1).trim(); + // An exact value beats any wildcard; between wildcards the longer prefix is the + // narrower pattern, which is the one Xcode picks: [sdk=iphoneos14.*] over + // [sdk=iphoneos*] for an iphoneos14.4 archive. Scoring every wildcard alike left that + // to Properties' iteration order. + precision += value.endsWith("*") ? value.length() - 1 : PRECISION_EXACT; } - return specificity; + // Conditions first, precision as the tiebreak: two conditions describe a narrower build + // than one, however precisely that one is written. + return conditions * PRECISION_SCALE + Math.min(precision, PRECISION_SCALE - 1); } + /// A value with no wildcard is as precise as a condition gets. + private static final long PRECISION_EXACT = 1000; + + /// Wide enough that the precision sum cannot reach into the condition count above it. + private static final long PRECISION_SCALE = 1000000; + /// Whether a qualified setting's condition can apply to the build being made. /// /// Only the two conditions this build knows its own answer to are judged -- sdk and config. @@ -7066,6 +7081,15 @@ static boolean conditionApplies(String key, String sdk, String configuration, St if ("arch".equals(name) && arch != null && !matchesCondition(value, arch)) { return false; } + // The build variant is not a parameter because it is never in doubt: this builder + // archives the normal variant, never Xcode's profile or debug variants. A + // [variant=profile] setting therefore belongs to a build that does not happen here, + // and letting it win -- it is more specific than the plain setting -- meant validating + // an identifier or reading entitlements Xcode would not use. + if ("variant".equals(name) && !matchesCondition(value, "normal")) { + return false; + } + } return true; } @@ -7167,13 +7191,18 @@ static String appExtensionDeploymentTarget(String declared, List entitleme ? declared.trim() : appTarget; if (chosen != null && chosen.indexOf('$') >= 0) { - // Written through another setting. What it resolves to decides whether it clears the - // floor; the declared text is what the target keeps, because Xcode resolves it there - // and a reference this build cannot evaluate is not one to overwrite with a guess. + // Written through another setting. What it RESOLVES to decides: a reference that lands + // on a version clearing the floor is kept as written, because Xcode resolves it on the + // target and that is the archive author's expression to keep. String resolved = extensionFolder == null ? "" : resolveSettingsInValue(chosen, extensionSettingsWithBuiltIns(extensionFolder, settings)); - return resolved.length() > 0 && isDeploymentTargetBelow(resolved, floor) - ? floor : chosen; + if (resolved.length() == 0) { + // And a reference to a setting nothing defines is not "unknown" -- Xcode expands + // it to the empty string, so the extension would declare no minimum at all. The + // floor is the answer, not the expression. + return floor; + } + return isDeploymentTargetBelow(resolved, floor) ? floor : chosen; } return isDeploymentTargetBelow(chosen, floor) ? floor : normalizeVersion(chosen.trim()); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 3da7e7dad84..3e0d86a33c8 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -311,18 +311,13 @@ public void aQualifiedTargetResolvingBelowTheFloorIsStillClamped() throws Except } @Test - public void aReferenceThisBuildCannotResolveIsLeftAsWritten() throws Exception { - java.util.Map settings = new java.util.LinkedHashMap(); - settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(SOMETHING_ELSE)"); - settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "$(SOMETHING_ELSE)"); - - java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, - "com.example.app", "14.0"); - - // Not evaluable here is not the same as known to be wrong. - assertEquals("$(SOMETHING_ELSE)", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); - assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); - assertTrue(notes.isEmpty()); + public void aReferenceToNothingIsNotAMinimumAtAll() throws Exception { + File extension = tmp.newFolder("dist21", "WalletUIExtension"); + // Xcode expands a reference nothing defines to the empty string, so the extension would + // declare no minimum -- which is why this is the floor's answer and not the expression's. + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget("$(SOMETHING_ELSE)", + walletEntitlements(), "11", extension, + new java.util.LinkedHashMap())); } @Test @@ -676,4 +671,37 @@ public void anExactConditionOutranksAWildcardOne() throws Exception { assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*,config=Release]") > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); } + + @Test + public void aNarrowerWildcardOutranksABroaderOne() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // Broader first, so iteration order would pick it if the two scored equal. + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "Broad.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos14.*]", "Narrow.entitlements"); + + assertEquals("Narrow.entitlements", IPhoneBuilder.winningSetting(settings, + "CODE_SIGN_ENTITLEMENTS", "iphoneos14.4", "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.*]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + // and an exact value still beats both + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.4]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.*]")); + } + + @Test + public void aVariantThisBuilderNeverArchivesDoesNotWin() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.other.Ext"); + + // This builder archives the normal variant, so a profile-variant setting belongs to a + // build that does not happen here -- and it is more specific than the plain one, so + // accepting it let it win. + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + assertFalse(IPhoneBuilder.conditionApplies("X[variant=profile]", "iphoneos14.4", + "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionApplies("X[variant=normal]", "iphoneos14.4", + "Release", "arm64")); + } } From 8f79057c9c556885f02a3fb3193553e52a246923 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:19:33 +0300 Subject: [PATCH 38/49] The archive's variant is the archive's to declare, and plist paths resolve Mirrors the cloud builder fixes. BUILD_VARIANTS is a setting an extension can carry, and it is copied onto the target, so an archive declaring profile is built as profile and its [variant=profile] settings are the ones Xcode applies. The hard-coded "normal" discarded exactly those; the variant comes from the settings now. Which is why sdk, configuration, architecture and variants stopped being loose parameters and became one ArchiveContext: they had grown one at a time, and the dimension that was not a parameter is the one that was wrong. And INFOPLIST_FILE = $(CONFIGURATION)/Info.plist did not resolve -- the entitlements path had the archive's context and the plist path did not, so the stamping skipped the plist that ships. Same context now. 112 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 98 ++++++++++++++++--- .../AppExtensionDeploymentTargetTest.java | 36 +++++++ 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 324780fbe6e..af147ca2fa7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4908,7 +4908,9 @@ public void usesClassMethod(String cls, String method) { // and upload to be told the same thing later. throw new BuildException(outOfNamespace); } - stampAppExtensionInfoPlist(appExtension, request); + stampAppExtensionInfoPlist(appExtension, request, + ArchiveContext.of(archiveSdk, archiveConfiguration, archiveArch, + appExtensionBuildSettings(appExtension))); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); buildSettingsMap.put("PROVISIONING_PROFILE", "$(NS_PROVISIONING_PROFILE)"); buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", codeSignEntitlements); @@ -5844,8 +5846,9 @@ static String embeddedExtensionBundleVersion(BuildRequest request) { * aligned here, and every change is logged: this edits a file the developer supplied. A value * that is already correct, and one written as a {@code $(...)} reference, are left alone.

*/ - private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request) throws IOException { - Map plists = appExtensionInfoPlists(appExtension); + private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, + ArchiveContext context) throws IOException { + Map plists = appExtensionInfoPlists(appExtension, context); Set stamped = new LinkedHashSet(); for (Map.Entry candidate : plists.entrySet()) { File infoPlist = candidate.getValue(); @@ -6052,7 +6055,18 @@ private static Charset charsetOf(byte[] data, byte[] bom) { /// @return the raw setting value that named each plist, mapped to the resolved file, or to /// null when that value is unresolvable or lands outside the project directory static Map appExtensionInfoPlists(File extensionFolder) { - Map settings = appExtensionBuildSettings(extensionFolder); + return appExtensionInfoPlists(extensionFolder, null); + } + + /// @param context this archive, so INFOPLIST_FILE = $(CONFIGURATION)/Info.plist resolves to + /// the plist Xcode actually processes. Without it the path came back unresolvable and the + /// stamping skipped the file that ships -- the same hole the entitlements path had. + static Map appExtensionInfoPlists(File extensionFolder, ArchiveContext context) { + Map declared = appExtensionBuildSettings(extensionFolder); + Map settings = context == null ? declared + : extensionSettingsWithBuiltIns(extensionFolder, declared, context.configuration, + context.sdk, context.arch); + Map out = new LinkedHashMap(); String base = settings.get("INFOPLIST_FILE"); if (base == null || base.trim().length() == 0) { @@ -6968,6 +6982,46 @@ private String selectedDeveloperDir() { ? developer.getAbsolutePath() : null; } + /// What this archive IS, for matching conditional build settings against. + /// + /// These four travelled as loose parameters and kept growing -- and the one that was NOT a + /// parameter, the build variant, was hard-coded to "normal" and turned out to be settable by + /// the archive (BUILD_VARIANTS). Held together here so the next dimension is one field rather + /// than a signature change in six places. + static final class ArchiveContext { + final String sdk; + final String configuration; + final String arch; + /// Every variant this build produces; a [variant=...] condition applies if it names one. + final List variants; + + ArchiveContext(String sdk, String configuration, String arch, List variants) { + this.sdk = sdk; + this.configuration = configuration; + this.arch = arch; + this.variants = variants; + } + + /// @param settings the extension's own, since BUILD_VARIANTS in them is copied onto the + /// target and decides which variants Xcode actually builds + static ArchiveContext of(String sdk, String configuration, String arch, + Map settings) { + List variants = new ArrayList(); + String declared = settings == null ? null : settings.get("BUILD_VARIANTS"); + if (declared != null) { + for (String variant : declared.trim().split("\\s+")) { + if (variant.length() > 0) { + variants.add(variant); + } + } + } + if (variants.isEmpty()) { + variants.add("normal"); + } + return new ArchiveContext(sdk, configuration, arch, variants); + } + } + /// The value of {@code name} that governs THIS archive. /// /// Xcode does not merge a qualified setting with the plain one, it OVERRIDES it: with both @@ -6986,6 +7040,12 @@ static String winningSetting(Map settings, String name, String s static String winningSetting(Map settings, String name, String sdk, String configuration, String arch) { + return winningSetting(settings, name, + ArchiveContext.of(sdk, configuration, arch, settings)); + } + + static String winningSetting(Map settings, String name, + ArchiveContext context) { if (settings == null) { return null; } @@ -6997,7 +7057,7 @@ static String winningSetting(Map settings, String name, String s if (!qualified && !name.equals(key)) { continue; } - if (qualified && !conditionApplies(key, sdk, configuration, arch)) { + if (qualified && !conditionApplies(key, context)) { continue; } long specificity = qualified ? conditionSpecificity(key) : 0; @@ -7060,6 +7120,14 @@ static boolean conditionApplies(String key, String sdk, String configuration) { /// @param arch the architecture the archive is built for, so [arch=arm64] and [arch=x86_64] /// are not both counted applicable and then decided by map order static boolean conditionApplies(String key, String sdk, String configuration, String arch) { + return conditionApplies(key, new ArchiveContext(sdk, configuration, arch, + java.util.Collections.singletonList("normal"))); + } + + static boolean conditionApplies(String key, ArchiveContext context) { + String sdk = context.sdk; + String configuration = context.configuration; + String arch = context.arch; int open = key.indexOf('['); if (open < 0) { return true; @@ -7081,15 +7149,19 @@ static boolean conditionApplies(String key, String sdk, String configuration, St if ("arch".equals(name) && arch != null && !matchesCondition(value, arch)) { return false; } - // The build variant is not a parameter because it is never in doubt: this builder - // archives the normal variant, never Xcode's profile or debug variants. A - // [variant=profile] setting therefore belongs to a build that does not happen here, - // and letting it win -- it is more specific than the plain setting -- meant validating - // an identifier or reading entitlements Xcode would not use. - if ("variant".equals(name) && !matchesCondition(value, "normal")) { - return false; + // Against the variants this build actually produces. "normal" unless the extension's + // own BUILD_VARIANTS says otherwise -- that setting is copied onto the target, so an + // archive declaring profile really is built as profile and its [variant=profile] + // settings are the ones Xcode applies. + if ("variant".equals(name)) { + boolean matches = false; + for (String variant : context.variants) { + matches |= matchesCondition(value, variant); + } + if (!matches) { + return false; + } } - } return true; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 3e0d86a33c8..45d2d8e61ae 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -704,4 +704,40 @@ public void aVariantThisBuilderNeverArchivesDoesNotWin() throws Exception { assertTrue(IPhoneBuilder.conditionApplies("X[variant=normal]", "iphoneos14.4", "Release", "arm64")); } + + @Test + public void anArchiveThatDeclaresItsVariantGetsThatVariantsSettings() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "profile"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.example.app.Ext.profile"); + + // BUILD_VARIANTS is copied onto the target, so Xcode really does build the profile variant + // and apply its settings; hard-coding "normal" discarded them. + assertEquals("com.example.app.Ext.profile", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + + java.util.Map ordinary = new java.util.LinkedHashMap(); + ordinary.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + ordinary.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.other.Ext"); + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(ordinary, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + } + + @Test + public void anInfoPlistPathThroughTheConfigurationResolves() throws Exception { + File dist = tmp.newFolder("dist22"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File release = new File(extension, "Release.plist"); + write(release, ""); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/$(CONFIGURATION).plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // Unresolvable here meant the stamping skipped the plist that actually ships. + assertTrue(plists.toString(), plists.values().contains(release)); + } } From e6d0a15f837a83c4bbe9044136cf6df55af0f67f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:25:52 +0300 Subject: [PATCH 39/49] Give repairs the whole context, and resolve a qualifier where it lives Mirrors the cloud builder fixes. The repairs took three loose values and rebuilt a context from them, which put the variant back to "normal" -- so an archive declaring BUILD_VARIANTS=profile had its [variant=profile] entries skipped, including an under-floor deployment target that Xcode then uses. A qualified plist was resolved in the ACTIVE context, so INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist came out as Release/Info.plist: the stamping rewrote a file belonging to another configuration and left the qualified one untouched. Each qualifier resolves in the context it declares. And the candidates were keyed by the setting's text, which those two settings share, so the second was discarded before resolution. They are keyed by the setting. 115 tests against this module's own IPhoneBuilder, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 75 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 52 +++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index af147ca2fa7..fe0ed6dfa64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4982,7 +4982,8 @@ public void usesClassMethod(String cls, String method) { for (String note : repairQualifiedExtensionSettings(buildSettingsMap, request.getPackageName(), appExtensionDeploymentFloor(signingEntitlements), - archiveSdk, archiveConfiguration, archiveArch)) { + ArchiveContext.of(archiveSdk, archiveConfiguration, archiveArch, + buildSettingsMap))) { debug("The " + extensionName + " app extension: " + note + "."); } @@ -6069,12 +6070,14 @@ static Map appExtensionInfoPlists(File extensionFolder, ArchiveCon Map out = new LinkedHashMap(); String base = settings.get("INFOPLIST_FILE"); + // The same settings go to the resolver: a path may reference any of them. if (base == null || base.trim().length() == 0) { File byDefault = new File(extensionFolder, "Info.plist"); out.put("Info.plist", insideProjectDir(byDefault, extensionFolder.getParentFile()) ? byDefault : null); } else { - out.put(base.trim(), resolveInfoPlistPath(base.trim(), extensionFolder, settings)); + out.put("INFOPLIST_FILE = " + base.trim(), + resolveInfoPlistPath(base.trim(), extensionFolder, settings)); } for (Map.Entry setting : settings.entrySet()) { String key = setting.getKey(); @@ -6086,8 +6089,18 @@ static Map appExtensionInfoPlists(File extensionFolder, ArchiveCon continue; } String value = setting.getValue() == null ? "" : setting.getValue().trim(); - if (value.length() > 0 && !out.containsKey(value)) { - out.put(value, resolveInfoPlistPath(value, extensionFolder, settings)); + if (value.length() > 0) { + // In the context the qualifier declares: [config=Debug] means $(CONFIGURATION) is + // Debug for THAT entry, whatever this archive builds. + ArchiveContext own = contextForCondition(key, context); + // Keyed by the SETTING, not by its text: two settings can carry the same text and + // still name different files, which is exactly what + // $(CONFIGURATION)/Info.plist under [config=Debug] does. Keying by the text threw + // the second one away before it was ever resolved. + out.put(key + " = " + value, resolveInfoPlistPath(value, extensionFolder, + context == null ? declared + : extensionSettingsWithBuiltIns(extensionFolder, declared, + own.configuration, own.sdk, own.arch))); } } return out; @@ -6876,11 +6889,21 @@ static List repairQualifiedExtensionSettings(Map setting /// loses iOS 12 and 13 for a Wallet entitlement it never carried. static List repairQualifiedExtensionSettings(Map settings, String hostPackage, String floor, String sdk, String configuration, String arch) { + return repairQualifiedExtensionSettings(settings, hostPackage, floor, + ArchiveContext.of(sdk, configuration, arch, settings)); + } + + /// @param context the whole of it. Rebuilding one here from three loose values put the variant + /// back to "normal", so an archive declaring BUILD_VARIANTS=profile had its + /// [variant=profile] entries skipped -- and an under-floor target among them was then copied + /// onto the target and won for the build Xcode actually makes. + static List repairQualifiedExtensionSettings(Map settings, + String hostPackage, String floor, ArchiveContext context) { List notes = new ArrayList(); for (Map.Entry setting : new ArrayList>( settings.entrySet())) { String key = setting.getKey(); - if (!conditionApplies(key, sdk, configuration, arch)) { + if (!conditionApplies(key, context)) { continue; } String value = setting.getValue() == null ? "" : setting.getValue().trim(); @@ -6982,6 +7005,48 @@ private String selectedDeveloperDir() { ? developer.getAbsolutePath() : null; } + /// The context a qualified setting describes, over the top of the archive's own. + /// + /// INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist means Debug/Info.plist, not + /// Release/Info.plist -- resolving every candidate in the ACTIVE context stamped a file that + /// belongs to another configuration and left the one the qualifier names untouched. Whatever + /// the condition does not name stays as this archive has it. + static ArchiveContext contextForCondition(String key, ArchiveContext active) { + int open = key == null ? -1 : key.indexOf('['); + if (open < 0) { + return active; + } + String sdk = active == null ? null : active.sdk; + String configuration = active == null ? null : active.configuration; + String arch = active == null ? null : active.arch; + List variants = active == null ? null : active.variants; + for (String condition : key.substring(open).split("[\\[\\],]")) { + int equals = condition.indexOf('='); + if (equals < 0) { + continue; + } + String name = condition.substring(0, equals).trim(); + String value = condition.substring(equals + 1).trim(); + if (value.endsWith("*")) { + // A pattern names a family, not a build; its stem is the closest thing to a value. + value = value.substring(0, value.length() - 1); + } + if (value.length() == 0) { + continue; + } + if ("sdk".equals(name)) { + sdk = value; + } else if ("config".equals(name)) { + configuration = value; + } else if ("arch".equals(name)) { + arch = value; + } else if ("variant".equals(name)) { + variants = java.util.Collections.singletonList(value); + } + } + return new ArchiveContext(sdk, configuration, arch, variants); + } + /// What this archive IS, for matching conditional build settings against. /// /// These four travelled as loose parameters and kept growing -- and the one that was NOT a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 45d2d8e61ae..2c109fb4235 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -740,4 +740,56 @@ public void anInfoPlistPathThroughTheConfigurationResolves() throws Exception { // Unresolvable here meant the stamping skipped the plist that actually ships. assertTrue(plists.toString(), plists.values().contains(release)); } + + @Test + public void repairsSeeTheVariantsTheArchiveDeclares() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "profile"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Rebuilding a context here from loose values put the variant back to "normal", so this + // entry was skipped -- and it is the one Xcode uses for the build it actually makes. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]")); + assertEquals(1, notes.size()); + } + + @Test + public void aQualifiedPlistResolvesInItsOwnConfiguration() throws Exception { + File dist = tmp.newFolder("dist23"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(new File(extension, "Debug").mkdirs()); + assertTrue(new File(extension, "Release").mkdirs()); + File debugPlist = new File(extension, "Debug/Info.plist"); + write(debugPlist, ""); + File releasePlist = new File(extension, "Release/Info.plist"); + write(releasePlist, ""); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/$(CONFIGURATION)/Info.plist\n" + + "INFOPLIST_FILE[config\\=Debug] = WalletUIExtension/$(CONFIGURATION)/Info.plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // The Debug-qualified entry means Debug/Info.plist. Resolving it in the ACTIVE context + // stamped Release/Info.plist twice and left the Debug one untouched. + assertTrue(plists.toString(), plists.values().contains(releasePlist)); + assertTrue(plists.toString(), plists.values().contains(debugPlist)); + } + + @Test + public void aConditionsOwnContextOverridesOnlyWhatItNames() throws Exception { + IPhoneBuilder.ArchiveContext active = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", null); + IPhoneBuilder.ArchiveContext own = IPhoneBuilder.contextForCondition("X[config=Debug]", active); + assertEquals("Debug", own.configuration); + assertEquals("iphoneos14.4", own.sdk); + assertEquals("arm64", own.arch); + // a pattern names a family; its stem is the closest thing to a value + assertEquals("iphonesimulator", IPhoneBuilder.contextForCondition( + "X[sdk=iphonesimulator*]", active).sdk); + } } From c587b750fd7d41817b42e3047f821eda860df186 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:31:35 +0300 Subject: [PATCH 40/49] Resolve conditionals before expanding, and three smaller corrections Mirrors the cloud builder fixes. A reference is expanded against a map, and a map lookup only sees the plain key -- so $(MARKETING_VERSION) resolved to the base value while MARKETING_VERSION[sdk=iphoneos*] sat beside it and was what Xcode used for the device archive, giving the extension a version its container does not have. The settings are flattened for this archive once, before anything expands against them. An applicable wildcard keeps the archive's own value: [sdk=iphoneos*] with $(SDK_NAME) in it means iphoneos14.4, and the pattern's stem sent the stamper after a path that does not exist. The stem stands in only for a pattern describing some other build. And the plist candidates are read from the DECLARED settings: flattening removes the qualified keys, so reading candidates from the flattened map made the conditionals vanish before they could be considered. 117 tests against this module's own IPhoneBuilder, all green, and the mirrored methods compared structurally against the daemon's. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 59 ++++++++++++++++--- .../builders/AppExtensionInfoPlistTest.java | 42 +++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index fe0ed6dfa64..e79049351fd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5886,7 +5886,8 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, settings.put("PRODUCT_BUNDLE_IDENTIFIER", declaredId != null ? declaredId : request.getPackageName() + "." + appExtension.getName()); List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), - embeddedExtensionBundleVersion(request), request.getPackageName(), settings); + embeddedExtensionBundleVersion(request), request.getPackageName(), + flattenForContext(settings, context)); if (changes == null) { debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + " as an XML property list, so its bundle identity was left as it is. If " @@ -6063,14 +6064,16 @@ static Map appExtensionInfoPlists(File extensionFolder) { /// the plist Xcode actually processes. Without it the path came back unresolvable and the /// stamping skipped the file that ships -- the same hole the entitlements path had. static Map appExtensionInfoPlists(File extensionFolder, ArchiveContext context) { + // The DECLARED settings, qualified keys and all: those keys are the candidates. The + // resolution map beside it is flattened for this archive, which is right for expanding a + // reference and useless for finding conditionals -- reading candidates out of it made the + // qualified entries disappear. Map declared = appExtensionBuildSettings(extensionFolder); Map settings = context == null ? declared : extensionSettingsWithBuiltIns(extensionFolder, declared, context.configuration, context.sdk, context.arch); - Map out = new LinkedHashMap(); - String base = settings.get("INFOPLIST_FILE"); - // The same settings go to the resolver: a path may reference any of them. + String base = declared.get("INFOPLIST_FILE"); if (base == null || base.trim().length() == 0) { File byDefault = new File(extensionFolder, "Info.plist"); out.put("Info.plist", @@ -6079,7 +6082,7 @@ static Map appExtensionInfoPlists(File extensionFolder, ArchiveCon out.put("INFOPLIST_FILE = " + base.trim(), resolveInfoPlistPath(base.trim(), extensionFolder, settings)); } - for (Map.Entry setting : settings.entrySet()) { + for (Map.Entry setting : declared.entrySet()) { String key = setting.getKey(); // Closing bracket included: an unescaped conditional leaves Properties with the key // INFOPLIST_FILE[sdk and the rest of the line as its value, and that is not a setting @@ -7005,13 +7008,43 @@ private String selectedDeveloperDir() { ? developer.getAbsolutePath() : null; } + /// The archive's settings with every conditional resolved to the value THIS build gets. + /// + /// A reference is expanded against a map, and a map lookup only ever sees the plain key -- so + /// $(MARKETING_VERSION) resolved to the base 5.4 while MARKETING_VERSION[sdk=iphoneos*] = 5.3 + /// sat beside it and was the value Xcode used for the device archive. The extension then + /// carried a version its containing app does not have, which is the rejection this stamping + /// exists to prevent. Flattened here, once, so everything downstream expands against the + /// values the build really has. + static Map flattenForContext(Map settings, + ArchiveContext context) { + if (settings == null) { + return null; + } + Map flat = new LinkedHashMap(); + for (String key : settings.keySet()) { + int open = key.indexOf('['); + String name = open < 0 ? key : key.substring(0, open); + if (flat.containsKey(name)) { + continue; + } + String winner = context == null ? settings.get(name) + : winningSetting(settings, name, context); + if (winner != null) { + flat.put(name, winner); + } + } + return flat; + } + /// The context a qualified setting describes, over the top of the archive's own. /// /// INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist means Debug/Info.plist, not /// Release/Info.plist -- resolving every candidate in the ACTIVE context stamped a file that /// belongs to another configuration and left the one the qualifier names untouched. Whatever /// the condition does not name stays as this archive has it. - static ArchiveContext contextForCondition(String key, ArchiveContext active) { + static ArchiveContext contextForCondition(String key, ArchiveContext context) { + ArchiveContext active = context; int open = key == null ? -1 : key.indexOf('['); if (open < 0) { return active; @@ -7027,8 +7060,18 @@ static ArchiveContext contextForCondition(String key, ArchiveContext active) { } String name = condition.substring(0, equals).trim(); String value = condition.substring(equals + 1).trim(); - if (value.endsWith("*")) { - // A pattern names a family, not a build; its stem is the closest thing to a value. + boolean pattern = value.endsWith("*"); + if (pattern) { + // A pattern names a family. If THIS archive is in that family, its own value is + // the one Xcode will expand -- $(SDK_NAME) under [sdk=iphoneos*] is iphoneos14.4, + // not "iphoneos", and looking for the stem's file left the real one unstamped. + // Only when the pattern describes some other build does the stem stand in for it. + String archiveValue = "sdk".equals(name) ? (context == null ? null : context.sdk) + : "config".equals(name) ? (context == null ? null : context.configuration) + : "arch".equals(name) ? (context == null ? null : context.arch) : null; + if (archiveValue != null && matchesCondition(value, archiveValue)) { + continue; + } value = value.substring(0, value.length() - 1); } if (value.length() == 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java index 3b1e69fde8b..fab657a2e59 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -476,4 +476,46 @@ public void aBinaryPlistIsReportedRatherThanMangled() { assertNull(IPhoneBuilder.stampInfoPlistIdentity("bplist00 ", "5.4", "5.4", NO_SETTINGS, changes)); assertEquals(1, changes.size()); } + + @Test + public void aVersionReferenceFollowsTheConditionalTheArchiveGets() { + // The base matches the app, the device-qualified value does not -- and the qualified one + // is what Xcode uses for this archive, so the extension shipped a version its container + // does not have. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "5.4"); + settings.put("MARKETING_VERSION[sdk=iphoneos*]", "5.3"); + + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", null, + IPhoneBuilder.flattenForContext(settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)), + changes); + + assertTrue(out, out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '5.3'")); + } + + @Test + public void aConditionalThatMatchesTheAppIsLeftAlone() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "1.0"); + settings.put("MARKETING_VERSION[sdk=iphoneos*]", "5.4"); + + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", null, + IPhoneBuilder.flattenForContext(settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)), + changes); + + // The qualified value is the app's version, so the reference is right as written. + assertTrue(out.contains("$(MARKETING_VERSION)")); + assertFalse(changes.toString().contains("CFBundleShortVersionString")); + } } From 3c6c724b30060c0de9ff93430cd87acc072ec35b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:14:03 +0300 Subject: [PATCH 41/49] Three review catches: padding, helper settings, and variant chains Mirrors the cloud builder fixes. An identifier with padding is not the identifier it reads as: the namespace check trimmed before comparing, so com.app.Ext passed and was kept while a plist parser keeps the spaces and Apple refuses the result. Judged exactly now, CDATA and entities included. A deployment target written through a helper setting was resolved without the archive's context, so an inactive [config=Debug] qualifier won by specificity and the expression was kept on the strength of a value Xcode never expands it to. And BUILD_VARIANTS may itself be written through another setting: the raw text was split, recording "$(EXTENSION_VARIANTS)" as the variant, so the settings Xcode applies matched nothing. 121 tests against this module's own IPhoneBuilder, all green, and all eleven shared methods compared structurally against the daemon's: no drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 59 +++++++++++++++---- .../AppExtensionDeploymentTargetTest.java | 32 ++++++++++ .../builders/AppExtensionInfoPlistTest.java | 24 ++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index e79049351fd..b7be6bb35f1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4960,9 +4960,13 @@ public void usesClassMethod(String cls, String method) { // The configuration this build hands to xcodebuild is Release for a // device archive whatever ios.buildType says, so that is what a // [config=...] condition must be matched against. + // The same context, now that buildSettingsMap holds the archive's own + // settings as well: BUILD_VARIANTS among them. + ArchiveContext buildContext = ArchiveContext.of(archiveSdk, + archiveConfiguration, archiveArch, buildSettingsMap); File signingEntitlements = appExtensionSigningEntitlements(appExtension, - buildSettingsMap, extEntitlementsFile, archiveSdk, - archiveConfiguration, archiveArch); + buildSettingsMap, extEntitlementsFile, buildContext.sdk, + buildContext.configuration, buildContext.arch); // The BASE setting is copied into every Xcode configuration, so // writing the archive's answer there hands Debug a minimum belonging // to Release; it gets the base value, clamped on its own. The @@ -4971,13 +4975,13 @@ public void usesClassMethod(String cls, String method) { buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), signingEntitlements, request.getArg("ios.deployment_target", null), - appExtension, buildSettingsMap); + appExtension, buildSettingsMap, buildContext); String archiveDeploymentTarget = appExtensionDeploymentTarget( winningSetting(buildSettingsMap, "IPHONEOS_DEPLOYMENT_TARGET", - archiveSdk, archiveConfiguration, archiveArch), + buildContext), signingEntitlements, request.getArg("ios.deployment_target", null), - appExtension, buildSettingsMap); + appExtension, buildSettingsMap, buildContext); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); for (String note : repairQualifiedExtensionSettings(buildSettingsMap, request.getPackageName(), @@ -6188,10 +6192,17 @@ static boolean identifierBelongsToApp(String plist, String hostBundleId, if (valueEnd < 0) { return true; } - String current = WatchNativeBuilder.plistStringContent(plist.substring(openEnd + 1, valueEnd)); + String exact = WatchNativeBuilder.plistStringContentExact( + plist.substring(openEnd + 1, valueEnd)); + String current = exact == null ? null : exact.trim(); if (current == null || current.length() == 0) { return true; } + if (!exact.equals(current)) { + // A plist parser keeps that padding, so " com.example.app.Ext " is not the identifier + // it reads as -- it is an invalid one. Not ours to keep, whatever it trims to. + return false; + } // Through the settings, because $(PRODUCT_BUNDLE_IDENTIFIER) is not automatically safe: // the archive may override PRODUCT_BUNDLE_IDENTIFIER itself, with the identifier from the // project the extension was exported from, and those overrides are written onto this @@ -6200,8 +6211,9 @@ static boolean identifierBelongsToApp(String plist, String hostBundleId, if (resolved.length() == 0) { // $(EXTENSION_BUNDLE_ID) with nothing defining it does not fall back to the target's // identifier -- Xcode expands it to the empty string and the .appex ships with no - // identifier at all. The one reference that IS safe, $(PRODUCT_BUNDLE_IDENTIFIER), - // resolves through the settings because the caller puts the target's own there. + // identifier at all. An unknown reference is therefore the opposite of safe, and the + // one reference that IS safe, $(PRODUCT_BUNDLE_IDENTIFIER), resolves through the + // settings above because the caller puts the target's own identifier in them. return false; } return resolved.startsWith(hostBundleId + "."); @@ -7115,7 +7127,12 @@ static final class ArchiveContext { static ArchiveContext of(String sdk, String configuration, String arch, Map settings) { List variants = new ArrayList(); - String declared = settings == null ? null : settings.get("BUILD_VARIANTS"); + // Resolved first: BUILD_VARIANTS = $(EXTENSION_VARIANTS) is a chain Xcode expands, and + // splitting the raw text recorded "$(EXTENSION_VARIANTS)" as the variant -- so the + // [variant=profile] settings Xcode applies were matched against a literal reference. + String declaredRaw = settings == null ? null : settings.get("BUILD_VARIANTS"); + String declared = declaredRaw == null ? null + : resolveSettingsInValue(declaredRaw, settings); if (declared != null) { for (String variant : declared.trim().split("\\s+")) { if (variant.length() > 0) { @@ -7353,15 +7370,32 @@ static String appExtensionDeploymentTarget(String declared, File entitlements, S /// $(EXTENSION_MIN) is judged by the version it resolves to rather than parsed as none static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget, File extensionFolder, Map settings) { + return appExtensionDeploymentTarget(declared, entitlements, appTarget, extensionFolder, + settings, null); + } + + static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget, + File extensionFolder, Map settings, ArchiveContext context) { return appExtensionDeploymentTarget(declared, entitlements == null ? new ArrayList() : Arrays.asList(entitlements), - appTarget, extensionFolder, settings); + appTarget, extensionFolder, settings, context); } /// @param entitlements every file this target may be signed with, since a qualified /// CODE_SIGN_ENTITLEMENTS can be the one that carries the Wallet entitlement static String appExtensionDeploymentTarget(String declared, List entitlements, String appTarget, File extensionFolder, Map settings) { + return appExtensionDeploymentTarget(declared, entitlements, appTarget, extensionFolder, + settings, null); + } + + /// @param context the archive's, so a target written through another setting is resolved with + /// the conditionals THIS build gets. Rebuilding an empty context here let an inactive + /// qualifier -- EXTENSION_MIN[config=Debug] beside a Release build -- win by specificity, and + /// the expression was then kept on the strength of a value Xcode never expands it to. + static String appExtensionDeploymentTarget(String declared, List entitlements, + String appTarget, File extensionFolder, Map settings, + ArchiveContext context) { // The floor is a floor, not a default. An archive exported from an old project may carry // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would // reproduce the very rejection this exists to prevent -- 10.0 does not even build against @@ -7375,7 +7409,10 @@ static String appExtensionDeploymentTarget(String declared, List entitleme // on a version clearing the floor is kept as written, because Xcode resolves it on the // target and that is the archive author's expression to keep. String resolved = extensionFolder == null ? "" : resolveSettingsInValue(chosen, - extensionSettingsWithBuiltIns(extensionFolder, settings)); + context == null + ? extensionSettingsWithBuiltIns(extensionFolder, settings) + : extensionSettingsWithBuiltIns(extensionFolder, settings, + context.configuration, context.sdk, context.arch)); if (resolved.length() == 0) { // And a reference to a setting nothing defines is not "unknown" -- Xcode expands // it to the empty string, so the extension would declare no minimum at all. The diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 2c109fb4235..4b5d859c1eb 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -792,4 +792,36 @@ public void aConditionsOwnContextOverridesOnlyWhatItNames() throws Exception { assertEquals("iphonesimulator", IPhoneBuilder.contextForCondition( "X[sdk=iphonesimulator*]", active).sdk); } + + @Test + public void aHelperSettingResolvesInTheActiveContext() throws Exception { + File extension = tmp.newFolder("dist24", "WalletUIExtension"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "10.0"); + settings.put("EXTENSION_MIN[config=Debug]", "16.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET", "$(EXTENSION_MIN)"); + + String target = IPhoneBuilder.appExtensionDeploymentTarget("$(EXTENSION_MIN)", + (File) null, "11", extension, settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Without the archive's context the Debug qualifier wins by specificity, the reference + // looks like 16.0, and the expression is kept -- while Xcode expands it to the Release + // base 10.0 and the floor is bypassed. + assertEquals("12.0", target); + } + + @Test + public void variantsWrittenThroughAnotherSettingAreExpanded() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_VARIANTS", "profile"); + settings.put("BUILD_VARIANTS", "$(EXTENSION_VARIANTS)"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.example.app.Ext.profile"); + + // Xcode expands the chain and applies the profile settings; splitting the raw text + // recorded "$(EXTENSION_VARIANTS)" as the variant and matched nothing. + assertEquals("com.example.app.Ext.profile", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java index fab657a2e59..a3a473f215b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -518,4 +518,28 @@ public void aConditionalThatMatchesTheAppIsLeftAlone() { assertTrue(out.contains("$(MARKETING_VERSION)")); assertFalse(changes.toString().contains("CFBundleShortVersionString")); } + + @Test + public void aPaddedIdentifierIsNotTheIdentifierItReadsAs() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t com.new.app.Ext \n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + // A plist parser keeps the padding, so this ships as " com.new.app.Ext " -- an identifier + // Apple refuses, however well it trims. + assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void paddingInsideCdataIsNoDifferent() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } } From 153c8971df1bb923efc51e870723dc7daa611a8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:25:14 +0300 Subject: [PATCH 42/49] Flatten before resolving, and pick the variant list for the archive This copy's extensionSettingsWithBuiltIns really did hand the raw map to the resolver -- the review was right about this file and wrong about the daemon's, which flattens. So PRODUCT_BUNDLE_IDENTIFIER = $(EXTENSION_ID) over an EXTENSION_ID[sdk=iphoneos*] resolved to the base value: the export-options key and the namespace refusal then named a bundle the device archive does not contain. The ported test failed here and passed there, which is how the drift surfaced at all. Also mirrored: BUILD_VARIANTS is chosen for the archive rather than read from the plain key, since Xcode honours BUILD_VARIANTS[sdk=iphoneos*] and preflight judged that archive as "normal"; and the repairs resolve their references against the flattened settings, so a qualified identifier written through a conditional helper is judged by the value Xcode gives it instead of the helper's base. The hand-picked list of methods I was comparing is why this drift lived through several mirrors: the comparison is now every method the two files share in this area, and it found two more -- a listFiles() that could return null unguarded, and an inlined bundle-id default that is now the same named helper as the daemon's. 125 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 59 ++++++++++++--- .../AppExtensionDeploymentTargetTest.java | 72 +++++++++++++++++++ 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b7be6bb35f1..6976f8f5917 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5884,11 +5884,11 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, // already matched its app into one that does not -- the very validation failure this // method exists to prevent. // The settings the TARGET will carry, so a $(PRODUCT_BUNDLE_IDENTIFIER) in the plist - // is judged by the identifier it will actually resolve to. + // is judged by the identifier it will actually resolve to -- the archive's override + // included, which is where an identifier from another project comes in. Map settings = appExtensionBuildSettings(appExtension); - String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); - settings.put("PRODUCT_BUNDLE_IDENTIFIER", declaredId != null ? declaredId - : request.getPackageName() + "." + appExtension.getName()); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", appExtensionBundleId(appExtension, + request.getPackageName() + "." + appExtension.getName())); List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), embeddedExtensionBundleVersion(request), request.getPackageName(), flattenForContext(settings, context)); @@ -6752,8 +6752,13 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, static Map extensionSettingsWithBuiltIns(File extensionFolder, Map settings, String configuration, String sdk, String arch) { Map out = new LinkedHashMap(); - if (settings != null) { - out.putAll(settings); + // Conditionals resolved to what this build gets, before anything expands a reference + // against them: a map lookup only ever sees the plain key, and the qualified value is the + // one Xcode uses. + Map flat = flattenForContext(settings, + ArchiveContext.of(sdk, configuration, arch, settings)); + if (flat != null) { + out.putAll(flat); } String targetName = extensionFolder.getName(); out.put("TARGET_NAME", targetName); @@ -6915,6 +6920,13 @@ static List repairQualifiedExtensionSettings(Map setting static List repairQualifiedExtensionSettings(Map settings, String hostPackage, String floor, ArchiveContext context) { List notes = new ArrayList(); + // Flattened once, for the references these values make. A qualified identifier written as + // $(EXTENSION_ID) resolved against the raw map, which only ever answers with the plain + // EXTENSION_ID -- so an archive whose EXTENSION_ID[config=Release] is a perfectly good + // extension of this app had the base value read instead, and the qualified identifier + // Xcode would have used was dropped for being out of namespace. The generated target then + // fell back to its base bundle id while the export options still named the custom one. + Map flat = flattenForContext(settings, context); for (Map.Entry setting : new ArrayList>( settings.entrySet())) { String key = setting.getKey(); @@ -6929,7 +6941,7 @@ static List repairQualifiedExtensionSettings(Map setting // overwritten with 12.0 -- taking an extension that compiles against iOS 16 APIs down // with it. A reference that resolves to nothing here is left exactly as written: this // build cannot evaluate it, which is not the same as knowing it is wrong. - String resolved = resolveSettingsInValue(value, settings); + String resolved = resolveSettingsInValue(value, flat); if (resolved.length() == 0) { continue; } @@ -7127,12 +7139,24 @@ static final class ArchiveContext { static ArchiveContext of(String sdk, String configuration, String arch, Map settings) { List variants = new ArrayList(); + // The variant list is what the OTHER conditions are matched against, so it has to be + // chosen before any of them and cannot be chosen BY one: this bootstrap context knows + // the archive's sdk, configuration and architecture and has no variants at all, which + // makes a [variant=...] qualifier on BUILD_VARIANTS itself apply to nothing. Xcode is + // in the same position -- the setting decides the variants, so it cannot be selected + // by them -- and reading only the plain key missed BUILD_VARIANTS[sdk=iphoneos*], + // which Xcode does honour: preflight then judged the device archive as "normal" and + // skipped the [variant=profile] target that outranked the clamped base on it. + ArchiveContext selection = new ArchiveContext(sdk, configuration, arch, + java.util.Collections.emptyList()); + String declaredRaw = settings == null ? null + : winningSetting(settings, "BUILD_VARIANTS", selection); // Resolved first: BUILD_VARIANTS = $(EXTENSION_VARIANTS) is a chain Xcode expands, and // splitting the raw text recorded "$(EXTENSION_VARIANTS)" as the variant -- so the // [variant=profile] settings Xcode applies were matched against a literal reference. - String declaredRaw = settings == null ? null : settings.get("BUILD_VARIANTS"); + // Against the flattened settings, since the helper it names may itself be qualified. String declared = declaredRaw == null ? null - : resolveSettingsInValue(declaredRaw, settings); + : resolveSettingsInValue(declaredRaw, flattenForContext(settings, selection)); if (declared != null) { for (String variant : declared.trim().split("\\s+")) { if (variant.length() > 0) { @@ -7540,6 +7564,17 @@ static boolean insideProjectDir(File candidate, File projectDir) { } } + /// The identifier this extension's archive declares, or the one derived from the app. + /// + /// Named rather than inlined because four call sites have to agree on it: an archive that + /// overrides PRODUCT_BUNDLE_IDENTIFIER decides the export-options key, the profile that can + /// sign it, the plist that is stamped and the namespace refusal, and one of them reading the + /// derived default instead pairs the target with a bundle the archive does not contain. + static String appExtensionBundleId(File extensionFolder, String defaultBundleId) { + String override = appExtensionBuildSetting(extensionFolder, "PRODUCT_BUNDLE_IDENTIFIER"); + return override == null ? defaultBundleId : override; + } + /// One build setting as the extension's own buildSettings.properties overrides it, or null /// when the archive carries no such override. /// @@ -8917,7 +8952,11 @@ private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) throw new IllegalArgumentException("extractAppExtensions sourceDirectory must be an existing directory but received "+sourceDirectory); } List out = new ArrayList<>(); - for (File appExtension : sourceDirectory.listFiles()) { + File[] entries = sourceDirectory.listFiles(); + if (entries == null) { + return new File[0]; + } + for (File appExtension : entries) { if (!appExtension.getName().endsWith(".ios.appext")) { // Only interested in files ending in .ios.appext since // Maven would have bundled the app extensions in this way. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index 4b5d859c1eb..e01749c2e84 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -824,4 +824,76 @@ public void variantsWrittenThroughAnotherSettingAreExpanded() throws Exception { assertEquals("com.example.app.Ext.profile", IPhoneBuilder.winningSetting(settings, "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); } + + @Test + public void aQualifiedVariantListSelectsTheArchivesVariants() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "normal"); + settings.put("BUILD_VARIANTS[sdk=iphoneos*]", "profile"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Xcode honours the qualified list for the device archive; reading the plain key alone + // judged that archive as "normal", skipped the profile target, and copied an under-floor + // 10.0 onto the target where it outranks the clamped base. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]")); + assertEquals(1, notes.size()); + } + + @Test + public void aVariantListQualifiedByItsOwnVariantIsIgnored() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "normal"); + settings.put("BUILD_VARIANTS[variant=profile]", "profile"); + + // The list decides the variants, so it cannot be selected by them -- and neither can + // Xcode select it that way. Letting this qualifier apply would be a self-fulfilling + // reading in which every archive builds every variant it mentions. + assertEquals(java.util.Collections.singletonList("normal"), IPhoneBuilder.ArchiveContext + .of("iphoneos14.4", "Release", "arm64", settings).variants); + } + + @Test + public void aQualifiedIdentifierResolvesItsHelperInTheArchivesContext() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_ID", "com.other.Ext"); + settings.put("EXTENSION_ID[config=Release]", "com.example.app.Custom"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "$(EXTENSION_ID)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Resolved against the raw map the helper answers with its base foreign value, the + // identifier is dropped as out of namespace, and the target falls back to a bundle id the + // export options and the signing profile do not name. + assertEquals("$(EXTENSION_ID)", settings.get("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); + assertEquals(notes.toString(), 0, notes.size()); + } + + @Test + public void anIdentifierWrittenThroughAConditionalHelperResolvesToTheConditional() + throws Exception { + File dist = tmp.newFolder("dist31"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_ID", "com.other.Ext"); + settings.put("EXTENSION_ID[sdk=iphoneos*]", "com.example.app.Wallet"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "$(EXTENSION_ID)"); + + // extensionSettingsWithBuiltIns flattens before it hands anything to the resolver, so the + // reference already expands to the value the device archive gets. Pinned because a review + // twice read that map as keeping qualified values under their bracketed keys only: it + // does not, and a change that makes it do so has to break this test first. + assertEquals("com.example.app.Wallet", IPhoneBuilder.resolveSettingsFully( + IPhoneBuilder.winningSetting(settings, "PRODUCT_BUNDLE_IDENTIFIER", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", + settings)), + IPhoneBuilder.extensionSettingsWithBuiltIns(extension, settings, "Release", + "iphoneos14.4", "arm64"))); + } } From b90cb2d4b4805bb1c23bb71e6867c152e285e033 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:35:50 +0300 Subject: [PATCH 43/49] Mirror: one settings reader, and device families from the project type Two readers of buildSettings.properties disagreed about trailing whitespace -- preflight trimmed, the loop that writes the target did not -- so a padded PRODUCT_BUNDLE_IDENTIFIER was validated as one string and built as another that no profile matches. One reader now, and it trims, which is what xcodebuild -showBuildSettings reports anyway. And TARGETED_DEVICE_FAMILY was "1,2" on all four extension targets while the app target takes "1" for ios.project_type=iphone and "2" for ipad: an iPhone-only app shipped an extension claiming iPad support, which builds and is refused on upload. Derived from the project type as the default, with an archive's own value still winning. 127 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 55 ++++++++++++++----- .../AppExtensionBuildSettingsTest.java | 29 ++++++++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 6976f8f5917..7677525f544 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4921,7 +4921,8 @@ public void usesClassMethod(String cls, String method) { // families than the app is an App Store rejection on upload, and // without SKIP_INSTALL the .appex is installed into the archive's // Products as a second copy of a bundle already inside the .app. - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("SKIP_INSTALL", "YES"); if (containsSwiftSource(appExtension)) { // The project's Swift settings are applied to the app target @@ -4935,16 +4936,12 @@ public void usesClassMethod(String cls, String method) { File buildSettingsProps = new File(appExtension, "buildSettings.properties"); if (buildSettingsProps.exists()) { - Properties _buildSettingsProps = new Properties(); - try (FileInputStream fis = new FileInputStream(buildSettingsProps)) { - _buildSettingsProps.load(fis); - } - for (Object key : _buildSettingsProps.keySet()) { - if (key instanceof String) { - String val = _buildSettingsProps.getProperty((String)key); - buildSettingsMap.put((String)key, val); - } - } + // Through the same reader preflight used, rather than a second + // parse of the same file: the two disagreed about trailing + // whitespace, so the identifier that was checked and the + // identifier that was written into the target were not the same + // string. + buildSettingsMap.putAll(appExtensionBuildSettings(appExtension)); buildSettingsProps.delete(); } @@ -7564,6 +7561,24 @@ static boolean insideProjectDir(File candidate, File projectDir) { } } + /// The device families an extension embedded in THIS app may declare. + /// + /// The app target's own come from ios.project_type: the translator rewrites the template's + /// TARGETED_DEVICE_FAMILY to "1" for iphone and "2" for anything else that is not "ios". + /// Every extension here was pinned to "1,2" regardless, so an iPhone-only app shipped an + /// extension claiming iPad support -- the project builds, and App Store validation refuses + /// the upload for an embedded bundle whose device families its container does not have. + /// + /// This is the DEFAULT only. An archive that states its own TARGETED_DEVICE_FAMILY is applied + /// over it further down, since an extension deliberately narrower than its app -- a widget on + /// iPhone alone -- is the author's call to make. + static String embeddedExtensionDeviceFamily(String projectType) { + if (projectType == null || "ios".equalsIgnoreCase(projectType)) { + return "1,2"; + } + return "iphone".equalsIgnoreCase(projectType) ? "1" : "2"; + } + /// The identifier this extension's archive declares, or the one derived from the app. /// /// Named rather than inlined because four call sites have to agree on it: an archive that @@ -7614,7 +7629,14 @@ static Map appExtensionBuildSettings(File extensionFolder) { } for (Object key : props.keySet()) { if (key instanceof String) { - out.put((String) key, props.getProperty((String) key)); + String value = props.getProperty((String) key); + // Trimmed, because Properties keeps trailing whitespace and Xcode does not: + // xcodebuild -showBuildSettings reports a padded value without its padding, and + // the parser for that output trims too. Untrimmed, preflight judged + // "com.example.app.Ext" while the target was handed "com.example.app.Ext " -- + // an identifier no profile matches and no bundle may carry, arrived at by two + // readers of the same file disagreeing about what it says. + out.put((String) key, value == null ? null : value.trim()); } } return out; @@ -8191,7 +8213,8 @@ private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", MatterExtensionBuilder.deploymentTarget(ownFabric)); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); @@ -8282,7 +8305,8 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S // PKIssuerProvisioningExtensionHandler requires iOS 14; the extension target // keeps its own deployment target even when the app targets lower. buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); @@ -8845,7 +8869,8 @@ private void appendWidgetExtensionRuby(StringBuilder sb, BuildRequest request, String extensionName = widgetBuilder.getExtensionName(); Map buildSettingsMap = new LinkedHashMap(); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); // The builder's buildSettings.properties supplies the deployment target, Swift diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java index 7bb239750d7..b2b27f5b7bd 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java @@ -27,6 +27,7 @@ import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; public class AppExtensionBuildSettingsTest { @@ -67,4 +68,32 @@ public void blankAndMalformedLinesAreSkipped() { assertEquals(1, settings.size()); assertEquals("YES", settings.get("CLANG_ENABLE_MODULES")); } + + @Test + public void paddingIsStrippedFromArchiveSettings() throws Exception { + java.io.File dist = java.nio.file.Files.createTempDirectory("appext").toFile(); + java.io.File extension = new java.io.File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.io.FileWriter w = new java.io.FileWriter( + new java.io.File(extension, "buildSettings.properties")); + w.write("PRODUCT_BUNDLE_IDENTIFIER=com.example.app.Ext \n"); + w.close(); + + // Properties keeps the trailing space and Xcode does not. Kept, preflight validated + // "com.example.app.Ext" while the target was handed "com.example.app.Ext " -- an + // identifier no profile matches, from two readers of one file disagreeing. + assertEquals("com.example.app.Ext", IPhoneBuilder.appExtensionBuildSettings(extension) + .get("PRODUCT_BUNDLE_IDENTIFIER")); + } + + @Test + public void extensionDeviceFamiliesFollowTheApp() { + // The translator gives the app target "1" for iphone and "2" for anything else that is + // not "ios"; an extension pinned to "1,2" beside an iPhone-only app is an upload + // rejection for an embedded bundle its container does not support. + assertEquals("1", IPhoneBuilder.embeddedExtensionDeviceFamily("iphone")); + assertEquals("2", IPhoneBuilder.embeddedExtensionDeviceFamily("ipad")); + assertEquals("1,2", IPhoneBuilder.embeddedExtensionDeviceFamily("ios")); + assertEquals("1,2", IPhoneBuilder.embeddedExtensionDeviceFamily(null)); + } } From 4638802559c52f334e70f31d3bd212cbe963e909 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:43:07 +0300 Subject: [PATCH 44/49] Mirror: each Info.plist is stamped in its own candidate's context All candidates were evaluated in the archive's context, so a $(MARKETING_VERSION) inside Debug/Info.plist was judged against the Release value, read as already matching the app, and left -- while the build that uses that file expands it to the Debug version its container does not have. The path was already resolved in the candidate's own context; the contents are now too. 129 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 27 ++++++++++- .../builders/AppExtensionInfoPlistTest.java | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 7677525f544..48ed717202a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5886,9 +5886,17 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, Map settings = appExtensionBuildSettings(appExtension); settings.put("PRODUCT_BUNDLE_IDENTIFIER", appExtensionBundleId(appExtension, request.getPackageName() + "." + appExtension.getName())); + // In the candidate's OWN context, not this archive's. Every candidate is stamped, + // because the generated project keeps them all and a later Debug build off + // sources.tar.bz2 ships whichever one it names -- but a reference inside the Debug + // plist expands to the Debug values, and judging it against the Release ones read + // $(MARKETING_VERSION) as already matching the app. Left in place, it becomes the + // stale Debug version on the build that actually uses that file, which is the + // host-mismatch rejection this stamping exists to prevent. List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), embeddedExtensionBundleVersion(request), request.getPackageName(), - flattenForContext(settings, context)); + flattenForContext(settings, + infoPlistCandidateContext(candidate.getKey(), context))); if (changes == null) { debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + " as an XML property list, so its bundle identity was left as it is. If " @@ -6061,6 +6069,23 @@ static Map appExtensionInfoPlists(File extensionFolder) { return appExtensionInfoPlists(extensionFolder, null); } + /// The context a candidate from {@link #appExtensionInfoPlists} belongs to. + /// + /// The candidates are keyed by the setting that names them -- "INFOPLIST_FILE[config=Debug] = + /// $(CONFIGURATION)/Info.plist" -- and the qualifier in that key is the whole difference + /// between the two files. The path was already resolved in it; what is IN the file has to be + /// resolved in it too. + /// + /// @return the archive's own context for an unqualified candidate + static ArchiveContext infoPlistCandidateContext(String candidateKey, ArchiveContext context) { + int open = candidateKey == null ? -1 : candidateKey.indexOf('['); + int close = open < 0 ? -1 : candidateKey.indexOf(']', open); + if (close < 0) { + return context; + } + return contextForCondition(candidateKey.substring(0, close + 1), context); + } + /// @param context this archive, so INFOPLIST_FILE = $(CONFIGURATION)/Info.plist resolves to /// the plist Xcode actually processes. Without it the path came back unresolvable and the /// stamping skipped the file that ships -- the same hole the entitlements path had. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java index a3a473f215b..d0df6d5d810 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -32,6 +32,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class AppExtensionInfoPlistTest { @@ -542,4 +543,48 @@ public void paddingInsideCdataIsNoDifferent() { NO_SETTINGS, changes); assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); } + + @Test + public void aQualifiedCandidateIsStampedInItsOwnContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("MARKETING_VERSION", "5.4"); + settings.put("MARKETING_VERSION[config=Debug]", "1.0"); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // The Debug plist's context, taken from the setting that names it. + IPhoneBuilder.ArchiveContext candidate = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist", release); + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", + IPhoneBuilder.flattenForContext(settings, candidate), changes); + + // Judged in the archive's Release context the reference reads as 5.4, already the app's + // version, and is left -- while the build that uses this file expands it to the Debug + // 1.0 and ships a version its container does not have. + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '1.0'")); + + // The contrast, pinned: in the archive's own context the same plist is left untouched, + // so this is the candidate's context doing the work and not some general strictness. + List underRelease = new ArrayList(); + assertTrue(IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", + IPhoneBuilder.flattenForContext(settings, release), underRelease) + .contains("$(MARKETING_VERSION)")); + } + + @Test + public void anUnqualifiedCandidateKeepsTheArchivesContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // Nothing to narrow: the base INFOPLIST_FILE is the file this archive builds with. + assertSame(release, IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE = WalletUIExtension/Info.plist", release)); + assertSame(release, IPhoneBuilder.infoPlistCandidateContext("Info.plist", release)); + } } From ec48ae49f19928cbfe2d8b81188df062edaa1fb6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:46:36 +0300 Subject: [PATCH 45/49] Mirror: the containing app's own plist is never stamped A review asked for inactive candidates to be skipped rather than stamped, because one may name a plist Xcode will not use for the extension. The file is the hazard, not the condition: what gets written is an extension's identity, and in the app's plist that means the app's version or its identifier handed to a $(PRODUCT_BUNDLE_IDENTIFIER) that means something else in that target. Filtering on the condition would leave the same hole for an active setting naming that file, and would stop stamping the candidates a Debug rebuild off sources.tar.bz2 actually ships. 130 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 43 +++++++++++++++++++ .../AppExtensionInfoPlistPathTest.java | 26 +++++++++++ 2 files changed, 69 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 48ed717202a..ab155992242 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5870,6 +5870,27 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, + ".ios.appext archive."); continue; } + if (isHostAppInfoPlist(infoPlist, appExtension.getParentFile(), request.getMainClass())) { + // The app's OWN plist, named by one of the extension's settings. Everything under + // the project directory is writable on purpose -- an extension may share a plist + // that sits beside its folder -- but the identity written here is an EXTENSION's, + // and putting it in the container rewrites the app's version or hands its + // identifier to $(PRODUCT_BUNDLE_IDENTIFIER), which for the app target is a + // different value entirely. + // + // Note this is about the FILE, not about the condition. Every candidate is + // stamped, applicable or not, because the generated project keeps them all and a + // Debug rebuild off sources.tar.bz2 ships whichever one it names -- an unstamped + // one then carries the stale identity Apple rejects. Skipping the inactive ones + // instead would leave that hole open AND leave this one, since an ACTIVE setting + // naming the app's plist would still be written. Do not swap this guard for a + // conditionApplies() filter without a test that covers both. + debug("The " + appExtension.getName() + " app extension names '" + candidate.getKey() + + "' as an Info.plist, and that is the containing app's own plist. An " + + "extension's bundle identity does not belong in it, so it was left as " + + "it is; point INFOPLIST_FILE at a plist inside the extension."); + continue; + } if (!stamped.add(infoPlist.getCanonicalPath())) { // Two settings naming the same file. Stamping is idempotent, but saying so twice // in the log reads like two files were touched. @@ -6069,6 +6090,28 @@ static Map appExtensionInfoPlists(File extensionFolder) { return appExtensionInfoPlists(extensionFolder, null); } + /// Whether this path is the containing app's own Info.plist. + /// + /// The generated project puts it at {@code /-src/-Info.plist}, + /// which is inside the project directory and therefore writable -- so an extension setting + /// that names it, by relative path or through a reference, reaches the stamper like any + /// other candidate. + /// + /// @return false when anything here is unknown, since a path that cannot be compared is not + /// one to declare safe + static boolean isHostAppInfoPlist(File candidate, File distDir, String mainClass) { + if (candidate == null || distDir == null || mainClass == null + || mainClass.length() == 0) { + return false; + } + File host = new File(new File(distDir, mainClass + "-src"), mainClass + "-Info.plist"); + try { + return candidate.getCanonicalPath().equals(host.getCanonicalPath()); + } catch (IOException cannotResolve) { + return false; + } + } + /// The context a candidate from {@link #appExtensionInfoPlists} belongs to. /// /// The candidates are keyed by the setting that names them -- "INFOPLIST_FILE[config=Debug] = diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java index e36da0ecc74..82f327226da 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java @@ -32,6 +32,8 @@ import java.nio.file.Files; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNull; public class AppExtensionInfoPlistPathTest { @@ -185,4 +187,28 @@ private static void write(File file, String contents) throws Exception { out.close(); } } + + @Test + public void theContainingAppsPlistIsNotStamped() throws Exception { + File dist = tmp.newFolder("hostplist"); + File appSrc = new File(dist, "MyApp-src"); + assertTrue(appSrc.mkdirs()); + File hostPlist = new File(appSrc, "MyApp-Info.plist"); + assertTrue(hostPlist.createNewFile()); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File ownPlist = new File(extension, "Info.plist"); + assertTrue(ownPlist.createNewFile()); + + // Everything under the project directory is writable on purpose, so an extension setting + // that names the app's own plist -- by relative path or through a reference -- reaches + // the stamper like any other candidate. What would be written there is an EXTENSION's + // identity: the app's version, or its identifier handed to a $(PRODUCT_BUNDLE_IDENTIFIER) + // that means something else in the app target. + assertTrue(IPhoneBuilder.isHostAppInfoPlist(hostPlist, dist, "MyApp")); + assertTrue(IPhoneBuilder.isHostAppInfoPlist( + new File(extension, "../MyApp-src/MyApp-Info.plist"), dist, "MyApp")); + assertFalse(IPhoneBuilder.isHostAppInfoPlist(ownPlist, dist, "MyApp")); + assertFalse(IPhoneBuilder.isHostAppInfoPlist(hostPlist, dist, null)); + } } From 3090361b53c90bf43f2e594c589621b6b7427edb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:53:12 +0300 Subject: [PATCH 46/49] Mirror: supply $(PROJECT_NAME), keep a wildcard a wildcard PROJECT_NAME was the one Xcode built-in this builder did not supply, so an identifier written as com.example.host.$(PROJECT_NAME) could not be resolved and every check that needs one was working from an expression. It is the .xcodeproj in the project directory -- where SRCROOT and PROJECT_DIR already come from -- supplied now, with PROJECT, when there is exactly one. And a condition describing another build kept only its stem, so [sdk=iphonesimulator*] with Wallet/$(SDK_NAME)/Info.plist resolved to a path nothing is ever at, while the simulator build that file belongs to expands SDK_NAME to a versioned iphonesimulator18.0: the wrong file was reported missing and the real one never stamped. The pattern is kept whole -- it still matches its family for conditionApplies, and a built-in it cannot supply is not supplied. 133 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 65 +++++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 59 ++++++++++++++++- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ab155992242..681b59907e7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6835,10 +6835,29 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, if (!out.containsKey("PROJECT_DIR")) { out.put("PROJECT_DIR", projectPath); } - if (configuration != null && !out.containsKey("CONFIGURATION")) { + // A project name is one of these too, and not knowing it is what left + // com.example.host.$(PROJECT_NAME) unresolvable: the identifier was then recorded as its + // own source text, which names no bundle and matches no profile. It is the .xcodeproj in + // the project directory -- the same directory SRCROOT and PROJECT_DIR come from -- and + // only when there is exactly one, since two would be a guess. + String projectName = singleXcodeProjectName(projectDir); + if (projectName != null) { + if (!out.containsKey("PROJECT_NAME")) { + out.put("PROJECT_NAME", projectName); + } + if (!out.containsKey("PROJECT")) { + out.put("PROJECT", projectName); + } + } + // Family patterns are not values. A context taken from [sdk=iphonesimulator*] that this + // archive does not match knows the family and NOT the SDK the build will use, so + // $(SDK_NAME) is left unexpanded and the candidate is reported as one this build cannot + // resolve -- which is true -- rather than resolved to a path nothing will ever be at. + if (configuration != null && !isFamilyPattern(configuration) + && !out.containsKey("CONFIGURATION")) { out.put("CONFIGURATION", configuration); } - if (sdk != null) { + if (sdk != null && !isFamilyPattern(sdk)) { if (!out.containsKey("SDK_NAME")) { out.put("SDK_NAME", sdk); } @@ -6846,7 +6865,7 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, out.put("PLATFORM_NAME", platformOf(sdk)); } } - if (arch != null) { + if (arch != null && !isFamilyPattern(arch)) { if (!out.containsKey("CURRENT_ARCH")) { out.put("CURRENT_ARCH", arch); } @@ -6866,6 +6885,38 @@ static Map extensionSettingsWithBuiltIns(File extensionFolder, return out; } + /// Whether this context value is a family rather than a value: "iphonesimulator*", from a + /// condition that describes some build other than this one. + static boolean isFamilyPattern(String value) { + return value != null && value.endsWith("*"); + } + + /// The name of the Xcode project in this directory, or null when there is not exactly one. + /// + /// $(PROJECT_NAME) is an Xcode built-in like $(TARGET_NAME), and an identifier written + /// through it is ordinary. Not supplying it left the identifier unresolvable, and what got + /// recorded for the export-options dictionary was the expression itself. + static String singleXcodeProjectName(File projectDir) { + if (projectDir == null) { + return null; + } + File[] entries = projectDir.listFiles(); + if (entries == null) { + return null; + } + String found = null; + for (File entry : entries) { + if (entry.getName().endsWith(".xcodeproj")) { + if (found != null) { + return null; + } + found = entry.getName().substring(0, + entry.getName().length() - ".xcodeproj".length()); + } + } + return found == null || found.length() == 0 ? null : found; + } + /// Every entitlements file this target may be signed with: the plain CODE_SIGN_ENTITLEMENTS /// and each qualified one. /// @@ -7161,7 +7212,13 @@ static ArchiveContext contextForCondition(String key, ArchiveContext context) { if (archiveValue != null && matchesCondition(value, archiveValue)) { continue; } - value = value.substring(0, value.length() - 1); + // The pattern is kept whole, star and all. Stripped to its stem it read as a + // concrete value and was expanded as one: [sdk=iphonesimulator*] with + // Wallet/$(SDK_NAME)/Info.plist went looking for Wallet/iphonesimulator, while + // the simulator build that file belongs to expands SDK_NAME to a versioned + // iphonesimulator18.0 -- so the wrong path was reported missing and the real + // plist was never stamped. As a pattern it still matches the family for + // conditionApplies, and the built-ins it cannot supply are simply not supplied. } if (value.length() == 0) { continue; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index e01749c2e84..c4f9dd280bc 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -788,9 +788,13 @@ public void aConditionsOwnContextOverridesOnlyWhatItNames() throws Exception { assertEquals("Debug", own.configuration); assertEquals("iphoneos14.4", own.sdk); assertEquals("arm64", own.arch); - // a pattern names a family; its stem is the closest thing to a value - assertEquals("iphonesimulator", IPhoneBuilder.contextForCondition( + // A pattern names a FAMILY and is kept as one. Its stem was standing in for a value: + // $(SDK_NAME) then expanded to "iphonesimulator", while the simulator build the setting + // belongs to expands it to a versioned iphonesimulator18.0 -- a path nothing is at. + assertEquals("iphonesimulator*", IPhoneBuilder.contextForCondition( "X[sdk=iphonesimulator*]", active).sdk); + assertTrue(IPhoneBuilder.isFamilyPattern("iphonesimulator*")); + assertFalse(IPhoneBuilder.isFamilyPattern("iphoneos14.4")); } @Test @@ -896,4 +900,55 @@ public void anIdentifierWrittenThroughAConditionalHelperResolvesToTheConditional IPhoneBuilder.extensionSettingsWithBuiltIns(extension, settings, "Release", "iphoneos14.4", "arm64"))); } + + @Test + public void anIdentifierWrittenThroughTheProjectNameResolves() throws Exception { + File dist = tmp.newFolder("dist40"); + assertTrue(new File(dist, "MyApp.xcodeproj").mkdirs()); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.$(PROJECT_NAME)"); + + // $(PROJECT_NAME) is an Xcode built-in like $(TARGET_NAME), and not supplying it left the + // identifier unresolvable -- recorded for the export-options dictionary as its own source + // text, which names no bundle and matches no profile. + assertEquals("com.example.app.MyApp", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PROJECT_NAME)", + IPhoneBuilder.extensionSettingsWithBuiltIns(extension, settings, "Release", + "iphoneos14.4", "arm64"))); + } + + @Test + public void twoProjectsInTheFolderLeaveTheProjectNameUnknown() throws Exception { + File dist = tmp.newFolder("dist41"); + assertTrue(new File(dist, "MyApp.xcodeproj").mkdirs()); + assertEquals("MyApp", IPhoneBuilder.singleXcodeProjectName(dist)); + + // Two would be a guess, and a guessed identifier is the thing being fixed here. + assertTrue(new File(dist, "OtherApp.xcodeproj").mkdirs()); + assertNull(IPhoneBuilder.singleXcodeProjectName(dist)); + } + + @Test + public void anInactiveWildcardDoesNotBecomeAConcreteSdk() throws Exception { + File dist = tmp.newFolder("dist42"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE[sdk\\=iphonesimulator*] = WalletUIExtension/$(SDK_NAME)/Info.plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // The simulator build this file belongs to expands SDK_NAME to a versioned + // iphonesimulator18.0, so the stem named a path nothing is ever at: the wrong file was + // reported missing and the real one was never stamped. Unresolvable is the honest + // answer, and it is reported as one this build will not edit. + for (java.util.Map.Entry candidate : plists.entrySet()) { + if (candidate.getKey().contains("iphonesimulator")) { + assertNull(candidate.getKey(), candidate.getValue()); + } + } + } } From 0c88b836623452bb40f3fff9babee8c2922e16ff Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:55:56 +0300 Subject: [PATCH 47/49] Mirror: every qualifier group reaches the candidate's context Cut at the first ']', INFOPLIST_FILE[config=Debug][sdk=iphonesimulator*] was read as a Debug candidate on the archive's own SDK, so an SDK-qualified helper inside that plist was judged against the device values and left as it was. The whole key is taken now, up to the " = " these labels append. 134 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 14 +++++++--- .../builders/AppExtensionInfoPlistTest.java | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 681b59907e7..50093c23885 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6121,12 +6121,18 @@ static boolean isHostAppInfoPlist(File candidate, File distDir, String mainClass /// /// @return the archive's own context for an unqualified candidate static ArchiveContext infoPlistCandidateContext(String candidateKey, ArchiveContext context) { - int open = candidateKey == null ? -1 : candidateKey.indexOf('['); - int close = open < 0 ? -1 : candidateKey.indexOf(']', open); - if (close < 0) { + if (candidateKey == null) { return context; } - return contextForCondition(candidateKey.substring(0, close + 1), context); + // Everything up to the " = " this method's callers append, and no less. Cutting at the + // first ']' instead dropped every qualifier group after the first, so + // INFOPLIST_FILE[config=Debug][sdk=iphonesimulator*] was read as a Debug candidate on the + // archive's own SDK -- and an SDK-qualified version helper inside that plist then looked + // correct and was left, while the build that uses the file expands it to another value. + // A settings key cannot contain " = ", so the first occurrence is the separator. + int separator = candidateKey.indexOf(" = "); + String key = separator < 0 ? candidateKey : candidateKey.substring(0, separator); + return key.indexOf('[') < 0 ? context : contextForCondition(key, context); } /// @param context this archive, so INFOPLIST_FILE = $(CONFIGURATION)/Info.plist resolves to diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java index d0df6d5d810..7e362b66439 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -587,4 +587,32 @@ public void anUnqualifiedCandidateKeepsTheArchivesContext() { "INFOPLIST_FILE = WalletUIExtension/Info.plist", release)); assertSame(release, IPhoneBuilder.infoPlistCandidateContext("Info.plist", release)); } + + @Test + public void everyQualifierGroupReachesTheCandidatesContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // Cut at the first ']' this read as a Debug candidate on the archive's own SDK, so an + // SDK-qualified helper inside that plist was judged against the device values and left + // as it was. + IPhoneBuilder.ArchiveContext both = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug][sdk=iphonesimulator*] = $(CONFIGURATION)/Info.plist", + release); + assertEquals("Debug", both.configuration); + assertEquals("iphonesimulator*", both.sdk); + + // One group, and the grouped form Xcode also accepts. + assertEquals("Debug", IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug] = Debug/Info.plist", release).configuration); + IPhoneBuilder.ArchiveContext commaSeparated = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug,arch=x86_64] = Debug/Info.plist", release); + assertEquals("Debug", commaSeparated.configuration); + assertEquals("x86_64", commaSeparated.arch); + + // And a path that happens to contain a bracket is not a qualifier. + assertSame(release, IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE = Wallet[beta]/Info.plist", release)); + } } From 3cf35f9d74afbf550fd7064a735bd135ab4809f8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:59:50 +0300 Subject: [PATCH 48/49] Mirror: one plist named by two conditions is stamped in both The dedup was on the canonical path alone, so a file named by the base setting and by a qualified one was stamped once: under Release the $(MARKETING_VERSION) in it already resolves to the app's version and is left, the Debug pass that would have replaced it was skipped, and the Debug build off those sources shipped the stale value. The key is the file AND the context now, and the test drives the whole stamping loop -- it fails on the old dedup. 135 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 27 +++++++++++---- .../AppExtensionInfoPlistPathTest.java | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 50093c23885..ec4c44b00c6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5848,7 +5848,7 @@ static String embeddedExtensionBundleVersion(BuildRequest request) { * aligned here, and every change is logged: this edits a file the developer supplied. A value * that is already correct, and one written as a {@code $(...)} reference, are left alone.

*/ - private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, + void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, ArchiveContext context) throws IOException { Map plists = appExtensionInfoPlists(appExtension, context); Set stamped = new LinkedHashSet(); @@ -5891,9 +5891,18 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, + "it is; point INFOPLIST_FILE at a plist inside the extension."); continue; } - if (!stamped.add(infoPlist.getCanonicalPath())) { - // Two settings naming the same file. Stamping is idempotent, but saying so twice - // in the log reads like two files were touched. + ArchiveContext candidateContext = infoPlistCandidateContext(candidate.getKey(), context); + if (!stamped.add(infoPlist.getCanonicalPath() + "\u0000" + candidateContext)) { + // Two settings naming the same file IN THE SAME CONTEXT. Stamping is idempotent, + // and saying so twice in the log reads like two files were touched. + // + // The context is part of the key because one physical plist is routinely named by + // the base setting and by a qualified one: a $(MARKETING_VERSION) in it resolves + // to the app's version under Release and to a stale 1.0 under + // [config=Debug], and the file cannot be right for both while the reference + // stands. Deduplicating on the path alone let the Release pass leave the + // reference and skipped the Debug pass that would have replaced it, so the Debug + // build off these sources shipped 1.0. continue; } // Through the shared resolvers rather than buildVersion / the ios.bundleVersion hint @@ -5916,8 +5925,7 @@ private void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, // host-mismatch rejection this stamping exists to prevent. List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), embeddedExtensionBundleVersion(request), request.getPackageName(), - flattenForContext(settings, - infoPlistCandidateContext(candidate.getKey(), context))); + flattenForContext(settings, candidateContext)); if (changes == null) { debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + " as an XML property list, so its bundle identity was left as it is. If " @@ -7262,6 +7270,13 @@ static final class ArchiveContext { this.variants = variants; } + /// The four dimensions, for logs and for use as a map key. + @Override + public String toString() { + return "sdk=" + sdk + ",config=" + configuration + ",arch=" + arch + + ",variants=" + variants; + } + /// @param settings the extension's own, since BUILD_VARIANTS in them is copied onto the /// target and decides which variants Xcode actually builds static ArchiveContext of(String sdk, String configuration, String arch, diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java index 82f327226da..41c68bfed2b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java @@ -211,4 +211,38 @@ public void theContainingAppsPlistIsNotStamped() throws Exception { assertFalse(IPhoneBuilder.isHostAppInfoPlist(ownPlist, dist, "MyApp")); assertFalse(IPhoneBuilder.isHostAppInfoPlist(hostPlist, dist, null)); } + + @Test + public void oneFileNamedByTwoConditionsIsStampedInBoth() throws Exception { + File dist = tmp.newFolder("shared-plist"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File shared = new File(extension, "Info.plist"); + write(shared, "\n\n\n" + + "\tCFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleVersion\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleIdentifier\n\tcom.example.app.WalletUIExtension\n" + + "\n\n"); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/Info.plist\n" + + "INFOPLIST_FILE[config\\=Debug] = WalletUIExtension/Info.plist\n" + + "MARKETING_VERSION = 5.4\n" + + "MARKETING_VERSION[config\\=Debug] = 1.0\n"); + + BuildRequest request = new BuildRequest(); + request.setMainClass("MyApp"); + request.setPackageName("com.example.app"); + request.setVersion("5.4"); + new IPhoneBuilder().stampAppExtensionInfoPlist(extension, request, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // One physical plist, named by the base setting and by a Debug-qualified one. Under + // Release the reference already resolves to the app's 5.4 and is left; deduplicating on + // the path alone then skipped the Debug pass, and the Debug build off these sources + // shipped $(MARKETING_VERSION) = 1.0. The file cannot be right for both while the + // reference stands, so the literal has to win. + String stamped = new String(Files.readAllBytes(shared.toPath()), "UTF-8"); + assertTrue(stamped, stamped.contains("CFBundleShortVersionString\n\t5.4")); + assertFalse(stamped, stamped.contains("$(MARKETING_VERSION)")); + } } From e59712bac23b503d221fea2c341ad543312ee181 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:10:01 +0300 Subject: [PATCH 49/49] Empty is a value, and / is not a developer directory Mirrors the daemon: a qualified deployment target that resolves to nothing is raised to the floor rather than skipped, since Xcode expands the same missing reference to the same nothing and a blank target is no minimum at all -- $(inherited) excepted, being a directive rather than a setting this build failed to find. And CODE_SIGN_ENTITLEMENTS declared empty means Xcode signs with no entitlements file, which is not the same as the setting being absent. Plugin-only, because only this copy asks xcrun for the SDK: XCODEBUILD set to /usr/bin/xcodebuild -- what `which xcodebuild` reports -- put the filesystem root two levels up, which has usr/bin and was accepted as the developer directory. DEVELOPER_DIR=/ makes xcrun fail, the SDK name falls back to the unversioned "iphoneos", and an exact [sdk=iphoneosNN] condition is then decided by map order instead of by the SDK the archive is built with. Platforms is what no other tree has. 139 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 52 ++++++++++++++-- .../AppExtensionDeploymentTargetTest.java | 62 +++++++++++++++++++ .../builders/AppExtensionStagingTest.java | 20 ++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ec4c44b00c6..458c2af77c1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6952,9 +6952,18 @@ static File appExtensionSigningEntitlements(File extensionFolder, Map settings, File byName, String sdk, String configuration, String arch) { String winner = winningSetting(settings, "CODE_SIGN_ENTITLEMENTS", sdk, configuration, arch); - if (winner == null || winner.trim().length() == 0) { + if (winner == null) { return byName; } + if (winner.trim().length() == 0) { + // Declared and empty is not the same as not declared. An archive that sets + // CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] to nothing is telling Xcode to sign the + // device build with no entitlements file at all, and Xcode does -- so reading the + // by-name file here found a Wallet entitlement the target is not signed with and + // raised it to iOS 14 for something it does not carry. + return null; + } + // With the archive's context, because $(CONFIGURATION)/Extension.entitlements is a // standard way to write this path; without it the path did not resolve and a different // file was read for the entitlement that sets the floor. @@ -7069,10 +7078,28 @@ static List repairQualifiedExtensionSettings(Map setting // EXTENSION_MIN = 16.0 is a perfectly good iOS 16 target. Comparing the raw text made // "$(EXTENSION_MIN)" parse as no version at all, read as below the floor, and be // overwritten with 12.0 -- taking an extension that compiles against iOS 16 APIs down - // with it. A reference that resolves to nothing here is left exactly as written: this - // build cannot evaluate it, which is not the same as knowing it is wrong. + // with it. An identifier that resolves to nothing is left exactly as written: this + // build cannot evaluate it, which is not the same as knowing it is wrong. A + // deployment target is the other way round, for the reason below. String resolved = resolveSettingsInValue(value, flat); if (resolved.length() == 0) { + // Nothing left after expansion. For a deployment target that is not "cannot + // tell": Xcode expands the same missing reference to the same nothing, and an + // empty IPHONEOS_DEPLOYMENT_TARGET is not the base value -- it is no minimum at + // all, so the qualified entry overrides the clamped base with a blank and the + // floor is bypassed. Raised to the floor like any other under-floor value. + // + // Except $(inherited), which is not a setting this build failed to find but a + // directive: Xcode replaces it with the value from the level above, and writing + // the floor over it would pin an extension that inherits iOS 16 down to 12. + if (!isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") + || value.toLowerCase().indexOf("$(inherited)") >= 0 + || value.toLowerCase().indexOf("${inherited}") >= 0) { + continue; + } + settings.put(key, floor); + notes.add(key + " = " + value + " resolves to nothing, and an empty deployment " + + "target is no minimum at all, so it was set to " + floor); continue; } if (isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") @@ -7158,8 +7185,23 @@ private String selectedDeveloperDir() { for (int i = 0; i < 2 && developer != null; i++) { developer = developer.getParentFile(); } - return developer != null && new File(developer, "usr/bin").isDirectory() - ? developer.getAbsolutePath() : null; + return isDeveloperDir(developer) ? developer.getAbsolutePath() : null; + } + + /// Whether this really is an Xcode developer directory. + /// + /// Two levels up from /usr/bin/xcodebuild -- the shim most machines have on PATH, and what + /// `which xcodebuild` reports into XCODEBUILD -- is the filesystem root, where usr/bin exists + /// and is not a developer directory at all. Handing DEVELOPER_DIR=/ to xcrun makes it fail, + /// the SDK name falls back to the unversioned "iphoneos", and an exact [sdk=iphoneosNN] + /// condition is then decided by map order rather than by the SDK the archive is built with. + /// + /// Platforms is the thing no other directory has: the CommandLineTools tree carries usr/bin + /// without it, and so does the root. + static boolean isDeveloperDir(File developer) { + return developer != null + && new File(developer, "usr/bin/xcodebuild").isFile() + && new File(developer, "Platforms").isDirectory(); } /// The archive's settings with every conditional resolved to the value THIS build gets. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java index c4f9dd280bc..425a102527e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -951,4 +951,66 @@ public void anInactiveWildcardDoesNotBecomeAConcreteSdk() throws Exception { } } } + + @Test + public void aQualifiedTargetThatResolvesToNothingIsClamped() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(MISSING_MIN)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Xcode expands the same missing reference to the same nothing, and an empty deployment + // target is not the base value -- it is no minimum at all, so the qualified entry + // overrides the clamped base with a blank and the floor is bypassed. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals(notes.toString(), 1, notes.size()); + } + + @Test + public void anInheritedTargetIsLeftToInherit() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(inherited)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Not a setting this build failed to find but a directive: Xcode replaces it with the + // value from the level above, and writing a floor over it pins an extension that + // inherits iOS 16 down to 12. + assertEquals("$(inherited)", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals(notes.toString(), 0, notes.size()); + } + + @Test + public void anEmptyEntitlementsOverrideMeansNoEntitlements() throws Exception { + File dist = tmp.newFolder("dist50"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File byName = new File(extension, "WalletUIExtension.entitlements"); + write(byName, "\n\n" + + "com.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/WalletUIExtension.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", ""); + + // Declared and empty is not the same as not declared: Xcode signs the device build with + // no entitlements file, so reading the by-name one found a Wallet entitlement the target + // does not carry and raised it to iOS 14 for it. + assertNull(IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, byName, + "iphoneos14.4", "Release", "arm64")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentFloor( + IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, byName, + "iphoneos14.4", "Release", "arm64"))); + + // And a missing winner still falls back to the file named after the extension. + settings.remove("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"); + settings.remove("CODE_SIGN_ENTITLEMENTS"); + assertEquals(byName, IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, + byName, "iphoneos14.4", "Release", "arm64")); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java index ae63bc36a1b..08102aad092 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java @@ -151,4 +151,24 @@ public void anInTreeFileLinkIsStillFine() throws Exception { new File(extension, "Info.plist").toPath()); assertNull(IPhoneBuilder.symlinkEscaping(extension, extension)); } + + @Test + public void theFilesystemRootIsNotADeveloperDirectory() throws Exception { + File fakeRoot = tmp.newFolder("fakeroot"); + assertTrue(new File(fakeRoot, "usr/bin").mkdirs()); + assertTrue(new File(fakeRoot, "usr/bin/xcodebuild").createNewFile()); + + // Two levels up from /usr/bin/xcodebuild -- the shim `which xcodebuild` reports -- is the + // root, which has usr/bin and is not a developer directory. DEVELOPER_DIR=/ makes xcrun + // fail, the SDK name falls back to the unversioned "iphoneos", and an exact + // [sdk=iphoneosNN] condition is then decided by map order. + assertFalse(IPhoneBuilder.isDeveloperDir(fakeRoot)); + + File developer = tmp.newFolder("Xcode.app-Contents-Developer"); + assertTrue(new File(developer, "usr/bin").mkdirs()); + assertTrue(new File(developer, "usr/bin/xcodebuild").createNewFile()); + assertTrue(new File(developer, "Platforms").mkdirs()); + assertTrue(IPhoneBuilder.isDeveloperDir(developer)); + assertFalse(IPhoneBuilder.isDeveloperDir(null)); + } }