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)) {
+ return -1;
+ }
+ int openEnd = plist.indexOf('>', 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("", at)) {
+ if (tag.equals(closeTagAt(plist, at))) {
+ depth--;
+ }
+ } else if (plist.charAt(gt - 1) != '/'
+ && tag.equals(WatchNativeBuilder.tagAt(plist, at))) {
+ depth++;
+ }
+ i = gt + 1;
+ }
+ return i;
+ }
+
+ /// The element name of the end tag at {@code at}, lowercased, or empty when that is not one.
+ private static String closeTagAt(String plist, int at) {
+ StringBuilder tag = new StringBuilder();
+ for (int j = at + 2; j < plist.length() && Character.isLetterOrDigit(plist.charAt(j)); j++) {
+ tag.append(plist.charAt(j));
+ }
+ return tag.toString().toLowerCase(java.util.Locale.ENGLISH);
+ }
+
+ /// The {@code <} of the next markup at or after {@code from}, with comments and CDATA stepped
+ /// over whole so a {@code <} inside either is not read as a tag.
+ private static int nextMarkupAt(String plist, int from) {
+ int i = from;
+ while (i < plist.length()) {
+ int at = plist.indexOf('<', i);
+ if (at < 0) {
+ return -1;
+ }
+ int skipped = WatchNativeBuilder.skipMarkupBefore(plist, at, i);
+ if (skipped < 0) {
+ return -1;
+ }
+ if (skipped != at) {
+ i = skipped;
+ continue;
+ }
+ return at;
+ }
+ return -1;
+ }
+
/// The Info.plist the extension target is actually built with.
///
/// {@code /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 "", unmistakable either way
round.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../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 "".
+ if (data.length >= 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